Skip to content

Commit 3b2e20d

Browse files
authored
Merge pull request #3394 from jasongrout/echostate
Echo state updates in a backwards-compatible way
2 parents 949ccde + 11c36db commit 3b2e20d

File tree

9 files changed

+96
-102
lines changed

9 files changed

+96
-102
lines changed

packages/base-manager/test/src/manager_test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ describe('ManagerBase', function () {
180180
},
181181
},
182182
metadata: {
183-
version: '3.0.0',
183+
version: '2.1.0',
184184
},
185185
});
186186
expect(model.comm).to.equal(comm);
@@ -243,7 +243,7 @@ describe('ManagerBase', function () {
243243
},
244244
buffers: [new DataView(new Uint8Array([1, 2, 3]).buffer)],
245245
metadata: {
246-
version: '3.0.0',
246+
version: '2.1.0',
247247
},
248248
});
249249
expect(model.comm).to.equal(comm);

packages/base/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"test:coverage": "npm run build:test && webpack --config test/webpack-cov.conf.js && karma start test/karma-cov.conf.js",
2727
"test:unit": "npm run test:unit:firefox && npm run test:unit:chrome",
2828
"test:unit:chrome": "npm run test:unit:default -- --browsers=Chrome",
29+
"test:unit:chrome:debug": "npm run test:unit:default -- --browsers=Chrome --single-run=false",
2930
"test:unit:default": "npm run build:test && karma start test/karma.conf.js --log-level debug",
3031
"test:unit:firefox": "npm run test:unit:default -- --browsers=Firefox",
3132
"test:unit:firefox:headless": "npm run test:unit:default -- --browsers=FirefoxHeadless",

packages/base/src/version.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33

44
export const JUPYTER_WIDGETS_VERSION = '2.0.0';
55

6-
export const PROTOCOL_VERSION = '3.0.0';
6+
export const PROTOCOL_VERSION = '2.1.0';

packages/base/src/widget.ts

