diff --git a/packages/base-manager/test/src/manager_test.ts b/packages/base-manager/test/src/manager_test.ts index 45793bc05e..8fbe295a85 100644 --- a/packages/base-manager/test/src/manager_test.ts +++ b/packages/base-manager/test/src/manager_test.ts @@ -168,7 +168,7 @@ describe('ManagerBase', function () { }, }, metadata: { - version: '2.0.0', + version: '2.1.0', }, }); expect(model.comm).to.equal(comm); @@ -231,7 +231,7 @@ describe('ManagerBase', function () { }, buffers: [new DataView(new Uint8Array([1, 2, 3]).buffer)], metadata: { - version: '2.0.0', + version: '2.1.0', }, }); expect(model.comm).to.equal(comm); diff --git a/packages/base/package.json b/packages/base/package.json index bad35dc238..28d50bd9d6 100644 --- a/packages/base/package.json +++ b/packages/base/package.json @@ -26,6 +26,7 @@ "test:coverage": "npm run build:test && webpack --config test/webpack-cov.conf.js && karma start test/karma-cov.conf.js", "test:unit": "npm run test:unit:firefox && npm run test:unit:chrome", "test:unit:chrome": "npm run test:unit:default -- --browsers=Chrome", + "test:unit:chrome:debug": "npm run test:unit:default -- --browsers=Chrome --single-run=false", "test:unit:default": "npm run build:test && karma start test/karma.conf.js --log-level debug", "test:unit:firefox": "npm run test:unit:default -- --browsers=Firefox", "test:unit:firefox:headless": "npm run test:unit:default -- --browsers=FirefoxHeadless", diff --git a/packages/base/src/version.ts b/packages/base/src/version.ts index d1e617d5bd..3897463d6f 100644 --- a/packages/base/src/version.ts +++ b/packages/base/src/version.ts @@ -3,4 +3,4 @@ export const JUPYTER_WIDGETS_VERSION = '2.0.0'; -export const PROTOCOL_VERSION = '2.0.0'; +export const PROTOCOL_VERSION = '2.1.0'; diff --git a/packages/base/src/widget.ts b/packages/base/src/widget.ts index 1bee253110..5fc95e169c 100644 --- a/packages/base/src/widget.ts +++ b/packages/base/src/widget.ts @@ -115,6 +115,9 @@ export class WidgetModel extends Backbone.Model { attributes: Backbone.ObjectHash, options: IBackboneModelOptions ): void { + this.expectedEchoMsgIds = new Map(); + this.attrsToUpdate = new Set(); + super.initialize(attributes, options); // Attributes should be initialized here, since user initialization may depend on it @@ -221,13 +224,46 @@ export class WidgetModel extends Backbone.Model { const method = data.method; switch (method) { case 'update': + case 'echo_update': this.state_change = this.state_change .then(() => { const state = data.state; - const buffer_paths = data.buffer_paths || []; - const buffers = msg.buffers || []; + const buffer_paths = data.buffer_paths ?? []; + const buffers = msg.buffers?.slice(0, buffer_paths.length) ?? []; utils.put_buffers(state, buffer_paths, buffers); + + if (msg.parent_header && method === 'echo_update') { + const msgId = (msg.parent_header as any).msg_id; + // we may have echos coming from other clients, we only care about + // dropping echos for which we expected a reply + const expectedEcho = Object.keys(state).filter((attrName) => + this.expectedEchoMsgIds.has(attrName) + ); + expectedEcho.forEach((attrName: string) => { + // Skip echo messages until we get the reply we are expecting. + const isOldMessage = + this.expectedEchoMsgIds.get(attrName) !== msgId; + if (isOldMessage) { + // Ignore an echo update that comes before our echo. + delete state[attrName]; + } else { + // we got our echo confirmation, so stop looking for it + this.expectedEchoMsgIds.delete(attrName); + // Start accepting echo updates unless we plan to send out a new state soon + if ( + this._msg_buffer !== null && + Object.prototype.hasOwnProperty.call( + this._msg_buffer, + attrName + ) + ) { + delete state[attrName]; + } + } + }); + } return (this.constructor as typeof WidgetModel)._deserialize_state( + // Combine the state updates, with preference for kernel updates state, this.widget_manager ); @@ -300,7 +336,11 @@ export class WidgetModel extends Backbone.Model { this._pending_msgs--; // Send buffer if one is waiting and we are below the throttle. if (this._msg_buffer !== null && this._pending_msgs < 1) { - this.send_sync_message(this._msg_buffer, this._msg_buffer_callbacks); + const msgId = this.send_sync_message( + this._msg_buffer, + this._msg_buffer_callbacks + ); + this.rememberLastUpdateFor(msgId); this._msg_buffer = null; this._msg_buffer_callbacks = null; } @@ -415,6 +455,10 @@ export class WidgetModel extends Backbone.Model { } } + Object.keys(attrs).forEach((attrName: string) => { + this.attrsToUpdate.add(attrName); + }); + const msgState = this.serialize(attrs); if (Object.keys(msgState).length > 0) { @@ -444,7 +488,8 @@ export class WidgetModel extends Backbone.Model { } else { // We haven't exceeded the throttle, send the message like // normal. - this.send_sync_message(attrs, callbacks); + const msgId = this.send_sync_message(attrs, callbacks); + this.rememberLastUpdateFor(msgId); // Since the comm is a one-way communication, assume the message // arrived and was processed successfully. // Don't call options.success since we don't have a model back from @@ -453,6 +498,12 @@ export class WidgetModel extends Backbone.Model { } } } + rememberLastUpdateFor(msgId: string) { + this.attrsToUpdate.forEach((attrName) => { + this.expectedEchoMsgIds.set(attrName, msgId); + }); + this.attrsToUpdate = new Set(); + } /** * Serialize widget state. @@ -488,9 +539,9 @@ export class WidgetModel extends Backbone.Model { /** * Send a sync message to the kernel. */ - send_sync_message(state: JSONObject, callbacks: any = {}): void { + send_sync_message(state: JSONObject, callbacks: any = {}): string { if (!this.comm) { - return; + return ''; } try { callbacks.iopub = callbacks.iopub || {}; @@ -504,7 +555,7 @@ export class WidgetModel extends Backbone.Model { // split out the binary buffers const split = utils.remove_buffers(state); - this.comm.send( + const msgId = this.comm.send( { method: 'update', state: split.state, @@ -515,9 +566,11 @@ export class WidgetModel extends Backbone.Model { split.buffers ); this._pending_msgs++; + return msgId; } catch (e) { console.error('Could not send widget sync message', e); } + return ''; } /** @@ -624,6 +677,12 @@ export class WidgetModel extends Backbone.Model { private _msg_buffer: any; private _msg_buffer_callbacks: any; private _pending_msgs: number; + // keep track of the msg id for each attr for updates we send out so + // that we can ignore old messages that we send in order to avoid + // 'drunken' sliders going back and forward + private expectedEchoMsgIds: Map; + // because we don't know the attrs in _handle_status, we keep track of what we will send + private attrsToUpdate: Set; } export class DOMWidgetModel extends WidgetModel { diff --git a/packages/schema/messages.md b/packages/schema/messages.md index 5cabf449bd..d2e950b721 100644 --- a/packages/schema/messages.md +++ b/packages/schema/messages.md @@ -292,6 +292,32 @@ The `data.state` and `data.buffer_paths` values are the same as in the `comm_ope See the [Model state](jupyterwidgetmodels.latest.md) documentation for the attributes of core Jupyter widgets. +#### Synchronizing multiple frontends: `echo_update` + +Starting with protocol version `2.1.0`, `echo_update` messages from the kernel to the frontend are optional update messages for echoing state in messages from a frontend to the kernel back out to all the frontends. + +``` +{ + 'comm_id' : 'u-u-i-d', + 'data' : { + 'method': 'echo_update', + 'state': { }, + 'buffer_paths': [ ] + } +} +``` + +The Jupyter comm protocol is asymmetric in how messages flow: messages flow from a single frontend to a single kernel, but messages are broadcast from the kernel to *all* frontends. In the widget protocol, if a frontend updates the value of a widget, the frontend does not have a way to directly notify other frontends about the state update. The `echo_update` optional messages enable a kernel to broadcast out frontend updates to all frontends. This can also help resolve the race condition where the kernel and a frontend simultaneously send updates to each other since the frontend now knows the order of kernel updates. + +The `echo_update` messages enable a frontend to optimistically update its widget views to reflect its own changes that it knows the kernel will yet process. These messages are intended to be used as follows: +1. A frontend model attribute is updated, and the frontend views are optimistically updated to reflect the attribute. +2. The frontend queues an update message to the kernel and records the message id for the attribute. +3. The frontend ignores updates to the attribute from the kernel contained in `echo_update` messages until it gets an `echo_update` message corresponding to its own update of the attribute (i.e., the [parent_header](https://jupyter-client.readthedocs.io/en/latest/messaging.html#parent-header) id matches the stored message id for the attribute). It also ignores `echo_update` updates if it has a pending attribute update to send to the kernel. Once the frontend receives its own `echo_update` and does not have any more pending attribute updates to send to the kernel, it starts applying attribute updates from `echo_update` messages. + +Since the `echo_update` update messages are optional, and not all attribute updates may be echoed, it is important that only `echo_update` updates are ignored in the last step above, and `update` message updates are always applied. + +Implementation note: For attributes where sending back an `echo_update` is considered too expensive or unnecessary, we have implemented an opt-out mechanism in the ipywidgets package. A model trait can have the `echo_update` metadata attribute set to `False` to flag that the kernel should never send an `echo_update` update for that attribute to the frontends. Additionally, we have a system-wide flag to disable echoing for all attributes via the environment variable `JUPYTER_WIDGETS_ECHO`. For ipywdgets 7.7, we default `JUPYTER_WIDGETS_ECHO` to off (disabling all echo messages) and in ipywidgets 8.0 we default `JUPYTER_WIDGETS_ECHO` to on (enabling echo messages). + #### State requests: `request_state` When a frontend wants to request the full state of a widget, the frontend sends a `request_state` message: diff --git a/python/ipywidgets/ipywidgets/_version.py b/python/ipywidgets/ipywidgets/_version.py index c775201291..b83683103d 100644 --- a/python/ipywidgets/ipywidgets/_version.py +++ b/python/ipywidgets/ipywidgets/_version.py @@ -3,7 +3,7 @@ __version__ = '8.0.0b0' -__protocol_version__ = '2.0.0' +__protocol_version__ = '2.1.0' __control_protocol_version__ = '1.0.0' # These are *protocol* versions for each package, *not* npm versions. To check, look at each package's src/version.ts file for the protocol version the package implements. diff --git a/python/ipywidgets/ipywidgets/widgets/tests/test_set_state.py b/python/ipywidgets/ipywidgets/widgets/tests/test_set_state.py index 07ee469ae5..0564dc5b94 100644 --- a/python/ipywidgets/ipywidgets/widgets/tests/test_set_state.py +++ b/python/ipywidgets/ipywidgets/widgets/tests/test_set_state.py @@ -80,7 +80,7 @@ def test_set_state_simple(): c=[False, True, False], )) - assert w.comm.messages == [] + assert len(w.comm.messages) == 1 def test_set_state_transformer(): @@ -90,11 +90,18 @@ def test_set_state_transformer(): )) # Since the deserialize step changes the state, this should send an update assert w.comm.messages == [((), dict( + buffers=[], + data=dict( + buffer_paths=[], + method='echo_update', + state=dict(d=[True, False, True]), + ))), + ((), dict( buffers=[], data=dict( buffer_paths=[], method='update', - state=dict(d=[False, True, False]) + state=dict(d=[False, True, False]), )))] @@ -105,7 +112,7 @@ def test_set_state_data(): a=True, d={'data': data}, )) - assert w.comm.messages == [] + assert len(w.comm.messages) == 1 def test_set_state_data_truncate(): @@ -116,15 +123,15 @@ def test_set_state_data_truncate(): d={'data': data}, )) # Get message for checking - assert len(w.comm.messages) == 1 # ensure we didn't get more than expected - msg = w.comm.messages[0] + assert len(w.comm.messages) == 2 # ensure we didn't get more than expected + msg = w.comm.messages[1] # Assert that the data update (truncation) sends an update buffers = msg[1].pop('buffers') assert msg == ((), dict( data=dict( - buffer_paths=[['d', 'data']], method='update', - state=dict(d={}) + state=dict(d={}), + buffer_paths=[['d', 'data']] ))) # Sanity: @@ -144,8 +151,8 @@ def test_set_state_numbers_int(): i = 3, ci = 4, )) - # Ensure no update message gets produced - assert len(w.comm.messages) == 0 + # Ensure one update message gets produced + assert len(w.comm.messages) == 1 def test_set_state_numbers_float(): @@ -156,8 +163,8 @@ def test_set_state_numbers_float(): cf = 2.0, ci = 4.0 )) - # Ensure no update message gets produced - assert len(w.comm.messages) == 0 + # Ensure one update message gets produced + assert len(w.comm.messages) == 1 def test_set_state_float_to_float(): @@ -167,8 +174,8 @@ def test_set_state_float_to_float(): f = 1.2, cf = 2.6, )) - # Ensure no update message gets produced - assert len(w.comm.messages) == 0 + # Ensure one message gets produced + assert len(w.comm.messages) == 1 def test_set_state_cint_to_float(): @@ -179,8 +186,8 @@ def test_set_state_cint_to_float(): ci = 5.6 )) # Ensure an update message gets produced - assert len(w.comm.messages) == 1 - msg = w.comm.messages[0] + assert len(w.comm.messages) == 2 + msg = w.comm.messages[1] data = msg[1]['data'] assert data['method'] == 'update' assert data['state'] == {'ci': 5} @@ -235,6 +242,7 @@ def _propagate_value(self, change): # this mimics a value coming from the front end widget.set_state({'value': 42}) assert widget.value == 42 + assert widget.stop is True # we expect no new state to be sent calls = [] @@ -262,9 +270,103 @@ def _propagate_value(self, change): assert widget.value == 2 assert widget.other == 11 - # we expect only single state to be sent, i.e. the {'value': 42.0} state + msg = {'method': 'echo_update', 'state': {'value': 42.0}, 'buffer_paths': []} + call42 = mock.call(msg, buffers=[]) + msg = {'method': 'update', 'state': {'value': 2.0, 'other': 11.0}, 'buffer_paths': []} + call2 = mock.call(msg, buffers=[]) + + calls = [call42, call2] + widget._send.assert_has_calls(calls) + + + +def test_echo(): + # we always echo values back to the frontend + class ValueWidget(Widget): + value = Float().tag(sync=True) + + widget = ValueWidget(value=1) + assert widget.value == 1 + + widget._send = mock.MagicMock() + # this mimics a value coming from the front end + widget.set_state({'value': 42}) + assert widget.value == 42 + + # we expect this to be echoed + msg = {'method': 'echo_update', 'state': {'value': 42.0}, 'buffer_paths': []} call42 = mock.call(msg, buffers=[]) calls = [call42] widget._send.assert_has_calls(calls) + + +def test_echo_single(): + # we always echo multiple changes back in 1 update + class ValueWidget(Widget): + value = Float().tag(sync=True) + square = Float().tag(sync=True) + @observe('value') + def _square(self, change): + self.square = self.value**2 + + widget = ValueWidget(value=1) + assert widget.value == 1 + + widget._send = mock.MagicMock() + # this mimics a value coming from the front end + widget._handle_msg({ + 'content': { + 'data': { + 'method': 'update', + 'state': { + 'value': 8, + } + } + } + }) + assert widget.value == 8 + assert widget.square == 64 + + # we expect this to be echoed + # note that only value is echoed, not square + msg = {'method': 'echo_update', 'state': {'value': 8.0}, 'buffer_paths': []} + call = mock.call(msg, buffers=[]) + + msg = {'method': 'update', 'state': {'square': 64}, 'buffer_paths': []} + call2 = mock.call(msg, buffers=[]) + + + calls = [call, call2] + widget._send.assert_has_calls(calls) + + +def test_no_echo(): + # in cases where values coming from the frontend are 'heavy', we might want to opt out + class ValueWidget(Widget): + value = Float().tag(sync=True, echo_update=False) + + widget = ValueWidget(value=1) + assert widget.value == 1 + + widget._send = mock.MagicMock() + # this mimics a value coming from the front end + widget._handle_msg({ + 'content': { + 'data': { + 'method': 'update', + 'state': { + 'value': 42, + } + } + } + }) + assert widget.value == 42 + + # widget._send.assert_not_called(calls) + widget._send.assert_not_called() + + # a regular set should sync to the frontend + widget.value = 43 + widget._send.assert_has_calls([mock.call({'method': 'update', 'state': {'value': 43.0}, 'buffer_paths': []}, buffers=[])]) diff --git a/python/ipywidgets/ipywidgets/widgets/widget.py b/python/ipywidgets/ipywidgets/widgets/widget.py index 3cc791ed18..a57d45a8da 100644 --- a/python/ipywidgets/ipywidgets/widgets/widget.py +++ b/python/ipywidgets/ipywidgets/widgets/widget.py @@ -5,7 +5,7 @@ """Base Widget class. Allows user to create widgets in the back-end that render in the Jupyter notebook front-end. """ - +import os from contextlib import contextmanager from collections.abc import Iterable from IPython import get_ipython @@ -18,8 +18,22 @@ from base64 import standard_b64encode from .._version import __protocol_version__, __control_protocol_version__, __jupyter_widgets_base_version__ + +# Based on jupyter_core.paths.envset +def envset(name, default): + """Return True if the given environment variable is turned on, otherwise False + If the environment variable is set, True will be returned if it is assigned to a value + other than 'no', 'n', 'false', 'off', '0', or '0.0' (case insensitive). + If the environment variable is not set, the default value is returned. + """ + if name in os.environ: + return os.environ[name].lower() not in ['no', 'n', 'false', 'off', '0', '0.0'] + else: + return bool(default) + PROTOCOL_VERSION_MAJOR = __protocol_version__.split('.')[0] CONTROL_PROTOCOL_VERSION_MAJOR = __control_protocol_version__.split('.')[0] +JUPYTER_WIDGETS_ECHO = envset('JUPYTER_WIDGETS_ECHO', default=True) def _widget_to_json(x, obj): if isinstance(x, dict): @@ -549,6 +563,21 @@ def _compare(self, a, b): def set_state(self, sync_data): """Called when a state is received from the front-end.""" + # Send an echo update message immediately + if JUPYTER_WIDGETS_ECHO: + echo_state = {} + for attr,value in sync_data.items(): + if self.trait_metadata(attr, 'echo_update', default=True): + echo_state[attr] = value + if echo_state: + echo_state, echo_buffer_paths, echo_buffers = _remove_buffers(echo_state) + msg = { + 'method': 'echo_update', + 'state': echo_state, + 'buffer_paths': echo_buffer_paths, + } + self._send(msg, buffers=echo_buffers) + # The order of these context managers is important. Properties must # be locked when the hold_trait_notification context manager is # released and notifications are fired. diff --git a/python/ipywidgets/ipywidgets/widgets/widget_upload.py b/python/ipywidgets/ipywidgets/widgets/widget_upload.py index 446997a3f9..bd0fe61c95 100644 --- a/python/ipywidgets/ipywidgets/widgets/widget_upload.py +++ b/python/ipywidgets/ipywidgets/widgets/widget_upload.py @@ -133,7 +133,7 @@ class FileUpload(DescriptionWidget, ValueWidget, CoreWidget): style = InstanceDict(ButtonStyle).tag(sync=True, **widget_serialization) error = Unicode(help='Error message').tag(sync=True) value = TypedTuple(Dict(), help='The file upload value').tag( - sync=True, **_value_serialization) + sync=True, echo_update=False, **_value_serialization) @default('description') def _default_description(self):