Skip to content

Commit f7c48af

Browse files
Merge pull request #3195 from maartenbreddels/fix_out_of_sync
Kernel is the source of truth: support multiple clients and fixes out of sync bug
2 parents ec946cf + 2d7909e commit f7c48af

File tree

4 files changed

+224
-23
lines changed

4 files changed

+224
-23
lines changed

packages/base/src/widget.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@ export class WidgetModel extends Backbone.Model {
115115
attributes: Backbone.ObjectHash,
116116
options: IBackboneModelOptions
117117
): void {
118+
this.expectedEchoMsgIds = {};
119+
this.attrsToUpdate = new Set<string>();
120+
118121
super.initialize(attributes, options);
119122

120123
// Attributes should be initialized here, since user initialization may depend on it
@@ -227,6 +230,38 @@ export class WidgetModel extends Backbone.Model {
227230
const buffer_paths = data.buffer_paths || [];
228231
const buffers = msg.buffers || [];
229232
utils.put_buffers(state, buffer_paths, buffers);
233+
if (msg.parent_header && data.echo) {
234+
const msgId = (msg.parent_header as any).msg_id;
235+
// we may have echos coming from other clients, we only care about
236+
// dropping echos for which we expected a reply
237+
const expectedEcho = data.echo.filter((attrName: string) =>
238+
Object.keys(this.expectedEchoMsgIds).includes(attrName)
239+
);
240+
expectedEcho.forEach((attrName: string) => {
241+
// we don't care about the old messages, only the one send with the
242+
// last msgId
243+
const isOldMessage =
244+
this.expectedEchoMsgIds[attrName] !== msgId;
245+
if (isOldMessage) {
246+
// get rid of old updates
247+
delete state[attrName];
248+
} 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
253+
if (
254+
this._msg_buffer !== null &&
255+
Object.prototype.hasOwnProperty.call(
256+
this._msg_buffer,
257+
attrName
258+
)
259+
) {
260+
delete state[attrName];
261+
}
262+
}
263+
});
264+
}
230265
return (this.constructor as typeof WidgetModel)._deserialize_state(
231266
state,
232267
this.widget_manager
@@ -300,7 +335,11 @@ export class WidgetModel extends Backbone.Model {
300335
this._pending_msgs--;
301336
// Send buffer if one is waiting and we are below the throttle.
302337
if (this._msg_buffer !== null && this._pending_msgs < 1) {
303-
this.send_sync_message(this._msg_buffer, this._msg_buffer_callbacks);
338+
const msgId = this.send_sync_message(
339+
this._msg_buffer,
340+
this._msg_buffer_callbacks
341+
);
342+
this.rememberLastUpdateFor(msgId);
304343
this._msg_buffer = null;
305344
this._msg_buffer_callbacks = null;
306345
}
@@ -415,6 +454,10 @@ export class WidgetModel extends Backbone.Model {
415454
}
416455
}
417456

457+
Object.keys(attrs).forEach((attrName: string) => {
458+
this.attrsToUpdate.add(attrName);
459+
});
460+
418461
const msgState = this.serialize(attrs);
419462

420463
if (Object.keys(msgState).length > 0) {
@@ -444,7 +487,8 @@ export class WidgetModel extends Backbone.Model {
444487
} else {
445488
// We haven't exceeded the throttle, send the message like
446489
// normal.
447-
this.send_sync_message(attrs, callbacks);
490+
const msgId = this.send_sync_message(attrs, callbacks);
491+
this.rememberLastUpdateFor(msgId);
448492
// Since the comm is a one-way communication, assume the message
449493
// arrived and was processed successfully.
450494
// Don't call options.success since we don't have a model back from
@@ -453,6 +497,12 @@ export class WidgetModel extends Backbone.Model {
453497
}
454498
}
455499
}
500+
rememberLastUpdateFor(msgId: string) {
501+
[...this.attrsToUpdate].forEach((attrName) => {
502+
this.expectedEchoMsgIds[attrName] = msgId;
503+
});
504+
this.attrsToUpdate = new Set<string>();
505+
}
456506

457507
/**
458508
* Serialize widget state.
@@ -488,9 +538,9 @@ export class WidgetModel extends Backbone.Model {
488538
/**
489539
* Send a sync message to the kernel.
490540
*/
491-
send_sync_message(state: JSONObject, callbacks: any = {}): void {
541+
send_sync_message(state: JSONObject, callbacks: any = {}): string {
492542
if (!this.comm) {
493-
return;
543+
return '';
494544
}
495545
try {
496546
callbacks.iopub = callbacks.iopub || {};
@@ -504,7 +554,7 @@ export class WidgetModel extends Backbone.Model {
504554

505555
// split out the binary buffers
506556
const split = utils.remove_buffers(state);
507-
this.comm.send(
557+
const msgId = this.comm.send(
508558
{
509559
method: 'update',
510560
state: split.state,
@@ -515,9 +565,11 @@ export class WidgetModel extends Backbone.Model {
515565
split.buffers
516566
);
517567
this._pending_msgs++;
568+
return msgId;
518569
} catch (e) {
519570
console.error('Could not send widget sync message', e);
520571
}
572+
return '';
521573
}
522574

523575
/**
@@ -624,6 +676,12 @@ export class WidgetModel extends Backbone.Model {
624676
private _msg_buffer: any;
625677
private _msg_buffer_callbacks: any;
626678
private _pending_msgs: number;
679+
// keep track of the msg id for each attr for updates we send out so
680+
// that we can ignore old messages that we send in order to avoid
681+
// 'drunken' sliders going back and forward
682+
private expectedEchoMsgIds: any;
683+
// because we don't know the attrs in _handle_status, we keep track of what we will send
684+
private attrsToUpdate: Set<string>;
627685
}
628686

629687
export class DOMWidgetModel extends WidgetModel {

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

Lines changed: 103 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def test_set_state_simple():
8080
c=[False, True, False],
8181
))
8282

83-
assert w.comm.messages == []
83+
assert len(w.comm.messages) == 1
8484

8585

8686
def test_set_state_transformer():
@@ -94,7 +94,8 @@ def test_set_state_transformer():
9494
data=dict(
9595
buffer_paths=[],
9696
method='update',
97-
state=dict(d=[False, True, False])
97+
state=dict(d=[False, True, False]),
98+
echo=['d'],
9899
)))]
99100

100101

@@ -105,7 +106,7 @@ def test_set_state_data():
105106
a=True,
106107
d={'data': data},
107108
))
108-
assert w.comm.messages == []
109+
assert len(w.comm.messages) == 1
109110

110111

111112
def test_set_state_data_truncate():
@@ -122,9 +123,10 @@ def test_set_state_data_truncate():
122123
buffers = msg[1].pop('buffers')
123124
assert msg == ((), dict(
124125
data=dict(
125-
buffer_paths=[['d', 'data']],
126126
method='update',
127-
state=dict(d={})
127+
state=dict(d={}, a=True),
128+
buffer_paths=[['d', 'data']],
129+
echo=['a', 'd'],
128130
)))
129131

130132
# Sanity:
@@ -144,8 +146,8 @@ def test_set_state_numbers_int():
144146
i = 3,
145147
ci = 4,
146148
))
147-
# Ensure no update message gets produced
148-
assert len(w.comm.messages) == 0
149+
# Ensure one update message gets produced
150+
assert len(w.comm.messages) == 1
149151

150152

151153
def test_set_state_numbers_float():
@@ -156,8 +158,8 @@ def test_set_state_numbers_float():
156158
cf = 2.0,
157159
ci = 4.0
158160
))
159-
# Ensure no update message gets produced
160-
assert len(w.comm.messages) == 0
161+
# Ensure one update message gets produced
162+
assert len(w.comm.messages) == 1
161163

162164

163165
def test_set_state_float_to_float():
@@ -167,8 +169,8 @@ def test_set_state_float_to_float():
167169
f = 1.2,
168170
cf = 2.6,
169171
))
170-
# Ensure no update message gets produced
171-
assert len(w.comm.messages) == 0
172+
# Ensure one message gets produced
173+
assert len(w.comm.messages) == 1
172174

173175

174176
def test_set_state_cint_to_float():
@@ -235,6 +237,7 @@ def _propagate_value(self, change):
235237
# this mimics a value coming from the front end
236238
widget.set_state({'value': 42})
237239
assert widget.value == 42
240+
assert widget.stop is True
238241

239242
# we expect no new state to be sent
240243
calls = []
@@ -263,8 +266,96 @@ def _propagate_value(self, change):
263266
assert widget.other == 11
264267

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

269272
calls = [call42]
270273
widget._send.assert_has_calls(calls)
274+
275+
276+
277+
def test_echo():
278+
# we always echo values back to the frontend
279+
class ValueWidget(Widget):
280+
value = Float().tag(sync=True)
281+
282+
widget = ValueWidget(value=1)
283+
assert widget.value == 1
284+
285+
widget._send = mock.MagicMock()
286+
# this mimics a value coming from the front end
287+
widget.set_state({'value': 42})
288+
assert widget.value == 42
289+
290+
# we expect this to be echoed
291+
msg = {'method': 'update', 'state': {'value': 42.0}, 'buffer_paths': [], 'echo': ['value']}
292+
call42 = mock.call(msg, buffers=[])
293+
294+
calls = [call42]
295+
widget._send.assert_has_calls(calls)
296+
297+
298+
def test_echo_single():
299+
# we always echo multiple changes back in 1 update
300+
class ValueWidget(Widget):
301+
value = Float().tag(sync=True)
302+
square = Float().tag(sync=True)
303+
@observe('value')
304+
def _square(self, change):
305+
self.square = self.value**2
306+
307+
widget = ValueWidget(value=1)
308+
assert widget.value == 1
309+
310+
widget._send = mock.MagicMock()
311+
# this mimics a value coming from the front end
312+
widget._handle_msg({
313+
'content': {
314+
'data': {
315+
'method': 'update',
316+
'state': {
317+
'value': 8,
318+
}
319+
}
320+
}
321+
})
322+
assert widget.value == 8
323+
assert widget.square == 64
324+
325+
# we expect this to be echoed
326+
# note that only value is echoed, not square
327+
msg = {'method': 'update', 'state': {'square': 64, 'value': 8.0}, 'buffer_paths': [], 'echo': ['value']}
328+
call = mock.call(msg, buffers=[])
329+
330+
calls = [call]
331+
widget._send.assert_has_calls(calls)
332+
333+
334+
def test_no_echo():
335+
# in cases where values coming fromt the frontend are 'heavy', we might want to opt out
336+
class ValueWidget(Widget):
337+
value = Float().tag(sync=True, no_echo=True)
338+
339+
widget = ValueWidget(value=1)
340+
assert widget.value == 1
341+
342+
widget._send = mock.MagicMock()
343+
# this mimics a value coming from the front end
344+
widget._handle_msg({
345+
'content': {
346+
'data': {
347+
'method': 'update',
348+
'state': {
349+
'value': 42,
350+
}
351+
}
352+
}
353+
})
354+
assert widget.value == 42
355+
356+
# widget._send.assert_not_called(calls)
357+
widget._send.assert_not_called()
358+
359+
# a regular set should sync to the frontend
360+
widget.value = 43
361+
widget._send.assert_has_calls([mock.call({'method': 'update', 'state': {'value': 43.0}, 'buffer_paths': [], 'echo': ['value']}, buffers=[])])

0 commit comments

Comments
 (0)