Skip to content

Commit b01f7df

Browse files
authored
Do not broadcast anywidget echo_update acks back to clients (#10178)
Closes #10173, MO-6686 Since 0.23.12 (#9454), the kernel coerced ipywidget's `echo_update` into an ordinary update and broadcast it, feeding a client its own write back. These messages re-fired `change:` listeners for traits that did not change and also looped widgets that call `save_changes` in such a listener. The echo existed only to get frontend-driven trait changes into the server replay state (#9420). Every client write already reaches the server as a `ModelCommand`, so `SessionView` now records it into replay state directly (stripping `_esm`/`_css`, since replayed state is served to future viewers), and the echo is dropped.
1 parent 360f0b6 commit b01f7df

5 files changed

Lines changed: 235 additions & 19 deletions

File tree

marimo/_plugins/ui/_impl/comm.py

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -113,13 +113,10 @@ def _create_model_message(
113113
) -> ModelMessage | None:
114114
"""Create the appropriate ModelMessage based on the method field.
115115
116-
Returns None for unknown methods that should be skipped. `data` is
117-
the ipywidgets-shaped comm payload; `esm_spec` is minted by the
118-
comm itself, never supplied by comm callers.
119-
120-
`echo_update` is coerced to `ModelUpdate`: marimo has no echo
121-
protocol, and dropping echoes would lose frontend-driven trait
122-
changes from reconnect replay.
116+
Returns None for methods that should be skipped, including
117+
`echo_update`. `data` is the ipywidgets-shaped comm payload;
118+
`esm_spec` is minted by the comm itself, never supplied by comm
119+
callers.
123120
"""
124121
bbuffers = [_ensure_bytes(b) for b in buffers]
125122
method = data.get("method", "update")
@@ -146,14 +143,11 @@ def _create_model_message(
146143
buffers=bbuffers,
147144
)
148145
elif method == "echo_update":
149-
# Preserve frontend-driven trait changes for reconnect replay.
150-
# anywidget/ipywidgets can emit echo_update as the synchronisation
151-
# acknowledgement path; dropping it causes stale replay state.
152-
return ModelUpdate(
153-
state=state,
154-
buffer_paths=buffer_paths,
155-
buffers=bbuffers,
156-
)
146+
# ipywidgets' acknowledgement of a client write. Broadcasting
147+
# it would feed the write back into the sender's model,
148+
# re-firing change listeners. Reconnect replay records client
149+
# writes server-side instead (SessionView.add_control_request).
150+
return None
157151
else:
158152
LOGGER.warning("Unknown method: %s, skipping", method)
159153
return None

marimo/_session/state/session_view.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040
CreateNotebookCommand,
4141
ExecuteCellCommand,
4242
ExecuteCellsCommand,
43+
ModelCommand,
44+
ModelUpdateMessage,
4345
SyncGraphCommand,
4446
UpdateUIElementCommand,
4547
)
@@ -229,6 +231,37 @@ def add_control_request(self, request: CommandMessage) -> None:
229231
self._add_ui_value(object_id, value)
230232
for execution_request in request.execution_requests:
231233
self._add_last_run_code(execution_request)
234+
elif isinstance(request, ModelCommand):
235+
self._apply_model_command(request)
236+
237+
def _apply_model_command(self, request: ModelCommand) -> None:
238+
"""Merge a client's model write into replay state.
239+
240+
The kernel does not echo client writes back (see
241+
`_create_model_message` in `marimo._plugins.ui._impl.comm`), so
242+
the command itself is the only record of frontend-driven trait
243+
changes for reconnect replay.
244+
"""
245+
message = request.message
246+
if not isinstance(message, ModelUpdateMessage):
247+
# Custom messages are ephemeral — never replayed.
248+
return
249+
view = self.model_states.get(request.model_id)
250+
if view is None:
251+
return
252+
# Clients may write widget state, never code or style: replayed
253+
# state reaches future viewers. Mirrors the kernel-side filter
254+
# in MarimoCommManager.receive_comm_message.
255+
state = {
256+
k: v for k, v in message.state.items() if k not in ("_esm", "_css")
257+
}
258+
view.apply_update(
259+
ModelUpdate(
260+
state=state,
261+
buffer_paths=message.buffer_paths,
262+
buffers=request.buffers,
263+
)
264+
)
232265

233266
def add_stdin(self, stdin: str) -> None:
234267
self._touch()