+18-17
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ export class WidgetModel extends Backbone.Model {
115115
attributes: Backbone.ObjectHash,
116116
options: IBackboneModelOptions
117117
): void {
118-
this.expectedEchoMsgIds = {};
118+
this.expectedEchoMsgIds = new Map<string, string>();
119119
this.attrsToUpdate = new Set<string>();
120120

121121
super.initialize(attributes, options);
@@ -224,32 +224,32 @@ export class WidgetModel extends Backbone.Model {
224224
const method = data.method;
225225
switch (method) {
226226
case 'update':
227+
case 'echo_update':
227228
this.state_change = this.state_change
228229
.then(() => {
229230
const state = data.state;
230-
const buffer_paths = data.buffer_paths || [];
231-
const buffers = msg.buffers || [];
231+
const buffer_paths = data.buffer_paths ?? [];
232+
const buffers = msg.buffers?.slice(0, buffer_paths.length) ?? [];
232233
utils.put_buffers(state, buffer_paths, buffers);
233-
if (msg.parent_header && data.echo) {
234+
235+
if (msg.parent_header && method === 'echo_update') {
234236
const msgId = (msg.parent_header as any).msg_id;
235237
// we may have echos coming from other clients, we only care about
236238
// dropping echos for which we expected a reply
237-
const expectedEcho = data.echo.filter((attrName: string) =>
238-
Object.keys(this.expectedEchoMsgIds).includes(attrName)
239+
const expectedEcho = Object.keys(state).filter((attrName) =>
240+
this.expectedEchoMsgIds.has(attrName)
239241
);
240242
expectedEcho.forEach((attrName: string) => {
241-
// we don't care about the old messages, only the one send with the
242-
// last msgId
243+
// Skip echo messages until we get the reply we are expecting.
243244
const isOldMessage =
244-
this.expectedEchoMsgIds[attrName] !== msgId;
245+
this.expectedEchoMsgIds.get(attrName) !== msgId;
245246
if (isOldMessage) {
246-
// get rid of old updates
247+
// Ignore an echo update that comes before our echo.
247248
delete state[attrName];
248249
} else {
249-
// we got our confirmation, from now on we accept everything
250-
delete this.expectedEchoMsgIds[attrName];
251-
// except, we plan to send out a new state for this soon, so we will
252-
// also ignore the update for this property
250+
// we got our echo confirmation, so stop looking for it
251+
this.expectedEchoMsgIds.delete(attrName);
252+
// Start accepting echo updates unless we plan to send out a new state soon
253253
if (
254254
this._msg_buffer !== null &&
255255
Object.prototype.hasOwnProperty.call(
@@ -263,6 +263,7 @@ export class WidgetModel extends Backbone.Model {
263263
});
264264
}
265265
return (this.constructor as typeof WidgetModel)._deserialize_state(
266+
// Combine the state updates, with preference for kernel updates
266267
state,
267268
this.widget_manager
268269
);
@@ -498,8 +499,8 @@ export class WidgetModel extends Backbone.Model {
498499
}
499500
}
500501
rememberLastUpdateFor(msgId: string) {
501-
[...this.attrsToUpdate].forEach((attrName) => {
502-
this.expectedEchoMsgIds[attrName] = msgId;
502+
this.attrsToUpdate.forEach((attrName) => {
503+
this.expectedEchoMsgIds.set(attrName, msgId);
503504
});
504505
this.attrsToUpdate = new Set<string>();
505506
}
@@ -679,7 +680,7 @@ export class WidgetModel extends Backbone.Model {
679680
// keep track of the msg id for each attr for updates we send out so
680681
// that we can ignore old messages that we send in order to avoid
681682
// 'drunken' sliders going back and forward
682-
private expectedEchoMsgIds: any;
683+
private expectedEchoMsgIds: Map<string, string>;
683684
// because we don't know the attrs in _handle_status, we keep track of what we will send
684685
private attrsToUpdate: Set<string>;
685686
}

packages/schema/messages.md

+12-8
Original file line numberDiff line numberDiff line change
@@ -292,27 +292,31 @@ The `data.state` and `data.buffer_paths` values are the same as in the `comm_ope
292292

293293
See the [Model state](jupyterwidgetmodels.latest.md) documentation for the attributes of core Jupyter widgets.
294294

295-
#### Synchronizing multiple frontends: `update` with echo
295+
#### Synchronizing multiple frontends: `echo_update`
296296

297-
Starting with protocol version `3.0.0` the kernel can send a special update message back, to allow all connected frontends to be in sync with the kernel state. This allows multiple frontends to be connected to a single kernel but also resolves a possible out of sync situation when the kernel and a frontend send out an update message at the same time, causing both to think they have the latest state.
298-
299-
In protocol version `3.0.0` the kernel is considered the single source of truth and is expected to send back to the frontends an update message that contains an extra list of keys to indicate which keys in the update are send back to the frontends as a reaction to an update received from a frontend.
297+
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.
300298

301299
```
302300
{
303301
'comm_id' : 'u-u-i-d',
304302
'data' : {
305-
'method': 'update',
303+
'method': 'echo_update',
306304
'state': { <dictionary of widget state> },
307305
'buffer_paths': [ <list with paths corresponding to the binary buffers> ]
308-
'echo': [ <list of keys for which the kernel is sending back the state>]
309306
}
310307
}
311308
```
312309

313-
In situations where a user does many changes to a widget on the frontend (e.g. moving a slider), the frontend will receive from the kernel many update messages (with the echo key set) from the kernel that can be considered old values. A frontend can choose to ignore all updates that are not originating from the last update it send to the kernel. This can be implemented by keeping track of the `msg_id` for each attribyte for which we send out an update message to the kernel, and ignoring all updates as a result from an `echo` for which the [`msg_id` of the parent header](https://jupyter-client.readthedocs.io/en/latest/messaging.html#parent-header) is not equal to `msg_id` we kept track of.
310+
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.
311+
312+
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:
313+
1. A frontend model attribute is updated, and the frontend views are optimistically updated to reflect the attribute.
314+
2. The frontend queues an update message to the kernel and records the message id for the attribute.
315+
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.
316+
317+
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.
314318

315-
For situations where sending back an echo update for a property is considered too expensive, we have implemented an opt-out mechanism in ipywidgets. A trait can have a `no_echo` metadata attribute to flag that the kernel should not send back an update to the frontends. We suggest other implementations implement a similar opt-out mechanism.
319+
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).
316320

317321
#### State requests: `request_state`
318322

python/ipywidgets/ipywidgets/_version.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
__version__ = '8.0.0b1'
55

6-
__protocol_version__ = '3.0.0'
6+
__protocol_version__ = '2.1.0'
77
__control_protocol_version__ = '1.0.0'
88

99
# 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.

python/ipywidgets/ipywidgets/widgets/tests/test_set_state.py

+28-17
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,18 @@ def test_set_state_transformer():
9090
))
9191
# Since the deserialize step changes the state, this should send an update
9292
assert w.comm.messages == [((), dict(
93+
buffers=[],
94+
data=dict(
95+
buffer_paths=[],
96+
method='echo_update',
97+
state=dict(d=[True, False, True]),
98+
))),
99+
((), dict(
93100
buffers=[],
94101
data=dict(
95102
buffer_paths=[],
96103
method='update',
97104
state=dict(d=[False, True, False]),
98-
echo=['d'],
99105
)))]
100106

101107

@@ -117,16 +123,15 @@ def test_set_state_data_truncate():
117123
d={'data': data},
118124
))
119125
# Get message for checking
120-
assert len(w.comm.messages) == 1 # ensure we didn't get more than expected
121-
msg = w.comm.messages[0]
126+
assert len(w.comm.messages) == 2 # ensure we didn't get more than expected
127+
msg = w.comm.messages[1]
122128
# Assert that the data update (truncation) sends an update
123129
buffers = msg[1].pop('buffers')
124130
assert msg == ((), dict(
125131
data=dict(
126132
method='update',
127-
state=dict(d={}, a=True),
128-
buffer_paths=[['d', 'data']],
129-
echo=['a', 'd'],
133+
state=dict(d={}),
134+
buffer_paths=[['d', 'data']]
130135
)))
131136

132137
# Sanity:
@@ -181,8 +186,8 @@ def test_set_state_cint_to_float():
181186
ci = 5.6
182187
))
183188
# Ensure an update message gets produced
184-
assert len(w.comm.messages) == 1
185-
msg = w.comm.messages[0]
189+
assert len(w.comm.messages) == 2
190+
msg = w.comm.messages[1]
186191
data = msg[1]['data']
187192
assert data['method'] == 'update'
188193
assert data['state'] == {'ci': 5}
@@ -265,11 +270,13 @@ def _propagate_value(self, change):
265270
assert widget.value == 2
266271
assert widget.other == 11
267272

268-
# we expect only single state to be sent, i.e. the {'value': 42.0} state
269-
msg = {'method': 'update', 'state': {'value': 2.0, 'other': 11.0}, 'buffer_paths': [], 'echo': ['value']}
273+
msg = {'method': 'echo_update', 'state': {'value': 42.0}, 'buffer_paths': []}
270274
call42 = mock.call(msg, buffers=[])
271275

272-
calls = [call42]
276+
msg = {'method': 'update', 'state': {'value': 2.0, 'other': 11.0}, 'buffer_paths': []}
277+
call2 = mock.call(msg, buffers=[])
278+
279+
calls = [call42, call2]
273280
widget._send.assert_has_calls(calls)
274281

275282

@@ -288,7 +295,7 @@ class ValueWidget(Widget):
288295
assert widget.value == 42
289296

290297
# we expect this to be echoed
291-
msg = {'method': 'update', 'state': {'value': 42.0}, 'buffer_paths': [], 'echo': ['value']}
298+
msg = {'method': 'echo_update', 'state': {'value': 42.0}, 'buffer_paths': []}
292299
call42 = mock.call(msg, buffers=[])
293300

294301
calls = [call42]
@@ -324,17 +331,21 @@ def _square(self, change):
324331

325332
# we expect this to be echoed
326333
# note that only value is echoed, not square
327-
msg = {'method': 'update', 'state': {'square': 64, 'value': 8.0}, 'buffer_paths': [], 'echo': ['value']}
334+
msg = {'method': 'echo_update', 'state': {'value': 8.0}, 'buffer_paths': []}
328335
call = mock.call(msg, buffers=[])
336+
337+
msg = {'method': 'update', 'state': {'square': 64}, 'buffer_paths': []}
338+
call2 = mock.call(msg, buffers=[])
339+
329340

330-
calls = [call]
341+
calls = [call, call2]
331342
widget._send.assert_has_calls(calls)
332343

333344

334345
def test_no_echo():
335-
# in cases where values coming fromt the frontend are 'heavy', we might want to opt out
346+
# in cases where values coming from the frontend are 'heavy', we might want to opt out
336347
class ValueWidget(Widget):
337-
value = Float().tag(sync=True, no_echo=True)
348+
value = Float().tag(sync=True, echo_update=False)
338349

339350
widget = ValueWidget(value=1)
340351
assert widget.value == 1
@@ -358,4 +369,4 @@ class ValueWidget(Widget):
358369

359370
# a regular set should sync to the frontend
360371
widget.value = 43
361-
widget._send.assert_has_calls([mock.call({'method': 'update', 'state': {'value': 43.0}, 'buffer_paths': [], 'echo': ['value']}, buffers=[])])
372+
widget._send.assert_has_calls([mock.call({'method': 'update', 'state': {'value': 43.0}, 'buffer_paths': []}, buffers=[])])

0 commit comments

Comments
 (0)