diff --git a/docs/redesign/callbacks.md b/docs/redesign/callbacks.md index 18f6b6d6..3a67f2b9 100644 --- a/docs/redesign/callbacks.md +++ b/docs/redesign/callbacks.md @@ -33,6 +33,12 @@ the full reducer migration: calls `set_props("scene-tabs", ...)`. - `sync_agent_state` still updates control props on tab switches and agent polls, but it no longer writes the tab DOM. +- A state poll whose browser has switched scenes, or whose interaction + generation is older than an edit already noted for that browser, does + not write controls or `agent-state-store`. The browser also drops a + poll response that arrives after a tab switch or while that edit's + callback is still unacknowledged. The next poll carries the new + generation. Part of #228. This is not the final operation reducer, but it establishes the invariant that the visible scene list has one writer and one source of truth. diff --git a/frontend/assets/poll_fence.js b/frontend/assets/poll_fence.js new file mode 100644 index 00000000..90ce1357 --- /dev/null +++ b/frontend/assets/poll_fence.js @@ -0,0 +1,289 @@ +/* Drop a state-poll response that would land on top of a newer edit or tab. + +``sync_agent_state`` preserves sidebar drafts it can see in the request, and +it refuses a poll whose browser has already recorded a newer scene or +generation. This script covers the remaining window: the poll request left +the browser, the user then switched tabs or edited, and the response +arrives afterwards. A poll sent while that edit's callback is still +unacknowledged is dropped too, because the server may have answered from +the pre-edit snapshot. Dash would apply that response directly, including +``agent-state-store`` (groups, transforms, polyhedra, selection) and the +shared sidebar. Rewriting the payload to an empty update leaves the newer +interaction in place. The next poll carries the new generation. + +A tab click or upload batched into the same request is that interaction's +own sync and must still apply. +*/ +(function (root) { + var MODIFIERS = {Shift: 1, Control: 1, Alt: 1, Meta: 1}; + + var UNACKED_RELEASE_MS = 800; + + function liveFence() { + return { + epoch: Number(root.__mvUiEpoch || 0), + scene: root.__mvSelectedSceneId ? String(root.__mvSelectedSceneId) : "", + unacked: Number(root.__mvUnackedEpoch || 0), + }; + } + + function sceneIdFromNode(node) { + if (!node || !node.closest) return ""; + var select = node.closest(".tab-select"); + if (select && select.id) { + try { + var parsed = JSON.parse(select.id); + if (parsed && parsed.scene_id) return String(parsed.scene_id); + } catch (error) { + /* Pattern ids are JSON; a plain id is handled below. */ + } + } + var tab = node.closest("[id^='scene-tab-']"); + if (!tab || !tab.id || tab.id === "scene-tabs") return ""; + if (tab.id.indexOf("scene-tab-close-") === 0) return ""; + if (tab.id.indexOf("scene-tab-label-") === 0) { + return tab.id.slice("scene-tab-label-".length); + } + return tab.id.slice("scene-tab-".length); + } + + function armUnackedRelease(epoch) { + if (typeof root.setTimeout !== "function") return; + root.setTimeout(function () { + if (Number(root.__mvUnackedEpoch || 0) !== Number(epoch || 0)) return; + if (Number(root.__mvUserWrites || 0) > 0) return; + root.__mvUnackedEpoch = 0; + }, UNACKED_RELEASE_MS); + } + + function finishUserWrite(startedEpoch) { + root.__mvUserWrites = Math.max(0, Number(root.__mvUserWrites || 1) - 1); + if (Number(root.__mvUserWrites || 0) > 0) return; + if (Number(root.__mvUiEpoch || 0) !== Number(startedEpoch || 0)) return; + root.__mvUnackedEpoch = 0; + } + + function noteInteraction(sceneId) { + root.__mvUiEpoch = Number(root.__mvUiEpoch || 0) + 1; + if (sceneId) root.__mvSelectedSceneId = String(sceneId); + root.__mvUnackedEpoch = root.__mvUiEpoch; + armUnackedRelease(root.__mvUnackedEpoch); + if (root.__mvFenceFlush) return; + root.__mvFenceFlush = true; + var publish = function () { + root.__mvFenceFlush = false; + var clientside = root.dash_clientside; + if (!clientside || typeof clientside.set_props !== "function") return; + if (!root.__mvClientId) { + root.__mvClientId = "mv-" + Math.random().toString(36).slice(2, 12); + clientside.set_props("ui-client-id", {data: root.__mvClientId}); + } + clientside.set_props("ui-interaction-epoch", {data: Number(root.__mvUiEpoch || 0)}); + }; + if (typeof root.Promise === "function") root.Promise.resolve().then(publish); + else publish(); + } + + function isAgentStatePoll(body) { + if (!body || typeof body !== "object") return false; + var changed = body.changedPropIds || []; + var names = []; + var index; + for (index = 0; index < changed.length; index += 1) { + names.push(String(changed[index]).split(".")[0]); + } + if (names.indexOf("agent-state-poll") === -1) return false; + if (names.indexOf("scene-tabs") !== -1) return false; + if (names.indexOf("native-upload-sync") !== -1) return false; + if (names.indexOf("display-defaults-btn") !== -1) return false; + var rendered = JSON.stringify(body.output == null ? "" : body.output); + return rendered.indexOf("agent-state-store") !== -1 + && rendered.indexOf("scene-tab-rename-input") !== -1; + } + + function isBackgroundTick(body) { + if (!body || typeof body !== "object") return false; + var changed = body.changedPropIds || []; + if (!changed.length) return false; + var prefixes = ["agent-state-poll.", "perf-log-poll.", "status-dismiss-timer."]; + var index; + var prop; + var prefix; + var matched; + for (index = 0; index < changed.length; index += 1) { + prop = String(changed[index]); + matched = false; + for (prefix = 0; prefix < prefixes.length; prefix += 1) { + if (prop.indexOf(prefixes[prefix]) === 0) { + matched = true; + break; + } + } + if (!matched) return false; + } + return true; + } + + function isUserWrite(bodyText) { + if (typeof bodyText !== "string" || !bodyText) return false; + try { + return !isBackgroundTick(JSON.parse(bodyText)); + } catch (error) { + return false; + } + } + + function requestFence(bodyText, live) { + if (typeof bodyText !== "string" || !bodyText) return null; + var body; + try { + body = JSON.parse(bodyText); + } catch (error) { + return null; + } + if (!isAgentStatePoll(body)) return null; + var snapshot = live || liveFence(); + return { + epoch: Number(snapshot.epoch || 0), + scene: snapshot.scene ? String(snapshot.scene) : "", + unacked: Number(snapshot.unacked || 0), + }; + } + + function responseIsStale(tracked, live) { + if (!tracked) return false; + var now = live || liveFence(); + // Sent while an edit or tab callback was still open, or one opened + // before this response could be applied. + if (Number(tracked.unacked || 0) > 0) return true; + if (Number(now.unacked || 0) > 0) return true; + if (Number(now.epoch || 0) !== Number(tracked.epoch || 0)) return true; + var thenScene = tracked.scene ? String(tracked.scene) : ""; + var nowScene = now.scene ? String(now.scene) : ""; + return Boolean(thenScene && nowScene && thenScene !== nowScene); + } + + function blankPollPayload(payload) { + if (!payload || typeof payload !== "object") return payload; + var copy = {}; + var key; + for (key in payload) { + if (Object.prototype.hasOwnProperty.call(payload, key)) copy[key] = payload[key]; + } + copy.response = {}; + if (copy.sideUpdate && typeof copy.sideUpdate === "object") copy.sideUpdate = {}; + return copy; + } + + function install() { + if (root.__mvPollFenceInstalled) return; + root.__mvPollFenceInstalled = true; + var documentRef = root.document; + if (!documentRef || typeof documentRef.addEventListener !== "function") return; + + documentRef.addEventListener("pointerdown", function (event) { + if (!event || event.isTrusted === false) return; + var node = event.target; + if (!node || !node.closest) return; + if (node.closest("#rightclick-target-fallback")) return; + noteInteraction(sceneIdFromNode(node)); + }, true); + + documentRef.addEventListener("keydown", function (event) { + if (!event || event.isTrusted === false || MODIFIERS[event.key]) return; + var node = event.target; + if (!node || !node.closest) return; + if (!node.closest("input, textarea, select")) return; + if (node.id === "rightclick-target-fallback") return; + noteInteraction(null); + }, true); + + function publishIdentity() { + var clientside = root.dash_clientside; + if (!clientside || typeof clientside.set_props !== "function") return false; + if (!root.__mvClientId) { + root.__mvClientId = "mv-" + Math.random().toString(36).slice(2, 12); + } + clientside.set_props("ui-client-id", {data: root.__mvClientId}); + clientside.set_props("ui-interaction-epoch", {data: Number(root.__mvUiEpoch || 0)}); + return true; + } + if (!publishIdentity() && typeof root.setTimeout === "function") { + var attempts = 0; + var timer = root.setInterval(function () { + attempts += 1; + if (publishIdentity() || attempts > 40) root.clearInterval(timer); + }, 250); + } + + if (typeof root.fetch !== "function" || root.fetch.__mvPollFence) return; + var originalFetch = root.fetch; + var wrapped = function (input, init) { + var url = typeof input === "string" ? input : (input && input.url) || ""; + var bodyText = init && typeof init.body === "string" ? init.body : ""; + var tracked = null; + var userWrite = false; + var userWriteEpoch = 0; + if (String(url).indexOf("_dash-update-component") !== -1) { + var live = liveFence(); + tracked = requestFence(bodyText, live); + if (!tracked && isUserWrite(bodyText)) { + userWrite = true; + userWriteEpoch = Number(live.epoch || 0); + root.__mvUserWrites = Number(root.__mvUserWrites || 0) + 1; + } + } + var pending = originalFetch.apply(this, arguments); + // Epoch 0 is the real first generation. A truthiness check would + // count that callback and never release it, and every later edit + // would leave the fence shut. State polls are what copy a server + // projection onto the radio and refresh the controls stamp, so a + // stuck fence drops those payloads for the rest of the page. + if (userWrite && pending && typeof pending.then === "function") { + var startedEpoch = userWriteEpoch; + pending = pending.then(function (response) { + finishUserWrite(startedEpoch); + return response; + }, function (error) { + finishUserWrite(startedEpoch); + throw error; + }); + } else if (userWrite) { + finishUserWrite(userWriteEpoch); + } + if (!tracked || !pending || typeof pending.then !== "function") return pending; + return pending.then(function (response) { + if (!response || typeof response.clone !== "function") return response; + return response.clone().json().then(function (payload) { + if (!responseIsStale(tracked, liveFence())) return response; + var headers = new root.Headers(response.headers); + headers.delete("content-length"); + headers.delete("content-encoding"); + return new root.Response(JSON.stringify(blankPollPayload(payload)), { + status: response.status, + statusText: response.statusText, + headers: headers, + }); + }, function () { + return response; + }); + }); + }; + wrapped.__mvPollFence = true; + root.fetch = wrapped; + } + + var api = { + noteInteraction: noteInteraction, + sceneIdFromNode: sceneIdFromNode, + isAgentStatePoll: isAgentStatePoll, + requestFence: requestFence, + responseIsStale: responseIsStale, + blankPollPayload: blankPollPayload, + isUserWrite: isUserWrite, + finishUserWrite: finishUserWrite, + install: install, + }; + root.MatterVisPollFence = api; + install(); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/mat_viewer/app/callbacks_state.py b/mat_viewer/app/callbacks_state.py index 5fe2b22a..603b74de 100644 --- a/mat_viewer/app/callbacks_state.py +++ b/mat_viewer/app/callbacks_state.py @@ -28,6 +28,11 @@ _stamp_label_matches, _stamp_scene_matches, _stamp_scene_or_camera_differs, + claim_browser_scene, + claim_poll_publish, + note_interaction_epoch, + poll_request_is_stale, + split_poll_fence, ) @@ -54,6 +59,20 @@ def _drop_acknowledged_pending(backend, state) -> None: backend.pending_state = None +def _restore_pending_if_absent(backend, state) -> None: + """Put a snapshot back when a stale poll had already taken it. + + A tab switch or a newer edit can land after the poll passed its first + check and popped the queue. A snapshot that arrived in that window + stays; only an empty queue is filled. + """ + if not isinstance(state, dict): + return + with backend._lock: + if backend.pending_state is None: + backend.pending_state = state + + def checklist_after_reconcile(mode, reconciled, incoming): """Checklist value after display options are reconciled. @@ -66,6 +85,19 @@ def checklist_after_reconcile(mode, reconciled, incoming): return visible +def interaction_fence_children(): + """Hidden stores for one browser's poll fence. + + The epoch and client id travel with sidebar captures. The sink is the + acknowledgement output; it is not shown. + """ + return ( + dcc.Store(id="ui-interaction-epoch", data=0), + dcc.Store(id="ui-client-id", data=None), + html.Div(id="ui-interaction-sink", style={"display": "none"}), + ) + + def register_state_callbacks(app, backend): # Widgets are filled for one scene at a time. A tab change updates # ``scene-tabs`` before those widgets, and a capture that runs in @@ -472,16 +504,22 @@ def mirror_legacy_status(message): State("property-center-input", "value"), State("property-nan-color-input", "value"), State("property-colorbar-toggle", "value"), + State("ui-interaction-epoch", "data"), + State("ui-client-id", "data"), prevent_initial_call=True, ) def sync_agent_state(_n_intervals, _native_upload_sync, *args): # Keep the callback callable with the historical signatures for # direct integrations/tests while Dash supplies the optional Restore # Defaults click, the browser control acknowledgement, the rename - # box, and the current sidebar values. + # box, the current sidebar values, and the per-browser interaction + # fence (epoch + client id) after those sidebar values. controls_scene_id = None rename_value = None client_controls: tuple[Any, ...] = () + interaction_epoch = None + client_id = None + fence_supplied = False if len(args) == 1: _display_defaults_clicks = None scene_id = args[0] @@ -497,6 +535,12 @@ def sync_agent_state(_n_intervals, _native_upload_sync, *args): rename_value, *client_controls, ) = args + ( + client_controls, + interaction_epoch, + client_id, + fence_supplied, + ) = split_poll_fence(client_controls) else: raise TypeError( "sync_agent_state expects (interval, upload, scene_id) or " @@ -505,6 +549,12 @@ def sync_agent_state(_n_intervals, _native_upload_sync, *args): ) triggered = _preferred_sync_trigger(getattr(callback_context, "triggered", None)) n_outputs = 29 + if triggered == "agent-state-poll" and poll_request_is_stale( + client_id, scene_id, interaction_epoch, supplied=fence_supplied + ): + # The browser already selected another scene or edited. Do not + # consume a snapshot that a later, current poll still needs. + return (no_update,) * n_outputs if triggered == "display-defaults-btn": if not scene_id or scene_id not in backend.scene_store.scenes: return (no_update,) * n_outputs @@ -514,6 +564,11 @@ def sync_agent_state(_n_intervals, _native_upload_sync, *args): if triggered == "scene-tabs": if not scene_id or scene_id not in backend.scene_store.scenes: return (no_update,) * n_outputs + # Record the selected scene before the refit. A poll already in + # flight for the previous scene must not publish over this one. + claim_browser_scene( + client_id, scene_id, filled_controls_scene, interaction_epoch + ) # The tab value is already the browser's confirmed scene intent. # Do not arm a new ``pending_state`` here, and drop a snapshot # this response already delivers. A later poll would otherwise @@ -531,7 +586,6 @@ def sync_agent_state(_n_intervals, _native_upload_sync, *args): # The browser acknowledges this response by returning its stamp. # A discarded response is replayed from live state on the poll. _drop_acknowledged_pending(backend, state) - filled_controls_scene["id"] = str(state.get("scene_id") or scene_id) # The confirmed state drives the view-update submitter. Do not # bypass the cache owner's live camera/uirevision refresh here. seq = getattr(sync_agent_state, "_switch_seq", 0) + 1 @@ -577,6 +631,10 @@ def sync_agent_state(_n_intervals, _native_upload_sync, *args): and str(peeked.get("scene_id")) != str(scene_id) ): return (no_update,) * n_outputs + if triggered == "agent-state-poll" and poll_request_is_stale( + client_id, scene_id, interaction_epoch, supplied=fence_supplied + ): + return (no_update,) * n_outputs state = backend.pop_pending_state() # A poll can race a tab switch or an upload. ``pending_state`` is # tagged with the scene that produced it; never apply that snapshot @@ -611,7 +669,29 @@ def sync_agent_state(_n_intervals, _native_upload_sync, *args): state = backend.get_state() if not state: return (no_update,) * n_outputs - filled_controls_scene["id"] = str(state.get("scene_id") or "") + if triggered == "native-upload-sync": + claim_browser_scene( + client_id, + state.get("scene_id"), + filled_controls_scene, + interaction_epoch, + ) + elif triggered == "agent-state-poll": + # Re-check under the same lock the tab callback uses. A switch + # that landed while this poll was reading state must not point + # the next sidebar capture at the scene the browser just left, + # and must not write that scene's controls or agent-state-store. + if not claim_poll_publish( + client_id, + state.get("scene_id") or scene_id, + interaction_epoch, + filled_controls_scene, + supplied=fence_supplied, + ): + _restore_pending_if_absent(backend, state) + return (no_update,) * n_outputs + else: + filled_controls_scene["id"] = str(state.get("scene_id") or "") # Defence-in-depth against the camera-snap-back bug: even when # the poll path legitimately picks up an externally-driven # state change (REST agent, WebSocket, scene CRUD), do NOT push @@ -654,6 +734,23 @@ def sync_agent_state(_n_intervals, _native_upload_sync, *args): outputs[-1] = no_update # camera-state-store slot return (*outputs, no_update) # scene-switch-seq unchanged + @app.callback( + Output("ui-interaction-sink", "children"), + Input("ui-interaction-epoch", "data"), + State("ui-client-id", "data"), + prevent_initial_call=True, + ) + def remember_interaction_epoch(epoch, browser_id): + """Record an edit generation that has not reached ``capture_state`` yet. + + Sliders, transform rows, and polyhedron fields bump the epoch in the + browser before their commit callback runs. A poll already on the + server has to see that generation or it will write the previous + ``agent-state-store`` over the edit. + """ + note_interaction_epoch(browser_id, epoch) + return no_update + @app.callback( Output("agent-state-store", "data", allow_duplicate=True), Output("display-options", "value", allow_duplicate=True), @@ -682,6 +779,8 @@ def sync_agent_state(_n_intervals, _native_upload_sync, *args): Input("property-nan-color-input", "value"), Input("property-colorbar-toggle", "value"), State("scene-controls-scene", "children"), + State("ui-interaction-epoch", "data"), + State("ui-client-id", "data"), prevent_initial_call=True, ) def capture_state( @@ -709,7 +808,14 @@ def capture_state( property_nan_color, property_colorbar, controls_scene_id=None, + interaction_epoch=None, + client_id=None, ): + # A keystroke can still be uncommitted when a poll that started + # earlier finishes. Recording the generation here lets that poll + # drop its write instead of putting the previous sidebar, groups, + # or transforms back into ``agent-state-store``. + note_interaction_epoch(client_id, interaction_epoch) triggered_props = [ item.get("prop_id", "").split(".")[0] for item in callback_context.triggered diff --git a/mat_viewer/app/factory.py b/mat_viewer/app/factory.py index 68168b8f..d0a81a02 100644 --- a/mat_viewer/app/factory.py +++ b/mat_viewer/app/factory.py @@ -13,7 +13,7 @@ from .callbacks_analysis import register_analysis_callbacks from .callbacks_operations import register_operations_callbacks from .callbacks_disorder import register_disorder_callbacks -from .callbacks_state import register_state_callbacks, _scene_controls_stamp +from .callbacks_state import interaction_fence_children, register_state_callbacks, _scene_controls_stamp from .callbacks_view import register_view_callbacks from .backend import ViewerBackend from .layout_left_panel import build_left_panel @@ -241,6 +241,7 @@ def _serve_layout(): dcc.Store(id="extension-browser-target", data=structure_host.new_target()), dcc.Store(id="extension-scene-observed"), dcc.Store(id="scene-switch-seq", data={"seq": 0, "scene_id": None}), + *interaction_fence_children(), dcc.Store( id="graph-interaction-store", data={"active": False, "ts": 0} ), @@ -337,6 +338,7 @@ def _serve_layout_inner(): dcc.Store(id="extension-browser-target", data=structure_host.new_target()), dcc.Store(id="extension-scene-observed"), dcc.Store(id="scene-switch-seq", data={"seq": 0, "scene_id": None}), + *interaction_fence_children(), dcc.Store( id="graph-interaction-store", data={"active": False, "ts": 0} ), diff --git a/mat_viewer/app/scene_controls.py b/mat_viewer/app/scene_controls.py index 16330ea4..f589296c 100644 --- a/mat_viewer/app/scene_controls.py +++ b/mat_viewer/app/scene_controls.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import threading from dash import no_update @@ -191,6 +192,109 @@ def _control_values_match(client, server) -> bool: return client == server +# Sidebar values Dash appends before the interaction fence. A direct test +# call stops at the sidebar; the browser also sends the epoch and client id. +_POLL_FENCE_EXTRA = 2 + +# Per browser, not per process. Two pages can sit on different scenes; +# one page's tab switch must not silence the other's poll. +_fence_lock = threading.Lock() +_browser_scenes: dict[str, str] = {} +_browser_epochs: dict[str, int] = {} + + +def split_poll_fence(client_controls): + """Split ``(sidebar..., epoch, client_id)`` from a historical sidebar tuple. + + The fence is present only when every sidebar state was sent and the two + trailing states follow it. Shorter direct calls keep the previous contract. + """ + controls = tuple(client_controls or ()) + if len(controls) != len(_POLL_CLIENT_CONTROL_INDEX) + _POLL_FENCE_EXTRA: + return controls, None, None, False + *sidebar, epoch, client_id = controls + return tuple(sidebar), epoch, client_id, True + + +def note_interaction_epoch(client_id, epoch) -> None: + """Remember the newest interaction generation for one browser.""" + if not client_id or epoch is None: + return + try: + observed = int(epoch) + except (TypeError, ValueError): + return + with _fence_lock: + key = str(client_id) + if observed > _browser_epochs.get(key, 0): + _browser_epochs[key] = observed + + +def claim_browser_scene(client_id, scene_id, filled: dict, epoch=None) -> None: + """Record the scene a browser just selected and point capture at it. + + The scene note and the filled-scene guard move together. A poll that + still names the previous scene cannot publish between those two writes. + """ + delivered = str(scene_id or "") + with _fence_lock: + if client_id and delivered: + _browser_scenes[str(client_id)] = delivered + if epoch is not None and client_id: + try: + observed = int(epoch) + except (TypeError, ValueError): + observed = None + if ( + observed is not None + and observed > _browser_epochs.get(str(client_id), 0) + ): + _browser_epochs[str(client_id)] = observed + filled["id"] = delivered + + +def poll_request_is_stale(client_id, scene_id, epoch, *, supplied: bool) -> bool: + """Read-only check used before a poll consumes a queued snapshot.""" + if not supplied or not client_id: + return False + with _fence_lock: + return _poll_is_stale_locked(str(client_id), scene_id, epoch) + + +def claim_poll_publish( + client_id, scene_id, epoch, filled: dict, *, supplied: bool +) -> bool: + """Publish a poll only for the scene and generation that sent it. + + Unfenced callers (direct tests and the first paint) keep the historical + write. A fenced poll whose browser has since switched scenes or edited + returns without moving the filled-scene guard. + """ + delivered = str(scene_id or "") + with _fence_lock: + if supplied and client_id: + key = str(client_id) + if _poll_is_stale_locked(key, scene_id, epoch): + return False + try: + _browser_epochs[key] = int(0 if epoch is None else epoch) + except (TypeError, ValueError): + return False + filled["id"] = delivered + return True + + +def _poll_is_stale_locked(client_id: str, scene_id, epoch) -> bool: + recorded_scene = _browser_scenes.get(client_id) + if recorded_scene is not None and recorded_scene != str(scene_id or ""): + return True + try: + observed = int(0 if epoch is None else epoch) + except (TypeError, ValueError): + return True + return observed < _browser_epochs.get(client_id, 0) + + def _preserve_inflight_controls(outputs, client_controls, stamp, state) -> None: parsed = _parse_controls_stamp(stamp) or {} acknowledged = parsed.get("controls") diff --git a/tests/app/test_poll_interaction_fence.py b/tests/app/test_poll_interaction_fence.py new file mode 100644 index 00000000..c255d1c1 --- /dev/null +++ b/tests/app/test_poll_interaction_fence.py @@ -0,0 +1,509 @@ +"""A state poll must not outrun a tab switch or an edit it did not see. + +Sidebar drafts that are already in the poll request stay put (#229). The +cases here are the ones that request cannot see: the browser has since +selected another scene, or it has bumped its interaction generation. The +poll then has to leave controls, ``agent-state-store``, and the filled-scene +guard alone. A different browser's poll is unaffected. +""" +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import pytest +from dash import no_update + +from mat_viewer.app import ViewerBackend, callbacks_state +from mat_viewer.app.scene_controls import ( + _POLL_CLIENT_CONTROL_INDEX, + _scene_control_values, +) + + +class _CallbackRegistry: + def __init__(self): + self.callbacks = {} + + def callback(self, *args, **kwargs): + def register(fn): + self.callbacks[fn.__name__] = fn + return fn + + return register + + +def _sidebar(state): + values = _scene_control_values(state) + return [values[index] for index in _POLL_CLIENT_CONTROL_INDEX] + + +def _trigger(monkeypatch, prop_id): + monkeypatch.setattr( + callbacks_state, + "callback_context", + SimpleNamespace(triggered=[{"prop_id": prop_id}]), + ) + + +def _sync(tmp_path, monkeypatch): + backend = ViewerBackend( + preset_path=str(tmp_path / "preset.json"), root_dir=str(tmp_path) + ) + registry = _CallbackRegistry() + callbacks_state.register_state_callbacks(registry, backend) + return backend, registry.callbacks["sync_agent_state"] + + +def test_poll_for_the_scene_just_left_does_not_publish(tmp_path, monkeypatch): + backend, sync = _sync(tmp_path, monkeypatch) + try: + first = backend.active_scene_id() + second = backend.create_scene( + structure=backend.get_state()["structure"], label="Second scene" + )["id"] + backend.pop_pending_state() + sidebar = _sidebar(backend.get_state(first)) + _trigger(monkeypatch, "scene-tabs.value") + switched = sync( + None, None, None, second, None, None, *sidebar, 1, "browser-a" + ) + assert switched[-1]["scene_id"] == second + assert backend.active_scene_id() == second + + # The page has left ``first``, but that scene is still the one an + # external edit can broadcast. Pending is only queued for the active + # scene, so point the server back without changing the browser note. + backend.set_active_scene(first, broadcast=False) + backend.patch_state({"axis_scale": 0.2}, scene_id=first, broadcast=True) + assert backend.peek_pending_state()["scene_id"] == first + _trigger(monkeypatch, "agent-state-poll.n_intervals") + stale = sync(1, None, None, first, None, None, *sidebar, 1, "browser-a") + assert all(value is no_update for value in stale) + assert backend.peek_pending_state()["scene_id"] == first + + # The other page is still on the first scene. Its poll publishes. + other = _sidebar(backend.get_state(first)) + published = sync( + 2, None, None, first, None, None, *other, 0, "browser-b" + ) + assert published[10] == 0.2 + assert published[-3]["scene_id"] == first + assert published[-2] is no_update + finally: + backend.close() + + +def test_older_edit_generation_does_not_rewrite_state(tmp_path, monkeypatch): + backend, sync = _sync(tmp_path, monkeypatch) + try: + scene_id = backend.active_scene_id() + backend.pop_pending_state() + backend.patch_state({"axis_scale": 1.4}, broadcast=True) + state = backend.get_state(scene_id) + stamp = callbacks_state._scene_controls_stamp(state) + sidebar = _sidebar(state) + sidebar[0] = "cluster" + registry_epoch = callbacks_state.register_state_callbacks + # ``register_state_callbacks`` already ran; call the recorder directly. + remember = None + probe = _CallbackRegistry() + registry_epoch(probe, backend) + remember = probe.callbacks["remember_interaction_epoch"] + assert remember(5, "browser-edit") is no_update + + _trigger(monkeypatch, "agent-state-poll.n_intervals") + stale = sync( + 1, None, None, scene_id, stamp, None, *sidebar, 4, "browser-edit" + ) + assert all(value is no_update for value in stale) + assert backend.get_state(scene_id)["display_mode"] != "cluster" + + current = sync( + 2, None, None, scene_id, stamp, None, *sidebar, 5, "browser-edit" + ) + assert current[1] is no_update + assert current[10] == 1.4 + assert current[-3]["axis_scale"] == 1.4 + assert current[-3]["display_mode"] != "cluster" + assert current[-2] is no_update + finally: + backend.close() + + +def test_capture_records_an_edit_ahead_of_an_older_poll(tmp_path, monkeypatch): + backend, sync = _sync(tmp_path, monkeypatch) + try: + scene_id = backend.active_scene_id() + backend.pop_pending_state() + state = backend.get_state(scene_id) + sidebar = _sidebar(state) + stamp = callbacks_state._scene_controls_stamp(state) + probe = _CallbackRegistry() + callbacks_state.register_state_callbacks(probe, backend) + _trigger(monkeypatch, "axis-scale-slider.value") + probe.callbacks["capture_state"]( + scene_id, + *sidebar, + stamp, + interaction_epoch=3, + client_id="browser-capture", + ) + _trigger(monkeypatch, "agent-state-poll.n_intervals") + stale = sync( + 1, None, None, scene_id, stamp, None, *sidebar, 2, "browser-capture" + ) + assert all(value is no_update for value in stale) + finally: + backend.close() + + +def test_stale_poll_restores_pending_it_already_took(tmp_path, monkeypatch): + backend, sync = _sync(tmp_path, monkeypatch) + try: + scene_id = backend.active_scene_id() + backend.pop_pending_state() + backend.patch_state({"axis_scale": 0.7}, broadcast=True) + sidebar = _sidebar(backend.get_state(scene_id)) + real_claim = callbacks_state.claim_poll_publish + + def claim_after_a_newer_edit(client_id, scene_id, epoch, filled, *, supplied): + callbacks_state.note_interaction_epoch(client_id, 9) + return real_claim( + client_id, scene_id, epoch, filled, supplied=supplied + ) + + monkeypatch.setattr( + callbacks_state, "claim_poll_publish", claim_after_a_newer_edit + ) + _trigger(monkeypatch, "agent-state-poll.n_intervals") + stale = sync( + 1, None, None, scene_id, None, None, *sidebar, 1, "browser-restore" + ) + assert all(value is no_update for value in stale) + assert backend.peek_pending_state()["axis_scale"] == 0.7 + + newer = {"scene_id": scene_id, "axis_scale": 0.9} + with backend._lock: + backend.pending_state = dict(newer) + callbacks_state._restore_pending_if_absent( + backend, {"scene_id": scene_id, "axis_scale": 0.1} + ) + assert backend.peek_pending_state()["axis_scale"] == 0.9 + finally: + backend.close() + + +def test_poll_fence_drops_a_response_after_the_browser_moves(): + node = shutil.which("node") + if node is None: + pytest.skip("Node.js is needed to execute the frontend regression") + + script = Path(__file__).resolve().parents[2] / "frontend" / "assets" / "poll_fence.js" + program = r""" +const fs = require("node:fs"); +const vm = require("node:vm"); +const assert = require("node:assert/strict"); +const document = {addEventListener() {}, readyState: "complete"}; +const payload = { + multi: true, + response: {"agent-state-store": {data: {scene_id: "scene-a"}}}, + sideUpdate: {"scene-tab-rename-input": {value: "old"}}, +}; +const sandbox = { + console, JSON, Math, Number, Object, String, Boolean, Array, Promise, + document, + setTimeout() {}, + setInterval() { return 0; }, + clearInterval() {}, + Headers: function () { this.delete = function () {}; }, + Response: function (body) { this.body = body; }, + __mvUiEpoch: 2, + __mvSelectedSceneId: "scene-a", +}; +sandbox.window = sandbox; +sandbox.fetch = function () { + return Promise.resolve({ + status: 200, + statusText: "OK", + headers: {}, + clone() { return this; }, + json() { + sandbox.__mvUiEpoch = 5; + return Promise.resolve(JSON.parse(JSON.stringify(payload))); + }, + }); +}; +vm.createContext(sandbox); +vm.runInContext(fs.readFileSync(process.argv[1], "utf8"), sandbox); +const fence = sandbox.MatterVisPollFence; +const pollBody = JSON.stringify({ + output: [ + {id: "scene-tab-rename-input", property: "value"}, + {id: "agent-state-store", property: "data"}, + ], + changedPropIds: ["agent-state-poll.n_intervals"], +}); +const tracked = fence.requestFence(pollBody, {epoch: 2, scene: "scene-a"}); +assert.equal( + JSON.stringify(tracked), + JSON.stringify({epoch: 2, scene: "scene-a", unacked: 0}) +); +assert.equal(fence.requestFence(JSON.stringify({ + output: "agent-state-store scene-tab-rename-input", + changedPropIds: ["agent-state-poll.n_intervals", "scene-tabs.value"], +}), {epoch: 2, scene: "scene-a"}), null); +assert.equal(fence.responseIsStale(tracked, {epoch: 2, scene: "scene-a"}), false); +assert.equal(fence.responseIsStale(tracked, {epoch: 3, scene: "scene-a"}), true); +assert.equal(fence.responseIsStale(tracked, {epoch: 2, scene: "scene-b"}), true); +const blank = fence.blankPollPayload(payload); +assert.equal(JSON.stringify(blank.response), "{}"); +assert.equal(JSON.stringify(blank.sideUpdate), "{}"); +assert.equal(payload.response["agent-state-store"].data.scene_id, "scene-a"); +sandbox.fetch("/_dash-update-component", {body: pollBody}).then(function (response) { + const dropped = JSON.parse(response.body); + assert.equal(JSON.stringify(dropped.response), "{}"); + process.exit(0); +}).catch(function (error) { + console.error(error); + process.exit(1); +}); +setTimeout(function () { + console.error("poll fence did not drop the late response"); + process.exit(1); +}, 1000); +""" + completed = subprocess.run( + ["node", "-e", program, str(script)], + check=False, + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stderr + completed.stdout + + +def test_poll_fence_drops_a_response_while_an_edit_is_unacked(): + node = shutil.which("node") + if node is None: + pytest.skip("Node.js is needed to execute the frontend regression") + + script = Path(__file__).resolve().parents[2] / "frontend" / "assets" / "poll_fence.js" + program = r""" +const fs = require("node:fs"); +const vm = require("node:vm"); +const assert = require("node:assert/strict"); +const document = {addEventListener() {}, readyState: "complete"}; +const payload = { + multi: true, + response: {"agent-state-store": {data: {scene_id: "scene-a", axis_scale: 1}}}, +}; +const sandbox = { + console, JSON, Math, Number, Object, String, Boolean, Array, Promise, + document, + setTimeout() {}, + setInterval() { return 0; }, + clearInterval() {}, + Headers: function () { this.delete = function () {}; }, + Response: function (body) { this.body = body; }, + __mvUiEpoch: 4, + __mvSelectedSceneId: "scene-a", + __mvUnackedEpoch: 4, + __mvUserWrites: 0, +}; +sandbox.window = sandbox; +sandbox.fetch = function () { + return Promise.resolve({ + status: 200, + statusText: "OK", + headers: {}, + clone() { return this; }, + json() { return Promise.resolve(JSON.parse(JSON.stringify(payload))); }, + body: JSON.stringify(payload), + }); +}; +vm.createContext(sandbox); +vm.runInContext(fs.readFileSync(process.argv[1], "utf8"), sandbox); +const pollBody = JSON.stringify({ + output: [ + {id: "scene-tab-rename-input", property: "value"}, + {id: "agent-state-store", property: "data"}, + ], + changedPropIds: ["agent-state-poll.n_intervals"], +}); +const editBody = JSON.stringify({ + output: "agent-state-store.data", + changedPropIds: ["axis-scale-slider.value"], +}); +sandbox.fetch("/_dash-update-component", {body: pollBody}).then(function (response) { + const dropped = JSON.parse(response.body); + assert.equal(JSON.stringify(dropped.response), "{}"); + return sandbox.fetch("/_dash-update-component", {body: editBody}); +}).then(function () { + assert.equal(sandbox.__mvUnackedEpoch, 0); + assert.equal(sandbox.__mvUserWrites, 0); + return sandbox.fetch("/_dash-update-component", {body: pollBody}); +}).then(function (response) { + const kept = JSON.parse(response.body); + assert.equal(kept.response["agent-state-store"].data.axis_scale, 1); + process.exit(0); +}).catch(function (error) { + console.error(error); + process.exit(1); +}); +setTimeout(function () { + console.error("poll fence did not settle the unacked edit"); + process.exit(1); +}, 1000); +""" + completed = subprocess.run( + ["node", "-e", program, str(script)], + check=False, + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stderr + completed.stdout + + +def test_poll_fence_epoch_zero_write_releases_later_polls(): + """The first Dash callback runs at generation 0. + + Counting it without a matching release left every later edit's fence + shut, so state polls stopped updating the projection radio and the + controls stamp. + """ + node = shutil.which("node") + if node is None: + pytest.skip("Node.js is needed to execute the frontend regression") + + script = Path(__file__).resolve().parents[2] / "frontend" / "assets" / "poll_fence.js" + program = r""" +const fs = require("node:fs"); +const vm = require("node:vm"); +const assert = require("node:assert/strict"); +const document = {addEventListener() {}, readyState: "complete"}; +const payload = { + multi: true, + response: {"agent-state-store": {data: {scene_id: "scene-a", axis_scale: 1}}}, +}; +const sandbox = { + console, JSON, Math, Number, Object, String, Boolean, Array, Promise, + document, + setTimeout() {}, + setInterval() { return 0; }, + clearInterval() {}, + Headers: function () { this.delete = function () {}; }, + Response: function (body) { this.body = body; }, + __mvUiEpoch: 0, + __mvSelectedSceneId: "scene-a", + __mvUnackedEpoch: 0, + __mvUserWrites: 0, +}; +sandbox.window = sandbox; +sandbox.fetch = function () { + return Promise.resolve({ + status: 200, + statusText: "OK", + headers: {}, + clone() { return this; }, + json() { return Promise.resolve(JSON.parse(JSON.stringify(payload))); }, + body: JSON.stringify(payload), + }); +}; +vm.createContext(sandbox); +vm.runInContext(fs.readFileSync(process.argv[1], "utf8"), sandbox); +const pollBody = JSON.stringify({ + output: [ + {id: "scene-tab-rename-input", property: "value"}, + {id: "agent-state-store", property: "data"}, + ], + changedPropIds: ["agent-state-poll.n_intervals"], +}); +const identityBody = JSON.stringify({ + output: "ui-interaction-sink.children", + changedPropIds: ["ui-interaction-epoch.data"], +}); +const editBody = JSON.stringify({ + output: "agent-state-store.data", + changedPropIds: ["view-projection.value"], +}); +sandbox.fetch("/_dash-update-component", {body: identityBody}).then(function () { + assert.equal(sandbox.__mvUserWrites, 0); + sandbox.__mvUiEpoch = 1; + sandbox.__mvUnackedEpoch = 1; + return sandbox.fetch("/_dash-update-component", {body: pollBody}); +}).then(function (response) { + assert.equal(JSON.stringify(JSON.parse(response.body).response), "{}"); + return sandbox.fetch("/_dash-update-component", {body: editBody}); +}).then(function () { + assert.equal(sandbox.__mvUserWrites, 0); + assert.equal(sandbox.__mvUnackedEpoch, 0); + return sandbox.fetch("/_dash-update-component", {body: pollBody}); +}).then(function (response) { + const kept = JSON.parse(response.body); + assert.equal(kept.response["agent-state-store"].data.axis_scale, 1); + process.exit(0); +}).catch(function (error) { + console.error(error); + process.exit(1); +}); +setTimeout(function () { + console.error("epoch 0 write did not release the poll fence"); + process.exit(1); +}, 1000); +""" + completed = subprocess.run( + ["node", "-e", program, str(script)], + check=False, + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stderr + completed.stdout + + +def test_poll_fence_tab_click_bumps_before_the_response(): + node = shutil.which("node") + if node is None: + pytest.skip("Node.js is needed to execute the frontend regression") + script = Path(__file__).resolve().parents[2] / "frontend" / "assets" / "poll_fence.js" + program = r""" +const fs = require("node:fs"); +const vm = require("node:vm"); +const assert = require("node:assert/strict"); +const listeners = []; +const document = { + addEventListener(name, fn, capture) { listeners.push({name, fn, capture}); }, + readyState: "complete", +}; +const sandbox = { + console, JSON, Math, Number, Object, String, Boolean, Array, + document, setTimeout() {}, setInterval() { return 0; }, clearInterval() {}, + Promise, +}; +sandbox.window = sandbox; +vm.createContext(sandbox); +vm.runInContext(fs.readFileSync(process.argv[1], "utf8"), sandbox); +const pointer = listeners.find((item) => item.name === "pointerdown" && item.capture); +assert.ok(pointer, "pointerdown must be observed before Dash handles the click"); +const button = { + id: JSON.stringify({type: "tab-select", scene_id: "scene-b"}), + closest(selector) { + if (selector === ".tab-select") return this; + if (selector === "#rightclick-target-fallback") return null; + return null; + }, +}; +sandbox.__mvUiEpoch = 1; +sandbox.__mvSelectedSceneId = "scene-a"; +pointer.fn({isTrusted: true, target: button}); +assert.equal(sandbox.__mvUiEpoch, 2); +assert.equal(sandbox.__mvSelectedSceneId, "scene-b"); +""" + completed = subprocess.run( + ["node", "-e", program, str(script)], + check=False, + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stderr + completed.stdout