tests/_plugins/ui/_impl/test_anywidget.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,55 @@ class TestWidget(_anywidget.AnyWidget):
297297
assert ui_element.value == {"value": 42}
298298
assert ui_element.widget.value == 42
299299

300+
@staticmethod
301+
async def test_client_update_not_echoed_back() -> None:
302+
"""Client write → observer update is broadcast, the write is not.
303+
304+
A frontend change runs a Python observer that regenerates a
305+
chart trait; only the observer's kernel-driven update may go
306+
back out to clients.
307+
"""
308+
from unittest.mock import patch
309+
310+
from marimo._plugins.ui._impl.anywidget.init import (
311+
WIDGET_COMM_MANAGER,
312+
)
313+
314+
class ChartWidget(_anywidget.AnyWidget):
315+
_esm = ""
316+
param = traitlets.Int(0).tag(sync=True)
317+
chart = traitlets.Unicode("").tag(sync=True)
318+
319+
@traitlets.observe("param")
320+
def _redraw(self, change) -> None:
321+
self.chart = f"<svg>{change['new']}</svg>"
322+
323+
w = anywidget(ChartWidget())
324+
model_id = WidgetModelId(w.widget._model_id)
325+
326+
with patch(
327+
"marimo._plugins.ui._impl.comm.broadcast_notification"
328+
) as mock_broadcast:
329+
WIDGET_COMM_MANAGER.receive_comm_message(
330+
ModelCommand(
331+
model_id=model_id,
332+
message=ModelUpdateMessage(
333+
state={"param": 3}, buffer_paths=[]
334+
),
335+
buffers=[],
336+
)
337+
)
338+
339+
assert w.widget.param == 3
340+
updates = [
341+
call.args[0].message.state
342+
for call in mock_broadcast.call_args_list
343+
]
344+
# The observer's chart refresh is broadcast...
345+
assert {"chart": "<svg>3</svg>"} in updates
346+
# ...the echo of the client's own write is not.
347+
assert all("param" not in state for state in updates)
348+
300349
@staticmethod
301350
async def test_buffers() -> None:
302351
class BufferWidget(_anywidget.AnyWidget):

tests/_plugins/ui/_impl/test_comm.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -168,19 +168,51 @@ def test_comm_broadcast(comm: MarimoComm):
168168
assert notification.model_id == comm.comm_id
169169

170170

171-
def test_comm_broadcast_echo_update(comm: MarimoComm):
172-
"""echo_update should still contribute to replay state."""
171+
def test_comm_drops_echo_update(comm: MarimoComm):
172+
"""echo_update (ipywidgets' ack of a client write) is never broadcast."""
173173
with patch(
174174
"marimo._plugins.ui._impl.comm.broadcast_notification"
175175
) as mock_broadcast:
176176
comm._broadcast(
177177
{"method": "echo_update", "state": {"key": "value"}},
178178
[],
179179
)
180+
mock_broadcast.assert_not_called()
181+
182+
183+
def test_client_update_is_not_echoed_back(
184+
comm_manager: MarimoCommManager, comm: MarimoComm
185+
):
186+
"""A client write must not bounce back as a live update.
187+
188+
Simulates ipywidgets' `Widget.set_state`, which acks a client
189+
update with `echo_update` before observers send genuine updates.
190+
Guarantees an echo is sent, unlike the real-stack test in
191+
test_anywidget.py, where echoing is up to ipywidgets.
192+
"""
193+
194+
def fake_set_state(msg: dict) -> None:
195+
state = msg["content"]["data"]["state"]
196+
# ipywidgets acks the client's own values first...
197+
comm.send({"method": "echo_update", "state": state})
198+
# ...then an observer reacts with a kernel-driven change.
199+
comm.send({"method": "update", "state": {"chart": "<new svg>"}})
200+
201+
comm.on_msg(fake_set_state)
202+
command = ModelCommand(
203+
model_id=comm.comm_id,
204+
message=ModelUpdateMessage(state={"slider": 5}, buffer_paths=[]),
205+
buffers=[],
206+
)
207+
with patch(
208+
"marimo._plugins.ui._impl.comm.broadcast_notification"
209+
) as mock_broadcast:
210+
comm_manager.receive_comm_message(command)
211+
# Exactly one broadcast: the observer's update. The echo of
212+
# {"slider": 5} is dropped.
180213
mock_broadcast.assert_called_once()
181214
notification = mock_broadcast.call_args[0][0]
182-
assert notification.model_id == comm.comm_id
183-
assert notification.message.state == {"key": "value"}
215+
assert notification.message.state == {"chart": "<new svg>"}
184216

185217

186218
def test_comm_manager_receive_update_message(

tests/_session/state/test_session_view.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@
4545
CreateNotebookCommand,
4646
ExecuteCellCommand,
4747
ExecuteCellsCommand,
48+
ModelCommand,
49+
ModelCustomMessage,
50+
ModelUpdateMessage,
4851
UpdateUIElementCommand,
4952
)
5053
from marimo._session.state.session_view import ModelReplayState, SessionView
@@ -351,6 +354,111 @@ def test_model_multiple_models(session_view: SessionView) -> None:
351354
assert session_view.model_states[model_id2].state == {"key": "v2"}
352355

353356

357+
def _open_model(
358+
session_view: SessionView,
359+
model_id: WidgetModelId,
360+
state: dict[str, Any],
361+
) -> None:
362+
session_view.add_notification(
363+
ModelLifecycleNotification(
364+
model_id=model_id,
365+
message=ModelOpen(state=state, buffer_paths=[], buffers=[]),
366+
)
367+
)
368+
369+
370+
def test_model_command_merges_into_replay(session_view: SessionView) -> None:
371+
"""A client's model write is recorded for reconnect replay."""
372+
model_id = WidgetModelId("test_model")
373+
_open_model(session_view, model_id, {"count": 0, "label": "hi"})
374+
375+
session_view.add_control_request(
376+
ModelCommand(
377+
model_id=model_id,
378+
message=ModelUpdateMessage(state={"count": 5}, buffer_paths=[]),
379+
buffers=[],
380+
)
381+
)
382+
assert session_view.model_states[model_id].state == {
383+
"count": 5,
384+
"label": "hi",
385+
}
386+
387+
388+
def test_model_command_merges_buffers(session_view: SessionView) -> None:
389+
model_id = WidgetModelId("test_model")
390+
_open_model(session_view, model_id, {"img": None})
391+
392+
session_view.add_control_request(
393+
ModelCommand(
394+
model_id=model_id,
395+
message=ModelUpdateMessage(
396+
state={"img": None}, buffer_paths=[["img"]]
397+
),
398+
buffers=[b"\x89PNG"],
399+
)
400+
)
401+
assert session_view.model_states[model_id].buffers == {
402+
("img",): b"\x89PNG"
403+
}
404+
405+
406+
def test_model_command_strips_code_and_style(
407+
session_view: SessionView,
408+
) -> None:
409+
"""Replayed state reaches future viewers, so a client must not
410+
be able to persist `_esm` or `_css` into it."""
411+
model_id = WidgetModelId("test_model")
412+
_open_model(session_view, model_id, {"count": 0})
413+
414+
session_view.add_control_request(
415+
ModelCommand(
416+
model_id=model_id,
417+
message=ModelUpdateMessage(
418+
state={
419+
"_esm": "alert('pwned')",
420+
"_css": "body { display: none }",
421+
"count": 2,
422+
},
423+
buffer_paths=[],
424+
),
425+
buffers=[],
426+
)
427+
)
428+
assert session_view.model_states[model_id].state == {"count": 2}
429+
430+
431+
def test_model_command_without_open_ignored(
432+
session_view: SessionView,
433+
) -> None:
434+
model_id = WidgetModelId("never_opened")
435+
session_view.add_control_request(
436+
ModelCommand(
437+
model_id=model_id,
438+
message=ModelUpdateMessage(state={"count": 1}, buffer_paths=[]),
439+
buffers=[],
440+
)
441+
)
442+
assert model_id not in session_view.model_states
443+
444+
445+
def test_model_command_custom_message_ignored(
446+
session_view: SessionView,
447+
) -> None:
448+
"""Custom messages are ephemeral — they never mutate replay state."""
449+
model_id = WidgetModelId("test_model")
450+
_open_model(session_view, model_id, {"count": 0})
451+
452+
session_view.add_control_request(
453+
ModelCommand(
454+
model_id=model_id,
455+
message=ModelCustomMessage(content={"foo": "bar"}),
456+
buffers=[],
457+
)
458+
)
459+
assert session_view.model_states[model_id].state == {"count": 0}
460+
461+
354462
def test_get_model_notifications(session_view: SessionView) -> None:
355463
# Empty initially
356464
assert session_view.get_model_notifications() == []

0 commit comments

Comments
 (0)