From 225a5d5b1badc00054f970b8cd5fce4249feb556 Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Sat, 8 Aug 2026 19:33:05 +0000 Subject: [PATCH 01/27] Add e2e tests pinning cross-scope (server vs client handle) semantics Scene/GUI elements can be created through both server.scene/gui (broadcast, persistent) and client.scene/gui (per-connection, ephemeral), but the frontend merges both scopes into single stores keyed by user-chosen node name, while the Python side keeps disjoint per-scope registries and buffers. This seam had no direct test coverage. Contract tests (must keep passing through any redesign): - per-client namespaces are isolated, and the same node name can coexist across different clients with independent state - client-scoped elements do not survive reconnect; broadcast state is replayed - scene pointer callbacks are deliberately cross-scope exclusive - a server-scope GUI container context does not capture client-scope additions Bug demonstrations (strict xfail, asserting intended semantics): - cross-scope same-name re-add does not reset pose (within-scope supersede force-broadcasts pose; cross-scope misses the other registry) - one click dispatches to BOTH scopes' handles for a shared name - a broadcast remove cascades into client-scoped descendants on the frontend but never invalidates the client-side Python handle - per-client world-axes cache makes 'visible = False' a silent no-op after the server broadcasts visible=True --- tests/e2e/test_cross_scope_handles.py | 409 ++++++++++++++++++++++++++ 1 file changed, 409 insertions(+) create mode 100644 tests/e2e/test_cross_scope_handles.py diff --git a/tests/e2e/test_cross_scope_handles.py b/tests/e2e/test_cross_scope_handles.py new file mode 100644 index 000000000..da6babc4d --- /dev/null +++ b/tests/e2e/test_cross_scope_handles.py @@ -0,0 +1,409 @@ +"""E2E tests for cross-scope (server vs. client handle) scene/GUI semantics. + +Scene and GUI elements can be added through two scopes: ``server.scene`` / +``server.gui`` (broadcast, persistent buffer, replayed to late joiners) and +``client.scene`` / ``client.gui`` (one connection, ephemeral buffer). The +frontend merges both scopes into single stores -- the scene tree is keyed by +user-chosen node name with no record of which scope created an entry -- while +the Python side keeps disjoint per-scope registries and buffers. + +This suite pins that seam from both directions: + +- **Contract tests** (plain asserts) lock in cross-scope behavior that any + future redesign must preserve: per-client namespace isolation, the + ephemeral lifecycle of client-scoped elements across reconnects, and the + deliberate cross-scope exclusivity of scene pointer callbacks. + +- **Bug demonstrations** (``xfail(strict=True)``) assert the *intended* + semantics -- generally "the cross-scope case should behave like the + documented within-scope case" -- and fail deterministically today. Fixing + the underlying issue flips them to XPASS, which strict mode reports as a + hard failure until the marker is removed. +""" + +from __future__ import annotations + +import threading +import time +from typing import Generator + +import pytest +from playwright.sync_api import Browser, Page + +import viser +import viser._client_autobuild + +from .utils import ( + canvas_center, + find_free_port, + wait_for_connection, + wait_for_scene_node, + wait_for_scene_node_hidden, + wait_for_scene_node_removed, + wait_for_scene_node_visible, + wait_for_server_ready, +) + +JS_GET_NODE_POSITION = """ +(nodeName) => { + const m = window.__viserMutable; + if (!m || !m.nodeRefFromName) return null; + const obj = m.nodeRefFromName[nodeName]; + if (!obj) return null; + return [obj.position.x, obj.position.y, obj.position.z]; +} +""" + + +def get_client_handle( + server: viser.ViserServer, expected_count: int = 1, timeout: float = 10.0 +) -> viser.ClientHandle: + """Wait until ``expected_count`` clients are registered, return the newest. + + Client handles register on the first camera message, which can trail + the websocket handshake that ``wait_for_connection`` observes. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + clients = server.get_clients() + if len(clients) >= expected_count: + return clients[max(clients.keys())] + time.sleep(0.05) + raise TimeoutError( + f"Expected {expected_count} connected client(s) within {timeout}s." + ) + + +def wait_for_node_position( + page: Page, + node_name: str, + position: tuple[float, float, float], + timeout: int = 5_000, +) -> None: + """Wait until a node's three.js local position matches ``position``.""" + page.wait_for_function( + """([nodeName, expected]) => { + const m = window.__viserMutable; + if (!m || !m.nodeRefFromName) return false; + const obj = m.nodeRefFromName[nodeName]; + if (!obj) return false; + const p = obj.position; + return ( + Math.abs(p.x - expected[0]) < 1e-4 && + Math.abs(p.y - expected[1]) < 1e-4 && + Math.abs(p.z - expected[2]) < 1e-4 + ); + }""", + arg=[node_name, list(position)], + timeout=timeout, + ) + + +@pytest.fixture() +def two_client_setup(browser: Browser) -> Generator[dict, None, None]: + """A viser server with two connected pages and their client handles.""" + viser._client_autobuild.ensure_client_is_built = lambda: None + + max_retries = 3 + server: viser.ViserServer | None = None + for attempt in range(max_retries): + port = find_free_port() + try: + server = viser.ViserServer(port=port, verbose=False) + break + except OSError: + if attempt == max_retries - 1: + raise + assert server is not None + wait_for_server_ready(server.get_port()) + + context1 = browser.new_context() + page1 = context1.new_page() + wait_for_connection(page1, server.get_port()) + client1 = get_client_handle(server, expected_count=1) + + context2 = browser.new_context() + page2 = context2.new_page() + wait_for_connection(page2, server.get_port()) + client2 = get_client_handle(server, expected_count=2) + assert client2.client_id != client1.client_id + + yield { + "server": server, + "page1": page1, + "page2": page2, + "client1": client1, + "client2": client2, + } + + context1.close() + context2.close() + server.stop() + + +# --------------------------------------------------------------------------- +# Contract tests: cross-scope behavior that must be preserved. +# --------------------------------------------------------------------------- + + +def test_per_client_namespaces_are_isolated(two_client_setup: dict) -> None: + """Elements added via one client handle must not appear for other clients.""" + page1: Page = two_client_setup["page1"] + page2: Page = two_client_setup["page2"] + client1: viser.ClientHandle = two_client_setup["client1"] + client2: viser.ClientHandle = two_client_setup["client2"] + + client1.scene.add_icosphere("/only_c1", radius=0.3) + client2.scene.add_icosphere("/only_c2", radius=0.3) + + wait_for_scene_node(page1, "/only_c1") + wait_for_scene_node(page2, "/only_c2") + + # Poll a little to let any (incorrect) cross-delivery land, then assert + # isolation. + time.sleep(0.5) + assert page1.evaluate( + "() => window.__viserSceneTree.getState()['/only_c2'] === undefined" + ) + assert page2.evaluate( + "() => window.__viserSceneTree.getState()['/only_c1'] === undefined" + ) + + +def test_same_name_coexists_across_different_clients(two_client_setup: dict) -> None: + """Two clients may each own a node with the SAME name, with independent + state. Any single-namespace redesign must key identity by (audience, + name), not name alone, to keep this working.""" + page1: Page = two_client_setup["page1"] + page2: Page = two_client_setup["page2"] + client1: viser.ClientHandle = two_client_setup["client1"] + client2: viser.ClientHandle = two_client_setup["client2"] + + client1.scene.add_icosphere("/own", radius=0.3, position=(1.0, 0.0, 0.0)) + client2.scene.add_icosphere("/own", radius=0.3, position=(0.0, 2.0, 0.0)) + + wait_for_node_position(page1, "/own", (1.0, 0.0, 0.0)) + wait_for_node_position(page2, "/own", (0.0, 2.0, 0.0)) + + +def test_client_scope_elements_do_not_survive_reconnect( + viser_server: viser.ViserServer, viser_page: Page +) -> None: + """Server-scoped elements are replayed after a reconnect; client-scoped + elements are not (the per-client buffer is ephemeral and the reconnected + browser is a brand-new ClientHandle). This lifecycle asymmetry is + currently implicit in the architecture; pin it so a change is a + deliberate decision.""" + client = get_client_handle(viser_server) + + viser_server.scene.add_box("/shared_box", dimensions=(1.0, 1.0, 1.0)) + client.scene.add_icosphere("/client_sphere", radius=0.3) + wait_for_scene_node(viser_page, "/shared_box") + wait_for_scene_node(viser_page, "/client_sphere") + + viser_server._websock_server.disconnect_all_clients() + + # The frontend reconnects automatically, resets its stores, and replays + # the broadcast backlog. + wait_for_scene_node_removed(viser_page, "/client_sphere") + wait_for_scene_node(viser_page, "/shared_box", timeout=15_000) + time.sleep(0.5) + assert viser_page.evaluate( + "() => window.__viserSceneTree.getState()['/client_sphere'] === undefined" + ), "client-scoped node unexpectedly survived (or was replayed after) reconnect" + + +def test_scene_pointer_callbacks_are_cross_scope_exclusive( + viser_server: viser.ViserServer, viser_page: Page +) -> None: + """Scene pointer callbacks (scene-level on_click) enforce cross-scope + exclusivity: registering on the server scope tears down every client + scope's registrations, and vice versa. This is a deliberate workaround + for the shared client-side enable toggle -- pin it so the + action-at-a-distance stays visible.""" + client = get_client_handle(viser_server) + + @client.scene.on_click() + def _(_event: viser.SceneClickEvent) -> None: + pass + + assert len(client.scene._scene_pointer_cb) == 1 + + @viser_server.scene.on_click() + def _(_event: viser.SceneClickEvent) -> None: + pass + + # Server-scope registration reached into the client scope and removed + # its callback. + assert len(viser_server.scene._scene_pointer_cb) == 1 + assert len(client.scene._scene_pointer_cb) == 0 + + # And the reverse: a client-scope registration tears down the server's. + @client.scene.on_click() + def _(_event: viser.SceneClickEvent) -> None: + pass + + assert len(client.scene._scene_pointer_cb) == 1 + assert len(viser_server.scene._scene_pointer_cb) == 0 + + +def test_gui_container_context_does_not_span_scopes( + viser_server: viser.ViserServer, viser_page: Page +) -> None: + """A ``with server.gui.add_folder(...)`` block does NOT capture elements + added through a client handle's GuiApi: the container context is + per-GuiApi-instance, so the button silently lands at the client's root. + Characterization -- if cross-scope nesting is ever supported (or made an + error), this should change deliberately.""" + client = get_client_handle(viser_server) + + with viser_server.gui.add_folder("SrvFolder"): + stray = client.gui.add_button("StrayBtn") + + assert stray._impl.parent_container_id == "root" + + # The button still renders for the client (at the root, not the folder). + button = viser_page.get_by_role("button", name="StrayBtn") + button.wait_for(state="visible", timeout=5_000) + + +# --------------------------------------------------------------------------- +# Bug demonstrations: strict xfails asserting intended semantics. +# --------------------------------------------------------------------------- + + +@pytest.mark.xfail( + strict=True, + reason=( + "Cross-scope same-name supersede does not reset pose: the " + "within-scope re-add path force-broadcasts the new node's pose " + "(_scene_handles.py SceneNodeHandle._make), but a client-scope " + "re-add of a server-scope name misses the other scope's registry, " + "so the frontend keeps the OLD node's position." + ), +) +def test_cross_scope_same_name_readd_resets_pose( + viser_server: viser.ViserServer, viser_page: Page +) -> None: + """Re-adding a name from the other scope should behave like the + documented within-scope replacement: the new add's pose wins.""" + client = get_client_handle(viser_server) + + viser_server.scene.add_icosphere("/dup", radius=0.3, position=(1.0, 2.0, 0.0)) + wait_for_node_position(viser_page, "/dup", (1.0, 2.0, 0.0)) + + # Client-scoped re-add at the origin. Within one scope this resets the + # node's pose; cross-scope it should too. + client.scene.add_icosphere("/dup", radius=0.3, position=(0.0, 0.0, 0.0)) + + wait_for_node_position(viser_page, "/dup", (0.0, 0.0, 0.0)) + + +@pytest.mark.xfail( + strict=True, + reason=( + "Incoming SceneNodeClickMessages are dispatched by name into BOTH " + "scopes' registries (infra handle_incoming fans out to server and " + "connection handlers), so one physical click fires callbacks on two " + "handles even though only one node exists on the frontend." + ), +) +def test_cross_scope_same_name_click_dispatches_once( + viser_server: viser.ViserServer, viser_page: Page +) -> None: + """When a client-scoped node has superseded a server-scoped node of the + same name, a click should dispatch only to the surviving (client-scoped) + handle.""" + viser_server.initial_camera.position = (0.0, 0.0, 4.0) + viser_server.initial_camera.look_at = (0.0, 0.0, 0.0) + client = get_client_handle(viser_server) + + server_clicked = threading.Event() + client_clicked = threading.Event() + + server_box = viser_server.scene.add_box( + "/dup_click", dimensions=(4.0, 4.0, 0.2), color=(200, 60, 60) + ) + + @server_box.on_click + def _(_event: viser.SceneNodePointerEvent[viser.BoxHandle]) -> None: + server_clicked.set() + + client_box = client.scene.add_box( + "/dup_click", dimensions=(4.0, 4.0, 0.2), color=(60, 200, 60) + ) + + @client_box.on_click + def _(_event: viser.SceneNodePointerEvent[viser.BoxHandle]) -> None: + client_clicked.set() + + wait_for_scene_node(viser_page, "/dup_click") + time.sleep(0.5) # Let click bindings reach the frontend. + + cx, cy = canvas_center(viser_page) + viser_page.mouse.move(cx, cy) + viser_page.mouse.down() + viser_page.mouse.up() + + assert client_clicked.wait(5.0), "click did not reach the client-scoped handle" + # Give the (incorrect) second dispatch a chance to land. + time.sleep(0.5) + assert not server_clicked.is_set(), ( + "click dispatched to the superseded server-scoped handle too" + ) + + +@pytest.mark.xfail( + strict=True, + reason=( + "A broadcast remove cascades on the frontend into client-scoped " + "descendants (single name-keyed tree), but the client scope's " + "Python registry never learns: the child handle stays live and " + "later writes silently target a nonexistent node." + ), +) +def test_broadcast_remove_invalidates_client_scope_child( + viser_server: viser.ViserServer, viser_page: Page +) -> None: + """Removing a server-scoped parent should invalidate a client-scoped + child handle parented under it, since the frontend already deleted the + child node.""" + client = get_client_handle(viser_server) + + parent = viser_server.scene.add_frame("/parent", show_axes=False) + child = client.scene.add_icosphere("/parent/child", radius=0.3) + wait_for_scene_node(viser_page, "/parent/child") + + parent.remove() + + # The frontend cascade removes the client-scoped child... + wait_for_scene_node_removed(viser_page, "/parent/child") + # ...so the Python handle must not still claim the node exists. + assert child._impl.removed, ( + "client-scoped child handle still live after its node was removed " + "by a broadcast cascade" + ) + + +@pytest.mark.xfail( + strict=True, + reason=( + "Every ClientHandle's SceneApi re-adds /WorldAxes with its own " + "cached visible=False; after the server broadcasts visible=True, " + "the client handle's stale cache makes `visible = False` a no-op " + "(equality early-out in SceneNodeHandle.visible), so no message is " + "sent and the axes stay visible." + ), +) +def test_world_axes_per_client_override_after_server_show( + viser_server: viser.ViserServer, viser_page: Page +) -> None: + """Hiding the world axes through a client handle should take effect even + after the server made them visible.""" + client = get_client_handle(viser_server) + + viser_server.scene.world_axes.visible = True + wait_for_scene_node_visible(viser_page, "/WorldAxes") + + client.scene.world_axes.visible = False + wait_for_scene_node_hidden(viser_page, "/WorldAxes", timeout=5_000) From 41abb42efcf171c53be6ca4f47bde07f23a48d6c Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Sun, 9 Aug 2026 00:46:38 +0000 Subject: [PATCH 02/27] Document the ephemeral-client contract Client state is deliberately ephemeral: a ClientHandle corresponds to one websocket connection, a reconnected browser is a new client, and per-client elements are rebuilt in on_client_connect. Durable state belongs client-side (browser storage) or in application code; the server never retains per-client element state. Records this as the decided contract in the ClientHandle and on_client_connect docstrings, and updates the e2e reconnect test's docstring from 'implicit behavior, pinned' to 'documented contract'. --- src/viser/_viser.py | 13 +++++++++++++ tests/e2e/test_cross_scope_handles.py | 7 ++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/viser/_viser.py b/src/viser/_viser.py index e2d777ffb..1398b1fd5 100644 --- a/src/viser/_viser.py +++ b/src/viser/_viser.py @@ -568,6 +568,15 @@ class ClientHandle(DeprecatedAttributeShim if not TYPE_CHECKING else object): these are used, for example via a client's :meth:`SceneApi.add_point_cloud()` method, created elements are local to only one specific client. + + **Client state is ephemeral.** A client handle corresponds to a single + websocket connection: when the browser disconnects or reloads, elements + created through the handle are gone, and the reconnected browser is a new + client (new handle, new ``client_id``). Per-client state should therefore + be (re)built in :meth:`ViserServer.on_client_connect`, which fires again + on reconnect. State that must outlive a connection belongs client-side + (browser storage) or in application code keyed however the application + identifies its users; the server never retains per-client element state. """ def __init__( @@ -1509,6 +1518,10 @@ def on_client_connect( ) -> Callable[[ClientHandle], NoneOrCoroutine]: """Attach a callback to run for newly connected clients. + This is also where per-client state should be (re)built: client state + is ephemeral (see :class:`ClientHandle`), and a browser that + reconnects or reloads arrives here as a brand-new client. + The callback can be either a standard function or an async function: - Standard functions (def) will be executed in a threadpool. - Async functions (async def) will be executed in the event loop. diff --git a/tests/e2e/test_cross_scope_handles.py b/tests/e2e/test_cross_scope_handles.py index da6babc4d..83f1fd7ed 100644 --- a/tests/e2e/test_cross_scope_handles.py +++ b/tests/e2e/test_cross_scope_handles.py @@ -191,9 +191,10 @@ def test_client_scope_elements_do_not_survive_reconnect( ) -> None: """Server-scoped elements are replayed after a reconnect; client-scoped elements are not (the per-client buffer is ephemeral and the reconnected - browser is a brand-new ClientHandle). This lifecycle asymmetry is - currently implicit in the architecture; pin it so a change is a - deliberate decision.""" + browser is a brand-new ClientHandle). This is the documented contract -- + client state is ephemeral, rebuilt in on_client_connect; durable state + lives client-side or in application code (see the ClientHandle + docstring). The server never retains per-client element state.""" client = get_client_handle(viser_server) viser_server.scene.add_box("/shared_box", dimensions=(1.0, 1.0, 1.0)) From f2a492f20d8bcf0b227aa60e13c8f67193ff424a Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Sun, 9 Aug 2026 01:26:25 +0000 Subject: [PATCH 03/27] Enforce cross-scope scene name rules; fix world-axes per-client handle Scene-node names share one namespace per viewer: the frontend's scene tree is keyed by name with no notion of which scope (server.scene vs a client.scene) created a node, while the Python side kept disjoint per-scope registries. Cross-scope name reuse therefore silently corrupted state: a same-name add from the other scope clobbered the node's props while both handles stayed live (stale pose replay, one click dispatching to both scopes' callbacks), broadcast removals cascaded through client-scoped descendants on the frontend but left their Python handles alive, and every ClientHandle re-added /WorldAxes over its own connection, racing the broadcast replay. Mechanism: a server-wide SceneNameIndex tracks which scopes claim each name, consulted under a now server-wide scene lifecycle lock (shared by server.scene and every client.scene). At add time it enforces: - No overlapping-scope name reuse: broadcast vs client-scope collisions raise ValueError before any side effect. Same-scope re-adds keep the documented supersede behavior; two different clients can still reuse the same name (their scopes never meet on one frontend). - Audience-subset parenting: a client-scoped child under a broadcast parent is allowed (and ancestor auto-creation no longer re-creates the shared parent in the client scope); a broadcast child under a client-scoped parent is rejected. Broadcast removals now cascade into other scopes' subtrees, marking per-client descendant handles removed (matching the frontend's name-keyed cascade) so later writes raise instead of silently targeting nonexistent nodes. Disconnects release the client's claims. client.scene.world_axes is now a non-authoritative VIEW onto the shared node: construction sends nothing (the broadcast replay already carries the server's state), and its setters never equality-skip, since the cached state can be stale relative to broadcast writers -- previously 'client.scene.world_axes.visible = False' after the server broadcast True was silently dropped by the early-out. ClientHandle.client_id is now assigned before the scene/gui APIs are constructed (SceneApi reads it for its scope key; the late assignment recursed through DeprecatedAttributeShim.__getattr__). Tests: tests/test_scene_name_index.py covers the index in isolation and the integrated lifecycle on a headless server with synthetic in-process clients (19 tests); the e2e cross-scope suite's four strict xfails are replaced by tests of the enforced semantics, plus new coverage for the parenting rule, disconnect name release, and deterministic world-axes state for late-joining clients (12 tests). --- src/viser/_assignable_props_api.py | 21 +- src/viser/_scene_api.py | 87 +++++-- src/viser/_scene_handles.py | 65 ++++- src/viser/_scene_name_index.py | 153 +++++++++++ src/viser/_viser.py | 38 ++- tests/e2e/test_cross_scope_handles.py | 229 ++++++++++------ tests/test_scene_name_index.py | 359 ++++++++++++++++++++++++++ 7 files changed, 841 insertions(+), 111 deletions(-) create mode 100644 src/viser/_scene_name_index.py create mode 100644 tests/test_scene_name_index.py diff --git a/src/viser/_assignable_props_api.py b/src/viser/_assignable_props_api.py index 063afd5cc..8dbc0cdb6 100644 --- a/src/viser/_assignable_props_api.py +++ b/src/viser/_assignable_props_api.py @@ -121,13 +121,20 @@ def props_setattr(self, name: str, value: Any) -> None: value = self._cast_value_recursive(self._prop_hints[name], value, name) current_value = getattr(self._impl.props, name) + # Non-authoritative view handles (e.g. a client scope's view of the + # shared world axes) never equality-skip: their cached props can be + # stale relative to other writers, so a "no-op" write may in fact be + # a needed override. + authoritative = getattr(self._impl, "authoritative", True) + # Skip update if value hasn't changed. - try: - hash(current_value) - if current_value == value: - return - except (TypeError, ValueError): - pass + if authoritative: + try: + hash(current_value) + if current_value == value: + return + except (TypeError, ValueError): + pass # Update the value based on type. if isinstance(value, np.ndarray): @@ -135,7 +142,7 @@ def props_setattr(self, name: str, value: Any) -> None: # Ensure consistent dtype. if value.dtype != current_value.dtype: value = value.astype(current_value.dtype) - if np.array_equal(current_value, value): + if authoritative and np.array_equal(current_value, value): return # In-place update for same shape arrays. diff --git a/src/viser/_scene_api.py b/src/viser/_scene_api.py index e5fc985c7..37c3068e8 100644 --- a/src/viser/_scene_api.py +++ b/src/viser/_scene_api.py @@ -4,7 +4,6 @@ import dataclasses import io import math -import threading import time import warnings from collections.abc import Coroutine @@ -73,6 +72,7 @@ _DragInput, _normalize_node_name, _RaycastSupportedSceneNodeHandle, + _SceneNodeHandleState, _TransformControlsState, ) from ._threadpool_exceptions import ( @@ -276,12 +276,29 @@ def __init__( str, TransformControlsHandle ] = {} self._handle_from_node_name: dict[str, SceneNodeHandle] = {} - self._node_lifecycle_lock = threading.RLock() + if isinstance(owner, ViserServer): + self._scope_key = None + """Which scope this API's elements belong to: ``None`` for the + broadcast scope, a client id for a per-client scope.""" + server_owner = owner + else: + self._scope_key = cast("ClientId", owner.client_id) + server_owner = owner._viser_server + self._name_index = server_owner._scene_name_index + """Server-wide index of claimed scene-node names across scopes. Scene + names share one namespace per viewer (the frontend's scene tree is + keyed by name with no notion of scope), so adds must be checked + against every scope the same viewer can see -- not just this API's + own registry. Only touched under ``_node_lifecycle_lock``.""" + self._node_lifecycle_lock = server_owner._scene_lifecycle_lock """Serializes scene-node lifecycle transitions (remove, same-name supersede) against interaction-callback (de)registration. All critical sections are short and synchronous (no awaits inside). - Reentrant defensively: no current teardown path re-enters (a 3D GUI - container's _on_remove removes GUI children only), but subclass + SERVER-WIDE and shared by every SceneApi (server- and client-scoped): + lifecycle transitions consult and mutate the cross-scope name index, + and broadcast removals cascade into per-client subtrees, so per-scope + locks would deadlock or race. Reentrant: ancestor auto-creation and + cross-scope cascade removal re-enter by design, and subclass _on_remove hooks run under the lock and must stay safe to extend. Without this lock, a registration racing a remove/supersede from another thread could publish a name-keyed binding into the @@ -322,14 +339,42 @@ def __init__( self._scene_pointer_cb: list[_PointerCallbackEntry] = [] self._scene_pointer_done_cb: list[Callable[[], None | Coroutine]] = [] - # Set up world axes handle. - self.world_axes: FrameHandle = self.add_frame( - "/WorldAxes", - axes_radius=0.0125, - ) - """Handle for the world axes, which are created by default.""" + # Set up world axes handle. Only the SERVER scope creates (and owns) + # the node; the name index would reject a second overlapping claim. + if self._scope_key is None: + self.world_axes: FrameHandle = self.add_frame( + "/WorldAxes", + axes_radius=0.0125, + ) + """Handle for the world axes, which are created by default.""" - self.world_axes.visible = False + self.world_axes.visible = False + else: + # Client scope: a NON-AUTHORITATIVE view onto the shared node. + # No messages are sent at construction (the broadcast replay + # already delivers the server's node + state), and the handle is + # not registered in this scope's registry or the name index -- + # it is a write-through override, not a claim. Because broadcast + # writers also mutate the node, this handle's cached state can + # be stale, so authoritative=False makes every setter send + # unconditionally instead of early-returning on cached equality. + # Reads reflect only writes made through THIS handle. + self.world_axes = FrameHandle( + _SceneNodeHandleState( + "/WorldAxes", + _messages.FrameProps( + show_axes=True, + axes_length=0.5, + axes_radius=0.0125, + origin_radius=0.025, + origin_color=(236, 236, 0), + scale=1.0, + ), + api=self, + visible=False, + authoritative=False, + ) + ) self._websock_interface.register_handler( _messages.TransformControlsUpdateMessage, @@ -416,12 +461,20 @@ async def _drop_active_drags_for_client( print_awaited_callback_error(exc) def _ensure_ancestors_exist(self, name: str) -> None: - """Create intermediate frame nodes for any missing ancestors of `name`.""" - parts = name.split("/") - for i in range(2, len(parts)): # skip root ("") and the node itself - ancestor = "/".join(parts[:i]) - if ancestor not in self._handle_from_node_name: - self.add_frame(ancestor, show_axes=False) + """Create intermediate frame nodes for any missing ancestors of `name`. + + "Missing" is judged against the cross-scope name index, not just this + scope's registry: a per-client child under a broadcast parent must + NOT re-create the parent in the client scope (the frontend keys nodes + by name, so the duplicate would clobber the shared parent for that + client). Runs under the lifecycle lock so the visibility check and + the creates are atomic against concurrent adds/removes.""" + with self._node_lifecycle_lock: + parts = name.split("/") + for i in range(2, len(parts)): # skip root ("") and the node itself + ancestor = "/".join(parts[:i]) + if not self._name_index.exists_visible(ancestor, self._scope_key): + self.add_frame(ancestor, show_axes=False) def set_up_direction( self, diff --git a/src/viser/_scene_handles.py b/src/viser/_scene_handles.py index 2c79065dc..67230c38c 100644 --- a/src/viser/_scene_handles.py +++ b/src/viser/_scene_handles.py @@ -49,6 +49,8 @@ def _set_pose_vector( length: int, websock: WebsockMessageHandler, make_message: Callable[[_PoseTupleT], _messages.Message], + *, + force: bool = False, ) -> None: """Shared write path for the scene-node and skinned-bone pose setters. @@ -56,12 +58,15 @@ def _set_pose_vector( ``current``, and otherwise writes it into ``current`` in place and queues the message built from the cast value. Keeping this in one place stops the four near-identical wxyz/position setters from drifting apart. + + ``force`` skips the unchanged-value no-op; used by non-authoritative view + handles, whose cached value may not match the node's actual state. """ from ._scene_api import cast_vector value_cast: _PoseTupleT = cast_vector(value, length) value_arr = np.asarray(value_cast) - if np.allclose(value_arr, current): + if not force and np.allclose(value_arr, current): return current[:] = value_arr websock.queue_message(make_message(value_cast)) @@ -227,6 +232,12 @@ class _SceneNodeHandleState: click_cb: list[_ClickCallbackEntry] = dataclasses.field(default_factory=list) drag_cb: list[_DragCallbackEntry] = dataclasses.field(default_factory=list) removed: bool = False + authoritative: bool = True + """Whether this handle's cached state is the source of truth for the + node. False for per-client VIEW handles onto a shared (broadcast) node, + e.g. ``client.scene.world_axes``: broadcast writers also mutate the node, + so the cache can be stale, and setters must send unconditionally instead + of early-returning when the new value equals the cached one.""" # Last bindings tuple published to the client. Used to dedup # redundant ``SetSceneNodeClickBindingsMessage`` emits — without # this, a no-op ``remove_click_callback("foo")`` for an @@ -275,9 +286,6 @@ def _make( name = _normalize_node_name(name) message.name = name - # Ensure all ancestor nodes exist (creates intermediate frames as needed). - api._ensure_ancestors_exist(name) - # Snapshot array props before the message is queued and persisted for # replay. The add_* methods use np.asarray casts that may alias the # caller's array; without a copy here, a caller mutating that array @@ -296,6 +304,22 @@ def _make( # is marked removed but still registered, where its remove() would # tear down the replacement's fresh state. with api._node_lifecycle_lock: + # Cross-scope checks come FIRST, before any side effect: names + # share one namespace per viewer (the frontend's tree is keyed by + # name, scope-blind), so an add that collides with an overlapping + # scope -- or violates the audience-subset parenting rule -- is + # rejected here with nothing queued and nothing registered. + # Within-scope re-adds pass this check and take the supersede + # path below. + api._name_index.check_claimable(name, api._scope_key) + + # Ensure all ancestor nodes exist (creates intermediate frames as + # needed; re-enters _make under the reentrant lifecycle lock). + # Index-aware: an ancestor owned by a scope this viewer already + # sees (e.g. a broadcast parent of a per-client child) is not + # re-created. + api._ensure_ancestors_exist(name) + old_handle = api._handle_from_node_name.get(name) if old_handle is not None and not old_handle._impl.removed: # 1. The old Python handle goes inert. Removal resolves by NAME, @@ -344,6 +368,10 @@ def _make( api._children_from_node_name.setdefault(parent, set()).add(name) api._children_from_node_name.setdefault(name, set()) + # Publish the claim in the cross-scope name index (idempotent for + # same-scope supersedes). + api._name_index.commit(name, api._scope_key, api) + out.wxyz = wxyz out.position = position if old_handle is not None: @@ -386,6 +414,7 @@ def wxyz(self, wxyz: tuple[float, float, float, float] | np.ndarray) -> None: 4, self._impl.api._websock_interface, lambda v: _messages.SetOrientationMessage(self._impl.name, v), + force=not self._impl.authoritative, ) @property @@ -403,6 +432,7 @@ def position(self, position: tuple[float, float, float] | np.ndarray) -> None: 3, self._impl.api._websock_interface, lambda v: _messages.SetPositionMessage(self._impl.name, v), + force=not self._impl.authoritative, ) @property @@ -412,7 +442,11 @@ def visible(self) -> bool: @visible.setter def visible(self, visible: bool) -> None: - if visible == self._impl.visible: + # Non-authoritative view handles must not equality-skip: their cache + # can be stale relative to broadcast writers, and a skipped send here + # silently drops the override (world_axes.visible = False after the + # server broadcast True was exactly this bug). + if visible == self._impl.visible and self._impl.authoritative: return self._impl.api._websock_interface.queue_message( _messages.SetSceneNodeVisibilityMessage(self._impl.name, visible) @@ -495,6 +529,7 @@ def _remove_locked(self) -> None: for node_name in to_remove: handle = api._handle_from_node_name.pop(node_name, None) api._children_from_node_name.pop(node_name, None) + api._name_index.release(node_name, api._scope_key) if handle is None: continue handle._impl.removed = True @@ -513,6 +548,26 @@ def _remove_locked(self) -> None: _messages.RemoveSceneNodeMessage(node_name) ) + # Cascade into OTHER scopes' subtrees. The frontend's scene tree is + # keyed by name with no notion of scope, so its cascade already + # deletes e.g. a per-client child parented under this broadcast node; + # without this, that child's Python handle would stay live in its + # scope's registry -- a zombie whose later writes silently target a + # nonexistent node. Only the broadcast scope can have foreign + # descendants (the audience-subset rule forbids a broader child under + # a narrower parent). Each foreign teardown runs the full + # _remove_locked path on its own scope (reentrant lifecycle lock), + # including a Remove message on that client's connection -- redundant + # with the frontend cascade but harmless, and it keeps the buffer + # purge + binding-reset logic on the one shared path. + if api._scope_key is None: + for foreign_api, foreign_name in api._name_index.foreign_descendants( + self._impl.name, api._scope_key + ): + foreign_handle = foreign_api._handle_from_node_name.get(foreign_name) + if foreign_handle is not None and not foreign_handle._impl.removed: + foreign_handle._remove_locked() + def _on_remove(self) -> None: """Release any subclass-specific registries for this node. diff --git a/src/viser/_scene_name_index.py b/src/viser/_scene_name_index.py new file mode 100644 index 000000000..96a02113c --- /dev/null +++ b/src/viser/_scene_name_index.py @@ -0,0 +1,153 @@ +"""Server-wide index of claimed scene-node names across scopes. + +Scene elements can be created through two kinds of scope: the server's +broadcast scope (``server.scene``, visible to every client) and per-client +scopes (``client.scene``, visible to one client). The frontend merges both +into a single scene tree keyed by node name, so names from scopes that are +visible to the same viewer share one namespace -- but each scope keeps its +own Python-side registry. This index is the one structure that sees every +scope's claims, and it enforces two rules at add time: + +1. **No overlapping-scope name reuse.** A name may not be claimed by two + scopes that any single viewer can see simultaneously: the broadcast scope + overlaps every client scope, while two different client scopes never + overlap (their elements never meet on one frontend). Re-adding a name + within one scope stays legal -- that is the documented supersede path. + +2. **A child's audience must be a subset of its parent's.** A per-client + child under a broadcast parent is fine; a broadcast child under a + per-client parent (or a child under another client's parent) would dangle + for every viewer that cannot see the parent, and is rejected. The rule is + only enforced when the parent name is claimed somewhere -- viser allows + adding nodes under not-yet-created parents, and those stay unchecked + until the parent is claimed. + +Thread safety: the index has no lock of its own. Every mutation and query +happens under the server-wide scene lifecycle lock (a reentrant lock shared +by ``server.scene`` and every ``client.scene`` -- see +``SceneApi._node_lifecycle_lock``), which also serializes the registry +operations the index mirrors. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from ._scene_api import SceneApi + from .infra import ClientId + +ScopeKey = Optional["ClientId"] +"""Identifies a scope: ``None`` for the broadcast scope, a client id for a +per-client scope.""" + + +def _scopes_overlap(a: ScopeKey, b: ScopeKey) -> bool: + """Whether any single viewer can see both scopes at once.""" + if a is None or b is None: + return True + return a == b + + +def _scope_covers(parent: ScopeKey, child: ScopeKey) -> bool: + """Whether ``parent``'s audience is a superset of ``child``'s.""" + return parent is None or parent == child + + +def _describe_scope(scope: ScopeKey) -> str: + return "the server (broadcast) scope" if scope is None else f"client {scope}" + + +class SceneNameIndex: + """Maps each claimed scene-node name to the scopes that claim it. + + Values are the owning ``SceneApi`` instances rather than handles: the + per-scope registries stay the source of truth for the current (possibly + subclass-typed, possibly superseded) handle, and cross-scope operations + resolve through them at use time. + """ + + def __init__(self) -> None: + self._scopes_from_name: dict[str, dict[ScopeKey, SceneApi]] = {} + + def check_claimable(self, name: str, scope: ScopeKey) -> None: + """Raise ``ValueError`` if claiming ``name`` from ``scope`` would + violate the overlap or audience-subset rule. Called before any side + effect of an add, so a rejected add leaves no trace.""" + claimants = self._scopes_from_name.get(name) + if claimants: + for other in claimants: + if other != scope and _scopes_overlap(other, scope): + raise ValueError( + f"Cannot add scene node {name!r} from " + f"{_describe_scope(scope)}: the name is already used " + f"by {_describe_scope(other)}, and the two are " + f"visible to the same client. Both scopes share one " + f"scene tree on the frontend, so this would silently " + f"corrupt the existing node's state. Remove the " + f"existing node first, or use a different name. " + f"(Re-adding a name from the SAME scope is supported " + f"and replaces the node.)" + ) + + parent = name.rsplit("/", 1)[0] + if parent: + parent_claimants = self._scopes_from_name.get(parent) + if parent_claimants and not any( + _scope_covers(parent_scope, scope) for parent_scope in parent_claimants + ): + parent_scope = next(iter(parent_claimants)) + raise ValueError( + f"Cannot add scene node {name!r} from " + f"{_describe_scope(scope)}: its parent {parent!r} belongs " + f"to {_describe_scope(parent_scope)}, whose audience does " + f"not include every viewer of the new node. A child must " + f"be visible to a subset of its parent's viewers -- " + f"otherwise it would dangle in the scene tree for viewers " + f"who cannot see the parent." + ) + + def commit(self, name: str, scope: ScopeKey, api: SceneApi) -> None: + """Record ``name`` as claimed by ``scope``. Idempotent for same-scope + re-adds (supersede).""" + self._scopes_from_name.setdefault(name, {})[scope] = api + + def release(self, name: str, scope: ScopeKey) -> None: + """Drop ``scope``'s claim on ``name``, if any.""" + claimants = self._scopes_from_name.get(name) + if claimants is None: + return + claimants.pop(scope, None) + if not claimants: + del self._scopes_from_name[name] + + def drop_scope(self, scope: ScopeKey) -> None: + """Drop every claim held by ``scope`` (client disconnect).""" + for name in list(self._scopes_from_name): + self.release(name, scope) + + def exists_visible(self, name: str, scope: ScopeKey) -> bool: + """Whether ``name`` is claimed by a scope whose elements every viewer + of ``scope`` can see -- i.e. whether an add from ``scope`` may treat + the node as an existing ancestor rather than creating it.""" + claimants = self._scopes_from_name.get(name) + if not claimants: + return False + return any(_scope_covers(other, scope) for other in claimants) + + def foreign_descendants( + self, name: str, scope: ScopeKey + ) -> list[tuple[SceneApi, str]]: + """Snapshot of (owning api, name) for every node claimed by a scope + other than ``scope`` whose name sits strictly under ``name``. Used by + broadcast removals to cascade into per-client subtrees the way the + frontend's name-keyed tree already does.""" + prefix = name + "/" + out: list[tuple[SceneApi, str]] = [] + for other_name, claimants in self._scopes_from_name.items(): + if not other_name.startswith(prefix): + continue + for other_scope, api in claimants.items(): + if other_scope != scope: + out.append((api, other_name)) + return out diff --git a/src/viser/_viser.py b/src/viser/_viser.py index 1398b1fd5..02cc4bd59 100644 --- a/src/viser/_viser.py +++ b/src/viser/_viser.py @@ -26,6 +26,7 @@ from ._gui_handles import _make_uuid from ._notification_handle import NotificationHandle, _NotificationHandleState from ._scene_api import SceneApi, cast_vector +from ._scene_name_index import SceneNameIndex from ._threadpool_exceptions import ( print_awaited_callback_error, print_task_error, @@ -577,6 +578,16 @@ class ClientHandle(DeprecatedAttributeShim if not TYPE_CHECKING else object): on reconnect. State that must outlive a connection belongs client-side (browser storage) or in application code keyed however the application identifies its users; the server never retains per-client element state. + + **Scene names are shared with the server scope.** The scene tree shown to + a client merges server-scoped and client-scoped elements into one + namespace, so adding a client-scoped scene node under a name the server + already uses (or vice versa) raises ``ValueError`` -- remove the existing + node first or pick a different name. Different clients may reuse the same + name freely. A client-scoped node may be parented under a server-scoped + node (e.g. per-client annotations under a shared frame), and is removed + with it; the reverse -- a server-scoped child under a client-scoped + parent -- is rejected, since other clients cannot see the parent. """ def __init__( @@ -587,6 +598,13 @@ def __init__( self._viser_server = server # Public attributes. + # client_id is assigned BEFORE the scene/gui APIs: SceneApi.__init__ + # reads it (per-client scope key for the scene name index), and an + # attribute miss during construction would recurse through + # DeprecatedAttributeShim.__getattr__ (whose `self.scene` lookup is + # also unset at that point). + self.client_id: int = conn.client_id + """Unique ID for this client.""" self.scene: SceneApi = SceneApi( self, thread_executor=server._thread_executor, event_loop=server._event_loop ) @@ -595,8 +613,6 @@ def __init__( self, thread_executor=server._thread_executor, event_loop=server._event_loop ) """Handle for interacting with the GUI.""" - self.client_id: int = conn.client_id - """Unique ID for this client.""" self.camera: CameraHandle = CameraHandle(self) """Handle for reading from and manipulating the client's viewport camera.""" @@ -1015,6 +1031,15 @@ def __init__( self._connection = server self._connected_clients: dict[int, ClientHandle] = {} self._client_lock = threading.Lock() + # Scene-node names live in ONE namespace per viewer even though + # server.scene and each client.scene keep separate registries; the + # index is the cross-scope view, and the lifecycle lock is shared by + # every SceneApi (server- and client-scoped) so lifecycle transitions + # are serialized across scopes. Created BEFORE server.start(): a + # client can connect (and build its SceneApi) as soon as the server + # thread runs, which may be before `self.scene` exists below. + self._scene_lifecycle_lock = threading.RLock() + self._scene_name_index = SceneNameIndex() self._client_connect_cb: list[Callable[[ClientHandle], None | Coroutine]] = [] self._client_disconnect_cb: list[ Callable[[ClientHandle], None | Coroutine] @@ -1126,6 +1151,15 @@ async def _(conn: infra.WebsockClientConnection) -> None: await self.scene._drop_active_drags_for_client( cast(infra.ClientId, conn.client_id), event_client=handle ) + + # Release the client's scene-name claims: its elements died with + # the connection (client state is ephemeral), so the names become + # available again -- e.g. for a broadcast add, which would + # otherwise be rejected by the cross-scope overlap rule forever. + # No messages are needed; the frontend is gone. + with self._scene_lifecycle_lock: + self._scene_name_index.drop_scope(cast(infra.ClientId, conn.client_id)) + await self._dispatch_client_callbacks(disconnect_cbs, handle) # Start the server. diff --git a/tests/e2e/test_cross_scope_handles.py b/tests/e2e/test_cross_scope_handles.py index 83f1fd7ed..9d9978374 100644 --- a/tests/e2e/test_cross_scope_handles.py +++ b/tests/e2e/test_cross_scope_handles.py @@ -9,16 +9,20 @@ This suite pins that seam from both directions: -- **Contract tests** (plain asserts) lock in cross-scope behavior that any - future redesign must preserve: per-client namespace isolation, the - ephemeral lifecycle of client-scoped elements across reconnects, and the - deliberate cross-scope exclusivity of scene pointer callbacks. - -- **Bug demonstrations** (``xfail(strict=True)``) assert the *intended* - semantics -- generally "the cross-scope case should behave like the - documented within-scope case" -- and fail deterministically today. Fixing - the underlying issue flips them to XPASS, which strict mode reports as a - hard failure until the marker is removed. +- **Contract tests** lock in cross-scope behavior that any future redesign + must preserve: per-client namespace isolation, the ephemeral lifecycle of + client-scoped elements across reconnects, and the deliberate cross-scope + exclusivity of scene pointer callbacks. + +- **Rule tests** cover the cross-scope name discipline enforced by the + server-wide ``SceneNameIndex``: overlapping-scope name reuse is rejected + at the add site, a child's audience must be a subset of its parent's, + broadcast removals cascade into per-client subtrees (Python handles + included, matching the frontend's name-keyed cascade), and disconnects + free a client's names. Fast Python-level coverage of the same rules lives + in ``tests/test_scene_name_index.py``; here they run against a real + browser so the frontend-observable halves (node state, click dispatch, + world-axes overrides) are exercised too. """ from __future__ import annotations @@ -269,58 +273,51 @@ def test_gui_container_context_does_not_span_scopes( # --------------------------------------------------------------------------- -# Bug demonstrations: strict xfails asserting intended semantics. +# Cross-scope name rules (enforced by SceneNameIndex). # --------------------------------------------------------------------------- -@pytest.mark.xfail( - strict=True, - reason=( - "Cross-scope same-name supersede does not reset pose: the " - "within-scope re-add path force-broadcasts the new node's pose " - "(_scene_handles.py SceneNodeHandle._make), but a client-scope " - "re-add of a server-scope name misses the other scope's registry, " - "so the frontend keeps the OLD node's position." - ), -) -def test_cross_scope_same_name_readd_resets_pose( +def test_cross_scope_same_name_add_raises( viser_server: viser.ViserServer, viser_page: Page ) -> None: - """Re-adding a name from the other scope should behave like the - documented within-scope replacement: the new add's pose wins.""" + """A name claimed by one scope cannot be re-added from an overlapping + scope: both scopes share one name-keyed scene tree on the frontend, so + the second add would silently corrupt the first node's state. The add + raises instead, leaving the existing node untouched.""" client = get_client_handle(viser_server) viser_server.scene.add_icosphere("/dup", radius=0.3, position=(1.0, 2.0, 0.0)) wait_for_node_position(viser_page, "/dup", (1.0, 2.0, 0.0)) - # Client-scoped re-add at the origin. Within one scope this resets the - # node's pose; cross-scope it should too. - client.scene.add_icosphere("/dup", radius=0.3, position=(0.0, 0.0, 0.0)) + with pytest.raises(ValueError, match="already used"): + client.scene.add_icosphere("/dup", radius=0.3, position=(0.0, 0.0, 0.0)) + + # The rejected add left no trace: no client-scope registry entry, and the + # frontend node keeps the server's state. + assert "/dup" not in client.scene._handle_from_node_name + time.sleep(0.3) + wait_for_node_position(viser_page, "/dup", (1.0, 2.0, 0.0)) - wait_for_node_position(viser_page, "/dup", (0.0, 0.0, 0.0)) + # And the reverse direction: a client-owned name rejects a broadcast add. + client.scene.add_icosphere("/own", radius=0.3) + wait_for_scene_node(viser_page, "/own") + with pytest.raises(ValueError, match="already used"): + viser_server.scene.add_icosphere("/own", radius=0.3) -@pytest.mark.xfail( - strict=True, - reason=( - "Incoming SceneNodeClickMessages are dispatched by name into BOTH " - "scopes' registries (infra handle_incoming fans out to server and " - "connection handlers), so one physical click fires callbacks on two " - "handles even though only one node exists on the frontend." - ), -) -def test_cross_scope_same_name_click_dispatches_once( +def test_click_dispatches_to_single_scope( viser_server: viser.ViserServer, viser_page: Page ) -> None: - """When a client-scoped node has superseded a server-scoped node of the - same name, a click should dispatch only to the surviving (client-scoped) - handle.""" + """One physical click reaches exactly one scope's callbacks. (Before the + name index, a cross-scope name collision made one click fire BOTH + scopes' handlers; collisions are now rejected at the add site, so + dispatch is unique by construction.)""" viser_server.initial_camera.position = (0.0, 0.0, 4.0) viser_server.initial_camera.look_at = (0.0, 0.0, 0.0) client = get_client_handle(viser_server) + server_clicks: list[int] = [] server_clicked = threading.Event() - client_clicked = threading.Event() server_box = viser_server.scene.add_box( "/dup_click", dimensions=(4.0, 4.0, 0.2), color=(200, 60, 60) @@ -328,15 +325,14 @@ def test_cross_scope_same_name_click_dispatches_once( @server_box.on_click def _(_event: viser.SceneNodePointerEvent[viser.BoxHandle]) -> None: + server_clicks.append(1) server_clicked.set() - client_box = client.scene.add_box( - "/dup_click", dimensions=(4.0, 4.0, 0.2), color=(60, 200, 60) - ) - - @client_box.on_click - def _(_event: viser.SceneNodePointerEvent[viser.BoxHandle]) -> None: - client_clicked.set() + # The conflicting client-scoped twin is rejected... + with pytest.raises(ValueError, match="already used"): + client.scene.add_box( + "/dup_click", dimensions=(4.0, 4.0, 0.2), color=(60, 200, 60) + ) wait_for_scene_node(viser_page, "/dup_click") time.sleep(0.5) # Let click bindings reach the frontend. @@ -346,65 +342,138 @@ def _(_event: viser.SceneNodePointerEvent[viser.BoxHandle]) -> None: viser_page.mouse.down() viser_page.mouse.up() - assert client_clicked.wait(5.0), "click did not reach the client-scoped handle" - # Give the (incorrect) second dispatch a chance to land. + # ...and the click reaches the server-scoped handle exactly once. + assert server_clicked.wait(5.0), "click did not reach the server-scoped handle" time.sleep(0.5) - assert not server_clicked.is_set(), ( - "click dispatched to the superseded server-scoped handle too" - ) + assert len(server_clicks) == 1 -@pytest.mark.xfail( - strict=True, - reason=( - "A broadcast remove cascades on the frontend into client-scoped " - "descendants (single name-keyed tree), but the client scope's " - "Python registry never learns: the child handle stays live and " - "later writes silently target a nonexistent node." - ), -) def test_broadcast_remove_invalidates_client_scope_child( viser_server: viser.ViserServer, viser_page: Page ) -> None: - """Removing a server-scoped parent should invalidate a client-scoped - child handle parented under it, since the frontend already deleted the - child node.""" + """Removing a server-scoped parent invalidates a client-scoped child + handle parented under it: the frontend's name-keyed cascade already + deleted the child node, so the Python handle must not stay live.""" client = get_client_handle(viser_server) parent = viser_server.scene.add_frame("/parent", show_axes=False) child = client.scene.add_icosphere("/parent/child", radius=0.3) wait_for_scene_node(viser_page, "/parent/child") + # The client scope did not re-create the broadcast parent for itself. + assert "/parent" not in client.scene._handle_from_node_name + parent.remove() # The frontend cascade removes the client-scoped child... wait_for_scene_node_removed(viser_page, "/parent/child") - # ...so the Python handle must not still claim the node exists. + # ...and the Python handle agrees; later writes fail loudly. assert child._impl.removed, ( "client-scoped child handle still live after its node was removed " "by a broadcast cascade" ) + with pytest.raises(RuntimeError, match="removed"): + child.position = (1.0, 0.0, 0.0) + + +def test_client_parent_rejects_broadcast_child( + viser_server: viser.ViserServer, viser_page: Page +) -> None: + """A child's audience must be a subset of its parent's: a broadcast + child under a per-client parent would dangle for every other viewer.""" + client = get_client_handle(viser_server) + + client.scene.add_frame("/client_parent", show_axes=False) + wait_for_scene_node(viser_page, "/client_parent") + + with pytest.raises(ValueError, match="audience"): + viser_server.scene.add_icosphere("/client_parent/child", radius=0.3) + + +def test_disconnect_frees_client_names(browser: Browser) -> None: + """A disconnect releases the client's name claims, so the names become + available to other scopes again.""" + viser._client_autobuild.ensure_client_is_built = lambda: None + + max_retries = 3 + server: viser.ViserServer | None = None + for attempt in range(max_retries): + port = find_free_port() + try: + server = viser.ViserServer(port=port, verbose=False) + break + except OSError: + if attempt == max_retries - 1: + raise + assert server is not None + wait_for_server_ready(server.get_port()) + + context = browser.new_context() + page = context.new_page() + wait_for_connection(page, server.get_port()) + client = get_client_handle(server) + + client.scene.add_icosphere("/mine", radius=0.3) + with pytest.raises(ValueError, match="already used"): + server.scene.add_icosphere("/mine", radius=0.3) + + context.close() + deadline = time.monotonic() + 10.0 + while server.get_clients() and time.monotonic() < deadline: + time.sleep(0.05) + assert not server.get_clients(), "client never disconnected" + + server.scene.add_icosphere("/mine", radius=0.3) + server.stop() -@pytest.mark.xfail( - strict=True, - reason=( - "Every ClientHandle's SceneApi re-adds /WorldAxes with its own " - "cached visible=False; after the server broadcasts visible=True, " - "the client handle's stale cache makes `visible = False` a no-op " - "(equality early-out in SceneNodeHandle.visible), so no message is " - "sent and the axes stay visible." - ), -) def test_world_axes_per_client_override_after_server_show( viser_server: viser.ViserServer, viser_page: Page ) -> None: - """Hiding the world axes through a client handle should take effect even - after the server made them visible.""" + """Hiding the world axes through a client handle takes effect even after + the server made them visible (the client-side handle is a + non-authoritative view: it never equality-skips sends), and the override + can be lifted again.""" client = get_client_handle(viser_server) viser_server.scene.world_axes.visible = True wait_for_scene_node_visible(viser_page, "/WorldAxes") client.scene.world_axes.visible = False - wait_for_scene_node_hidden(viser_page, "/WorldAxes", timeout=5_000) + wait_for_scene_node_hidden(viser_page, "/WorldAxes") + + client.scene.world_axes.visible = True + wait_for_scene_node_visible(viser_page, "/WorldAxes") + + +def test_world_axes_server_state_deterministic_for_new_client( + browser: Browser, +) -> None: + """A client connecting after the server showed the world axes sees them + visible. (Previously each ClientHandle re-added /WorldAxes with + visible=False over the per-client connection, racing the broadcast + replay -- the axes' state at connect was nondeterministic.)""" + viser._client_autobuild.ensure_client_is_built = lambda: None + + max_retries = 3 + server: viser.ViserServer | None = None + for attempt in range(max_retries): + port = find_free_port() + try: + server = viser.ViserServer(port=port, verbose=False) + break + except OSError: + if attempt == max_retries - 1: + raise + assert server is not None + wait_for_server_ready(server.get_port()) + + server.scene.world_axes.visible = True + + context = browser.new_context() + page = context.new_page() + wait_for_connection(page, server.get_port()) + wait_for_scene_node_visible(page, "/WorldAxes") + + context.close() + server.stop() diff --git a/tests/test_scene_name_index.py b/tests/test_scene_name_index.py new file mode 100644 index 000000000..6d953c82d --- /dev/null +++ b/tests/test_scene_name_index.py @@ -0,0 +1,359 @@ +"""Tests for cross-scope scene-node name handling. + +Scene-node names share one namespace per viewer: the frontend's scene tree is +keyed by name with no notion of which scope (``server.scene`` vs. a +``client.scene``) created a node. ``SceneNameIndex`` is the server-wide +structure that sees every scope's claims and enforces: + +1. No overlapping-scope name reuse (broadcast overlaps every client scope; + two different client scopes never overlap). +2. A child's audience must be a subset of its parent's. + +Covers the index in isolation, plus the integrated behavior on a headless +``ViserServer`` with synthetic in-process client connections (no browser; the +e2e side lives in ``tests/e2e/test_cross_scope_handles.py``). +""" + +from __future__ import annotations + +import asyncio +from typing import cast + +import pytest + +import viser +import viser._client_autobuild +from viser import _messages as m +from viser._scene_name_index import SceneNameIndex +from viser._viser import ClientHandle +from viser.infra import ClientId +from viser.infra._async_message_buffer import AsyncMessageBuffer +from viser.infra._infra import WebsockClientConnection, _ClientHandleState + +# --------------------------------------------------------------------------- +# SceneNameIndex in isolation. +# --------------------------------------------------------------------------- + +_API_A = object() # Stand-ins; the index never calls into these. +_API_B = object() + + +def test_index_broadcast_vs_client_conflicts() -> None: + index = SceneNameIndex() + index.commit("/x", None, _API_A) # type: ignore[arg-type] + + # Broadcast /x conflicts with any client's /x, both directions. + with pytest.raises(ValueError, match="already used"): + index.check_claimable("/x", cast(ClientId, 0)) + + index2 = SceneNameIndex() + index2.commit("/x", cast(ClientId, 0), _API_A) # type: ignore[arg-type] + with pytest.raises(ValueError, match="already used"): + index2.check_claimable("/x", None) + + +def test_index_same_scope_and_disjoint_clients_allowed() -> None: + index = SceneNameIndex() + index.commit("/x", cast(ClientId, 0), _API_A) # type: ignore[arg-type] + + # Same scope: allowed (supersede path). + index.check_claimable("/x", cast(ClientId, 0)) + # A different client: allowed (audiences never meet). + index.check_claimable("/x", cast(ClientId, 1)) + + index2 = SceneNameIndex() + index2.commit("/x", None, _API_A) # type: ignore[arg-type] + index2.check_claimable("/x", None) + + +def test_index_parent_audience_subset_rule() -> None: + index = SceneNameIndex() + index.commit("/bcast", None, _API_A) # type: ignore[arg-type] + index.commit("/client0", cast(ClientId, 0), _API_B) # type: ignore[arg-type] + + # Broadcast parent covers every child scope. + index.check_claimable("/bcast/child", None) + index.check_claimable("/bcast/child", cast(ClientId, 0)) + + # Client parent covers only its own scope. + index.check_claimable("/client0/child", cast(ClientId, 0)) + with pytest.raises(ValueError, match="audience"): + index.check_claimable("/client0/child", None) + with pytest.raises(ValueError, match="audience"): + index.check_claimable("/client0/child", cast(ClientId, 1)) + + # Unclaimed parents are unchecked (nodes may be added under parents that + # don't exist yet). + index.check_claimable("/nowhere/child", None) + index.check_claimable("/nowhere/child", cast(ClientId, 1)) + + +def test_index_release_and_drop_scope() -> None: + index = SceneNameIndex() + index.commit("/x", None, _API_A) # type: ignore[arg-type] + index.commit("/y", cast(ClientId, 0), _API_A) # type: ignore[arg-type] + index.commit("/z", cast(ClientId, 0), _API_A) # type: ignore[arg-type] + + index.release("/x", None) + index.check_claimable("/x", cast(ClientId, 0)) # Freed. + index.release("/x", None) # Idempotent. + + index.drop_scope(cast(ClientId, 0)) + index.check_claimable("/y", None) # Freed. + index.check_claimable("/z", None) # Freed. + + +def test_index_exists_visible() -> None: + index = SceneNameIndex() + index.commit("/bcast", None, _API_A) # type: ignore[arg-type] + index.commit("/mine", cast(ClientId, 0), _API_B) # type: ignore[arg-type] + + # Broadcast nodes are visible to every scope. + assert index.exists_visible("/bcast", None) + assert index.exists_visible("/bcast", cast(ClientId, 0)) + # Client nodes are visible only within their own scope. + assert index.exists_visible("/mine", cast(ClientId, 0)) + assert not index.exists_visible("/mine", None) + assert not index.exists_visible("/mine", cast(ClientId, 1)) + assert not index.exists_visible("/absent", None) + + +def test_index_foreign_descendants() -> None: + index = SceneNameIndex() + index.commit("/a", None, _API_A) # type: ignore[arg-type] + index.commit("/a/own", None, _API_A) # type: ignore[arg-type] + index.commit("/a/c0", cast(ClientId, 0), _API_B) # type: ignore[arg-type] + index.commit("/a/c0/deep", cast(ClientId, 0), _API_B) # type: ignore[arg-type] + index.commit("/aa", cast(ClientId, 0), _API_B) # type: ignore[arg-type] + + foreign = index.foreign_descendants("/a", None) + names = sorted(name for _, name in foreign) + # Own-scope descendants and prefix-similar names (/aa) are excluded. + assert names == ["/a/c0", "/a/c0/deep"] + + +# --------------------------------------------------------------------------- +# Integrated behavior on a headless server + synthetic clients. +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def server() -> viser.ViserServer: + viser._client_autobuild.ensure_client_is_built = lambda: None + server = viser.ViserServer(port=0, verbose=False) + yield server + server.stop() + + +def _make_synthetic_client(server: viser.ViserServer, client_id: int) -> ClientHandle: + """In-process ClientHandle with a real per-client message buffer but no + websocket (mirrors how WebsockServer builds per-client state; same + pattern as tests/test_panel.py). Buffer construction hops to the server's + loop thread because AsyncMessageBuffer's asyncio primitives bind to the + running loop.""" + + async def _make_buffer() -> AsyncMessageBuffer: + return AsyncMessageBuffer(server._event_loop, persistent_messages=False) + + buffer = asyncio.run_coroutine_threadsafe( + _make_buffer(), server._event_loop + ).result(timeout=5.0) + conn = WebsockClientConnection( + client_id, _ClientHandleState(buffer, server._event_loop) + ) + return ClientHandle(conn, server) + + +def test_cross_scope_same_name_raises_both_directions( + server: viser.ViserServer, +) -> None: + client = _make_synthetic_client(server, 0) + + server.scene.add_icosphere("/dup", radius=0.1) + with pytest.raises(ValueError, match="already used"): + client.scene.add_icosphere("/dup", radius=0.1) + # The rejected add left no trace in the client scope. + assert "/dup" not in client.scene._handle_from_node_name + + client.scene.add_icosphere("/own", radius=0.1) + with pytest.raises(ValueError, match="already used"): + server.scene.add_icosphere("/own", radius=0.1) + assert "/own" not in server.scene._handle_from_node_name + + +def test_same_name_across_clients_coexists(server: viser.ViserServer) -> None: + client0 = _make_synthetic_client(server, 0) + client1 = _make_synthetic_client(server, 1) + + h0 = client0.scene.add_icosphere("/own", radius=0.1) + h1 = client1.scene.add_icosphere("/own", radius=0.2) + assert not h0._impl.removed + assert not h1._impl.removed + + +def test_same_scope_supersede_still_works(server: viser.ViserServer) -> None: + old = server.scene.add_box("/re", dimensions=(1.0, 1.0, 1.0)) + new = server.scene.add_box("/re", dimensions=(2.0, 2.0, 2.0)) + assert old._impl.removed + assert not new._impl.removed + assert server.scene._handle_from_node_name["/re"] is new + + +def test_client_child_under_broadcast_parent(server: viser.ViserServer) -> None: + client = _make_synthetic_client(server, 0) + + server.scene.add_frame("/parent", show_axes=False) + child = client.scene.add_icosphere("/parent/child", radius=0.1) + + # The client scope did NOT re-create the broadcast parent (the frontend + # keys nodes by name; a duplicate parent add would clobber the shared + # node for this client). + assert "/parent" not in client.scene._handle_from_node_name + assert "/parent/child" in client.scene._handle_from_node_name + assert not child._impl.removed + + +def test_broadcast_child_under_client_parent_raises( + server: viser.ViserServer, +) -> None: + client = _make_synthetic_client(server, 0) + + client.scene.add_frame("/cp", show_axes=False) + with pytest.raises(ValueError, match="audience"): + server.scene.add_icosphere("/cp/child", radius=0.1) + with pytest.raises(ValueError, match="audience"): + _make_synthetic_client(server, 1).scene.add_icosphere("/cp/child", radius=0.1) + + +def test_broadcast_remove_cascades_into_client_scope( + server: viser.ViserServer, +) -> None: + client = _make_synthetic_client(server, 0) + + parent = server.scene.add_frame("/parent", show_axes=False) + child = client.scene.add_icosphere("/parent/child", radius=0.1) + grandchild = client.scene.add_icosphere("/parent/child/deep", radius=0.1) + + parent.remove() + + # The whole client-scope subtree is invalidated, matching the frontend's + # name-keyed cascade. + assert child._impl.removed + assert grandchild._impl.removed + assert "/parent/child" not in client.scene._handle_from_node_name + # Writes to the dead handles fail loudly instead of silently targeting a + # nonexistent node. + with pytest.raises(RuntimeError, match="removed"): + child.position = (1.0, 0.0, 0.0) + # The names are free again, in any scope. + server.scene.add_icosphere("/parent/child", radius=0.1) + + +def test_transform_controls_cascade_cleans_registry( + server: viser.ViserServer, +) -> None: + client = _make_synthetic_client(server, 0) + + parent = server.scene.add_frame("/parent", show_axes=False) + tc = client.scene.add_transform_controls("/parent/gizmo") + assert "/parent/gizmo" in client.scene._handle_from_transform_controls_name + + parent.remove() + + assert tc._impl.removed + assert "/parent/gizmo" not in client.scene._handle_from_transform_controls_name + + +def test_client_remove_does_not_touch_broadcast_siblings( + server: viser.ViserServer, +) -> None: + client = _make_synthetic_client(server, 0) + + server.scene.add_frame("/parent", show_axes=False) + server_child = server.scene.add_icosphere("/parent/shared", radius=0.1) + client_child = client.scene.add_icosphere("/parent/mine", radius=0.1) + + client_child.remove() + + assert client_child._impl.removed + assert not server_child._impl.removed + assert "/parent" in server.scene._handle_from_node_name + + +def test_disconnect_frees_client_names(server: viser.ViserServer) -> None: + client = _make_synthetic_client(server, 0) + client.scene.add_icosphere("/mine", radius=0.1) + + with pytest.raises(ValueError, match="already used"): + server.scene.add_icosphere("/mine", radius=0.1) + + # Simulate the disconnect teardown's index cleanup. + with server._scene_lifecycle_lock: + server._scene_name_index.drop_scope(cast(ClientId, 0)) + + server.scene.add_icosphere("/mine", radius=0.1) + + +def test_server_reset_cascades_client_subtrees_only( + server: viser.ViserServer, +) -> None: + client = _make_synthetic_client(server, 0) + + server.scene.add_frame("/shared", show_axes=False) + nested = client.scene.add_icosphere("/shared/mine", radius=0.1) + top_level = client.scene.add_icosphere("/standalone", radius=0.1) + + server.scene.reset() + + # Client nodes under broadcast parents die with them; top-level client + # nodes are untouched (reset is scoped to the caller's own elements). + assert nested._impl.removed + assert not top_level._impl.removed + + +# --------------------------------------------------------------------------- +# World-axes view handle (client scope). +# --------------------------------------------------------------------------- + + +def test_client_world_axes_sends_nothing_at_construction( + server: viser.ViserServer, +) -> None: + client = _make_synthetic_client(server, 0) + buffer = client._websock_connection._state.message_buffer + assert len(buffer.message_from_id) == 0, ( + "client SceneApi construction queued messages; the world-axes view " + "handle must not re-add the shared node" + ) + # And the view handle holds no claim: the server owns the name. + assert "/WorldAxes" not in client.scene._handle_from_node_name + + +def test_client_world_axes_setters_never_skip(server: viser.ViserServer) -> None: + client = _make_synthetic_client(server, 0) + buffer = client._websock_connection._state.message_buffer + + def visibility_messages() -> list[m.Message]: + return [ + msg + for msg in buffer.message_from_id.values() + if isinstance(msg, m.SetSceneNodeVisibilityMessage) + ] + + # The cached value is False, but the write must send anyway: the cache is + # non-authoritative (broadcast writers also mutate this node). + client.scene.world_axes.visible = False + assert len(visibility_messages()) == 1 + + # Redundant same-value writes coalesce in the buffer (same redundancy + # key) but must still be QUEUED -- verify by flipping and re-flipping. + client.scene.world_axes.visible = True + assert visibility_messages()[-1].visible is True + client.scene.world_axes.visible = False + assert visibility_messages()[-1].visible is False + + +def test_client_add_world_axes_name_raises(server: viser.ViserServer) -> None: + client = _make_synthetic_client(server, 0) + with pytest.raises(ValueError, match="already used"): + client.scene.add_frame("/WorldAxes") From 15da0fa21f9aa1c7cd2d4895138a6141aa8d841d Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Sun, 9 Aug 2026 01:53:01 +0000 Subject: [PATCH 04/27] Remove client.scene.world_axes; world axes are server-owned only The world axes are a single shared (broadcast) scene node, so a per-client handle for them was inherently a cache over state that broadcast writers also mutate. The previous commit kept it alive as a 'non-authoritative view' (never equality-skipping sends), but that carried a genuine cross-buffer ordering caveat and a subtle concept for an API almost nobody uses: a GitHub-wide code search for client.scene.world_axes finds three files, all in one repository, all doing 'client.scene.world_axes.visible = True' in on_client_connect -- equivalent to assigning server.scene.world_axes.visible once. world_axes is now a SceneApi property backed only by the server scope; accessing it on a client handle raises AttributeError with a message pointing at server.scene.world_axes. The view-handle machinery from the previous commit (the authoritative flag on _SceneNodeHandleState, the force parameter on _set_pose_vector, and the gated equality early-outs in the pose/visibility/prop setters) is reverted as dead code. Client SceneApi construction still sends nothing, and /WorldAxes remains claimed by the server in the name index, so a client-scope add of the name still raises. --- src/viser/_assignable_props_api.py | 21 ++++------ src/viser/_scene_api.py | 59 ++++++++++++--------------- src/viser/_scene_handles.py | 21 +--------- tests/e2e/test_cross_scope_handles.py | 16 ++++---- tests/test_scene_name_index.py | 41 +++++++------------ 5 files changed, 57 insertions(+), 101 deletions(-) diff --git a/src/viser/_assignable_props_api.py b/src/viser/_assignable_props_api.py index 8dbc0cdb6..063afd5cc 100644 --- a/src/viser/_assignable_props_api.py +++ b/src/viser/_assignable_props_api.py @@ -121,20 +121,13 @@ def props_setattr(self, name: str, value: Any) -> None: value = self._cast_value_recursive(self._prop_hints[name], value, name) current_value = getattr(self._impl.props, name) - # Non-authoritative view handles (e.g. a client scope's view of the - # shared world axes) never equality-skip: their cached props can be - # stale relative to other writers, so a "no-op" write may in fact be - # a needed override. - authoritative = getattr(self._impl, "authoritative", True) - # Skip update if value hasn't changed. - if authoritative: - try: - hash(current_value) - if current_value == value: - return - except (TypeError, ValueError): - pass + try: + hash(current_value) + if current_value == value: + return + except (TypeError, ValueError): + pass # Update the value based on type. if isinstance(value, np.ndarray): @@ -142,7 +135,7 @@ def props_setattr(self, name: str, value: Any) -> None: # Ensure consistent dtype. if value.dtype != current_value.dtype: value = value.astype(current_value.dtype) - if authoritative and np.array_equal(current_value, value): + if np.array_equal(current_value, value): return # In-place update for same shape arrays. diff --git a/src/viser/_scene_api.py b/src/viser/_scene_api.py index 37c3068e8..433589893 100644 --- a/src/viser/_scene_api.py +++ b/src/viser/_scene_api.py @@ -72,7 +72,6 @@ _DragInput, _normalize_node_name, _RaycastSupportedSceneNodeHandle, - _SceneNodeHandleState, _TransformControlsState, ) from ._threadpool_exceptions import ( @@ -340,41 +339,18 @@ def __init__( self._scene_pointer_done_cb: list[Callable[[], None | Coroutine]] = [] # Set up world axes handle. Only the SERVER scope creates (and owns) - # the node; the name index would reject a second overlapping claim. + # the node: the world axes are one shared scene node, and a + # client-scoped duplicate would collide with the server's claim in + # the name index (each ClientHandle used to re-add /WorldAxes over + # its own connection, racing the broadcast replay). Client-scoped + # SceneApis expose no world_axes -- see the property below. + self._world_axes: FrameHandle | None = None if self._scope_key is None: - self.world_axes: FrameHandle = self.add_frame( + self._world_axes = self.add_frame( "/WorldAxes", axes_radius=0.0125, ) - """Handle for the world axes, which are created by default.""" - - self.world_axes.visible = False - else: - # Client scope: a NON-AUTHORITATIVE view onto the shared node. - # No messages are sent at construction (the broadcast replay - # already delivers the server's node + state), and the handle is - # not registered in this scope's registry or the name index -- - # it is a write-through override, not a claim. Because broadcast - # writers also mutate the node, this handle's cached state can - # be stale, so authoritative=False makes every setter send - # unconditionally instead of early-returning on cached equality. - # Reads reflect only writes made through THIS handle. - self.world_axes = FrameHandle( - _SceneNodeHandleState( - "/WorldAxes", - _messages.FrameProps( - show_axes=True, - axes_length=0.5, - axes_radius=0.0125, - origin_radius=0.025, - origin_color=(236, 236, 0), - scale=1.0, - ), - api=self, - visible=False, - authoritative=False, - ) - ) + self._world_axes.visible = False self._websock_interface.register_handler( _messages.TransformControlsUpdateMessage, @@ -400,6 +376,25 @@ def __init__( self._handle_scene_pointer_updates, ) + @property + def world_axes(self) -> FrameHandle: + """Handle for the world axes, which are created by default. Hidden + until made visible via ``server.scene.world_axes.visible = True``. + + Only available on the server's scene API: the world axes are a single + shared (broadcast) scene node, so per-client handles would hold stale + caches of state that broadcast writers also mutate. Accessing this on + a client handle's ``client.scene`` raises ``AttributeError``.""" + if self._world_axes is None: + raise AttributeError( + "world_axes is only available on the server's scene API " + "(server.scene.world_axes): the world axes are a single " + "shared scene node owned by the server scope. To show or " + "hide them for every client, assign " + "server.scene.world_axes.visible." + ) + return self._world_axes + def _is_drag_active_for(self, name: str) -> bool: """Whether the named scene node currently has any in-flight drag gesture (from any connected client). Used by ``remove()`` to diff --git a/src/viser/_scene_handles.py b/src/viser/_scene_handles.py index 67230c38c..80a321f4c 100644 --- a/src/viser/_scene_handles.py +++ b/src/viser/_scene_handles.py @@ -49,8 +49,6 @@ def _set_pose_vector( length: int, websock: WebsockMessageHandler, make_message: Callable[[_PoseTupleT], _messages.Message], - *, - force: bool = False, ) -> None: """Shared write path for the scene-node and skinned-bone pose setters. @@ -58,15 +56,12 @@ def _set_pose_vector( ``current``, and otherwise writes it into ``current`` in place and queues the message built from the cast value. Keeping this in one place stops the four near-identical wxyz/position setters from drifting apart. - - ``force`` skips the unchanged-value no-op; used by non-authoritative view - handles, whose cached value may not match the node's actual state. """ from ._scene_api import cast_vector value_cast: _PoseTupleT = cast_vector(value, length) value_arr = np.asarray(value_cast) - if not force and np.allclose(value_arr, current): + if np.allclose(value_arr, current): return current[:] = value_arr websock.queue_message(make_message(value_cast)) @@ -232,12 +227,6 @@ class _SceneNodeHandleState: click_cb: list[_ClickCallbackEntry] = dataclasses.field(default_factory=list) drag_cb: list[_DragCallbackEntry] = dataclasses.field(default_factory=list) removed: bool = False - authoritative: bool = True - """Whether this handle's cached state is the source of truth for the - node. False for per-client VIEW handles onto a shared (broadcast) node, - e.g. ``client.scene.world_axes``: broadcast writers also mutate the node, - so the cache can be stale, and setters must send unconditionally instead - of early-returning when the new value equals the cached one.""" # Last bindings tuple published to the client. Used to dedup # redundant ``SetSceneNodeClickBindingsMessage`` emits — without # this, a no-op ``remove_click_callback("foo")`` for an @@ -414,7 +403,6 @@ def wxyz(self, wxyz: tuple[float, float, float, float] | np.ndarray) -> None: 4, self._impl.api._websock_interface, lambda v: _messages.SetOrientationMessage(self._impl.name, v), - force=not self._impl.authoritative, ) @property @@ -432,7 +420,6 @@ def position(self, position: tuple[float, float, float] | np.ndarray) -> None: 3, self._impl.api._websock_interface, lambda v: _messages.SetPositionMessage(self._impl.name, v), - force=not self._impl.authoritative, ) @property @@ -442,11 +429,7 @@ def visible(self) -> bool: @visible.setter def visible(self, visible: bool) -> None: - # Non-authoritative view handles must not equality-skip: their cache - # can be stale relative to broadcast writers, and a skipped send here - # silently drops the override (world_axes.visible = False after the - # server broadcast True was exactly this bug). - if visible == self._impl.visible and self._impl.authoritative: + if visible == self._impl.visible: return self._impl.api._websock_interface.queue_message( _messages.SetSceneNodeVisibilityMessage(self._impl.name, visible) diff --git a/tests/e2e/test_cross_scope_handles.py b/tests/e2e/test_cross_scope_handles.py index 9d9978374..5bb081f09 100644 --- a/tests/e2e/test_cross_scope_handles.py +++ b/tests/e2e/test_cross_scope_handles.py @@ -427,24 +427,22 @@ def test_disconnect_frees_client_names(browser: Browser) -> None: server.stop() -def test_world_axes_per_client_override_after_server_show( +def test_world_axes_toggle_reaches_connected_client( viser_server: viser.ViserServer, viser_page: Page ) -> None: - """Hiding the world axes through a client handle takes effect even after - the server made them visible (the client-side handle is a - non-authoritative view: it never equality-skips sends), and the override - can be lifted again.""" + """server.scene.world_axes is the only world-axes handle (client scopes + have none -- accessing client.scene.world_axes raises), and its toggles + reach an already-connected client in both directions.""" client = get_client_handle(viser_server) + with pytest.raises(AttributeError, match="server.scene.world_axes"): + _ = client.scene.world_axes viser_server.scene.world_axes.visible = True wait_for_scene_node_visible(viser_page, "/WorldAxes") - client.scene.world_axes.visible = False + viser_server.scene.world_axes.visible = False wait_for_scene_node_hidden(viser_page, "/WorldAxes") - client.scene.world_axes.visible = True - wait_for_scene_node_visible(viser_page, "/WorldAxes") - def test_world_axes_server_state_deterministic_for_new_client( browser: Browser, diff --git a/tests/test_scene_name_index.py b/tests/test_scene_name_index.py index 6d953c82d..3a092c380 100644 --- a/tests/test_scene_name_index.py +++ b/tests/test_scene_name_index.py @@ -23,7 +23,6 @@ import viser import viser._client_autobuild -from viser import _messages as m from viser._scene_name_index import SceneNameIndex from viser._viser import ClientHandle from viser.infra import ClientId @@ -312,45 +311,33 @@ def test_server_reset_cascades_client_subtrees_only( # --------------------------------------------------------------------------- -# World-axes view handle (client scope). +# World axes (server-owned; no client-scope handle). # --------------------------------------------------------------------------- -def test_client_world_axes_sends_nothing_at_construction( +def test_client_scene_construction_sends_nothing( server: viser.ViserServer, ) -> None: client = _make_synthetic_client(server, 0) buffer = client._websock_connection._state.message_buffer assert len(buffer.message_from_id) == 0, ( - "client SceneApi construction queued messages; the world-axes view " - "handle must not re-add the shared node" + "client SceneApi construction queued messages; it must not re-add " + "/WorldAxes (or anything else) over the per-client connection" ) - # And the view handle holds no claim: the server owns the name. assert "/WorldAxes" not in client.scene._handle_from_node_name -def test_client_world_axes_setters_never_skip(server: viser.ViserServer) -> None: +def test_client_world_axes_raises_with_pointer_to_server( + server: viser.ViserServer, +) -> None: + """The world axes are one shared broadcast node; there is no per-client + handle for them. The error message points at server.scene.world_axes.""" client = _make_synthetic_client(server, 0) - buffer = client._websock_connection._state.message_buffer - - def visibility_messages() -> list[m.Message]: - return [ - msg - for msg in buffer.message_from_id.values() - if isinstance(msg, m.SetSceneNodeVisibilityMessage) - ] - - # The cached value is False, but the write must send anyway: the cache is - # non-authoritative (broadcast writers also mutate this node). - client.scene.world_axes.visible = False - assert len(visibility_messages()) == 1 - - # Redundant same-value writes coalesce in the buffer (same redundancy - # key) but must still be QUEUED -- verify by flipping and re-flipping. - client.scene.world_axes.visible = True - assert visibility_messages()[-1].visible is True - client.scene.world_axes.visible = False - assert visibility_messages()[-1].visible is False + with pytest.raises(AttributeError, match="server.scene.world_axes"): + _ = client.scene.world_axes + # The server-side handle is unaffected. + server.scene.world_axes.visible = True + assert server.scene.world_axes.visible def test_client_add_world_axes_name_raises(server: viser.ViserServer) -> None: From d197c88cd6e0cec25193fb333c13f2a6b677cd31 Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Sun, 9 Aug 2026 03:03:41 +0000 Subject: [PATCH 05/27] Add design doc: (owner, name) scene-node identity Records the agreed direction for relaxing the cross-scope name rules: scene nodes become identified by (owner, name) so broadcast and client-scoped nodes with the same name coexist instead of erroring. Documents the load-bearing decisions -- opaque owner string (not a boolean, so audience sets need no second migration), per-message owner field (batch tagging dies in the merged-buffer endgame), frozen add-time parent resolution replacing the audience-subset rule, cascade along resolved edges, and the migration sequencing from today's rejection semantics. --- docs/design/scene_node_identity.md | Bin 0 -> 6764 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/design/scene_node_identity.md diff --git a/docs/design/scene_node_identity.md b/docs/design/scene_node_identity.md new file mode 100644 index 0000000000000000000000000000000000000000..d9d06b7d7d6d2ec6ec92ddbb6f6f4d923985869a GIT binary patch literal 6764 zcmZvh&2r<$6@|0LQ*>z-p=gn+*zrbFUW~_1Y8D<(dB*lqYyvG35eU#|KosqARaSX` zRG!dJlJDGZKvF7IQ?^79{d4a*=bn4@%6%+D8(iB}!PQl0hk7{QxVyePxYi%S>OP!9 z<%Z5Jx>FnaEBo{^eOweD2S1GQ#x0k}zB_g?RLiAXR3X;8*4=fz^X_gO#y+@m@7rB? z%|?T(5631P*eg_*>*7Nn`tZwG$9f2^55B6~-HqGCzPx^@W9YAMqmFp1SE^<0f z8i$VcQarVkBCb2RQ$6hYl{1xHU%Bos-Gp_zBY`6%1j_lg;8y+lil3^q`ST1NLX3VF z++sXdd_P{X+@X8W4`oxA_x!)={VrbedF&b<6n^LHHVz!#4~{F@a`EWPuy$XE(vLBa zhR}1*wcTms7Tdn_Rq5k!nU3L!_=XKQ+Wehk=vV2ZOSkQ~+g%WZ$VSw46&Nm&?p`*& zKBU+Vr>?LoM@Qc4Eg=JT^J@=vIECJED1&3H8}@9@j+pi1aUi^0n~Z+yDu4cSba(Zm zTj=eLrSv^pzY};im*gNEV+vl^vc!aYyV7#mhkt}}h|6U`{QAZpk0fxFv!U~nR7DNM zRAgJQU2J~x%^1Gz`>x+y0!5{ijQO!5G-X)DaT~*6a5P?&eHUY1^9au1UAwdWpFbhc zr3WTmTg-t^dG$?PyHA2O|6(_`)s@?hP-}1pe{YZnI$hHc$*u(gKE`4RBe!)0%a^YA zT%cF(TelqBJ{-XX$MD2`nJ~s$yil@O0zo>S<+><^UeZ%`K(=lPeL~N3Nc{l|AMBzcM{k58|UV_hL6ok9<=p5@s48D{w+f*FZAc%W29bhX%+VsEswlDzDW{+CfYB)MFYWNER7qFobwds7rEXXFux zh6Ezw@gQI$@tcbof)`WBjp<$6LKmSd@d&N#*R1s;Y!kqs$;<*MfNqB-G4tZpmlqDj z+`&!Bw{&<8ed+t2q_l;b-?v1+P@6Rjyj|KAiVLcKR`^IP0QTzJ=XrEh-UOo1dlsxYlYkF|kdTd~Zc z-oZJ-^jg`-xv=Gk8<>J-#5WIq!zfB9};(jBrv+P455- zOod%AXD6M z$2l)W+7!_AydavJH}Tw--=zZkwcTZ%j&VC5V{^H7?+Cn<1$vTy71WdzA-P8W4z+ln z5R?=)JEDmticbp2?>`Hz47J014sxvs8ES0~IY zHn(rhNTgy22lIV4K$GHR1wv*aS4+O|qZx_mYkCrf-@fN26ba$@>O)8E(+^PV_stax z=_~UFKdFe<4Sw?38b6pRnZNV&WDCxKtca2V4baS=h%HIUZX{hBvSh$Fh!RiItpKHS zzd6<$O9JblbqE*0UAwntKBL>oPxRfm+q=!}w2!&T&(I8#oKXQn%=H}4tg~9_$;7^h z_K~MJ`bHx6)`|`pNN?Owvp^#SMS3{xfIXOR%nRlHd~J)F9Orui{tfu!DO2 zdCfXMV_!Plgz=O$@sO!nk|8s3D=Tb5n5t1S)N$+~o4v#mRW}0Q)x7<7+}U#WqPcja zzCM_Bs1(U>t@U{FV%(fFRX7{pmS_29m9gX_=St-M{??@;FQtCrAJ%pYiD5Q`@dy^P zuH=?Ll@SG9bw{@_MNF#0Qv0r<(kng|O5!sm^YS%0wyPXMzYEoBQYvFTJFsYjNnUM( zSE0>{{f+^o1453pTbdqnMwlJiV%m z)!cn|vaPL4(EVTAul8l(*dMm_Zd7xl^K91E9d2Y!Yv0trnkXd^OMtXIpyUXfe!$3w(?gw-5iDIKse}SQH=%mc_&GqPc z8*o_^N}g(M-84{A20Xew?#cJ%K37$2?Y?d*@Rzy-ou(yD>QbzY!((9Ls;U_1qb#@h zm0xlAODe=)C?I{H_F5qG3cA?@;c0QMl7MbJvlg)UeC@t%h{T=ecv}yYGBlndCQ_y^ zf04iJ$Dv!LmKDbnRo6KbaRBUSaU#z1U8f~*go&r51aS^6auROmS8vU%gKU*%qof=z z4B)COKdjV=9sD3CaO0{ocdSeDv9c)Tlu$modU|^bC(T`=Y0&^I<`pQ+g}RQ0lV2nz zC;^wV%uP`=XvZV0svDAZsH;^ZfvSgvwSO9ifSfim+>kl9hD{TWoVO{VdFZ0dCb-HZ zJPTB@NVR1Z@mEs_C$+Zjo{F3vs)d23M$iBsakrF!8B)d2du(jP$F}mb3J@N>W*Cs; zp$P)`NwXi7TTzEVTc-&H)8xQ5nLO5WOuy(Vk6mw8v&MVTWlS_HHrM`oyTR(HJ($}z zYs7i90p4g8v!dPwwbRjDBPlt$&%t8dAYt~~7e&IEG<;s2zKln8T{6#PTPhi9?Ly_a7PaU#ASUlToZYd*LU1SRhJI&fXhD{j9(|EoNSb zhNH%_Gq6c^*lD;z4S0&!i?*IDPCXI2GqU=0RSSR`e7-{Au`8?4pphmloam-_>e;8j zQWWPg0ESl_$+`boLCeF6rrxTr2=v^x{rm4fsW*CH6)=%ZkagOo!;H-pm^V6KIz~y#)RjNAAbi2_tfA)GU3^SslKrRso?TY# zRC3sF z*BYjJxiIEwYhe>O&OuM5(FK!bYu6N@C(h=Pr4ErVb;VArDYlR(drPhcV%{@T)B-jy z*kUSZSnDB~g+vZ(5d>XN@`&v;UDl9>e!#}Q-%b6v2}BlYu}oES@eS4wZB6**sKWQ9 zQ^8)n92wF$ByZ27G+!jtnjdmjnKfrKK~6%T+5SW*R`OQ375uRr3gaN%hM*o?Dnd3B zQk~y+72blU!5`O5;FFpQ}#Z<2HE6Rwc);1BB07$!fB9XGVkRLRBV zMy+cVj=~|Gw@n3z9bnWHUsFXxlGPS1l2VBC&As?xqV~GL2BcD9?{WdyzNGkIPvBOH1DLJ_r-iJX?4`a2~cM_K4#HVfwi*2K=sa6`H!2qvvlTO&r{jV)mbXXBwqq zQaF(YLIn8ObZ3>bj$B)GgbHh!rKOrPgBlHzo-Bcmwl4mP-sDjheCEE3S6`C#&BV&D zkj7TFFd3$-6i?jCMl2;zXPE~X)XQn%!aqO+g|#FP Olj>@=0RE7hb@5+hw`ffO literal 0 HcmV?d00001 From dd7d3c00cb0d2b93df74270a26c253596bf2a037 Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Sun, 9 Aug 2026 06:11:36 +0000 Subject: [PATCH 06/27] Rework identity design doc: variant slots with client-wins display Replaces the frozen-parent-edge model with a simpler and stronger one: each scene-tree name is a slot holding up to two variants (broadcast + client), both fed independently by their scopes' messages, with one effective variant chosen by a local display rule -- real client > real broadcast > virtual client > virtual broadcast. This provides client-over-server supersede (the override semantics name-only shadowing couldn't deliver safely) without per-client send filtering or resurrection machinery: shadowed broadcast state keeps accumulating and simply shows again when the client variant is removed. Hierarchy stays name-based, auto-created ancestors are flagged virtual so they yield to real variants, and both claim-time ValueErrors become relaxable. --- docs/design/scene_node_identity.md | Bin 6764 -> 8127 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/design/scene_node_identity.md b/docs/design/scene_node_identity.md index d9d06b7d7d6d2ec6ec92ddbb6f6f4d923985869a..fbbc3c4a67ba93f9fd112d60e5cf44807a1dad9b 100644 GIT binary patch literal 8127 zcmaKx&64BBv4!_|ii*)fY^uqQYkSobwqx|;YeiVnge6PM1_%^Mc<@7`fM(MUZ~Xv% zL_dkYlT`rO^4Jk04Tl60S(ztKo=kk=zAt^}UDsFM)s^qYdb~WklOHx+(R%k>40X|s zE;RiZ+_@f)u54;9+nnnzxT+4PrnqF;_hT_m;nA(vr=dUf!B^|GTU9>PhtBQ$!4+;l zjnm*=c`Ukvf6Eg`SGT9ew^;Ml-8Oqa_`&~WszW_`H~6BeyThZ)!%#jv*TD}DPeHyu z$+4%ptNhFMbje-YS5xC%)t6x$xaHoRk9B#>_+pan;Kn2NxJI99oQ__d`>CmTPE)*W zv+R>k+)qcI@6O&8gXcs`T!Owx!P(coa=VMO;1o^Y`FrP|{ct(!=0|(fe&{(-S2=Un zjf3|Yk1brlJ3I|zkG&86l1wS|X3xUg_4riwL*@E?JPCIC6M-Wn1j-d%#;JzOJ)f%h zaJzjIEw(;{;^5tCI#paB?lIT)&-y^-$`AZq4aFhc@qOqUe#nYLK{7|2FGk0a%v?AX zrQf>GeOXMwlLkL<&aIs(cdOmd7gbq=@h-Yi5b-=hW4T5sieKHiT@Q5j9{-h%AZ#fx z93q~*Y>K*#0U6JIW=9T=yw^KIHul%so@;;hgTpCo^#rvc9L>cMvtc>`RiNm}=ns8W zTs{qMU%$AO9-3Q9zvarQd{U7yM{&w7MvVK7?fR1kNbH z?qJWq{e?i61u*HmYzchKYte+Q`$4egPw1zvx_7&Y(i&Y`d@#rZoxW*^WZwaSB7|&B zMegc~a$mcl-~fYi-?{bF4gLf!aHAmZ>xeOIaYKo*2!iOI(Lh!Ty`-V+fo$Co`iP!b zQa=zI>eDw__7w<;6|qs`VG3a$me_%IK&$$IWZbcDRQPT>S=P@zS0)xB6OXRAiNMTG zDuf~t(@a@FSCE@;wOl)~#);D8d=V$vN_<64h_mh^-s%Ppyyx1{4@EgDY&&kmsX#{e z!0yHjsDTa$VLc?!e(F*Ql9;bvFu)}$s@Ylcv|juD9%$9i3c!l2SFlHQpDoXP_IDy- zizS77#5=sWaLT4Hi)JQX!_i}w{l>kgv<-^$W7^`SZtK4)lzG0Y!xQw;aH{Z>N;1zM zxliBDkxT!&BJ4K=WEiL0E9Nhji%9gFALCVTfZXtbOip9Jnd!=oL}k~4MnMUP{(@Tt zFtGDYf4&obBSB{eA`+528x`FqAudcrgNlbbd`m;B%iuPfCF!w%00DIfZAMPX$jXe^ z{!F)8hhtIo=diXwEhmW0Pfcnez{D{;K?U4TV^(xzac?)aAi3_~{CItBHI0ZTQ&O$K<%)iu&yg)JcYQ)F}11chSf@Kxq+F0~fk zs1)k%X9rA}fDDZs-|FYfr!=9U4oo-eM@*$=4!&ui>Qm_{TlX!MB-hHO-|fhQnNN-< z+uQj}u*Wpe!K`kUQ0WMNR3DNV<>mF=#AtlKn=A}lyvv0DU2@d zRIX?m{_A-FdE*(^BSpHU@YGq`)1)?Pb;Q}})~%yc7Thb*K^KVOvo;-%^`$2_ZQa!Y z;LW_N>#VC)bvmvk>U-;x@gmMsqd)S87i!Jfxv#063$^iuKyP4$0N*a4zGaN5D~vIG zwZ+gE#=ZafjQ{e4RZU5Bmpd{F`kl4=c(6aHDF56G&3q3aP*Be`fsE~xEQ)fkKGPkL zdywTxp~q_h`(ybE(87BC`x*uv%Hu`2yQEILE2v}P;X6ptT7j?7D~PwLG=(_x-dlLD zZYtO@E5=dnBg%k!aIsUm*h212OUA-D`Lf=VqXjmq2XQ+Ms@2~CRv7x}aE#hY+HJmA z9dzMkkrMyr&snM{Dh1_eR(DwxvM#iQ>k${Mn&+fOZKE%(r*G_tCb%Xm43neQBR_G8 zJ;9Su7MU_+*}KG*H*~62w92307nJnVLZ(mt1!SUUBIgE2k^tv^puwVl`XOuUAn|$S z->v(Tv6D`e>OK|;Y)7!#2{|Z*9yx%nn+g^o1GmplHU*PE6uFCH8k+FK8LfU*Wvc3k zZL13?$U2U0%2q0l;ziO_pDax%KKf0dDZKk{T_hn@f}6AXY~K`z0~L)R(_2^NiZl!KA zBbVi?s2rD87Hj@z9wf!MJl?IMt{N_xT2b94t+-fWaLPW4AowiwO0KDVi^0cKsB(KP za88lYfJo27j;t!i`bW+=fmp0}1tcKZ5+u}BY(Tkb`=ltFvvIY)Hd9;oonVsgmZppV zv0_h66D?YIN&M4#(LaMVEk_@ZjYu=-EtnEgV@%^6>RZlBiMx)$!*pBb2_g?)IQyQX zfE*+8TkTHWs4*yaHk(QEy%#Hjf;TXlW6*#VRf`1AkZauJY(`MN(9f0>LWF$KV}-JUQ5x@{!e zSo@!qXQV`=a^KTlI_6RhLmCRkI20hOpEal;#NI5~f5W3Bq-}2NyDm2IEKWrf(}4Wd zY!QWxLURQaGYwMZ0O;My$Dtk}R}82m{GYFT`+7#)0=K7izKj52Iuru~<*W``d=@-( zwv><{#x9!7!UJ)b$DK>zsd4|guPcJajb$$}l$IKvQ`)eUYua`ce{x46e#xHI#A?z| z(G>W+d0yRR`MYrG%5UO|;&VI7HoEb&xRJyCUyE`nBud8^oKeBTE$%+7+@{rjGkfIe)u+rQ`WWBB2m`46jCxAtq zK=GiB(E~hM+qg&+oN63$$Sl!9oJk#&F?qo+y8Z4{=UW#KdSgkw%RDvVXI2%?Jhi6W zsl#%gZSI0QXc=P~O7VD_{jp`-XDo`yaDoKzu%*%I0$la|Tyi0o{){q~%*4wcqXJ4J z_iUEbEFt5D^1~9XSK0WIo!0UTva;v`;-KBY0prj&fqjCl!>nku08^-j@qya8!;q)N z#9EpM^H~+rWDIB4D5L=li1Y4DlGI;^d@NZnw9a&({)P+fPOBxW)1TW``QMEElK%71 zg{6g;jsGw?Zy_A=pMB?`K^QU~>qeATG;{%UAy^buH^QjgqT5ZJ^;G@^!@7exyO!GTPdB zS^AT$)u8-o=oDU4ie-=3X0EjcK$`WXZQsq#hT=pbY+SA`_(#7dj_tQx0yABQs<``I z{SSYmKW545DQ@9TMdz7bbo8M=TQW`V*ur!fhUVZe3r*;lnGOGhDVwApBJz~^SxcMK zTU4BM4F6-Utp94%_y|(t_(bqDlWTQ&0FAG@hiZ$m;VBCyVs+gFb=hog_F5{UhBQfl z;-sqHUl?eRNVHSo?g!CYq!S|ewKQFxW;@-7T>79%Pp5!1q>koMXJZ0bD_s1c&)6dU zV=LMQ8`Fs;dkqtqhCE#vqc-s_%A;EFqbcE9#P3%h7QxVBQT(QMvbr?EQf7TT<(e5Nsw7>@Nx;xekn zRSY7QXaJR>f;K0Y;?pY1@@3Csl2094%R3tRPsSQrp3OxUwGzhC3~$fIYw00vI^)D| z*rpmsCM_+-SS)L$MnsiLSWL`?kr*{!F~w+Hgq`DnP%M*OoCSm>Wfnnq;uPG!Jc*M$ zE2)&DNIG2~2U;4qR{V>G)QEcTMtI}Y54L*JEwd>Dd?c*+4#f1Wjp3^|4?juEykpvZ zb9Ys|x;^knB_5})Y6*u-{A@HITQmo}U~OtkIRVseorh|11J!B!nR5O$fdW&BMw3@& z_(_KIl+wu1-^>cx+Ct!69C*iAmi^z>t-i!V5~SsxL?s=zJl;7m(d+f5Lj(N0#1?RC zd*$ry2-PmC?T2$QRJ7@q#;SKcj~s$ma_J0h8nbQ6Vu94}`s%XHezA4mSZhgg_bR*9 zj#AwbQdBD&Q6hU6IS!&R@X(lj(MpoB7r4HF$&tZrU9Rp>Ng{{&Re@oaDobw;H3N|c zG(h5~q?~=Fx1vaV_TXRwS&gf|Oq-;5FK*bd@!5h;8#_S$Tp#gG>u0aGGMt2T%Uz~Y zd%1`rq@>;Jku{B`WGPlZp*%BI+Iupw$K3g3Xqi9dAC2AeX}Trx5HQ!ozi(=L6I1pJ z175jahIpamsRa>NQgjbd&p9Z-UOFAfEP!3uJ-$n7C8Y3;FgN`ryp=5$W^&k;b%KM* zW6=?>+Tskq&3>iTP<)%lAx2(0=ks{^>s%dxhROS!>zs34t!t9wtT9P?-ZFGBNX|Cf zDe8Bb5;eGaoJGl5?lfN7a)9yb*ig|x)vEtFfe2qkDOtu+@XIRZPf?B5K}R89Z@ literal 6764 zcmZvh&2r<$6@|0LQ*>z-p=gn+*zrbFUW~_1Y8D<(dB*lqYyvG35eU#|KosqARaSX` zRG!dJlJDGZKvF7IQ?^79{d4a*=bn4@%6%+D8(iB}!PQl0hk7{QxVyePxYi%S>OP!9 z<%Z5Jx>FnaEBo{^eOweD2S1GQ#x0k}zB_g?RLiAXR3X;8*4=fz^X_gO#y+@m@7rB? z%|?T(5631P*eg_*>*7Nn`tZwG$9f2^55B6~-HqGCzPx^@W9YAMqmFp1SE^<0f z8i$VcQarVkBCb2RQ$6hYl{1xHU%Bos-Gp_zBY`6%1j_lg;8y+lil3^q`ST1NLX3VF z++sXdd_P{X+@X8W4`oxA_x!)={VrbedF&b<6n^LHHVz!#4~{F@a`EWPuy$XE(vLBa zhR}1*wcTms7Tdn_Rq5k!nU3L!_=XKQ+Wehk=vV2ZOSkQ~+g%WZ$VSw46&Nm&?p`*& zKBU+Vr>?LoM@Qc4Eg=JT^J@=vIECJED1&3H8}@9@j+pi1aUi^0n~Z+yDu4cSba(Zm zTj=eLrSv^pzY};im*gNEV+vl^vc!aYyV7#mhkt}}h|6U`{QAZpk0fxFv!U~nR7DNM zRAgJQU2J~x%^1Gz`>x+y0!5{ijQO!5G-X)DaT~*6a5P?&eHUY1^9au1UAwdWpFbhc zr3WTmTg-t^dG$?PyHA2O|6(_`)s@?hP-}1pe{YZnI$hHc$*u(gKE`4RBe!)0%a^YA zT%cF(TelqBJ{-XX$MD2`nJ~s$yil@O0zo>S<+><^UeZ%`K(=lPeL~N3Nc{l|AMBzcM{k58|UV_hL6ok9<=p5@s48D{w+f*FZAc%W29bhX%+VsEswlDzDW{+CfYB)MFYWNER7qFobwds7rEXXFux zh6Ezw@gQI$@tcbof)`WBjp<$6LKmSd@d&N#*R1s;Y!kqs$;<*MfNqB-G4tZpmlqDj z+`&!Bw{&<8ed+t2q_l;b-?v1+P@6Rjyj|KAiVLcKR`^IP0QTzJ=XrEh-UOo1dlsxYlYkF|kdTd~Zc z-oZJ-^jg`-xv=Gk8<>J-#5WIq!zfB9};(jBrv+P455- zOod%AXD6M z$2l)W+7!_AydavJH}Tw--=zZkwcTZ%j&VC5V{^H7?+Cn<1$vTy71WdzA-P8W4z+ln z5R?=)JEDmticbp2?>`Hz47J014sxvs8ES0~IY zHn(rhNTgy22lIV4K$GHR1wv*aS4+O|qZx_mYkCrf-@fN26ba$@>O)8E(+^PV_stax z=_~UFKdFe<4Sw?38b6pRnZNV&WDCxKtca2V4baS=h%HIUZX{hBvSh$Fh!RiItpKHS zzd6<$O9JblbqE*0UAwntKBL>oPxRfm+q=!}w2!&T&(I8#oKXQn%=H}4tg~9_$;7^h z_K~MJ`bHx6)`|`pNN?Owvp^#SMS3{xfIXOR%nRlHd~J)F9Orui{tfu!DO2 zdCfXMV_!Plgz=O$@sO!nk|8s3D=Tb5n5t1S)N$+~o4v#mRW}0Q)x7<7+}U#WqPcja zzCM_Bs1(U>t@U{FV%(fFRX7{pmS_29m9gX_=St-M{??@;FQtCrAJ%pYiD5Q`@dy^P zuH=?Ll@SG9bw{@_MNF#0Qv0r<(kng|O5!sm^YS%0wyPXMzYEoBQYvFTJFsYjNnUM( zSE0>{{f+^o1453pTbdqnMwlJiV%m z)!cn|vaPL4(EVTAul8l(*dMm_Zd7xl^K91E9d2Y!Yv0trnkXd^OMtXIpyUXfe!$3w(?gw-5iDIKse}SQH=%mc_&GqPc z8*o_^N}g(M-84{A20Xew?#cJ%K37$2?Y?d*@Rzy-ou(yD>QbzY!((9Ls;U_1qb#@h zm0xlAODe=)C?I{H_F5qG3cA?@;c0QMl7MbJvlg)UeC@t%h{T=ecv}yYGBlndCQ_y^ zf04iJ$Dv!LmKDbnRo6KbaRBUSaU#z1U8f~*go&r51aS^6auROmS8vU%gKU*%qof=z z4B)COKdjV=9sD3CaO0{ocdSeDv9c)Tlu$modU|^bC(T`=Y0&^I<`pQ+g}RQ0lV2nz zC;^wV%uP`=XvZV0svDAZsH;^ZfvSgvwSO9ifSfim+>kl9hD{TWoVO{VdFZ0dCb-HZ zJPTB@NVR1Z@mEs_C$+Zjo{F3vs)d23M$iBsakrF!8B)d2du(jP$F}mb3J@N>W*Cs; zp$P)`NwXi7TTzEVTc-&H)8xQ5nLO5WOuy(Vk6mw8v&MVTWlS_HHrM`oyTR(HJ($}z zYs7i90p4g8v!dPwwbRjDBPlt$&%t8dAYt~~7e&IEG<;s2zKln8T{6#PTPhi9?Ly_a7PaU#ASUlToZYd*LU1SRhJI&fXhD{j9(|EoNSb zhNH%_Gq6c^*lD;z4S0&!i?*IDPCXI2GqU=0RSSR`e7-{Au`8?4pphmloam-_>e;8j zQWWPg0ESl_$+`boLCeF6rrxTr2=v^x{rm4fsW*CH6)=%ZkagOo!;H-pm^V6KIz~y#)RjNAAbi2_tfA)GU3^SslKrRso?TY# zRC3sF z*BYjJxiIEwYhe>O&OuM5(FK!bYu6N@C(h=Pr4ErVb;VArDYlR(drPhcV%{@T)B-jy z*kUSZSnDB~g+vZ(5d>XN@`&v;UDl9>e!#}Q-%b6v2}BlYu}oES@eS4wZB6**sKWQ9 zQ^8)n92wF$ByZ27G+!jtnjdmjnKfrKK~6%T+5SW*R`OQ375uRr3gaN%hM*o?Dnd3B zQk~y+72blU!5`O5;FFpQ}#Z<2HE6Rwc);1BB07$!fB9XGVkRLRBV zMy+cVj=~|Gw@n3z9bnWHUsFXxlGPS1l2VBC&As?xqV~GL2BcD9?{WdyzNGkIPvBOH1DLJ_r-iJX?4`a2~cM_K4#HVfwi*2K=sa6`H!2qvvlTO&r{jV)mbXXBwqq zQaF(YLIn8ObZ3>bj$B)GgbHh!rKOrPgBlHzo-Bcmwl4mP-sDjheCEE3S6`C#&BV&D zkj7TFFd3$-6i?jCMl2;zXPE~X)XQn%!aqO+g|#FP Olj>@=0RE7hb@5+hw`ffO From 05d98e738789028f9d2e7ce7cd82f0e8f310dc6b Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Sun, 9 Aug 2026 06:24:22 +0000 Subject: [PATCH 07/27] Design doc: scope-local cascade + unconditional virtual anchors Removal no longer reaches across scopes: a server remove cascades through broadcast descendants only, a client remove through that client's descendants only. Neither scope can destroy the other's state -- both are driven by the same program, so coupled teardown is the author's explicit call. Two supporting mechanisms make this orphan-free and jump-free: ancestor auto-creation becomes unconditional per scope (virtual anchors give every node a complete same-scope ancestor chain) and a virtual anchor that becomes the effective variant inherits the departing variant's last pose. Deletes the cross-scope handle-invalidation machinery from step 3's scope: the zombie problem is solved by keeping the frontend node alive instead of killing the Python handle. --- docs/design/scene_node_identity.md | 65 +++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 20 deletions(-) diff --git a/docs/design/scene_node_identity.md b/docs/design/scene_node_identity.md index fbbc3c4a6..e4dfc5f89 100644 --- a/docs/design/scene_node_identity.md +++ b/docs/design/scene_node_identity.md @@ -55,14 +55,23 @@ change small: no per-variant tree, no parent-edge resolution rules. ### Virtual intermediates -Ancestor auto-creation (`_ensure_ancestors_exist`) creates plain frames for -missing ancestors. Under the display rule those must not shadow: a client -auto-ancestor for `/a` would otherwise silently hide the server's real `/a` -(its axes, its pose visuals). Auto-created intermediates are therefore -flagged **virtual** -- a field on the create message -- and virtual variants -yield to real ones in the display rule. A later explicit add of the same name -from the same scope supersedes the virtual variant with a real one (ordinary -within-scope supersede). +Ancestor auto-creation (`_ensure_ancestors_exist`) becomes **unconditional +per scope**: every add creates anchors for all missing *same-scope* +ancestors, even when another scope's variant of that name exists. These +anchors are flagged **virtual** -- a field on the create message -- and: + +- Virtual variants yield to real ones in the display rule, so a client + auto-ancestor for `/a` never shadows the server's real `/a` (its axes, its + pose visuals). A later explicit add of the same name from the same scope + supersedes the virtual variant with a real one (ordinary within-scope + supersede). +- Virtual variants render nothing and are never interactive; while a real + variant of the name exists, the anchor is pure lifecycle bookkeeping. + +Unconditional anchors give every node a complete same-scope ancestor chain, +which is what makes scope-local cascade (below) orphan-free: the two scopes +are two complete overlaid trees, merged per name by the display rule. The +cost is a handful of tiny anchor messages per deep add. Virtual intermediates also dissolve the audience-subset rule: a broadcast add of `/a/b` where `/a` exists only in some client's scope auto-creates a @@ -117,14 +126,28 @@ owners. Pay the schema sweep once: ## Python side - The `SceneNameIndex` keeps its bookkeeping roles (ancestor-existence - checks, cross-scope cascade lookup, disconnect cleanup) and loses both - claim-time rejections. -- Cascade: when the **last** variant of a name is removed, removal cascades - into other-scope children of that name exactly as shipped today (Python - handles invalidated; the machinery carries over). While any variant of the - parent name remains, children stay put. Alternative considered and - rejected: auto-spawning a virtual anchor to keep orphaned children alive - -- the anchor would sit at identity pose, teleporting the children. + checks, disconnect cleanup) and loses both claim-time rejections -- and, + with scope-local cascade, its cross-scope cascade lookup. +- **Cascade is scope-local**: a server remove cascades through broadcast + descendants only; a client remove cascades through that client's + descendants only. Neither scope can destroy the other's state -- both + scopes are driven by the same Python program, so when coupled teardown is + wanted (a per-client annotation that should die with the mesh it + annotates), the author removes it explicitly rather than the design doing + it behind their back. Unconditional same-scope virtual anchors (above) + guarantee no orphans: the surviving scope's subtree keeps a complete + ancestor chain. This also deletes the cross-scope handle-invalidation + machinery from the current branch -- the zombie problem is solved from + the other side, by keeping the frontend node alive so handle and frontend + agree by construction. +- **Frozen-pose inheritance**: children compose pose through the parent + name's *effective* variant; virtual anchors contribute nothing while a + real variant exists. When the effective variant is removed and a virtual + anchor becomes effective, the anchor inherits the departing variant's + last pose (a frontend-local copy) -- surviving children stay where they + were instead of teleporting to identity. Accepted caveat: a client child + can outlive the broadcast object it annotated, frozen in place, until its + author removes it. - `client.scene.add_frame("/WorldAxes")` becomes the sanctioned per-client world-axes override (shadowing the server's node), replacing the removed `client.scene.world_axes` handle. @@ -137,10 +160,12 @@ owners. Pay the schema sweep once: cross-scope `atomic()`. Independent of this design but shares the filtered-window machinery. 3. This design: owner + virtual fields, variant slots + display rule on the - frontend, both `ValueError`s relaxed, cascade rewired to last-variant - semantics. The branch's rejection tests flip to coexistence/shadowing - assertions. Client/server version gating already forces matched deploys; - no wire compatibility shims needed. + frontend, both `ValueError`s relaxed, cascade rewired to scope-local + semantics (the interim cross-scope cascade + handle invalidation from + step 1 is deleted; handles that used to be invalidated stay valid, so the + change is again a relaxation). The branch's rejection tests flip to + coexistence/shadowing assertions. Client/server version gating already + forces matched deploys; no wire compatibility shims needed. 4. Audience sets: `audience=` on add, per-client filtering in the window generator, audience mutation on live elements. Owner ids from step 3 are the identity substrate; the display rule generalizes by ranking owner From e284f2b383a40c9c0c56327627331990be52dab9 Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Sun, 9 Aug 2026 07:57:13 +0000 Subject: [PATCH 08/27] Implement (owner, name) scene-node identity with client-wins shadowing Scene nodes are now identified by (owner, name) per the design doc: each scene-tree name holds at most one variant per scope (broadcast + this client), both fed independently by their scopes' messages, with the effective variant chosen by a local display rule -- real client > real broadcast > virtual client > virtual broadcast. A client-scoped add of a server-owned name SHADOWS it for that client instead of raising; server updates keep accumulating in the hidden variant, and removing the client variant promotes the server's node with its latest state (no resurrection round trip). The interim SceneNameIndex and both of its claim-time ValueErrors are removed. Wire: scene messages carry an opaque owner field ('' = broadcast, otherwise a per-client id) stamped by the queueing SceneApi; create messages carry a virtual flag; click/drag/transform-controls messages echo the effective variant's owner, so incoming dispatch resolves to exactly one scope's registry (the double-dispatch class is gone by construction). Ancestors: auto-creation is now unconditional per scope -- every add creates virtual anchor frames for missing same-scope ancestors, so every node has a complete same-scope ancestor chain. Virtual variants yield to real ones in the display rule (an anchor never hides a real node) and are superseded by a later real add of the same name. Removal: cascade is scope-local. A broadcast remove enumerates broadcast descendants only; client children survive, hanging from their own scope's anchor, which inherits the departing variant's pose client-side (frozen-pose inheritance -- no teleporting). The frontend no longer recurses on removes; the server's enumeration is the complete removal set. The interim cross-scope handle invalidation is deleted: Python handles stay truthful because the frontend node stays alive. client.scene.add_frame('/WorldAxes') is now the sanctioned per-client world-axes override; client.scene.world_axes still raises with a pointer to it. Frontend: SceneTreeState grows a shadowed-variant slot per node with promotion/parking logic; MessageHandler routes pose/visibility/props/ binding/remove messages to the effective or shadowed variant by owner. Tests: tests/test_scene_scopes.py (12, headless server + synthetic clients: stamping, anchors, scope-local cascade, coexistence); SceneTreeState.test.ts (8 new: display rule, shadow/promotion, frozen-pose inheritance, scope-local removal); e2e cross-scope suite rewritten for shadowing (12: shadow/unshadow with latest state, effective-variant click routing, cascade survival at frozen pose, virtual anchors, world-axes override, reconnect). Full unit suite, client gates (tsc/eslint/prettier/vitest), and 91 e2e regression tests across multi-client, GC/disconnect, scene objects, interactions, drags, serialization, and pointer suites pass. --- docs/design/scene_node_identity.md | 46 +-- src/viser/_messages.py | 42 ++- src/viser/_scene_api.py | 153 ++++++--- src/viser/_scene_handles.py | 99 ++---- src/viser/_scene_name_index.py | 153 --------- src/viser/_viser.py | 42 +-- src/viser/client/src/DragLayer.tsx | 7 + src/viser/client/src/MessageHandler.tsx | 108 ++++-- src/viser/client/src/SceneTree.tsx | 15 +- src/viser/client/src/SceneTreeState.test.ts | 135 +++++++- src/viser/client/src/SceneTreeState.ts | 206 ++++++++++++ src/viser/client/src/WebsocketMessages.ts | 75 ++++- tests/e2e/test_cross_scope_handles.py | 275 +++++++++------- tests/test_scene_name_index.py | 346 -------------------- tests/test_scene_scopes.py | 266 +++++++++++++++ 15 files changed, 1151 insertions(+), 817 deletions(-) delete mode 100644 src/viser/_scene_name_index.py delete mode 100644 tests/test_scene_name_index.py create mode 100644 tests/test_scene_scopes.py diff --git a/docs/design/scene_node_identity.md b/docs/design/scene_node_identity.md index e4dfc5f89..9b2e93f70 100644 --- a/docs/design/scene_node_identity.md +++ b/docs/design/scene_node_identity.md @@ -1,24 +1,32 @@ # Scene node identity: per-name variant slots with client-wins display -Status: **proposed** (design for a future change; not implemented). -Prerequisite reading: `src/viser/_scene_name_index.py` module docstring, which -documents the rules this design would relax. - -## Where we are - -Scene nodes are identified by name alone, everywhere: the frontend scene tree -is a single store keyed by node name with no record of which scope created an -entry, and every name-keyed message (updates, removes, clicks, drags) resolves -against that one namespace. Because `server.scene` (broadcast) and each -`client.scene` (per-client) both feed the same tree, a name claimed by two -scopes visible to the same viewer used to silently corrupt state. - -Today's fix (the `SceneNameIndex`) keeps name-only identity and **rejects** -overlapping-scope claims at the add site (`ValueError`), with an -audience-subset rule for cross-scope parenting and cross-scope cascade on -broadcast removals. This is sound, but it makes the collision class -*forbidden* rather than *unrepresentable*, and it forces server and client -code to coordinate names. +Status: **implemented** (same branch, after an interim rejection-based +design; the "where we are" section below describes the state this replaced). +Code map: owner/virtual fields in `_messages.py`; owner stamping via +`SceneApi._queue_scene_message` and virtual anchors via +`_ensure_ancestors_exist` in `_scene_api.py` / `_scene_handles.py`; variant +slots, display rule, and frozen-pose inheritance in the frontend's +`SceneTreeState.ts` with owner routing in `MessageHandler.tsx`. Tests: +`tests/test_scene_scopes.py`, `SceneTreeState.test.ts`, +`tests/e2e/test_cross_scope_handles.py`. Not yet implemented: a scene-tree +panel badge for shadowing/local variants (cosmetic follow-up). + +## Where this started + +Scene nodes were identified by name alone, everywhere: the frontend scene +tree was a single store keyed by node name with no record of which scope +created an entry, and every name-keyed message (updates, removes, clicks, +drags) resolved against that one namespace. Because `server.scene` +(broadcast) and each `client.scene` (per-client) both feed the same tree, a +name claimed by two scopes visible to the same viewer silently corrupted +state. + +An interim fix (a `SceneNameIndex`, since removed) kept name-only identity +and **rejected** overlapping-scope claims at the add site (`ValueError`), +with an audience-subset rule for cross-scope parenting and cross-scope +cascade on broadcast removals. That was sound, but it made the collision +class *forbidden* rather than *unrepresentable*, and it forced server and +client code to coordinate names. ## The model diff --git a/src/viser/_messages.py b/src/viser/_messages.py index f112a5138..8901cd476 100644 --- a/src/viser/_messages.py +++ b/src/viser/_messages.py @@ -306,6 +306,19 @@ class _CreateSceneNodeMessage( include_in_scene_serialization=True, ): name: str + owner: str = dataclasses.field(default="", init=False) + """Scope that owns this node: "" for the broadcast scope + (``server.scene``), otherwise an opaque per-client identifier. Each + scene-tree name holds at most one variant per owner; the client renders + the effective variant chosen by the display rule (real client > real + broadcast > virtual client > virtual broadcast). ``init=False`` so + defaulted fields don't precede subclasses' non-default props on + Python < 3.10; stamped by the queueing SceneApi.""" + virtual: bool = dataclasses.field(default=False, init=False) + """True for auto-created intermediate ancestor frames. Virtual variants + yield to real ones in the display rule and exist so every node has a + complete same-scope ancestor chain (which is what makes scope-local + cascade removal orphan-free).""" @dataclasses.dataclass @@ -314,9 +327,13 @@ class RemoveSceneNodeMessage( entity=EntityLifecycle("scene", "remove", "name"), include_in_scene_serialization=True, ): - """Remove a particular node from the scene.""" + """Remove a particular node's variant, for the scope stamped in + ``owner``, from the scene. Removal is scope-local: it never touches the + other scope's variant of the same name (the server enumerates one such + message per same-scope descendant; the client does not cascade).""" name: str + owner: str = dataclasses.field(default="", init=False) @dataclasses.dataclass @@ -1178,6 +1195,7 @@ class SetBoneOrientationMessage( name: str bone_index: int wxyz: Tuple[float, float, float, float] + owner: str = dataclasses.field(default="", init=False) @override def redundancy_key(self) -> str: @@ -1197,6 +1215,7 @@ class SetBonePositionMessage( name: str bone_index: int position: Tuple[float, float, float] + owner: str = dataclasses.field(default="", init=False) @override def redundancy_key(self) -> str: @@ -1332,6 +1351,7 @@ class SetOrientationMessage( name: str wxyz: Tuple[float, float, float, float] + owner: str = dataclasses.field(default="", init=False) @dataclasses.dataclass @@ -1346,6 +1366,7 @@ class SetPositionMessage( name: str position: Tuple[float, float, float] + owner: str = dataclasses.field(default="", init=False) @dataclasses.dataclass @@ -1357,6 +1378,10 @@ class TransformControlsUpdateMessage(Message, include_in_scene_serialization=Fal name: str wxyz: Tuple[float, float, float, float] position: Tuple[float, float, float] + owner: str = "" + """Echo of the effective variant's owner, so the server dispatches to + exactly one scope's registry (regular init field: this message is + deserialized).""" @dataclasses.dataclass @@ -1364,6 +1389,7 @@ class TransformControlsDragStartMessage(Message, include_in_scene_serialization= """Client -> server message when a transform control drag starts.""" name: str + owner: str = "" @dataclasses.dataclass @@ -1371,6 +1397,7 @@ class TransformControlsDragEndMessage(Message, include_in_scene_serialization=Fa """Client -> server message when a transform control drag ends.""" name: str + owner: str = "" @dataclasses.dataclass @@ -1420,6 +1447,7 @@ class SetSceneNodeVisibilityMessage( name: str visible: bool + owner: str = dataclasses.field(default="", init=False) @dataclasses.dataclass(frozen=True) @@ -1449,6 +1477,7 @@ class SetSceneNodeDragBindingsMessage(Message, include_in_scene_serialization=Fa name: str bindings: Tuple[DragBinding, ...] + owner: str = dataclasses.field(default="", init=False) @dataclasses.dataclass @@ -1466,6 +1495,7 @@ class SetSceneNodeClickBindingsMessage(Message, include_in_scene_serialization=F name: str bindings: Tuple[DragBinding, ...] + owner: str = dataclasses.field(default="", init=False) @dataclasses.dataclass @@ -1479,6 +1509,9 @@ class SceneNodeClickMessage(Message, include_in_scene_serialization=False): ray_direction: Tuple[float, float, float] screen_pos: Tuple[float, float] modifier: Optional[KeyModifier] + owner: str = "" + """Echo of the clicked variant's owner, so the server dispatches to + exactly one scope's registry.""" _DragPhase: TypeAlias = Literal["start", "update", "end"] @@ -1509,6 +1542,9 @@ class SceneNodeDragMessage(Message, include_in_scene_serialization=False): """Current pointer in OpenCV screen-space coordinates.""" button: Literal["left", "middle", "right"] modifier: Optional[KeyModifier] + owner: str = "" + """Echo of the dragged variant's owner, so the server dispatches to + exactly one scope's registry.""" @dataclasses.dataclass @@ -2196,6 +2232,10 @@ class SceneNodeUpdateMessage( name: str updates: Dict[str, Any] """Mapping from property name to new value.""" + owner: str = "" + """Owning scope of the targeted variant. A regular init field (unlike + the other server->client owner stamps) so the message stays + deserializable in both directions.""" @dataclasses.dataclass diff --git a/src/viser/_scene_api.py b/src/viser/_scene_api.py index 433589893..a5db3ab77 100644 --- a/src/viser/_scene_api.py +++ b/src/viser/_scene_api.py @@ -276,34 +276,33 @@ def __init__( ] = {} self._handle_from_node_name: dict[str, SceneNodeHandle] = {} if isinstance(owner, ViserServer): - self._scope_key = None - """Which scope this API's elements belong to: ``None`` for the - broadcast scope, a client id for a per-client scope.""" + self._owner_id = "" + """Opaque owner id stamped on every outgoing scene message: "" + for the broadcast scope, otherwise a per-client identifier. Each + scene-tree name holds at most one variant per owner on the + frontend; the effective (rendered, interactive) variant follows + the display rule: real client > real broadcast > virtual client + > virtual broadcast. Scene state is fully scope-local -- adds, + updates, and removals from one scope never touch the other + scope's variant of the same name.""" server_owner = owner else: - self._scope_key = cast("ClientId", owner.client_id) + self._owner_id = str(owner.client_id) server_owner = owner._viser_server - self._name_index = server_owner._scene_name_index - """Server-wide index of claimed scene-node names across scopes. Scene - names share one namespace per viewer (the frontend's scene tree is - keyed by name with no notion of scope), so adds must be checked - against every scope the same viewer can see -- not just this API's - own registry. Only touched under ``_node_lifecycle_lock``.""" self._node_lifecycle_lock = server_owner._scene_lifecycle_lock """Serializes scene-node lifecycle transitions (remove, same-name supersede) against interaction-callback (de)registration. All critical sections are short and synchronous (no awaits inside). - SERVER-WIDE and shared by every SceneApi (server- and client-scoped): - lifecycle transitions consult and mutate the cross-scope name index, - and broadcast removals cascade into per-client subtrees, so per-scope - locks would deadlock or race. Reentrant: ancestor auto-creation and - cross-scope cascade removal re-enter by design, and subclass - _on_remove hooks run under the lock and must stay safe to extend. - Without this lock, a registration racing a remove/supersede from - another thread could publish a name-keyed binding into the - persistent buffer AFTER the teardown's empty-bindings emit -- a - ghost a same-name successor would inherit on late-joining - clients.""" + Shared server-wide by every SceneApi: scene lifecycle is scope-local, + so per-scope locks would also be correct, but one lock keeps the + invariants easy to reason about and costs nothing (lifecycle ops are + rare and short). Reentrant: ancestor auto-creation re-enters by + design, and subclass _on_remove hooks run under the lock and must + stay safe to extend. Without this lock, a registration racing a + remove/supersede from another thread could publish a name-keyed + binding into the persistent buffer AFTER the teardown's + empty-bindings emit -- a ghost a same-name successor would inherit + on late-joining clients.""" self._children_from_node_name: dict[str, set[str]] = {} # Tracks handles with an in-flight drag gesture, plus the last # message we processed for that drag. Populated on @@ -338,14 +337,14 @@ def __init__( self._scene_pointer_cb: list[_PointerCallbackEntry] = [] self._scene_pointer_done_cb: list[Callable[[], None | Coroutine]] = [] - # Set up world axes handle. Only the SERVER scope creates (and owns) - # the node: the world axes are one shared scene node, and a - # client-scoped duplicate would collide with the server's claim in - # the name index (each ClientHandle used to re-add /WorldAxes over - # its own connection, racing the broadcast replay). Client-scoped - # SceneApis expose no world_axes -- see the property below. + # Set up world axes handle. Only the SERVER scope creates one by + # default (each ClientHandle used to re-add /WorldAxes over its own + # connection, racing the broadcast replay). Client-scoped SceneApis + # expose no world_axes handle -- see the property below; a client + # that wants different axes adds its own "/WorldAxes" frame, which + # shadows the server's variant for that one client. self._world_axes: FrameHandle | None = None - if self._scope_key is None: + if self._owner_id == "": self._world_axes = self.add_frame( "/WorldAxes", axes_radius=0.0125, @@ -381,20 +380,31 @@ def world_axes(self) -> FrameHandle: """Handle for the world axes, which are created by default. Hidden until made visible via ``server.scene.world_axes.visible = True``. - Only available on the server's scene API: the world axes are a single - shared (broadcast) scene node, so per-client handles would hold stale - caches of state that broadcast writers also mutate. Accessing this on - a client handle's ``client.scene`` raises ``AttributeError``.""" + Only available on the server's scene API; accessing this on a client + handle's ``client.scene`` raises ``AttributeError``. To show + different axes for one client, add a client-scoped frame named + ``"/WorldAxes"`` -- the client's variant shadows the server's for + that one viewer.""" if self._world_axes is None: raise AttributeError( "world_axes is only available on the server's scene API " - "(server.scene.world_axes): the world axes are a single " - "shared scene node owned by the server scope. To show or " - "hide them for every client, assign " - "server.scene.world_axes.visible." + "(server.scene.world_axes). To show or hide the shared axes " + "for every client, assign server.scene.world_axes.visible; " + "to override them for one client, add a client-scoped frame " + 'named "/WorldAxes" (it shadows the server\'s node for that ' + "client)." ) return self._world_axes + def _queue_scene_message(self, message: _messages.Message) -> None: + """Queue a name-keyed scene message, stamped with this scope's owner + id. Every scene message that targets a node by name MUST go through + here (or stamp ``owner`` itself): an unstamped message defaults to + the broadcast owner and would be routed to the wrong variant on the + client.""" + message.owner = self._owner_id # type: ignore[attr-defined] + self._websock_interface.queue_message(message) + def _is_drag_active_for(self, name: str) -> bool: """Whether the named scene node currently has any in-flight drag gesture (from any connected client). Used by ``remove()`` to @@ -456,20 +466,44 @@ async def _drop_active_drags_for_client( print_awaited_callback_error(exc) def _ensure_ancestors_exist(self, name: str) -> None: - """Create intermediate frame nodes for any missing ancestors of `name`. - - "Missing" is judged against the cross-scope name index, not just this - scope's registry: a per-client child under a broadcast parent must - NOT re-create the parent in the client scope (the frontend keys nodes - by name, so the duplicate would clobber the shared parent for that - client). Runs under the lifecycle lock so the visibility check and - the creates are atomic against concurrent adds/removes.""" + """Create VIRTUAL intermediate frames for any ancestors of ``name`` + missing from THIS scope's registry. + + Unconditional per scope: an anchor is created even when another + scope has a (real) variant of the ancestor name. Virtual variants + yield to real ones in the client's display rule, so the anchor never + shadows anything -- it exists so every node has a complete + same-scope ancestor chain, which is what makes scope-local cascade + removal orphan-free (a client child survives a broadcast parent's + removal by hanging from its own scope's anchor, which inherits the + departing variant's pose client-side). Runs under the lifecycle lock + so the existence checks and creates are atomic against concurrent + adds/removes.""" with self._node_lifecycle_lock: parts = name.split("/") for i in range(2, len(parts)): # skip root ("") and the node itself ancestor = "/".join(parts[:i]) - if not self._name_index.exists_visible(ancestor, self._scope_key): - self.add_frame(ancestor, show_axes=False) + if ancestor not in self._handle_from_node_name: + message = _messages.FrameMessage( + name=ancestor, + props=_messages.FrameProps( + show_axes=False, + axes_length=0.5, + axes_radius=0.025, + origin_radius=0.05, + origin_color=(236, 236, 0), + scale=1.0, + ), + ) + message.virtual = True + FrameHandle._make( + self, + message, + ancestor, + wxyz=(1.0, 0.0, 0.0, 0.0), + position=(0.0, 0.0, 0.0), + visible=True, + ) def set_up_direction( self, @@ -539,8 +573,10 @@ def rotate_between(before: np.ndarray, after: np.ndarray) -> tf.SO3: ) if not np.any(np.isnan(R_threeworld_world.wxyz)): - # Set the orientation of the root node. - self._websock_interface.queue_message( + # Set the orientation of the root node. The root ("") is a + # singleton on the client -- name-empty messages apply to it + # regardless of the stamped owner. + self._queue_scene_message( _messages.SetOrientationMessage( "", cast_vector(R_threeworld_world.wxyz, 4) ) @@ -557,9 +593,7 @@ def set_global_visibility(self, visible: bool) -> None: Args: visible: Whether or not all scene nodes should be visible. """ - self._websock_interface.queue_message( - _messages.SetSceneNodeVisibilityMessage("", visible) - ) + self._queue_scene_message(_messages.SetSceneNodeVisibilityMessage("", visible)) @deprecated_positional_shim def add_light_directional( @@ -2990,14 +3024,14 @@ def sync_cb(client_id: ClientId, state: TransformControlsHandle) -> None: wxyz=tuple(map(float, state._impl.wxyz)), # type: ignore ) message_orientation.excluded_self_client = client_id - self._websock_interface.queue_message(message_orientation) + self._queue_scene_message(message_orientation) message_position = _messages.SetPositionMessage( name=name, position=tuple(map(float, state._impl.position)), # type: ignore ) message_position.excluded_self_client = client_id - self._websock_interface.queue_message(message_position) + self._queue_scene_message(message_position) node_handle = SceneNodeHandle._make( self, message, name, wxyz, position, visible @@ -3059,6 +3093,11 @@ async def _handle_transform_controls_updates( self, client_id: ClientId, message: _messages.TransformControlsUpdateMessage ) -> None: """Apply pose update and fire `update_cb` with phase="update".""" + # Node-keyed messages carry the effective variant's owner; only the + # owning scope's SceneApi handles them (incoming messages fan out to + # both the server's and the connection's handler lists). + if message.owner != self._owner_id: + return # Prefer the active-drag map so a late update still resolves after the # gizmo was removed mid-drag (which pops it from the live registry). handle = self._active_transform_drag_handles.get( @@ -3084,6 +3123,8 @@ async def _handle_transform_controls_updates( async def _handle_transform_controls_drag_start( self, client_id: ClientId, message: _messages.TransformControlsDragStartMessage ) -> None: + if message.owner != self._owner_id: + return handle = self._handle_from_transform_controls_name.get(message.name, None) if handle is None: return @@ -3093,6 +3134,8 @@ async def _handle_transform_controls_drag_start( async def _handle_transform_controls_drag_end( self, client_id: ClientId, message: _messages.TransformControlsDragEndMessage ) -> None: + if message.owner != self._owner_id: + return handle = self._active_transform_drag_handles.pop( (client_id, message.name), None ) or self._handle_from_transform_controls_name.get(message.name, None) @@ -3142,6 +3185,8 @@ async def _handle_node_click_updates( self, client_id: ClientId, message: _messages.SceneNodeClickMessage ) -> None: """Callback for handling click messages.""" + if message.owner != self._owner_id: + return handle = self._handle_from_node_name.get(message.name, None) if handle is None or handle._impl.click_cb is None: return @@ -3180,6 +3225,8 @@ async def _handle_node_drag( have this issue, so for stateful gestures define your callbacks as ``async def`` (with no internal ``await`` s, so each runs atomically on the event loop).""" + if message.owner != self._owner_id: + return # On phase="start", look up the handle in the live registry and # remember it (with the message, so a synthetic end on # disconnect can carry the latest positions). On update, refresh diff --git a/src/viser/_scene_handles.py b/src/viser/_scene_handles.py index 80a321f4c..a29452159 100644 --- a/src/viser/_scene_handles.py +++ b/src/viser/_scene_handles.py @@ -29,7 +29,6 @@ from ._assignable_props_api import AssignablePropsBase from .infra._infra import ( WebsockClientConnection, - WebsockMessageHandler, WebsockServer, ) @@ -47,15 +46,17 @@ def _set_pose_vector( current: np.ndarray, value: _PoseTupleT | np.ndarray, length: int, - websock: WebsockMessageHandler, + queue: Callable[[_messages.Message], None], make_message: Callable[[_PoseTupleT], _messages.Message], ) -> None: """Shared write path for the scene-node and skinned-bone pose setters. Casts and validates ``value``, no-ops if it is numerically unchanged from ``current``, and otherwise writes it into ``current`` in place and queues the - message built from the cast value. Keeping this in one place stops the four - near-identical wxyz/position setters from drifting apart. + message built from the cast value (via ``queue``, typically the owning + SceneApi's owner-stamping ``_queue_scene_message``). Keeping this in one + place stops the four near-identical wxyz/position setters from drifting + apart. """ from ._scene_api import cast_vector @@ -64,7 +65,7 @@ def _set_pose_vector( if np.allclose(value_arr, current): return current[:] = value_arr - websock.queue_message(make_message(value_cast)) + queue(make_message(value_cast)) def _queue_empty_interaction_bindings( @@ -77,13 +78,9 @@ def _queue_empty_interaction_bindings( ``_make`` so the two can't drift; emits only -- callers own any ``drag_cb`` bookkeeping.""" if had_click: - api._websock_interface.queue_message( - _messages.SetSceneNodeClickBindingsMessage(name, ()) - ) + api._queue_scene_message(_messages.SetSceneNodeClickBindingsMessage(name, ())) if had_drag: - api._websock_interface.queue_message( - _messages.SetSceneNodeDragBindingsMessage(name, ()) - ) + api._queue_scene_message(_messages.SetSceneNodeDragBindingsMessage(name, ())) @dataclasses.dataclass(frozen=True) @@ -250,7 +247,7 @@ class SceneNodeHandle(AssignablePropsBase[_SceneNodeHandleState]): @override def _queue_update(self, name: str, value: Any) -> None: - self._impl.api._websock_interface.queue_message( + self._impl.api._queue_scene_message( _messages.SceneNodeUpdateMessage(self._impl.name, {name: value}) ) @@ -293,20 +290,11 @@ def _make( # is marked removed but still registered, where its remove() would # tear down the replacement's fresh state. with api._node_lifecycle_lock: - # Cross-scope checks come FIRST, before any side effect: names - # share one namespace per viewer (the frontend's tree is keyed by - # name, scope-blind), so an add that collides with an overlapping - # scope -- or violates the audience-subset parenting rule -- is - # rejected here with nothing queued and nothing registered. - # Within-scope re-adds pass this check and take the supersede - # path below. - api._name_index.check_claimable(name, api._scope_key) - - # Ensure all ancestor nodes exist (creates intermediate frames as - # needed; re-enters _make under the reentrant lifecycle lock). - # Index-aware: an ancestor owned by a scope this viewer already - # sees (e.g. a broadcast parent of a per-client child) is not - # re-created. + # Ensure all SAME-SCOPE ancestors exist (creates virtual anchor + # frames as needed; re-enters _make under the reentrant lifecycle + # lock). Scene state is scope-local: another scope's variant of + # an ancestor name neither satisfies nor blocks this scope's + # chain. api._ensure_ancestors_exist(name) old_handle = api._handle_from_node_name.get(name) @@ -341,9 +329,9 @@ def _make( had_drag=bool(old_handle._impl.drag_cb), ) - # Send message. + # Send message, stamped with this scope's owner id. assert isinstance(message, _messages.Message) - api._websock_interface.queue_message(message) + api._queue_scene_message(message) # Shallow copy is enough to decouple the handle from the queued # message: AssignablePropsBase.__init__ copies each top-level @@ -357,10 +345,6 @@ def _make( api._children_from_node_name.setdefault(parent, set()).add(name) api._children_from_node_name.setdefault(name, set()) - # Publish the claim in the cross-scope name index (idempotent for - # same-scope supersedes). - api._name_index.commit(name, api._scope_key, api) - out.wxyz = wxyz out.position = position if old_handle is not None: @@ -373,10 +357,10 @@ def _make( # entry in the buffer via its redundancy key. from ._scene_api import cast_vector - api._websock_interface.queue_message( + api._queue_scene_message( _messages.SetOrientationMessage(name, cast_vector(out._impl.wxyz, 4)) ) - api._websock_interface.queue_message( + api._queue_scene_message( _messages.SetPositionMessage(name, cast_vector(out._impl.position, 3)) ) @@ -401,7 +385,7 @@ def wxyz(self, wxyz: tuple[float, float, float, float] | np.ndarray) -> None: self._impl.wxyz, wxyz, 4, - self._impl.api._websock_interface, + self._impl.api._queue_scene_message, lambda v: _messages.SetOrientationMessage(self._impl.name, v), ) @@ -418,7 +402,7 @@ def position(self, position: tuple[float, float, float] | np.ndarray) -> None: self._impl.position, position, 3, - self._impl.api._websock_interface, + self._impl.api._queue_scene_message, lambda v: _messages.SetPositionMessage(self._impl.name, v), ) @@ -431,7 +415,7 @@ def visible(self) -> bool: def visible(self, visible: bool) -> None: if visible == self._impl.visible: return - self._impl.api._websock_interface.queue_message( + self._impl.api._queue_scene_message( _messages.SetSceneNodeVisibilityMessage(self._impl.name, visible) ) self._impl.visible = visible @@ -512,7 +496,6 @@ def _remove_locked(self) -> None: for node_name in to_remove: handle = api._handle_from_node_name.pop(node_name, None) api._children_from_node_name.pop(node_name, None) - api._name_index.release(node_name, api._scope_key) if handle is None: continue handle._impl.removed = True @@ -524,32 +507,14 @@ def _remove_locked(self) -> None: if parent_children is not None: parent_children.discard(self._impl.name) - # Send a RemoveSceneNodeMessage per descendant so redundancy keys - # clean up their creation messages from the broadcast buffer. + # Send a RemoveSceneNodeMessage per SAME-SCOPE descendant so + # redundancy keys clean up their creation messages from the buffer. + # Cascade is scope-local by design: the client does not recurse on + # removes (this enumeration is the complete removal set), and the + # other scope's variants of these names -- including any children + # hanging from their own scope's virtual anchors -- are untouched. for node_name in to_remove: - api._websock_interface.queue_message( - _messages.RemoveSceneNodeMessage(node_name) - ) - - # Cascade into OTHER scopes' subtrees. The frontend's scene tree is - # keyed by name with no notion of scope, so its cascade already - # deletes e.g. a per-client child parented under this broadcast node; - # without this, that child's Python handle would stay live in its - # scope's registry -- a zombie whose later writes silently target a - # nonexistent node. Only the broadcast scope can have foreign - # descendants (the audience-subset rule forbids a broader child under - # a narrower parent). Each foreign teardown runs the full - # _remove_locked path on its own scope (reentrant lifecycle lock), - # including a Remove message on that client's connection -- redundant - # with the frontend cascade but harmless, and it keeps the buffer - # purge + binding-reset logic on the one shared path. - if api._scope_key is None: - for foreign_api, foreign_name in api._name_index.foreign_descendants( - self._impl.name, api._scope_key - ): - foreign_handle = foreign_api._handle_from_node_name.get(foreign_name) - if foreign_handle is not None and not foreign_handle._impl.removed: - foreign_handle._remove_locked() + api._queue_scene_message(_messages.RemoveSceneNodeMessage(node_name)) def _on_remove(self) -> None: """Release any subclass-specific registries for this node. @@ -689,7 +654,7 @@ def _sync_drag_bindings(self) -> None: bindings.append( _messages.DragBinding(button=entry.button, modifier=entry.modifier) ) - self._impl.api._websock_interface.queue_message( + self._impl.api._queue_scene_message( _messages.SetSceneNodeDragBindingsMessage(self._impl.name, tuple(bindings)) ) @@ -974,7 +939,7 @@ def _publish_click_state(self) -> None: # Queue the message BEFORE committing the cache. If # ``queue_message`` raises, the cache stays at its previous # value so the next state change retries the publish. - self._impl.api._websock_interface.queue_message( + self._impl.api._queue_scene_message( _messages.SetSceneNodeClickBindingsMessage(self._impl.name, bindings) ) self._impl._last_published_click_bindings = bindings @@ -1405,7 +1370,7 @@ def wxyz(self, wxyz: tuple[float, float, float, float] | np.ndarray) -> None: self._impl.wxyz, wxyz, 4, - self._impl.websock_interface, + self._impl.mesh_impl.api._queue_scene_message, lambda v: _messages.SetBoneOrientationMessage( self._impl.name, self._impl.bone_index, v ), @@ -1425,7 +1390,7 @@ def position(self, position: tuple[float, float, float] | np.ndarray) -> None: self._impl.position, position, 3, - self._impl.websock_interface, + self._impl.mesh_impl.api._queue_scene_message, lambda v: _messages.SetBonePositionMessage( self._impl.name, self._impl.bone_index, v ), diff --git a/src/viser/_scene_name_index.py b/src/viser/_scene_name_index.py deleted file mode 100644 index 96a02113c..000000000 --- a/src/viser/_scene_name_index.py +++ /dev/null @@ -1,153 +0,0 @@ -"""Server-wide index of claimed scene-node names across scopes. - -Scene elements can be created through two kinds of scope: the server's -broadcast scope (``server.scene``, visible to every client) and per-client -scopes (``client.scene``, visible to one client). The frontend merges both -into a single scene tree keyed by node name, so names from scopes that are -visible to the same viewer share one namespace -- but each scope keeps its -own Python-side registry. This index is the one structure that sees every -scope's claims, and it enforces two rules at add time: - -1. **No overlapping-scope name reuse.** A name may not be claimed by two - scopes that any single viewer can see simultaneously: the broadcast scope - overlaps every client scope, while two different client scopes never - overlap (their elements never meet on one frontend). Re-adding a name - within one scope stays legal -- that is the documented supersede path. - -2. **A child's audience must be a subset of its parent's.** A per-client - child under a broadcast parent is fine; a broadcast child under a - per-client parent (or a child under another client's parent) would dangle - for every viewer that cannot see the parent, and is rejected. The rule is - only enforced when the parent name is claimed somewhere -- viser allows - adding nodes under not-yet-created parents, and those stay unchecked - until the parent is claimed. - -Thread safety: the index has no lock of its own. Every mutation and query -happens under the server-wide scene lifecycle lock (a reentrant lock shared -by ``server.scene`` and every ``client.scene`` -- see -``SceneApi._node_lifecycle_lock``), which also serializes the registry -operations the index mirrors. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Optional - -if TYPE_CHECKING: - from ._scene_api import SceneApi - from .infra import ClientId - -ScopeKey = Optional["ClientId"] -"""Identifies a scope: ``None`` for the broadcast scope, a client id for a -per-client scope.""" - - -def _scopes_overlap(a: ScopeKey, b: ScopeKey) -> bool: - """Whether any single viewer can see both scopes at once.""" - if a is None or b is None: - return True - return a == b - - -def _scope_covers(parent: ScopeKey, child: ScopeKey) -> bool: - """Whether ``parent``'s audience is a superset of ``child``'s.""" - return parent is None or parent == child - - -def _describe_scope(scope: ScopeKey) -> str: - return "the server (broadcast) scope" if scope is None else f"client {scope}" - - -class SceneNameIndex: - """Maps each claimed scene-node name to the scopes that claim it. - - Values are the owning ``SceneApi`` instances rather than handles: the - per-scope registries stay the source of truth for the current (possibly - subclass-typed, possibly superseded) handle, and cross-scope operations - resolve through them at use time. - """ - - def __init__(self) -> None: - self._scopes_from_name: dict[str, dict[ScopeKey, SceneApi]] = {} - - def check_claimable(self, name: str, scope: ScopeKey) -> None: - """Raise ``ValueError`` if claiming ``name`` from ``scope`` would - violate the overlap or audience-subset rule. Called before any side - effect of an add, so a rejected add leaves no trace.""" - claimants = self._scopes_from_name.get(name) - if claimants: - for other in claimants: - if other != scope and _scopes_overlap(other, scope): - raise ValueError( - f"Cannot add scene node {name!r} from " - f"{_describe_scope(scope)}: the name is already used " - f"by {_describe_scope(other)}, and the two are " - f"visible to the same client. Both scopes share one " - f"scene tree on the frontend, so this would silently " - f"corrupt the existing node's state. Remove the " - f"existing node first, or use a different name. " - f"(Re-adding a name from the SAME scope is supported " - f"and replaces the node.)" - ) - - parent = name.rsplit("/", 1)[0] - if parent: - parent_claimants = self._scopes_from_name.get(parent) - if parent_claimants and not any( - _scope_covers(parent_scope, scope) for parent_scope in parent_claimants - ): - parent_scope = next(iter(parent_claimants)) - raise ValueError( - f"Cannot add scene node {name!r} from " - f"{_describe_scope(scope)}: its parent {parent!r} belongs " - f"to {_describe_scope(parent_scope)}, whose audience does " - f"not include every viewer of the new node. A child must " - f"be visible to a subset of its parent's viewers -- " - f"otherwise it would dangle in the scene tree for viewers " - f"who cannot see the parent." - ) - - def commit(self, name: str, scope: ScopeKey, api: SceneApi) -> None: - """Record ``name`` as claimed by ``scope``. Idempotent for same-scope - re-adds (supersede).""" - self._scopes_from_name.setdefault(name, {})[scope] = api - - def release(self, name: str, scope: ScopeKey) -> None: - """Drop ``scope``'s claim on ``name``, if any.""" - claimants = self._scopes_from_name.get(name) - if claimants is None: - return - claimants.pop(scope, None) - if not claimants: - del self._scopes_from_name[name] - - def drop_scope(self, scope: ScopeKey) -> None: - """Drop every claim held by ``scope`` (client disconnect).""" - for name in list(self._scopes_from_name): - self.release(name, scope) - - def exists_visible(self, name: str, scope: ScopeKey) -> bool: - """Whether ``name`` is claimed by a scope whose elements every viewer - of ``scope`` can see -- i.e. whether an add from ``scope`` may treat - the node as an existing ancestor rather than creating it.""" - claimants = self._scopes_from_name.get(name) - if not claimants: - return False - return any(_scope_covers(other, scope) for other in claimants) - - def foreign_descendants( - self, name: str, scope: ScopeKey - ) -> list[tuple[SceneApi, str]]: - """Snapshot of (owning api, name) for every node claimed by a scope - other than ``scope`` whose name sits strictly under ``name``. Used by - broadcast removals to cascade into per-client subtrees the way the - frontend's name-keyed tree already does.""" - prefix = name + "/" - out: list[tuple[SceneApi, str]] = [] - for other_name, claimants in self._scopes_from_name.items(): - if not other_name.startswith(prefix): - continue - for other_scope, api in claimants.items(): - if other_scope != scope: - out.append((api, other_name)) - return out diff --git a/src/viser/_viser.py b/src/viser/_viser.py index 02cc4bd59..0f1195039 100644 --- a/src/viser/_viser.py +++ b/src/viser/_viser.py @@ -26,7 +26,6 @@ from ._gui_handles import _make_uuid from ._notification_handle import NotificationHandle, _NotificationHandleState from ._scene_api import SceneApi, cast_vector -from ._scene_name_index import SceneNameIndex from ._threadpool_exceptions import ( print_awaited_callback_error, print_task_error, @@ -579,15 +578,17 @@ class ClientHandle(DeprecatedAttributeShim if not TYPE_CHECKING else object): (browser storage) or in application code keyed however the application identifies its users; the server never retains per-client element state. - **Scene names are shared with the server scope.** The scene tree shown to - a client merges server-scoped and client-scoped elements into one - namespace, so adding a client-scoped scene node under a name the server - already uses (or vice versa) raises ``ValueError`` -- remove the existing - node first or pick a different name. Different clients may reuse the same - name freely. A client-scoped node may be parented under a server-scoped - node (e.g. per-client annotations under a shared frame), and is removed - with it; the reverse -- a server-scoped child under a client-scoped - parent -- is rejected, since other clients cannot see the parent. + **Scene names shadow, not collide.** Each scene-tree name holds at most + one node per scope: adding a client-scoped node under a name the server + also uses creates an independent per-client variant that *shadows* the + server's node for this one client (the server's node, with its latest + state, shows again when the client-scoped variant is removed). State is + fully scope-local -- updates and removals from one scope never touch the + other scope's variant, and removing a node cascades only through its own + scope's descendants. A client-scoped node may be named under a + server-scoped parent (e.g. per-client annotations under a shared frame); + it survives the parent's removal, anchored at the parent's last pose, + until this handle removes it. """ def __init__( @@ -1031,15 +1032,11 @@ def __init__( self._connection = server self._connected_clients: dict[int, ClientHandle] = {} self._client_lock = threading.Lock() - # Scene-node names live in ONE namespace per viewer even though - # server.scene and each client.scene keep separate registries; the - # index is the cross-scope view, and the lifecycle lock is shared by - # every SceneApi (server- and client-scoped) so lifecycle transitions - # are serialized across scopes. Created BEFORE server.start(): a - # client can connect (and build its SceneApi) as soon as the server - # thread runs, which may be before `self.scene` exists below. + # Lifecycle lock shared by every SceneApi (server- and + # client-scoped). Created BEFORE server.start(): a client can + # connect (and build its SceneApi) as soon as the server thread + # runs, which may be before `self.scene` exists below. self._scene_lifecycle_lock = threading.RLock() - self._scene_name_index = SceneNameIndex() self._client_connect_cb: list[Callable[[ClientHandle], None | Coroutine]] = [] self._client_disconnect_cb: list[ Callable[[ClientHandle], None | Coroutine] @@ -1151,15 +1148,6 @@ async def _(conn: infra.WebsockClientConnection) -> None: await self.scene._drop_active_drags_for_client( cast(infra.ClientId, conn.client_id), event_client=handle ) - - # Release the client's scene-name claims: its elements died with - # the connection (client state is ephemeral), so the names become - # available again -- e.g. for a broadcast add, which would - # otherwise be rejected by the cross-scope overlap rule forever. - # No messages are needed; the frontend is gone. - with self._scene_lifecycle_lock: - self._scene_name_index.drop_scope(cast(infra.ClientId, conn.client_id)) - await self._dispatch_client_callbacks(disconnect_cbs, handle) # Start the server. diff --git a/src/viser/client/src/DragLayer.tsx b/src/viser/client/src/DragLayer.tsx index ece09be86..5c6859b61 100644 --- a/src/viser/client/src/DragLayer.tsx +++ b/src/viser/client/src/DragLayer.tsx @@ -17,6 +17,7 @@ import React from "react"; import * as THREE from "three"; import { useThree } from "@react-three/fiber"; import { ViewerContext } from "./ViewerContext"; +import { ownerOf } from "./SceneTreeState"; import { SceneNodeDragMessage } from "./WebsocketMessages"; import { ndcFromPointerXyClamped, @@ -216,6 +217,11 @@ function DragLayerActive({ children }: { children?: React.ReactNode }) { viewer, activeDrag.endPointerXy, ); + // Echo the effective variant's owner so the server routes the drag + // to exactly one scope's registry. + const draggedMessage = viewer.useSceneTree.get( + activeDrag.nodeName, + )?.message; return { type: "SceneNodeDragMessage", phase, @@ -227,6 +233,7 @@ function DragLayerActive({ children }: { children?: React.ReactNode }) { end_screen_pos: [endScreenPos.x, endScreenPos.y], button: activeDrag.input.button, modifier: activeDrag.input.modifier, + owner: draggedMessage === undefined ? "" : ownerOf(draggedMessage), }; }, [ diff --git a/src/viser/client/src/MessageHandler.tsx b/src/viser/client/src/MessageHandler.tsx index ad51b359b..34ce7b8c6 100644 --- a/src/viser/client/src/MessageHandler.tsx +++ b/src/viser/client/src/MessageHandler.tsx @@ -20,7 +20,7 @@ import { useFrame, useThree } from "@react-three/fiber"; import { Button, Progress } from "@mantine/core"; import { IconCheck, IconDownload } from "@tabler/icons-react"; import { computeT_threeworld_world } from "./WorldTransformUtils"; -import { rootNodeTemplate, SceneNode } from "./SceneTreeState"; +import { ownerOf, rootNodeTemplate, SceneNode } from "./SceneTreeState"; import { applyGuiConfigUpdate } from "./ControlPanel/GuiState"; import { GaussianSplatsContext } from "./Splatting/GaussianSplatsHelpers"; @@ -81,7 +81,6 @@ function useMessageHandler() { const viewer = useContext(ViewerContext)!; const viewerMutable = viewer.mutable.current; - const removeSceneNode = viewer.sceneTreeActions.removeSceneNode; const addSceneNode = viewer.sceneTreeActions.addSceneNode; const setTheme = viewer.guiActions.setTheme; @@ -107,7 +106,9 @@ function useMessageHandler() { // Make sure scene node is in attributes. const currentNode = viewer.useSceneTree.get(message.name); - // Make sure parents exists. + // Make sure parents exists. (Rarely needed on the live wire -- the + // server sends same-scope virtual anchors for missing ancestors before + // the child -- but kept for old recordings.) const parentName = message.name.split("/").slice(0, -1).join("/"); if (viewer.useSceneTree.get(parentName)?.message === undefined) { addSceneNodeMakeParents({ @@ -123,8 +124,13 @@ function useMessageHandler() { // If the object is new or changed, we need to wait until it's created // before updating its pose. Updating the pose too early can cause // flickering when we replace objects (old object will take the pose of the new - // object while it's being loaded/mounted). - if (message !== currentNode?.message) { + // object while it's being loaded/mounted). Skipped when the add was + // parked in the shadow slot (lower-ranked variant): the EFFECTIVE node + // did not change, and freezing its pose on waitForMakeObject would wait + // for a remount that never happens. + const becameEffective = + viewer.useSceneTree.get(message.name)?.message === message; + if (becameEffective && message !== currentNode?.message) { const pose = viewerMutable.nodePoseData[message.name]; if (pose) { pose.poseUpdateState = "waitForMakeObject"; @@ -138,6 +144,15 @@ function useMessageHandler() { } } + /** Whether a node-keyed message (carrying `owner`) targets the EFFECTIVE + * variant of its name. Returns false when it targets the shadowed variant + * (or nothing) -- callers then route through updateShadowedVariant, whose + * own owner check drops updates with no matching parked variant. */ + function targetsEffectiveVariant(name: string, owner: string): boolean { + const node = viewer.useSceneTree.get(name); + return node !== undefined && ownerOf(node.message) === owner; + } + const fileDownloadHandler = useFileDownloadHandler(); // Return type for the message handler. Messages either: @@ -242,6 +257,14 @@ function useMessageHandler() { switch (message.type) { case "SceneNodeUpdateMessage": { + if (!targetsEffectiveVariant(message.name, message.owner ?? "")) { + viewer.sceneTreeActions.updateShadowedVariant( + message.name, + message.owner ?? "", + { propsUpdates: message.updates }, + ); + return; + } return { kind: "sceneNodePropsUpdate", targetNode: message.name, @@ -622,6 +645,15 @@ function useMessageHandler() { updates: { wxyz: message.wxyz }, }; } + // Shadowed variant: accumulate its pose in the shadow slot. + if (!targetsEffectiveVariant(message.name, message.owner ?? "")) { + viewer.sceneTreeActions.updateShadowedVariant( + message.name, + message.owner ?? "", + { wxyz: message.wxyz }, + ); + return; + } // All other nodes: write pose to mutable ref (no React re-render). const pose = viewerMutable.nodePoseData[message.name]; if (pose) { @@ -639,6 +671,17 @@ function useMessageHandler() { return; } case "SetPositionMessage": { + if ( + message.name !== "" && + !targetsEffectiveVariant(message.name, message.owner ?? "") + ) { + viewer.sceneTreeActions.updateShadowedVariant( + message.name, + message.owner ?? "", + { position: message.position }, + ); + return; + } // Write pose to mutable ref (no React re-render). const pose = viewerMutable.nodePoseData[message.name]; if (pose) { @@ -656,6 +699,17 @@ function useMessageHandler() { return; } case "SetSceneNodeVisibilityMessage": { + if ( + message.name !== "" && + !targetsEffectiveVariant(message.name, message.owner ?? "") + ) { + viewer.sceneTreeActions.updateShadowedVariant( + message.name, + message.owner ?? "", + { visibility: message.visible }, + ); + return; + } return { kind: "sceneNodeAttrUpdate", targetNode: message.name, @@ -710,28 +764,32 @@ function useMessageHandler() { } return; } - // Remove a scene node and its children by name. + // Remove one scope's variant of a scene node. Scope-local, and NOT + // recursive: the server sends one message per same-scope descendant, + // and the other scope's variants of these names must survive. case "RemoveSceneNodeMessage": { - if (viewer.useSceneTree.get(message.name) === undefined) { - console.log("(OK) Skipping scene node removal for " + message.name); - return; - } - removeSceneNode(message.name); - - // Clear skinned-mesh state for the removed node AND its descendants. - // `removeSceneNode` recurses the subtree, and this map is keyed by node - // name, so deleting only the exact name leaks any skinned mesh nested - // under a removed ancestor. - const subtreePrefix = message.name + "/"; - for (const key of Object.keys(viewerMutable.skinnedMeshState)) { - if (key === message.name || key.startsWith(subtreePrefix)) { - delete viewerMutable.skinnedMeshState[key]; - } + const owner = message.owner ?? ""; + const wasEffective = targetsEffectiveVariant(message.name, owner); + viewer.sceneTreeActions.removeSceneNodeVariant(message.name, owner); + + // Clear skinned-mesh state for the removed node. Exact name only: + // descendants arrive as their own removal messages, and a surviving + // other-scope variant of a descendant name must keep its state. + if (wasEffective) { + delete viewerMutable.skinnedMeshState[message.name]; } return; } // Set the drag-binding set for a particular scene node. case "SetSceneNodeDragBindingsMessage": { + if (!targetsEffectiveVariant(message.name, message.owner ?? "")) { + viewer.sceneTreeActions.updateShadowedVariant( + message.name, + message.owner ?? "", + { dragBindings: [...message.bindings] }, + ); + return; + } return { kind: "sceneNodeAttrUpdate", targetNode: message.name, @@ -739,6 +797,14 @@ function useMessageHandler() { }; } case "SetSceneNodeClickBindingsMessage": { + if (!targetsEffectiveVariant(message.name, message.owner ?? "")) { + viewer.sceneTreeActions.updateShadowedVariant( + message.name, + message.owner ?? "", + { clickBindings: [...message.bindings] }, + ); + return; + } return { kind: "sceneNodeAttrUpdate", targetNode: message.name, diff --git a/src/viser/client/src/SceneTree.tsx b/src/viser/client/src/SceneTree.tsx index 2ffb38c2a..60f48da9d 100644 --- a/src/viser/client/src/SceneTree.tsx +++ b/src/viser/client/src/SceneTree.tsx @@ -11,7 +11,7 @@ import { useThrottledMessageSender, } from "./WebsocketUtils"; import { Html } from "@react-three/drei"; -import { useSceneTreeState } from "./SceneTreeState"; +import { ownerOf, useSceneTreeState } from "./SceneTreeState"; import { rayToViserCoords } from "./WorldTransformUtils"; import { HoverableContext, HoverState } from "./HoverContext"; import { shallowArrayEqual } from "./utils/shallowArrayEqual"; @@ -445,6 +445,7 @@ function createObjectFactory( viewer.mutable.current.sendMessage({ type: "TransformControlsDragStartMessage", name: message.name, + owner: ownerOf(message), }); }} onDrag={(l) => { @@ -485,6 +486,7 @@ function createObjectFactory( name: message.name, wxyz: wxyzArray, position: positionArray, + owner: ownerOf(message), }); }} onDragEnd={() => { @@ -494,6 +496,7 @@ function createObjectFactory( viewer.mutable.current.sendMessage({ type: "TransformControlsDragEndMessage", name: message.name, + owner: ownerOf(message), }); } }} @@ -1191,6 +1194,12 @@ export function SceneNodeThreeObject(props: { name: string }) { getPointerXy(e.clientX, e.clientY), ); + // Echo the EFFECTIVE variant's owner: only the mounted + // variant is interactive, and the server routes the event + // to that scope's registry alone. + const clickedMessage = viewer.useSceneTree.get( + props.name, + )?.message; sendClicksThrottled({ type: "SceneNodeClickMessage", name: props.name, @@ -1207,6 +1216,10 @@ export function SceneNodeThreeObject(props: { name: string }) { ], screen_pos: [mouseVectorOpenCV.x, mouseVectorOpenCV.y], modifier: keyModifierFromEvent(e), + owner: + clickedMessage === undefined + ? "" + : ownerOf(clickedMessage), }); } } diff --git a/src/viser/client/src/SceneTreeState.test.ts b/src/viser/client/src/SceneTreeState.test.ts index b757b4785..c99583a94 100644 --- a/src/viser/client/src/SceneTreeState.test.ts +++ b/src/viser/client/src/SceneTreeState.test.ts @@ -5,10 +5,16 @@ import { createKeyedStore } from "./store"; import { FrameMessage } from "./WebsocketMessages"; import { NodePoseDataMap } from "./ViewerContext"; -function makeFrameMessage(name: string): FrameMessage { +function makeFrameMessage( + name: string, + owner: string = "", + virtual: boolean = false, +): FrameMessage { return { type: "FrameMessage", name, + owner, + virtual, props: { show_axes: true, axes_length: 0.5, @@ -32,7 +38,7 @@ function setup() { const nodeRefFromName: { [name: string]: undefined | THREE.Object3D } = {}; const nodePoseData: NodePoseDataMap = {}; const actions = createSceneTreeActions(store, nodeRefFromName, nodePoseData); - return { store, nodeRefFromName, actions }; + return { store, nodeRefFromName, nodePoseData, actions }; } describe("addSceneNode ref handling", () => { @@ -77,3 +83,128 @@ describe("addSceneNode ref handling", () => { expect(nodeRefFromName["/node"]).toBe(obj); }); }); + +describe("variant slots and the display rule", () => { + it("client variant shadows a broadcast variant, preserving its state", () => { + const { store, nodePoseData, actions } = setup(); + + const broadcastMsg = makeFrameMessage("/x", ""); + actions.addSceneNode(broadcastMsg); + nodePoseData["/x"] = { + wxyz: [0, 0, 0, 1], + position: [1, 2, 3], + poseUpdateState: "updated", + }; + + const clientMsg = makeFrameMessage("/x", "7"); + actions.addSceneNode(clientMsg); + + const node = store.get("/x")!; + expect(node.message).toBe(clientMsg); + // The broadcast variant is parked with its pose snapshot. + expect(node.shadowed?.message).toBe(broadcastMsg); + expect(node.shadowed?.position).toEqual([1, 2, 3]); + // The fresh client variant starts from identity pose. + expect(nodePoseData["/x"]!.position).toEqual([0, 0, 0]); + }); + + it("a virtual client anchor does not shadow a real broadcast node", () => { + const { store, actions } = setup(); + + const broadcastMsg = makeFrameMessage("/x", ""); + actions.addSceneNode(broadcastMsg); + const anchorMsg = makeFrameMessage("/x", "7", true); + actions.addSceneNode(anchorMsg); + + const node = store.get("/x")!; + expect(node.message).toBe(broadcastMsg); // Still effective. + expect(node.shadowed?.message).toBe(anchorMsg); // Parked. + }); + + it("a real broadcast node arriving late does not displace a real client node", () => { + const { store, actions } = setup(); + + const clientMsg = makeFrameMessage("/x", "7"); + actions.addSceneNode(clientMsg); + const broadcastMsg = makeFrameMessage("/x", ""); + actions.addSceneNode(broadcastMsg); + + const node = store.get("/x")!; + expect(node.message).toBe(clientMsg); + expect(node.shadowed?.message).toBe(broadcastMsg); + }); + + it("removing the effective variant promotes the shadowed one with accumulated state", () => { + const { store, nodePoseData, actions } = setup(); + + actions.addSceneNode(makeFrameMessage("/x", "")); + actions.addSceneNode(makeFrameMessage("/x", "7")); // Shadows broadcast. + + // Broadcast keeps updating while shadowed. + actions.updateShadowedVariant("/x", "", { position: [4, 5, 6] }); + + actions.removeSceneNodeVariant("/x", "7"); + const node = store.get("/x")!; + expect(node.message.owner).toBe(""); + expect(node.shadowed).toBeUndefined(); + // Promotion restores the broadcast variant's LATEST pose. + expect(nodePoseData["/x"]!.position).toEqual([4, 5, 6]); + }); + + it("a promoted virtual anchor inherits the departing variant's pose", () => { + const { store, nodePoseData, actions } = setup(); + + actions.addSceneNode(makeFrameMessage("/a", "")); // Real broadcast parent. + actions.addSceneNode(makeFrameMessage("/a", "7", true)); // Client anchor, parked. + nodePoseData["/a"] = { + wxyz: [1, 0, 0, 0], + position: [9, 9, 9], + poseUpdateState: "updated", + }; + + actions.removeSceneNodeVariant("/a", ""); + const node = store.get("/a")!; + expect(node.message.virtual).toBe(true); + // Frozen-pose inheritance: children of /a stay where they were. + expect(nodePoseData["/a"]!.position).toEqual([9, 9, 9]); + }); + + it("variant removal is scope-local and non-recursive", () => { + const { store, actions } = setup(); + + actions.addSceneNode(makeFrameMessage("/a", "")); + actions.addSceneNode(makeFrameMessage("/a/child", "7")); + + // Broadcast /a removed; the client child's entry must survive (its own + // scope's anchor/removals are the only things that may touch it). + actions.removeSceneNodeVariant("/a", ""); + expect(store.get("/a")).toBeUndefined(); + expect(store.get("/a/child")).toBeDefined(); + }); + + it("removing a shadowed variant leaves the effective one untouched", () => { + const { store, actions } = setup(); + + const clientMsg = makeFrameMessage("/x", "7"); + actions.addSceneNode(makeFrameMessage("/x", "")); + actions.addSceneNode(clientMsg); + + actions.removeSceneNodeVariant("/x", ""); // Drop the parked broadcast copy. + const node = store.get("/x")!; + expect(node.message).toBe(clientMsg); + expect(node.shadowed).toBeUndefined(); + }); + + it("same-scope supersede preserves the shadow slot", () => { + const { store, actions } = setup(); + + actions.addSceneNode(makeFrameMessage("/x", "7")); // Client, effective. + actions.addSceneNode(makeFrameMessage("/x", "")); // Broadcast, parked. + const newClientMsg = makeFrameMessage("/x", "7"); + actions.addSceneNode(newClientMsg); // Client supersede. + + const node = store.get("/x")!; + expect(node.message).toBe(newClientMsg); + expect(node.shadowed?.message.owner).toBe(""); + }); +}); diff --git a/src/viser/client/src/SceneTreeState.ts b/src/viser/client/src/SceneTreeState.ts index 13815ad7a..7b85f71a4 100644 --- a/src/viser/client/src/SceneTreeState.ts +++ b/src/viser/client/src/SceneTreeState.ts @@ -20,8 +20,42 @@ export type SceneNode = { visibility?: boolean; // Visibility state from the server. overrideVisibility?: boolean; // Override from the GUI. effectiveVisibility?: boolean; // Computed visibility including parent chain. + /** The lower-ranked variant of this name, when both scopes (broadcast + + * this client) have one. Each scene-tree name holds at most one variant + * per scope; only the effective (higher-ranked) variant is mounted and + * interactive, but the shadowed variant keeps accumulating state from its + * scope's messages so that removing the effective variant promotes it + * with up-to-date state -- no resurrection round-trip needed. */ + shadowed?: ShadowedVariant; }; +export type ShadowedVariant = { + message: SceneNodeMessage; + clickBindings: DragBinding[]; + dragBindings: DragBinding[]; + wxyz: [number, number, number, number]; + position: [number, number, number]; + visibility: boolean; +}; + +/** Owner id stamped on scene messages: "" is the broadcast scope + * (server.scene), anything else is a per-client scope. Old recordings + * predate the field; missing means broadcast. */ +export function ownerOf(message: SceneNodeMessage): string { + return (message as { owner?: string }).owner ?? ""; +} + +function isVirtual(message: SceneNodeMessage): boolean { + return (message as { virtual?: boolean }).virtual ?? false; +} + +/** Display-rule rank: real client > real broadcast > virtual client > + * virtual broadcast. The higher-ranked variant of a name is effective + * (rendered, interactive); the other is shadowed. */ +function variantRank(message: SceneNodeMessage): number { + return (isVirtual(message) ? 0 : 2) + (ownerOf(message) !== "" ? 1 : 0); +} + function makeRootNodeTemplate(): SceneNode { // Default quaternion: 90 deg around X, 180 deg around Y, -90 deg around Z. // This matches the coordinate system transformation. @@ -33,6 +67,8 @@ function makeRootNodeTemplate(): SceneNode { message: { type: "FrameMessage", name: "", + owner: "", + virtual: false, props: { show_axes: false, axes_length: 0.5, @@ -57,6 +93,8 @@ function makeWorldAxesNodeTemplate(): SceneNode { message: { type: "FrameMessage", name: "/WorldAxes", + owner: "", + virtual: false, props: { show_axes: true, axes_length: 0.5, @@ -93,6 +131,69 @@ export function createSceneTreeActions( const actions = { addSceneNode: (message: SceneNodeMessage) => { const existingNode = store.get(message.name); + + // Cross-scope add: the name already has a variant from the OTHER + // scope. The display rule decides which becomes effective; the loser + // is parked in the shadow slot, where its scope's messages keep + // updating it. + if ( + existingNode !== undefined && + ownerOf(existingNode.message) !== ownerOf(message) + ) { + if (variantRank(message) >= variantRank(existingNode.message)) { + // Incoming variant shadows the current effective one. Snapshot the + // effective variant's state (including its live pose) into the + // shadow slot, then install the incoming variant fresh: a new + // variant starts at default pose/visibility, and its own Set* + // messages follow its create in the same buffer. + const pose = nodePoseData[message.name]; + const shadowed: ShadowedVariant = { + message: existingNode.message, + clickBindings: existingNode.clickBindings, + dragBindings: existingNode.dragBindings, + wxyz: pose?.wxyz ?? [1, 0, 0, 0], + position: pose?.position ?? [0, 0, 0], + visibility: existingNode.visibility ?? true, + }; + delete nodeRefFromName[message.name]; + nodePoseData[message.name] = { + wxyz: [1, 0, 0, 0], + position: [0, 0, 0], + poseUpdateState: "needsUpdate", + }; + store.set({ + [message.name]: { + ...existingNode, + message, + shadowed, + clickBindings: [], + dragBindings: [], + visibility: true, + }, + }); + actions.computeEffectiveVisibility(message.name); + } else { + // Incoming variant is lower-ranked (e.g. a virtual anchor next to + // a real node): park it in the shadow slot, effective untouched. + store.set({ + [message.name]: { + ...existingNode, + shadowed: { + message, + clickBindings: [], + dragBindings: [], + wxyz: [1, 0, 0, 0], + position: [0, 0, 0], + visibility: true, + }, + }, + }); + } + return; + } + + // Same-owner add (within-scope create or supersede), or a brand-new + // name. `...existingNode` carries any shadow slot across a supersede. const parentName = message.name.split("/").slice(0, -1).join("/"); const parentNode = store.get(parentName); @@ -125,6 +226,111 @@ export function createSceneTreeActions( store.set(updates); }, + /** Remove ONE scope's variant of a name. Scope-local by design: the + * server enumerates a message per same-scope descendant, and the other + * scope's variants (including children hanging from their own scope's + * virtual anchors) are untouched -- so unlike `removeSceneNode`, this + * does not recurse. Removing the effective variant promotes the + * shadowed one; a promoted VIRTUAL anchor inherits the departing + * variant's pose (frozen-pose inheritance), so surviving children stay + * where they were instead of teleporting to identity. */ + removeSceneNodeVariant: (name: string, owner: string) => { + const node = store.get(name); + if (node === undefined) { + console.log(`(OK) Skipping variant removal for ${name}`); + return; + } + if (ownerOf(node.message) === owner) { + const shadowed = node.shadowed; + if (shadowed !== undefined) { + // Promote the shadowed variant, with the state its scope's + // messages have been accumulating while it was hidden. + delete nodeRefFromName[name]; + if (!isVirtual(shadowed.message)) { + nodePoseData[name] = { + wxyz: shadowed.wxyz, + position: shadowed.position, + poseUpdateState: "needsUpdate", + }; + } + store.set({ + [name]: { + ...node, + message: shadowed.message, + clickBindings: shadowed.clickBindings, + dragBindings: shadowed.dragBindings, + visibility: isVirtual(shadowed.message) + ? true + : shadowed.visibility, + shadowed: undefined, + }, + }); + actions.computeEffectiveVisibility(name); + return; + } + // Last variant: drop the entry (no recursion -- see docstring). + const updates: Record = { + [name]: undefined, + }; + delete nodeRefFromName[name]; + delete nodePoseData[name]; + const parentName = name.split("/").slice(0, -1).join("/"); + const parentNode = store.get(parentName); + if (parentNode) { + updates[parentName] = { + ...parentNode, + children: parentNode.children.filter( + (child_name) => child_name !== name, + ), + }; + } + store.set(updates); + return; + } + if (node.shadowed && ownerOf(node.shadowed.message) === owner) { + store.set({ [name]: { ...node, shadowed: undefined } }); + } + }, + + /** Merge state updates into the SHADOWED variant of a name, matched by + * owner. Used to route messages that target the non-effective variant; + * silently drops updates for owners with no parked variant (e.g. a + * remove raced the update). */ + updateShadowedVariant: ( + name: string, + owner: string, + updates: Partial> & { + propsUpdates?: { [key: string]: any }; + }, + ) => { + const node = store.get(name); + const shadowed = node?.shadowed; + if ( + node === undefined || + shadowed === undefined || + ownerOf(shadowed.message) !== owner + ) { + return; + } + const { propsUpdates, ...rest } = updates; + store.set({ + [name]: { + ...node, + shadowed: { + ...shadowed, + ...rest, + message: + propsUpdates === undefined + ? shadowed.message + : ({ + ...shadowed.message, + props: { ...shadowed.message.props, ...propsUpdates }, + } as SceneNodeMessage), + }, + }, + }); + }, + removeSceneNode: (name: string) => { // Remove this scene node and all children. const removeNames: string[] = []; diff --git a/src/viser/client/src/WebsocketMessages.ts b/src/viser/client/src/WebsocketMessages.ts index 3ce7acd43..70bcb952f 100644 --- a/src/viser/client/src/WebsocketMessages.ts +++ b/src/viser/client/src/WebsocketMessages.ts @@ -9,6 +9,8 @@ export interface CameraFrustumMessage { type: "CameraFrustumMessage"; name: string; + owner: string; + virtual: boolean; props: { fov: number; aspect: number; @@ -30,6 +32,8 @@ export interface CameraFrustumMessage { export interface GlbMessage { type: "GlbMessage"; name: string; + owner: string; + virtual: boolean; props: { glb_data: Uint8Array; cast_shadow: boolean; @@ -44,6 +48,8 @@ export interface GlbMessage { export interface FrameMessage { type: "FrameMessage"; name: string; + owner: string; + virtual: boolean; props: { show_axes: boolean; axes_length: number; @@ -63,6 +69,8 @@ export interface FrameMessage { export interface BatchedAxesMessage { type: "BatchedAxesMessage"; name: string; + owner: string; + virtual: boolean; props: { batched_wxyzs: Float32Array; batched_positions: Float32Array; @@ -79,6 +87,8 @@ export interface BatchedAxesMessage { export interface GridMessage { type: "GridMessage"; name: string; + owner: string; + virtual: boolean; props: { width: number; height: number; @@ -106,6 +116,8 @@ export interface GridMessage { export interface LabelMessage { type: "LabelMessage"; name: string; + owner: string; + virtual: boolean; props: { text: string; font_size_mode: "screen" | "scene"; @@ -131,6 +143,8 @@ export interface LabelMessage { export interface Gui3DMessage { type: "Gui3DMessage"; name: string; + owner: string; + virtual: boolean; props: { order: number; container_uuid: string }; } /** Point cloud message. @@ -145,6 +159,8 @@ export interface Gui3DMessage { export interface PointCloudMessage { type: "PointCloudMessage"; name: string; + owner: string; + virtual: boolean; props: { points: Uint16Array | Float32Array; colors: Uint8Array; @@ -162,6 +178,8 @@ export interface PointCloudMessage { export interface DirectionalLightMessage { type: "DirectionalLightMessage"; name: string; + owner: string; + virtual: boolean; props: { color: [number, number, number]; intensity: number; @@ -175,6 +193,8 @@ export interface DirectionalLightMessage { export interface AmbientLightMessage { type: "AmbientLightMessage"; name: string; + owner: string; + virtual: boolean; props: { color: [number, number, number]; intensity: number }; } /** Hemisphere light message. @@ -184,6 +204,8 @@ export interface AmbientLightMessage { export interface HemisphereLightMessage { type: "HemisphereLightMessage"; name: string; + owner: string; + virtual: boolean; props: { sky_color: [number, number, number]; ground_color: [number, number, number]; @@ -197,6 +219,8 @@ export interface HemisphereLightMessage { export interface PointLightMessage { type: "PointLightMessage"; name: string; + owner: string; + virtual: boolean; props: { color: [number, number, number]; intensity: number; @@ -212,6 +236,8 @@ export interface PointLightMessage { export interface RectAreaLightMessage { type: "RectAreaLightMessage"; name: string; + owner: string; + virtual: boolean; props: { color: [number, number, number]; intensity: number; @@ -226,6 +252,8 @@ export interface RectAreaLightMessage { export interface SpotLightMessage { type: "SpotLightMessage"; name: string; + owner: string; + virtual: boolean; props: { color: [number, number, number]; intensity: number; @@ -246,6 +274,8 @@ export interface SpotLightMessage { export interface MeshMessage { type: "MeshMessage"; name: string; + owner: string; + virtual: boolean; props: { vertices: Float32Array; faces: Uint32Array; @@ -267,6 +297,8 @@ export interface MeshMessage { export interface BoxMessage { type: "BoxMessage"; name: string; + owner: string; + virtual: boolean; props: { dimensions: [number, number, number]; color: [number, number, number]; @@ -287,6 +319,8 @@ export interface BoxMessage { export interface IcosphereMessage { type: "IcosphereMessage"; name: string; + owner: string; + virtual: boolean; props: { radius: number; subdivisions: number; @@ -308,6 +342,8 @@ export interface IcosphereMessage { export interface CylinderMessage { type: "CylinderMessage"; name: string; + owner: string; + virtual: boolean; props: { radius: number; height: number; @@ -330,6 +366,8 @@ export interface CylinderMessage { export interface SkinnedMeshMessage { type: "SkinnedMeshMessage"; name: string; + owner: string; + virtual: boolean; props: { vertices: Float32Array; faces: Uint32Array; @@ -355,6 +393,8 @@ export interface SkinnedMeshMessage { export interface BatchedMeshesMessage { type: "BatchedMeshesMessage"; name: string; + owner: string; + virtual: boolean; props: { batched_wxyzs: Float32Array; batched_positions: Float32Array; @@ -381,6 +421,8 @@ export interface BatchedMeshesMessage { export interface BatchedGlbMessage { type: "BatchedGlbMessage"; name: string; + owner: string; + virtual: boolean; props: { batched_wxyzs: Float32Array; batched_positions: Float32Array; @@ -399,6 +441,8 @@ export interface BatchedGlbMessage { export interface TransformControlsMessage { type: "TransformControlsMessage"; name: string; + owner: string; + virtual: boolean; props: { scale: number; line_width: number; @@ -420,6 +464,8 @@ export interface TransformControlsMessage { export interface ImageMessage { type: "ImageMessage"; name: string; + owner: string; + virtual: boolean; props: { _format: "jpeg" | "png"; _data: Uint8Array; @@ -437,6 +483,8 @@ export interface ImageMessage { export interface LineSegmentsMessage { type: "LineSegmentsMessage"; name: string; + owner: string; + virtual: boolean; props: { points: Float32Array; thickness: number; @@ -452,6 +500,8 @@ export interface LineSegmentsMessage { export interface ArrowMessage { type: "ArrowMessage"; name: string; + owner: string; + virtual: boolean; props: { points: Float32Array; colors: Uint8Array; @@ -468,6 +518,8 @@ export interface ArrowMessage { export interface CatmullRomSplineMessage { type: "CatmullRomSplineMessage"; name: string; + owner: string; + virtual: boolean; props: { points: Float32Array; curve_type: "centripetal" | "chordal" | "catmullrom"; @@ -487,6 +539,8 @@ export interface CatmullRomSplineMessage { export interface CubicBezierSplineMessage { type: "CubicBezierSplineMessage"; name: string; + owner: string; + virtual: boolean; props: { points: Float32Array; control_points: Float32Array; @@ -504,15 +558,21 @@ export interface CubicBezierSplineMessage { export interface GaussianSplatsMessage { type: "GaussianSplatsMessage"; name: string; + owner: string; + virtual: boolean; props: { buffer: Uint32Array; scale: number | [number, number, number] }; } -/** Remove a particular node from the scene. +/** Remove a particular node's variant, for the scope stamped in + * ``owner``, from the scene. Removal is scope-local: it never touches the + * other scope's variant of the same name (the server enumerates one such + * message per same-scope descendant; the client does not cascade). * * (automatically generated) */ export interface RemoveSceneNodeMessage { type: "RemoveSceneNodeMessage"; name: string; + owner: string; } /** GuiFolderMessage(uuid: 'str', container_uuid: 'str', props: 'GuiFolderProps') * @@ -1350,6 +1410,7 @@ export interface SetBoneOrientationMessage { name: string; bone_index: number; wxyz: [number, number, number, number]; + owner: string; } /** Server -> client message to set a skinned mesh bone's position. * @@ -1362,6 +1423,7 @@ export interface SetBonePositionMessage { name: string; bone_index: number; position: [number, number, number]; + owner: string; } /** Server -> client message to set the camera's position. * @@ -1450,6 +1512,7 @@ export interface SetOrientationMessage { type: "SetOrientationMessage"; name: string; wxyz: [number, number, number, number]; + owner: string; } /** Server -> client message to set a scene node's position. * @@ -1461,6 +1524,7 @@ export interface SetPositionMessage { type: "SetPositionMessage"; name: string; position: [number, number, number]; + owner: string; } /** Client -> server message when a transform control is updated. * @@ -1473,6 +1537,7 @@ export interface TransformControlsUpdateMessage { name: string; wxyz: [number, number, number, number]; position: [number, number, number]; + owner: string; } /** Client -> server message when a transform control drag starts. * @@ -1481,6 +1546,7 @@ export interface TransformControlsUpdateMessage { export interface TransformControlsDragStartMessage { type: "TransformControlsDragStartMessage"; name: string; + owner: string; } /** Client -> server message when a transform control drag ends. * @@ -1489,6 +1555,7 @@ export interface TransformControlsDragStartMessage { export interface TransformControlsDragEndMessage { type: "TransformControlsDragEndMessage"; name: string; + owner: string; } /** Message for rendering a background image. * @@ -1508,6 +1575,7 @@ export interface SetSceneNodeVisibilityMessage { type: "SetSceneNodeVisibilityMessage"; name: string; visible: boolean; + owner: string; } /** Declare the drag-input combinations a scene node listens for. * @@ -1537,6 +1605,7 @@ export interface SetSceneNodeDragBindingsMessage { | "cmd/ctrl+alt+shift" | null; }[]; + owner: string; } /** Declare the click-input combinations a scene node listens for. * @@ -1566,6 +1635,7 @@ export interface SetSceneNodeClickBindingsMessage { | "cmd/ctrl+alt+shift" | null; }[]; + owner: string; } /** Message for clicked objects. * @@ -1587,6 +1657,7 @@ export interface SceneNodeClickMessage { | "alt+shift" | "cmd/ctrl+alt+shift" | null; + owner: string; } /** Client -> server message for a scene-node drag (start/update/end). * @@ -1616,6 +1687,7 @@ export interface SceneNodeDragMessage { | "alt+shift" | "cmd/ctrl+alt+shift" | null; + owner: string; } /** Reset GUI. * @@ -1779,6 +1851,7 @@ export interface SceneNodeUpdateMessage { type: "SceneNodeUpdateMessage"; name: string; updates: { [key: string]: any }; + owner: string; } /** Message from server->client to configure parts of the GUI. * diff --git a/tests/e2e/test_cross_scope_handles.py b/tests/e2e/test_cross_scope_handles.py index 5bb081f09..9f5e934f5 100644 --- a/tests/e2e/test_cross_scope_handles.py +++ b/tests/e2e/test_cross_scope_handles.py @@ -1,11 +1,12 @@ """E2E tests for cross-scope (server vs. client handle) scene/GUI semantics. -Scene and GUI elements can be added through two scopes: ``server.scene`` / -``server.gui`` (broadcast, persistent buffer, replayed to late joiners) and -``client.scene`` / ``client.gui`` (one connection, ephemeral buffer). The -frontend merges both scopes into single stores -- the scene tree is keyed by -user-chosen node name with no record of which scope created an entry -- while -the Python side keeps disjoint per-scope registries and buffers. +Scene and GUI elements can be created through two scopes: ``server.scene`` / +``server.gui`` (broadcast, persistent) and ``client.scene`` / ``client.gui`` +(one connection, ephemeral). Scene nodes are identified by (owner, name): +each scene-tree name holds at most one variant per scope, fed independently +by its scope's messages, and the client renders the effective variant chosen +by the display rule (real client > real broadcast > virtual client > virtual +broadcast). This suite pins that seam from both directions: @@ -14,15 +15,14 @@ client-scoped elements across reconnects, and the deliberate cross-scope exclusivity of scene pointer callbacks. -- **Rule tests** cover the cross-scope name discipline enforced by the - server-wide ``SceneNameIndex``: overlapping-scope name reuse is rejected - at the add site, a child's audience must be a subset of its parent's, - broadcast removals cascade into per-client subtrees (Python handles - included, matching the frontend's name-keyed cascade), and disconnects - free a client's names. Fast Python-level coverage of the same rules lives - in ``tests/test_scene_name_index.py``; here they run against a real - browser so the frontend-observable halves (node state, click dispatch, - world-axes overrides) are exercised too. +- **Variant/shadowing tests** cover the display rule end to end: a client + variant shadows the server's and un-shadows with the server's LATEST + state, clicks dispatch to exactly the effective variant's scope, removal + cascades are scope-local (client children survive broadcast parent + removal, anchored at the parent's frozen pose), and virtual anchors never + shadow real nodes. Fast Python-side coverage of the same rules lives in + ``tests/test_scene_scopes.py``; the frontend store logic is unit-tested in + ``src/viser/client/src/SceneTreeState.test.ts``. """ from __future__ import annotations @@ -48,13 +48,13 @@ wait_for_server_ready, ) -JS_GET_NODE_POSITION = """ +JS_GET_EFFECTIVE_OWNER = """ (nodeName) => { - const m = window.__viserMutable; - if (!m || !m.nodeRefFromName) return null; - const obj = m.nodeRefFromName[nodeName]; - if (!obj) return null; - return [obj.position.x, obj.position.y, obj.position.z]; + const tree = window.__viserSceneTree; + if (!tree) return null; + const node = tree.getState()[nodeName]; + if (!node) return null; + return node.message.owner ?? ""; } """ @@ -176,8 +176,7 @@ def test_per_client_namespaces_are_isolated(two_client_setup: dict) -> None: def test_same_name_coexists_across_different_clients(two_client_setup: dict) -> None: """Two clients may each own a node with the SAME name, with independent - state. Any single-namespace redesign must key identity by (audience, - name), not name alone, to keep this working.""" + state.""" page1: Page = two_client_setup["page1"] page2: Page = two_client_setup["page2"] client1: viser.ClientHandle = two_client_setup["client1"] @@ -195,27 +194,28 @@ def test_client_scope_elements_do_not_survive_reconnect( ) -> None: """Server-scoped elements are replayed after a reconnect; client-scoped elements are not (the per-client buffer is ephemeral and the reconnected - browser is a brand-new ClientHandle). This is the documented contract -- - client state is ephemeral, rebuilt in on_client_connect; durable state - lives client-side or in application code (see the ClientHandle - docstring). The server never retains per-client element state.""" + browser is a brand-new ClientHandle). This includes shadowing variants: + after a reconnect, the server's variant of a previously-shadowed name is + the one shown.""" client = get_client_handle(viser_server) - viser_server.scene.add_box("/shared_box", dimensions=(1.0, 1.0, 1.0)) + viser_server.scene.add_box( + "/shared_box", dimensions=(1.0, 1.0, 1.0), position=(2.0, 0.0, 0.0) + ) client.scene.add_icosphere("/client_sphere", radius=0.3) - wait_for_scene_node(viser_page, "/shared_box") + # A client variant shadowing a server name. + client.scene.add_box("/shared_box", dimensions=(0.5, 0.5, 0.5)) wait_for_scene_node(viser_page, "/client_sphere") + wait_for_node_position(viser_page, "/shared_box", (0.0, 0.0, 0.0)) viser_server._websock_server.disconnect_all_clients() # The frontend reconnects automatically, resets its stores, and replays - # the broadcast backlog. + # the broadcast backlog: the client sphere is gone, and the server's + # variant of /shared_box (at ITS position) is effective again. wait_for_scene_node_removed(viser_page, "/client_sphere") - wait_for_scene_node(viser_page, "/shared_box", timeout=15_000) - time.sleep(0.5) - assert viser_page.evaluate( - "() => window.__viserSceneTree.getState()['/client_sphere'] === undefined" - ), "client-scoped node unexpectedly survived (or was replayed after) reconnect" + wait_for_node_position(viser_page, "/shared_box", (2.0, 0.0, 0.0), timeout=15_000) + assert viser_page.evaluate(JS_GET_EFFECTIVE_OWNER, "/shared_box") == "" def test_scene_pointer_callbacks_are_cross_scope_exclusive( @@ -273,51 +273,52 @@ def test_gui_container_context_does_not_span_scopes( # --------------------------------------------------------------------------- -# Cross-scope name rules (enforced by SceneNameIndex). +# Variant slots + display rule (shadowing). # --------------------------------------------------------------------------- -def test_cross_scope_same_name_add_raises( +def test_client_variant_shadows_and_unshadows_with_latest_state( viser_server: viser.ViserServer, viser_page: Page ) -> None: - """A name claimed by one scope cannot be re-added from an overlapping - scope: both scopes share one name-keyed scene tree on the frontend, so - the second add would silently corrupt the first node's state. The add - raises instead, leaving the existing node untouched.""" + """The core shadowing round trip: a client-scoped add of a server-owned + name shadows it for that client; server updates keep accumulating in the + hidden variant; removing the client variant promotes the server's node + with its LATEST state -- no resurrection round trip.""" client = get_client_handle(viser_server) viser_server.scene.add_icosphere("/dup", radius=0.3, position=(1.0, 2.0, 0.0)) wait_for_node_position(viser_page, "/dup", (1.0, 2.0, 0.0)) - with pytest.raises(ValueError, match="already used"): - client.scene.add_icosphere("/dup", radius=0.3, position=(0.0, 0.0, 0.0)) + # Client-scoped variant at the origin: shadows the server's node. + client_variant = client.scene.add_icosphere( + "/dup", radius=0.3, position=(0.0, 0.0, 0.0) + ) + wait_for_node_position(viser_page, "/dup", (0.0, 0.0, 0.0)) + assert viser_page.evaluate(JS_GET_EFFECTIVE_OWNER, "/dup") == str(client.client_id) - # The rejected add left no trace: no client-scope registry entry, and the - # frontend node keeps the server's state. - assert "/dup" not in client.scene._handle_from_node_name - time.sleep(0.3) - wait_for_node_position(viser_page, "/dup", (1.0, 2.0, 0.0)) + # Server keeps writing while shadowed; the display doesn't budge. + viser_server.scene._handle_from_node_name["/dup"].position = (3.0, 3.0, 0.0) + time.sleep(0.5) + wait_for_node_position(viser_page, "/dup", (0.0, 0.0, 0.0)) - # And the reverse direction: a client-owned name rejects a broadcast add. - client.scene.add_icosphere("/own", radius=0.3) - wait_for_scene_node(viser_page, "/own") - with pytest.raises(ValueError, match="already used"): - viser_server.scene.add_icosphere("/own", radius=0.3) + # Un-shadow: the server's variant shows again, at its LATEST position. + client_variant.remove() + wait_for_node_position(viser_page, "/dup", (3.0, 3.0, 0.0)) + assert viser_page.evaluate(JS_GET_EFFECTIVE_OWNER, "/dup") == "" -def test_click_dispatches_to_single_scope( +def test_click_dispatches_to_effective_variant_only( viser_server: viser.ViserServer, viser_page: Page ) -> None: - """One physical click reaches exactly one scope's callbacks. (Before the - name index, a cross-scope name collision made one click fire BOTH - scopes' handlers; collisions are now rejected at the add site, so - dispatch is unique by construction.)""" + """A click on a shadowing client variant reaches the client scope's + callback and never the shadowed server variant's.""" viser_server.initial_camera.position = (0.0, 0.0, 4.0) viser_server.initial_camera.look_at = (0.0, 0.0, 0.0) client = get_client_handle(viser_server) - server_clicks: list[int] = [] server_clicked = threading.Event() + client_clicks: list[int] = [] + client_clicked = threading.Event() server_box = viser_server.scene.add_box( "/dup_click", dimensions=(4.0, 4.0, 0.2), color=(200, 60, 60) @@ -325,16 +326,23 @@ def test_click_dispatches_to_single_scope( @server_box.on_click def _(_event: viser.SceneNodePointerEvent[viser.BoxHandle]) -> None: - server_clicks.append(1) server_clicked.set() - # The conflicting client-scoped twin is rejected... - with pytest.raises(ValueError, match="already used"): - client.scene.add_box( - "/dup_click", dimensions=(4.0, 4.0, 0.2), color=(60, 200, 60) - ) + client_box = client.scene.add_box( + "/dup_click", dimensions=(4.0, 4.0, 0.2), color=(60, 200, 60) + ) + + @client_box.on_click + def _(_event: viser.SceneNodePointerEvent[viser.BoxHandle]) -> None: + client_clicks.append(1) + client_clicked.set() wait_for_scene_node(viser_page, "/dup_click") + viser_page.wait_for_function( + JS_GET_EFFECTIVE_OWNER + "", + arg="/dup_click", + timeout=5_000, + ) time.sleep(0.5) # Let click bindings reach the frontend. cx, cy = canvas_center(viser_page) @@ -342,97 +350,109 @@ def _(_event: viser.SceneNodePointerEvent[viser.BoxHandle]) -> None: viser_page.mouse.down() viser_page.mouse.up() - # ...and the click reaches the server-scoped handle exactly once. - assert server_clicked.wait(5.0), "click did not reach the server-scoped handle" + assert client_clicked.wait(5.0), "click did not reach the client-scoped handle" time.sleep(0.5) - assert len(server_clicks) == 1 + assert not server_clicked.is_set(), ( + "click dispatched to the shadowed server-scoped handle too" + ) + assert len(client_clicks) == 1 -def test_broadcast_remove_invalidates_client_scope_child( +def test_scope_local_cascade_client_child_survives( viser_server: viser.ViserServer, viser_page: Page ) -> None: - """Removing a server-scoped parent invalidates a client-scoped child - handle parented under it: the frontend's name-keyed cascade already - deleted the child node, so the Python handle must not stay live.""" + """Removing a broadcast parent removes only broadcast descendants: a + client-scoped child survives, hanging from its own scope's virtual + anchor, which inherits the departing parent's pose (children don't + teleport).""" client = get_client_handle(viser_server) - parent = viser_server.scene.add_frame("/parent", show_axes=False) + parent = viser_server.scene.add_frame( + "/parent", show_axes=False, position=(1.0, 0.0, 1.0) + ) child = client.scene.add_icosphere("/parent/child", radius=0.3) wait_for_scene_node(viser_page, "/parent/child") - # The client scope did not re-create the broadcast parent for itself. - assert "/parent" not in client.scene._handle_from_node_name - parent.remove() - # The frontend cascade removes the client-scoped child... - wait_for_scene_node_removed(viser_page, "/parent/child") - # ...and the Python handle agrees; later writes fail loudly. - assert child._impl.removed, ( - "client-scoped child handle still live after its node was removed " - "by a broadcast cascade" + # The child's entry survives on the frontend... + time.sleep(0.5) + wait_for_scene_node(viser_page, "/parent/child") + # ...hanging from the client's promoted virtual anchor... + assert viser_page.evaluate(JS_GET_EFFECTIVE_OWNER, "/parent") == str( + client.client_id + ) + # ...which inherited the departed parent's pose (frozen-pose + # inheritance -- the child stays where it was). + viser_page.wait_for_function( + """(expected) => { + const m = window.__viserMutable; + const pose = m && m.nodePoseData && m.nodePoseData["/parent"]; + if (!pose) return false; + return ( + Math.abs(pose.position[0] - expected[0]) < 1e-4 && + Math.abs(pose.position[1] - expected[1]) < 1e-4 && + Math.abs(pose.position[2] - expected[2]) < 1e-4 + ); + }""", + arg=[1.0, 0.0, 1.0], + timeout=5_000, ) - with pytest.raises(RuntimeError, match="removed"): - child.position = (1.0, 0.0, 0.0) + # The Python handle is still live; the author removes it explicitly. + assert not child._impl.removed + child.remove() + wait_for_scene_node_removed(viser_page, "/parent/child") -def test_client_parent_rejects_broadcast_child( +def test_virtual_anchor_does_not_shadow_real_broadcast_node( viser_server: viser.ViserServer, viser_page: Page ) -> None: - """A child's audience must be a subset of its parent's: a broadcast - child under a per-client parent would dangle for every other viewer.""" + """A client's auto-created ancestor anchor must not hide the server's + real node of the same name.""" client = get_client_handle(viser_server) - client.scene.add_frame("/client_parent", show_axes=False) - wait_for_scene_node(viser_page, "/client_parent") + viser_server.scene.add_frame("/anchor_parent", show_axes=True) + wait_for_scene_node(viser_page, "/anchor_parent") - with pytest.raises(ValueError, match="audience"): - viser_server.scene.add_icosphere("/client_parent/child", radius=0.3) + # Deep client add auto-creates client-scoped anchors for /anchor_parent. + client.scene.add_icosphere("/anchor_parent/mine", radius=0.3) + wait_for_scene_node(viser_page, "/anchor_parent/mine") + # The server's real frame is still the effective variant. + assert viser_page.evaluate(JS_GET_EFFECTIVE_OWNER, "/anchor_parent") == "" -def test_disconnect_frees_client_names(browser: Browser) -> None: - """A disconnect releases the client's name claims, so the names become - available to other scopes again.""" - viser._client_autobuild.ensure_client_is_built = lambda: None - max_retries = 3 - server: viser.ViserServer | None = None - for attempt in range(max_retries): - port = find_free_port() - try: - server = viser.ViserServer(port=port, verbose=False) - break - except OSError: - if attempt == max_retries - 1: - raise - assert server is not None - wait_for_server_ready(server.get_port()) +def test_broadcast_child_under_client_parent_coexists( + viser_server: viser.ViserServer, viser_page: Page +) -> None: + """A server add under a client-owned name is legal: the server's chain + hangs from its own virtual anchor, which is shadowed by the client's + real node on this client.""" + client = get_client_handle(viser_server) - context = browser.new_context() - page = context.new_page() - wait_for_connection(page, server.get_port()) - client = get_client_handle(server) + client.scene.add_frame("/cp", show_axes=False) + wait_for_scene_node(viser_page, "/cp") - client.scene.add_icosphere("/mine", radius=0.3) - with pytest.raises(ValueError, match="already used"): - server.scene.add_icosphere("/mine", radius=0.3) + viser_server.scene.add_icosphere("/cp/child", radius=0.3) + wait_for_scene_node(viser_page, "/cp/child") - context.close() - deadline = time.monotonic() + 10.0 - while server.get_clients() and time.monotonic() < deadline: - time.sleep(0.05) - assert not server.get_clients(), "client never disconnected" + # The client's real frame stays effective over the server's virtual + # anchor for /cp. + assert viser_page.evaluate(JS_GET_EFFECTIVE_OWNER, "/cp") == str(client.client_id) + assert viser_page.evaluate(JS_GET_EFFECTIVE_OWNER, "/cp/child") == "" - server.scene.add_icosphere("/mine", radius=0.3) - server.stop() + +# --------------------------------------------------------------------------- +# World axes. +# --------------------------------------------------------------------------- -def test_world_axes_toggle_reaches_connected_client( +def test_world_axes_client_shadow_override( viser_server: viser.ViserServer, viser_page: Page ) -> None: - """server.scene.world_axes is the only world-axes handle (client scopes - have none -- accessing client.scene.world_axes raises), and its toggles - reach an already-connected client in both directions.""" + """The sanctioned per-client world-axes override: a client-scoped + "/WorldAxes" frame shadows the server's, and removing it restores the + server's state. (client.scene.world_axes itself raises AttributeError.)""" client = get_client_handle(viser_server) with pytest.raises(AttributeError, match="server.scene.world_axes"): _ = client.scene.world_axes @@ -440,9 +460,12 @@ def test_world_axes_toggle_reaches_connected_client( viser_server.scene.world_axes.visible = True wait_for_scene_node_visible(viser_page, "/WorldAxes") - viser_server.scene.world_axes.visible = False + override = client.scene.add_frame("/WorldAxes", show_axes=True, visible=False) wait_for_scene_node_hidden(viser_page, "/WorldAxes") + override.remove() + wait_for_scene_node_visible(viser_page, "/WorldAxes") + def test_world_axes_server_state_deterministic_for_new_client( browser: Browser, diff --git a/tests/test_scene_name_index.py b/tests/test_scene_name_index.py deleted file mode 100644 index 3a092c380..000000000 --- a/tests/test_scene_name_index.py +++ /dev/null @@ -1,346 +0,0 @@ -"""Tests for cross-scope scene-node name handling. - -Scene-node names share one namespace per viewer: the frontend's scene tree is -keyed by name with no notion of which scope (``server.scene`` vs. a -``client.scene``) created a node. ``SceneNameIndex`` is the server-wide -structure that sees every scope's claims and enforces: - -1. No overlapping-scope name reuse (broadcast overlaps every client scope; - two different client scopes never overlap). -2. A child's audience must be a subset of its parent's. - -Covers the index in isolation, plus the integrated behavior on a headless -``ViserServer`` with synthetic in-process client connections (no browser; the -e2e side lives in ``tests/e2e/test_cross_scope_handles.py``). -""" - -from __future__ import annotations - -import asyncio -from typing import cast - -import pytest - -import viser -import viser._client_autobuild -from viser._scene_name_index import SceneNameIndex -from viser._viser import ClientHandle -from viser.infra import ClientId -from viser.infra._async_message_buffer import AsyncMessageBuffer -from viser.infra._infra import WebsockClientConnection, _ClientHandleState - -# --------------------------------------------------------------------------- -# SceneNameIndex in isolation. -# --------------------------------------------------------------------------- - -_API_A = object() # Stand-ins; the index never calls into these. -_API_B = object() - - -def test_index_broadcast_vs_client_conflicts() -> None: - index = SceneNameIndex() - index.commit("/x", None, _API_A) # type: ignore[arg-type] - - # Broadcast /x conflicts with any client's /x, both directions. - with pytest.raises(ValueError, match="already used"): - index.check_claimable("/x", cast(ClientId, 0)) - - index2 = SceneNameIndex() - index2.commit("/x", cast(ClientId, 0), _API_A) # type: ignore[arg-type] - with pytest.raises(ValueError, match="already used"): - index2.check_claimable("/x", None) - - -def test_index_same_scope_and_disjoint_clients_allowed() -> None: - index = SceneNameIndex() - index.commit("/x", cast(ClientId, 0), _API_A) # type: ignore[arg-type] - - # Same scope: allowed (supersede path). - index.check_claimable("/x", cast(ClientId, 0)) - # A different client: allowed (audiences never meet). - index.check_claimable("/x", cast(ClientId, 1)) - - index2 = SceneNameIndex() - index2.commit("/x", None, _API_A) # type: ignore[arg-type] - index2.check_claimable("/x", None) - - -def test_index_parent_audience_subset_rule() -> None: - index = SceneNameIndex() - index.commit("/bcast", None, _API_A) # type: ignore[arg-type] - index.commit("/client0", cast(ClientId, 0), _API_B) # type: ignore[arg-type] - - # Broadcast parent covers every child scope. - index.check_claimable("/bcast/child", None) - index.check_claimable("/bcast/child", cast(ClientId, 0)) - - # Client parent covers only its own scope. - index.check_claimable("/client0/child", cast(ClientId, 0)) - with pytest.raises(ValueError, match="audience"): - index.check_claimable("/client0/child", None) - with pytest.raises(ValueError, match="audience"): - index.check_claimable("/client0/child", cast(ClientId, 1)) - - # Unclaimed parents are unchecked (nodes may be added under parents that - # don't exist yet). - index.check_claimable("/nowhere/child", None) - index.check_claimable("/nowhere/child", cast(ClientId, 1)) - - -def test_index_release_and_drop_scope() -> None: - index = SceneNameIndex() - index.commit("/x", None, _API_A) # type: ignore[arg-type] - index.commit("/y", cast(ClientId, 0), _API_A) # type: ignore[arg-type] - index.commit("/z", cast(ClientId, 0), _API_A) # type: ignore[arg-type] - - index.release("/x", None) - index.check_claimable("/x", cast(ClientId, 0)) # Freed. - index.release("/x", None) # Idempotent. - - index.drop_scope(cast(ClientId, 0)) - index.check_claimable("/y", None) # Freed. - index.check_claimable("/z", None) # Freed. - - -def test_index_exists_visible() -> None: - index = SceneNameIndex() - index.commit("/bcast", None, _API_A) # type: ignore[arg-type] - index.commit("/mine", cast(ClientId, 0), _API_B) # type: ignore[arg-type] - - # Broadcast nodes are visible to every scope. - assert index.exists_visible("/bcast", None) - assert index.exists_visible("/bcast", cast(ClientId, 0)) - # Client nodes are visible only within their own scope. - assert index.exists_visible("/mine", cast(ClientId, 0)) - assert not index.exists_visible("/mine", None) - assert not index.exists_visible("/mine", cast(ClientId, 1)) - assert not index.exists_visible("/absent", None) - - -def test_index_foreign_descendants() -> None: - index = SceneNameIndex() - index.commit("/a", None, _API_A) # type: ignore[arg-type] - index.commit("/a/own", None, _API_A) # type: ignore[arg-type] - index.commit("/a/c0", cast(ClientId, 0), _API_B) # type: ignore[arg-type] - index.commit("/a/c0/deep", cast(ClientId, 0), _API_B) # type: ignore[arg-type] - index.commit("/aa", cast(ClientId, 0), _API_B) # type: ignore[arg-type] - - foreign = index.foreign_descendants("/a", None) - names = sorted(name for _, name in foreign) - # Own-scope descendants and prefix-similar names (/aa) are excluded. - assert names == ["/a/c0", "/a/c0/deep"] - - -# --------------------------------------------------------------------------- -# Integrated behavior on a headless server + synthetic clients. -# --------------------------------------------------------------------------- - - -@pytest.fixture() -def server() -> viser.ViserServer: - viser._client_autobuild.ensure_client_is_built = lambda: None - server = viser.ViserServer(port=0, verbose=False) - yield server - server.stop() - - -def _make_synthetic_client(server: viser.ViserServer, client_id: int) -> ClientHandle: - """In-process ClientHandle with a real per-client message buffer but no - websocket (mirrors how WebsockServer builds per-client state; same - pattern as tests/test_panel.py). Buffer construction hops to the server's - loop thread because AsyncMessageBuffer's asyncio primitives bind to the - running loop.""" - - async def _make_buffer() -> AsyncMessageBuffer: - return AsyncMessageBuffer(server._event_loop, persistent_messages=False) - - buffer = asyncio.run_coroutine_threadsafe( - _make_buffer(), server._event_loop - ).result(timeout=5.0) - conn = WebsockClientConnection( - client_id, _ClientHandleState(buffer, server._event_loop) - ) - return ClientHandle(conn, server) - - -def test_cross_scope_same_name_raises_both_directions( - server: viser.ViserServer, -) -> None: - client = _make_synthetic_client(server, 0) - - server.scene.add_icosphere("/dup", radius=0.1) - with pytest.raises(ValueError, match="already used"): - client.scene.add_icosphere("/dup", radius=0.1) - # The rejected add left no trace in the client scope. - assert "/dup" not in client.scene._handle_from_node_name - - client.scene.add_icosphere("/own", radius=0.1) - with pytest.raises(ValueError, match="already used"): - server.scene.add_icosphere("/own", radius=0.1) - assert "/own" not in server.scene._handle_from_node_name - - -def test_same_name_across_clients_coexists(server: viser.ViserServer) -> None: - client0 = _make_synthetic_client(server, 0) - client1 = _make_synthetic_client(server, 1) - - h0 = client0.scene.add_icosphere("/own", radius=0.1) - h1 = client1.scene.add_icosphere("/own", radius=0.2) - assert not h0._impl.removed - assert not h1._impl.removed - - -def test_same_scope_supersede_still_works(server: viser.ViserServer) -> None: - old = server.scene.add_box("/re", dimensions=(1.0, 1.0, 1.0)) - new = server.scene.add_box("/re", dimensions=(2.0, 2.0, 2.0)) - assert old._impl.removed - assert not new._impl.removed - assert server.scene._handle_from_node_name["/re"] is new - - -def test_client_child_under_broadcast_parent(server: viser.ViserServer) -> None: - client = _make_synthetic_client(server, 0) - - server.scene.add_frame("/parent", show_axes=False) - child = client.scene.add_icosphere("/parent/child", radius=0.1) - - # The client scope did NOT re-create the broadcast parent (the frontend - # keys nodes by name; a duplicate parent add would clobber the shared - # node for this client). - assert "/parent" not in client.scene._handle_from_node_name - assert "/parent/child" in client.scene._handle_from_node_name - assert not child._impl.removed - - -def test_broadcast_child_under_client_parent_raises( - server: viser.ViserServer, -) -> None: - client = _make_synthetic_client(server, 0) - - client.scene.add_frame("/cp", show_axes=False) - with pytest.raises(ValueError, match="audience"): - server.scene.add_icosphere("/cp/child", radius=0.1) - with pytest.raises(ValueError, match="audience"): - _make_synthetic_client(server, 1).scene.add_icosphere("/cp/child", radius=0.1) - - -def test_broadcast_remove_cascades_into_client_scope( - server: viser.ViserServer, -) -> None: - client = _make_synthetic_client(server, 0) - - parent = server.scene.add_frame("/parent", show_axes=False) - child = client.scene.add_icosphere("/parent/child", radius=0.1) - grandchild = client.scene.add_icosphere("/parent/child/deep", radius=0.1) - - parent.remove() - - # The whole client-scope subtree is invalidated, matching the frontend's - # name-keyed cascade. - assert child._impl.removed - assert grandchild._impl.removed - assert "/parent/child" not in client.scene._handle_from_node_name - # Writes to the dead handles fail loudly instead of silently targeting a - # nonexistent node. - with pytest.raises(RuntimeError, match="removed"): - child.position = (1.0, 0.0, 0.0) - # The names are free again, in any scope. - server.scene.add_icosphere("/parent/child", radius=0.1) - - -def test_transform_controls_cascade_cleans_registry( - server: viser.ViserServer, -) -> None: - client = _make_synthetic_client(server, 0) - - parent = server.scene.add_frame("/parent", show_axes=False) - tc = client.scene.add_transform_controls("/parent/gizmo") - assert "/parent/gizmo" in client.scene._handle_from_transform_controls_name - - parent.remove() - - assert tc._impl.removed - assert "/parent/gizmo" not in client.scene._handle_from_transform_controls_name - - -def test_client_remove_does_not_touch_broadcast_siblings( - server: viser.ViserServer, -) -> None: - client = _make_synthetic_client(server, 0) - - server.scene.add_frame("/parent", show_axes=False) - server_child = server.scene.add_icosphere("/parent/shared", radius=0.1) - client_child = client.scene.add_icosphere("/parent/mine", radius=0.1) - - client_child.remove() - - assert client_child._impl.removed - assert not server_child._impl.removed - assert "/parent" in server.scene._handle_from_node_name - - -def test_disconnect_frees_client_names(server: viser.ViserServer) -> None: - client = _make_synthetic_client(server, 0) - client.scene.add_icosphere("/mine", radius=0.1) - - with pytest.raises(ValueError, match="already used"): - server.scene.add_icosphere("/mine", radius=0.1) - - # Simulate the disconnect teardown's index cleanup. - with server._scene_lifecycle_lock: - server._scene_name_index.drop_scope(cast(ClientId, 0)) - - server.scene.add_icosphere("/mine", radius=0.1) - - -def test_server_reset_cascades_client_subtrees_only( - server: viser.ViserServer, -) -> None: - client = _make_synthetic_client(server, 0) - - server.scene.add_frame("/shared", show_axes=False) - nested = client.scene.add_icosphere("/shared/mine", radius=0.1) - top_level = client.scene.add_icosphere("/standalone", radius=0.1) - - server.scene.reset() - - # Client nodes under broadcast parents die with them; top-level client - # nodes are untouched (reset is scoped to the caller's own elements). - assert nested._impl.removed - assert not top_level._impl.removed - - -# --------------------------------------------------------------------------- -# World axes (server-owned; no client-scope handle). -# --------------------------------------------------------------------------- - - -def test_client_scene_construction_sends_nothing( - server: viser.ViserServer, -) -> None: - client = _make_synthetic_client(server, 0) - buffer = client._websock_connection._state.message_buffer - assert len(buffer.message_from_id) == 0, ( - "client SceneApi construction queued messages; it must not re-add " - "/WorldAxes (or anything else) over the per-client connection" - ) - assert "/WorldAxes" not in client.scene._handle_from_node_name - - -def test_client_world_axes_raises_with_pointer_to_server( - server: viser.ViserServer, -) -> None: - """The world axes are one shared broadcast node; there is no per-client - handle for them. The error message points at server.scene.world_axes.""" - client = _make_synthetic_client(server, 0) - with pytest.raises(AttributeError, match="server.scene.world_axes"): - _ = client.scene.world_axes - # The server-side handle is unaffected. - server.scene.world_axes.visible = True - assert server.scene.world_axes.visible - - -def test_client_add_world_axes_name_raises(server: viser.ViserServer) -> None: - client = _make_synthetic_client(server, 0) - with pytest.raises(ValueError, match="already used"): - client.scene.add_frame("/WorldAxes") diff --git a/tests/test_scene_scopes.py b/tests/test_scene_scopes.py new file mode 100644 index 000000000..54784d4f7 --- /dev/null +++ b/tests/test_scene_scopes.py @@ -0,0 +1,266 @@ +"""Tests for cross-scope scene semantics: per-name variant slots. + +Scene nodes are identified by (owner, name): the broadcast scope +(``server.scene``) and each per-client scope (``client.scene``) may each hold +one variant of a name, fed independently by their scopes' messages. The +client renders the effective variant per the display rule (real client > +real broadcast > virtual client > virtual broadcast); shadowing and +promotion are covered by the frontend's ``SceneTreeState.test.ts`` and the +e2e suite (``tests/e2e/test_cross_scope_handles.py``). + +This file covers the Python/server half on a headless server with synthetic +in-process clients: owner stamping, unconditional same-scope virtual +anchors, scope-local cascade, and coexistence in the registries. +""" + +from __future__ import annotations + +import asyncio +from typing import Generator + +import pytest + +import viser +import viser._client_autobuild +from viser import _messages as m +from viser._viser import ClientHandle +from viser.infra._async_message_buffer import AsyncMessageBuffer +from viser.infra._infra import WebsockClientConnection, _ClientHandleState + + +@pytest.fixture() +def server() -> Generator[viser.ViserServer, None, None]: + viser._client_autobuild.ensure_client_is_built = lambda: None + server = viser.ViserServer(port=0, verbose=False) + yield server + server.stop() + + +def _make_synthetic_client(server: viser.ViserServer, client_id: int) -> ClientHandle: + """In-process ClientHandle with a real per-client message buffer but no + websocket (mirrors how WebsockServer builds per-client state; same + pattern as tests/test_panel.py). Buffer construction hops to the server's + loop thread because AsyncMessageBuffer's asyncio primitives bind to the + running loop.""" + + async def _make_buffer() -> AsyncMessageBuffer: + return AsyncMessageBuffer(server._event_loop, persistent_messages=False) + + buffer = asyncio.run_coroutine_threadsafe( + _make_buffer(), server._event_loop + ).result(timeout=5.0) + conn = WebsockClientConnection( + client_id, _ClientHandleState(buffer, server._event_loop) + ) + return ClientHandle(conn, server) + + +def _client_buffer_messages(client: ClientHandle) -> list[m.Message]: + return list( + client._websock_connection._state.message_buffer.message_from_id.values() + ) + + +def _broadcast_messages(server: viser.ViserServer) -> list[m.Message]: + return list(server._websock_server._broadcast_buffer.message_from_id.values()) + + +# --------------------------------------------------------------------------- +# Owner stamping. +# --------------------------------------------------------------------------- + + +def test_owner_stamped_on_broadcast_messages(server: viser.ViserServer) -> None: + handle = server.scene.add_icosphere("/ball", radius=0.1, position=(1.0, 0.0, 0.0)) + handle.visible = False + handle.color = (10, 20, 30) + + for msg in _broadcast_messages(server): + if hasattr(msg, "owner"): + assert msg.owner == "", f"{type(msg).__name__} not broadcast-stamped" + + +def test_owner_stamped_on_client_messages(server: viser.ViserServer) -> None: + client = _make_synthetic_client(server, 3) + handle = client.scene.add_icosphere("/ball", radius=0.1, position=(1.0, 0.0, 0.0)) + handle.visible = False + handle.color = (10, 20, 30) + handle.remove() + + scene_messages = [ + msg for msg in _client_buffer_messages(client) if hasattr(msg, "owner") + ] + assert len(scene_messages) > 0 + for msg in scene_messages: + assert msg.owner == "3", f"{type(msg).__name__} not client-stamped" + + +# --------------------------------------------------------------------------- +# Coexistence: same name in both scopes is legal, state is scope-local. +# --------------------------------------------------------------------------- + + +def test_same_name_coexists_across_scopes(server: viser.ViserServer) -> None: + client = _make_synthetic_client(server, 0) + + server_handle = server.scene.add_icosphere("/dup", radius=0.1) + client_handle = client.scene.add_icosphere("/dup", radius=0.2) + + # Both registries hold their own live handle; neither superseded. + assert not server_handle._impl.removed + assert not client_handle._impl.removed + assert server.scene._handle_from_node_name["/dup"] is server_handle + assert client.scene._handle_from_node_name["/dup"] is client_handle + + # Removing one scope's variant does not touch the other's. + client_handle.remove() + assert client_handle._impl.removed + assert not server_handle._impl.removed + + +def test_same_name_across_clients_coexists(server: viser.ViserServer) -> None: + client0 = _make_synthetic_client(server, 0) + client1 = _make_synthetic_client(server, 1) + + h0 = client0.scene.add_icosphere("/own", radius=0.1) + h1 = client1.scene.add_icosphere("/own", radius=0.2) + assert not h0._impl.removed + assert not h1._impl.removed + + +def test_same_scope_supersede_still_works(server: viser.ViserServer) -> None: + old = server.scene.add_box("/re", dimensions=(1.0, 1.0, 1.0)) + new = server.scene.add_box("/re", dimensions=(2.0, 2.0, 2.0)) + assert old._impl.removed + assert not new._impl.removed + assert server.scene._handle_from_node_name["/re"] is new + + +# --------------------------------------------------------------------------- +# Unconditional same-scope virtual anchors. +# --------------------------------------------------------------------------- + + +def test_virtual_anchors_created_per_scope(server: viser.ViserServer) -> None: + client = _make_synthetic_client(server, 0) + + # Broadcast parent exists; the client's deep add still creates a + # same-scope anchor chain (which does not shadow: anchors are virtual). + server.scene.add_frame("/parent", show_axes=False) + client.scene.add_icosphere("/parent/child/deep", radius=0.1) + + assert "/parent" in client.scene._handle_from_node_name + assert "/parent/child" in client.scene._handle_from_node_name + + creates = { + msg.name: msg + for msg in _client_buffer_messages(client) + if isinstance(msg, m._CreateSceneNodeMessage) + } + assert creates["/parent"].virtual is True + assert creates["/parent/child"].virtual is True + assert creates["/parent/child/deep"].virtual is False + for msg in creates.values(): + assert msg.owner == "0" + + +def test_real_add_supersedes_virtual_anchor(server: viser.ViserServer) -> None: + server.scene.add_icosphere("/a/b", radius=0.1) + anchor = server.scene._handle_from_node_name["/a"] + + real = server.scene.add_frame("/a", show_axes=True) + assert anchor._impl.removed + assert server.scene._handle_from_node_name["/a"] is real + # The real frame's create message is not virtual. + creates = [ + msg + for msg in _broadcast_messages(server) + if isinstance(msg, m._CreateSceneNodeMessage) and msg.name == "/a" + ] + assert len(creates) == 1 # Redundancy key replaced the anchor's create. + assert creates[0].virtual is False + + +# --------------------------------------------------------------------------- +# Scope-local cascade. +# --------------------------------------------------------------------------- + + +def test_cascade_is_scope_local(server: viser.ViserServer) -> None: + client = _make_synthetic_client(server, 0) + + parent = server.scene.add_frame("/parent", show_axes=False) + server_child = server.scene.add_icosphere("/parent/shared", radius=0.1) + client_child = client.scene.add_icosphere("/parent/mine", radius=0.1) + + parent.remove() + + # Broadcast descendants die with the parent; the client's subtree + # survives, anchored by its own scope's virtual /parent. + assert server_child._impl.removed + assert not client_child._impl.removed + assert "/parent/mine" in client.scene._handle_from_node_name + assert "/parent" in client.scene._handle_from_node_name # The anchor. + + # The client can still update and remove its node normally. + client_child.position = (1.0, 2.0, 3.0) + client_child.remove() + assert client_child._impl.removed + + +def test_client_cascade_does_not_touch_broadcast(server: viser.ViserServer) -> None: + client = _make_synthetic_client(server, 0) + + server.scene.add_frame("/parent", show_axes=False) + server_child = server.scene.add_icosphere("/parent/shared", radius=0.1) + client_parent_variant = client.scene.add_frame("/parent", show_axes=True) + client_child = client.scene.add_icosphere("/parent/mine", radius=0.1) + + client_parent_variant.remove() + + assert client_child._impl.removed # Same-scope cascade. + assert not server_child._impl.removed # Other scope untouched. + assert "/parent" in server.scene._handle_from_node_name + + +def test_remove_messages_enumerate_descendants(server: viser.ViserServer) -> None: + """The frontend does not recurse on removes; the server must enumerate + one RemoveSceneNodeMessage per same-scope descendant.""" + parent = server.scene.add_frame("/p", show_axes=False) + server.scene.add_icosphere("/p/a", radius=0.1) + server.scene.add_icosphere("/p/a/b", radius=0.1) + + parent.remove() + + # After GC/redundancy, creates are gone; a remove tombstone per name. + removed_names = { + msg.name + for msg in _broadcast_messages(server) + if isinstance(msg, m.RemoveSceneNodeMessage) + } + assert {"/p", "/p/a", "/p/a/b"} <= removed_names + + +# --------------------------------------------------------------------------- +# World axes. +# --------------------------------------------------------------------------- + + +def test_client_scene_construction_sends_nothing(server: viser.ViserServer) -> None: + client = _make_synthetic_client(server, 0) + assert len(_client_buffer_messages(client)) == 0, ( + "client SceneApi construction queued messages; it must not re-add " + "/WorldAxes (or anything else) over the per-client connection" + ) + assert "/WorldAxes" not in client.scene._handle_from_node_name + + +def test_client_world_axes_property_raises(server: viser.ViserServer) -> None: + client = _make_synthetic_client(server, 0) + with pytest.raises(AttributeError, match="server.scene.world_axes"): + _ = client.scene.world_axes + # The sanctioned per-client override: a client-scoped "/WorldAxes" frame + # (shadows the server's variant on that client's frontend). + override = client.scene.add_frame("/WorldAxes", show_axes=True) + assert not override._impl.removed + assert not server.scene.world_axes._impl.removed From 956e5fbeb284a7f0d19d579b54f67082cc7c4e45 Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Sun, 9 Aug 2026 08:02:23 +0000 Subject: [PATCH 09/27] Add pixel-level e2e tests for cross-scope shadowing Verifies the rendered pixels (via get_render), not just store state: the shadowing round trip (red server box -> green client shadow -> server recolors blue while hidden, display unchanged -> un-shadow reveals the LATEST blue), per-client isolation (the shadowing client sees green while a second client keeps seeing red), and scope-local cascade (a client child stays on screen after its broadcast parent is removed, then disappears when its own scope removes it). Follows the dominant-channel conventions of test_get_render_capture.py. --- tests/e2e/test_cross_scope_visual.py | 190 +++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 tests/e2e/test_cross_scope_visual.py diff --git a/tests/e2e/test_cross_scope_visual.py b/tests/e2e/test_cross_scope_visual.py new file mode 100644 index 000000000..c8286bca7 --- /dev/null +++ b/tests/e2e/test_cross_scope_visual.py @@ -0,0 +1,190 @@ +"""Pixel-level verification of cross-scope shadowing, via get_render(). + +The store-level halves of these behaviors are covered by +``test_cross_scope_handles.py`` (frontend state) and +``tests/test_scene_scopes.py`` (server state); here we verify the actually +rendered pixels: a shadowing client variant is what that client SEES, other +clients keep seeing the broadcast variant, un-shadowing reveals the +broadcast variant's LATEST state, and scope-local cascade leaves a client +child visibly on screen after its broadcast parent is removed. + +Conventions follow ``test_get_render_capture.py``: a box big enough to fill +the view center from the default camera pose, pure-channel colors, and +dominant-channel assertions on the center patch. +""" + +from __future__ import annotations + +import time +from typing import Generator + +import numpy as np +import pytest +from playwright.sync_api import Browser + +import viser +import viser._client_autobuild + +from .utils import find_free_port, wait_for_connection, wait_for_server_ready + +RED = (255, 0, 0) +GREEN = (0, 255, 0) +BLUE = (0, 0, 255) + + +@pytest.fixture() +def own_server() -> Generator[viser.ViserServer, None, None]: + viser._client_autobuild.ensure_client_is_built = lambda: None + server: viser.ViserServer | None = None + for attempt in range(3): + try: + server = viser.ViserServer(port=find_free_port(), verbose=False) + break + except OSError: + if attempt == 2: + raise + assert server is not None + wait_for_server_ready(server.get_port()) + yield server + server.stop() + + +def _connect_client(own_server: viser.ViserServer, browser: Browser): + captured: list[viser.ClientHandle] = [] + seen_ids = {c.client_id for c in own_server.get_clients().values()} + own_server.on_client_connect( + lambda client: ( + captured.append(client) if client.client_id not in seen_ids else None + ) + ) + context = browser.new_context() + page = context.new_page() + wait_for_connection(page, own_server.get_port()) + deadline = time.monotonic() + 10 + while not captured and time.monotonic() < deadline: + time.sleep(0.05) + assert captured, "client never connected" + client = captured[-1] + while client.camera._state.update_timestamp == 0.0 and ( + time.monotonic() < deadline + ): + time.sleep(0.05) + assert client.camera._state.update_timestamp != 0.0, "camera never synced" + return client, page, context + + +def _center_mean(img: np.ndarray) -> np.ndarray: + h, w = img.shape[:2] + return img[ + h // 2 - h // 8 : h // 2 + h // 8, + w // 2 - w // 8 : w // 2 + w // 8, + :3, + ].mean(axis=(0, 1)) + + +def _capture_center(client: viser.ClientHandle) -> np.ndarray: + img = client.get_render(height=96, width=128, timeout=30.0) + return _center_mean(img) + + +def _assert_dominant( + client: viser.ClientHandle, color: tuple[int, int, int], label: str +) -> None: + """The center patch's dominant channel must match `color`. Captures can + race the ~1-frame shadow remount, so retry briefly before failing.""" + deadline = time.monotonic() + 5.0 + center = _capture_center(client) + while time.monotonic() < deadline: + if int(np.argmax(center)) == int(np.argmax(color)) and center.max() > 60: + return + time.sleep(0.2) + center = _capture_center(client) + raise AssertionError(f"{label}: expected dominant {color}, captured {center}") + + +def test_shadowing_pixels_round_trip( + own_server: viser.ViserServer, browser: Browser +) -> None: + """RED server box -> GREEN client variant shadows it -> server recolors + its hidden variant BLUE (display unchanged) -> un-shadow reveals BLUE.""" + client, page, context = _connect_client(own_server, browser) + try: + server_box = own_server.scene.add_box( + "/box", color=RED, dimensions=(2.0, 2.0, 2.0) + ) + _assert_dominant(client, RED, "baseline server box") + + client_box = client.scene.add_box( + "/box", color=GREEN, dimensions=(2.0, 2.0, 2.0) + ) + _assert_dominant(client, GREEN, "client variant shadows") + + # Server updates its shadowed variant; this client's view must not + # change. + server_box.color = BLUE + time.sleep(0.5) + _assert_dominant(client, GREEN, "shadowed server update hidden") + + # Un-shadow: the server variant reappears with its LATEST color. + client_box.remove() + _assert_dominant(client, BLUE, "un-shadow reveals latest state") + finally: + page.close() + context.close() + + +def test_two_clients_see_their_own_variant( + own_server: viser.ViserServer, browser: Browser +) -> None: + """A shadowing variant is per-client: the shadowing client sees GREEN + while a second client keeps seeing the server's RED.""" + client1, page1, context1 = _connect_client(own_server, browser) + client2, page2, context2 = _connect_client(own_server, browser) + assert client1.client_id != client2.client_id + try: + own_server.scene.add_box("/box", color=RED, dimensions=(2.0, 2.0, 2.0)) + _assert_dominant(client1, RED, "client1 baseline") + _assert_dominant(client2, RED, "client2 baseline") + + client1.scene.add_box("/box", color=GREEN, dimensions=(2.0, 2.0, 2.0)) + _assert_dominant(client1, GREEN, "client1 sees own variant") + _assert_dominant(client2, RED, "client2 unaffected") + finally: + page1.close() + context1.close() + page2.close() + context2.close() + + +def test_scope_local_cascade_child_stays_on_screen( + own_server: viser.ViserServer, browser: Browser +) -> None: + """A client child under a broadcast parent remains VISIBLE (not just in + the store) after the parent is removed, then disappears when its own + scope removes it.""" + client, page, context = _connect_client(own_server, browser) + try: + parent = own_server.scene.add_frame("/parent", show_axes=False) + child = client.scene.add_box( + "/parent/child", color=GREEN, dimensions=(2.0, 2.0, 2.0) + ) + _assert_dominant(client, GREEN, "child visible under broadcast parent") + + parent.remove() + time.sleep(0.5) + _assert_dominant(client, GREEN, "child survives broadcast cascade") + + child.remove() + # Background: all channels bright (white-ish), nothing green-dominant + # at high saturation. + deadline = time.monotonic() + 5.0 + center = _capture_center(client) + while time.monotonic() < deadline and not np.all(center > 200): + time.sleep(0.2) + center = _capture_center(client) + assert np.all(center > 200), ( + f"child still visible after its own removal: center {center}" + ) + finally: + page.close() + context.close() From d318802a902868e1c5346089d7cb77843b3ee09a Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Sun, 9 Aug 2026 10:16:58 +0000 Subject: [PATCH 10/27] Simplify and clean up the cross-scope shadowing implementation Applied from a four-angle review (reuse / simplification / efficiency / altitude) of the branch diff: Frontend: - The six copy-pasted effective-vs-shadowed routing blocks in MessageHandler collapse into one store action, routeShadowedUpdate, which owns the root-singleton exemption (previously applied inconsistently across cases), the owner normalization, and the store lookup (previously done twice per routed message). - Shadowed-variant updates now mutate the slot in place with no store write: nothing subscribes to the slot (only promotion reads it), and a server animating a shadowed node would otherwise trigger a reactive notification per pose message at up to 60Hz for state that nothing renders. A shadowed-slot counter short-circuits routing to one integer compare in the no-shadowing common case, restoring the pre-branch zero-lookup cost on the hot pose paths. - addSceneNode and removeSceneNodeVariant return their disposition (effective/shadowed; removed/promoted/noop), so MessageHandler no longer re-derives display-rule decisions by inspecting the store before/after calls (the message-identity probe is gone). - ownerOf accepts undefined, absorbing the missing-message fallback duplicated at the click/drag echo sites; the entry-drop tail shared by variant and recursive removal is factored into one helper. Python: - _ensure_ancestors_exist reuses add_frame (new private _virtual flag) instead of hand-copying its message construction and defaults, gains a parent-registered fast path (one rsplit + dict lookup per add, valid because every add maintains a complete same-scope ancestor chain), and drops a redundant reentrant lock acquire (the caller holds it). - The owner-filter guard moves from five handler bodies into a _register_owner_scoped_handler wrapper at the registration layer -- forgetting it on a future node-keyed handler meant silent double-dispatch, and the deliberately unscoped ScenePointerMessage registration is now legible as the exception. - _queue_scene_message asserts the message declares an owner field: a missing field would silently serialize without one and be routed to the wrong variant client-side. - Stale comment referencing the removed name-index design fixed. Tests: - Shared helpers hoisted: center_mean/connect_client/get_client_handle into tests/e2e/utils.py (test_get_render_capture migrated to them), make_synthetic_client into new tests/infra_utils.py. - Dead '+ ""' concat in a wait predicate replaced with an explicit non-empty-owner condition; the pixel-retry helper now evaluates every capture before the deadline check (a slow software-WebGL capture could previously discard a passing frame). Skipped, deliberately: a typed owner base class (Python's type system cannot express 'Message and has owner' without a larger restructure; the runtime assert covers the failure mode), caching the drag owner on the active-drag state (one Map.get per pointermove amid per-move matrix work), and promoting the two-browser fixture to conftest (the per-file server fixture is the suite's existing convention). --- src/viser/_scene_api.py | 110 +++++++----- src/viser/_viser.py | 4 +- src/viser/client/src/DragLayer.tsx | 9 +- src/viser/client/src/MessageHandler.tsx | 91 ++++------ src/viser/client/src/SceneTree.tsx | 16 +- src/viser/client/src/SceneTreeState.test.ts | 21 ++- src/viser/client/src/SceneTreeState.ts | 188 ++++++++++++-------- tests/e2e/test_cross_scope_handles.py | 28 +-- tests/e2e/test_cross_scope_visual.py | 62 ++----- tests/e2e/test_get_render_capture.py | 54 ++---- tests/e2e/utils.py | 67 ++++++- tests/infra_utils.py | 33 ++++ tests/test_scene_scopes.py | 42 ++--- 13 files changed, 389 insertions(+), 336 deletions(-) create mode 100644 tests/infra_utils.py diff --git a/src/viser/_scene_api.py b/src/viser/_scene_api.py index a5db3ab77..2a2e2b1d3 100644 --- a/src/viser/_scene_api.py +++ b/src/viser/_scene_api.py @@ -351,25 +351,36 @@ def __init__( ) self._world_axes.visible = False - self._websock_interface.register_handler( + # Node-keyed interaction messages echo the effective variant's owner, + # and every incoming message fans out to BOTH the server's and the + # connection's handler lists -- so these handlers are registered + # through the owner-scoping wrapper, which makes exactly one scope's + # SceneApi act on each message. Registering one of these directly + # would not fail; it would silently double-dispatch callbacks in + # both scopes. + self._register_owner_scoped_handler( _messages.TransformControlsUpdateMessage, self._handle_transform_controls_updates, ) - self._websock_interface.register_handler( + self._register_owner_scoped_handler( _messages.TransformControlsDragStartMessage, self._handle_transform_controls_drag_start, ) - self._websock_interface.register_handler( + self._register_owner_scoped_handler( _messages.TransformControlsDragEndMessage, self._handle_transform_controls_drag_end, ) - self._websock_interface.register_handler( + self._register_owner_scoped_handler( _messages.SceneNodeClickMessage, self._handle_node_click_updates, ) - self._websock_interface.register_handler( + self._register_owner_scoped_handler( _messages.SceneNodeDragMessage, self._handle_node_drag ) + # Deliberately NOT owner-scoped: scene pointer events are scene-level + # (no target node, no owner), and cross-scope arbitration is handled + # by the registration-time exclusivity in + # _register_scene_pointer_callback instead. self._websock_interface.register_handler( _messages.ScenePointerMessage, self._handle_scene_pointer_updates, @@ -402,9 +413,32 @@ def _queue_scene_message(self, message: _messages.Message) -> None: here (or stamp ``owner`` itself): an unstamped message defaults to the broadcast owner and would be routed to the wrong variant on the client.""" + # A message class without a declared `owner` field would accept the + # assignment below but silently DROP it at serialization (only + # declared fields go over the wire) -- the client would then route + # the message to the wrong variant. Catch that at the first test + # that exercises the new message instead. + assert hasattr(message, "owner"), ( + f"{type(message).__name__} is queued as a scene message but " + "declares no `owner` field." + ) message.owner = self._owner_id # type: ignore[attr-defined] self._websock_interface.queue_message(message) + def _register_owner_scoped_handler(self, message_cls, handler) -> None: + """Register an incoming-message handler that only fires when the + message's echoed ``owner`` matches this scope. This is the dispatch + rule that makes the fan-out registration model safe: node-keyed + interaction messages reach both the server's and the connection's + handler lists, and exactly one scope may act on each.""" + + async def owner_scoped(client_id: ClientId, message) -> None: + if message.owner != self._owner_id: + return + await handler(client_id, message) + + self._websock_interface.register_handler(message_cls, owner_scoped) + def _is_drag_active_for(self, name: str) -> bool: """Whether the named scene node currently has any in-flight drag gesture (from any connected client). Used by ``remove()`` to @@ -476,34 +510,23 @@ def _ensure_ancestors_exist(self, name: str) -> None: same-scope ancestor chain, which is what makes scope-local cascade removal orphan-free (a client child survives a broadcast parent's removal by hanging from its own scope's anchor, which inherits the - departing variant's pose client-side). Runs under the lifecycle lock - so the existence checks and creates are atomic against concurrent + departing variant's pose client-side). + + Caller (``SceneNodeHandle._make``) holds the lifecycle lock, so the + existence checks and creates are atomic against concurrent adds/removes.""" - with self._node_lifecycle_lock: - parts = name.split("/") - for i in range(2, len(parts)): # skip root ("") and the node itself - ancestor = "/".join(parts[:i]) - if ancestor not in self._handle_from_node_name: - message = _messages.FrameMessage( - name=ancestor, - props=_messages.FrameProps( - show_axes=False, - axes_length=0.5, - axes_radius=0.025, - origin_radius=0.05, - origin_color=(236, 236, 0), - scale=1.0, - ), - ) - message.virtual = True - FrameHandle._make( - self, - message, - ancestor, - wxyz=(1.0, 0.0, 0.0, 0.0), - position=(0.0, 0.0, 0.0), - visible=True, - ) + # Fast path: a registered parent implies a complete ancestor chain + # (every add ensures its own chain; cascade removes whole same-scope + # subtrees), so per-add cost is one rsplit + dict lookup. + parent = name.rsplit("/", 1)[0] + if parent == "" or parent in self._handle_from_node_name: + return + parts = name.split("/") + for i in range(2, len(parts)): # skip root ("") and the node itself + ancestor = "/".join(parts[:i]) + if ancestor not in self._handle_from_node_name: + # Recurses into _make under the reentrant lifecycle lock. + self.add_frame(ancestor, show_axes=False, _virtual=True) def set_up_direction( self, @@ -1720,6 +1743,7 @@ def add_frame( wxyz: tuple[float, float, float, float] | np.ndarray = (1.0, 0.0, 0.0, 0.0), position: tuple[float, float, float] | np.ndarray = (0.0, 0.0, 0.0), visible: bool = True, + _virtual: bool = False, ) -> FrameHandle: """Add a coordinate frame to the scene. @@ -1761,6 +1785,9 @@ def add_frame( scale=scale, ), ) + # Internal: auto-created ancestor anchors are marked virtual so they + # yield to real variants in the client's display rule. + message.virtual = _virtual return FrameHandle._make(self, message, name, wxyz, position, visible) @deprecated_positional_shim @@ -3092,12 +3119,11 @@ def _get_client_handle(self, client_id: ClientId) -> ClientHandle: async def _handle_transform_controls_updates( self, client_id: ClientId, message: _messages.TransformControlsUpdateMessage ) -> None: - """Apply pose update and fire `update_cb` with phase="update".""" - # Node-keyed messages carry the effective variant's owner; only the - # owning scope's SceneApi handles them (incoming messages fan out to - # both the server's and the connection's handler lists). - if message.owner != self._owner_id: - return + """Apply pose update and fire `update_cb` with phase="update". + + Registered via _register_owner_scoped_handler, like every node-keyed + handler below: only the scope whose owner the message echoes runs it. + """ # Prefer the active-drag map so a late update still resolves after the # gizmo was removed mid-drag (which pops it from the live registry). handle = self._active_transform_drag_handles.get( @@ -3123,8 +3149,6 @@ async def _handle_transform_controls_updates( async def _handle_transform_controls_drag_start( self, client_id: ClientId, message: _messages.TransformControlsDragStartMessage ) -> None: - if message.owner != self._owner_id: - return handle = self._handle_from_transform_controls_name.get(message.name, None) if handle is None: return @@ -3134,8 +3158,6 @@ async def _handle_transform_controls_drag_start( async def _handle_transform_controls_drag_end( self, client_id: ClientId, message: _messages.TransformControlsDragEndMessage ) -> None: - if message.owner != self._owner_id: - return handle = self._active_transform_drag_handles.pop( (client_id, message.name), None ) or self._handle_from_transform_controls_name.get(message.name, None) @@ -3185,8 +3207,6 @@ async def _handle_node_click_updates( self, client_id: ClientId, message: _messages.SceneNodeClickMessage ) -> None: """Callback for handling click messages.""" - if message.owner != self._owner_id: - return handle = self._handle_from_node_name.get(message.name, None) if handle is None or handle._impl.click_cb is None: return @@ -3225,8 +3245,6 @@ async def _handle_node_drag( have this issue, so for stateful gestures define your callbacks as ``async def`` (with no internal ``await`` s, so each runs atomically on the event loop).""" - if message.owner != self._owner_id: - return # On phase="start", look up the handle in the live registry and # remember it (with the message, so a synthetic end on # disconnect can carry the latest positions). On update, refresh diff --git a/src/viser/_viser.py b/src/viser/_viser.py index 0f1195039..c5af21695 100644 --- a/src/viser/_viser.py +++ b/src/viser/_viser.py @@ -600,8 +600,8 @@ def __init__( # Public attributes. # client_id is assigned BEFORE the scene/gui APIs: SceneApi.__init__ - # reads it (per-client scope key for the scene name index), and an - # attribute miss during construction would recurse through + # reads it (the owner id stamped on this scope's scene messages), and + # an attribute miss during construction would recurse through # DeprecatedAttributeShim.__getattr__ (whose `self.scene` lookup is # also unset at that point). self.client_id: int = conn.client_id diff --git a/src/viser/client/src/DragLayer.tsx b/src/viser/client/src/DragLayer.tsx index 5c6859b61..2eba0da78 100644 --- a/src/viser/client/src/DragLayer.tsx +++ b/src/viser/client/src/DragLayer.tsx @@ -217,11 +217,6 @@ function DragLayerActive({ children }: { children?: React.ReactNode }) { viewer, activeDrag.endPointerXy, ); - // Echo the effective variant's owner so the server routes the drag - // to exactly one scope's registry. - const draggedMessage = viewer.useSceneTree.get( - activeDrag.nodeName, - )?.message; return { type: "SceneNodeDragMessage", phase, @@ -233,7 +228,9 @@ function DragLayerActive({ children }: { children?: React.ReactNode }) { end_screen_pos: [endScreenPos.x, endScreenPos.y], button: activeDrag.input.button, modifier: activeDrag.input.modifier, - owner: draggedMessage === undefined ? "" : ownerOf(draggedMessage), + // Echo the effective variant's owner so the server routes the drag + // to exactly one scope's registry. + owner: ownerOf(viewer.useSceneTree.get(activeDrag.nodeName)?.message), }; }, [ diff --git a/src/viser/client/src/MessageHandler.tsx b/src/viser/client/src/MessageHandler.tsx index 34ce7b8c6..8fdcbbba9 100644 --- a/src/viser/client/src/MessageHandler.tsx +++ b/src/viser/client/src/MessageHandler.tsx @@ -20,7 +20,7 @@ import { useFrame, useThree } from "@react-three/fiber"; import { Button, Progress } from "@mantine/core"; import { IconCheck, IconDownload } from "@tabler/icons-react"; import { computeT_threeworld_world } from "./WorldTransformUtils"; -import { ownerOf, rootNodeTemplate, SceneNode } from "./SceneTreeState"; +import { rootNodeTemplate, SceneNode } from "./SceneTreeState"; import { applyGuiConfigUpdate } from "./ControlPanel/GuiState"; import { GaussianSplatsContext } from "./Splatting/GaussianSplatsHelpers"; @@ -119,7 +119,7 @@ function useMessageHandler() { visibility: true, }); } - addSceneNode(message); + const disposition = addSceneNode(message); // If the object is new or changed, we need to wait until it's created // before updating its pose. Updating the pose too early can cause @@ -128,9 +128,7 @@ function useMessageHandler() { // parked in the shadow slot (lower-ranked variant): the EFFECTIVE node // did not change, and freezing its pose on waitForMakeObject would wait // for a remount that never happens. - const becameEffective = - viewer.useSceneTree.get(message.name)?.message === message; - if (becameEffective && message !== currentNode?.message) { + if (disposition === "effective" && message !== currentNode?.message) { const pose = viewerMutable.nodePoseData[message.name]; if (pose) { pose.poseUpdateState = "waitForMakeObject"; @@ -144,15 +142,6 @@ function useMessageHandler() { } } - /** Whether a node-keyed message (carrying `owner`) targets the EFFECTIVE - * variant of its name. Returns false when it targets the shadowed variant - * (or nothing) -- callers then route through updateShadowedVariant, whose - * own owner check drops updates with no matching parked variant. */ - function targetsEffectiveVariant(name: string, owner: string): boolean { - const node = viewer.useSceneTree.get(name); - return node !== undefined && ownerOf(node.message) === owner; - } - const fileDownloadHandler = useFileDownloadHandler(); // Return type for the message handler. Messages either: @@ -257,14 +246,14 @@ function useMessageHandler() { switch (message.type) { case "SceneNodeUpdateMessage": { - if (!targetsEffectiveVariant(message.name, message.owner ?? "")) { - viewer.sceneTreeActions.updateShadowedVariant( + if ( + viewer.sceneTreeActions.routeShadowedUpdate( message.name, - message.owner ?? "", + message.owner, { propsUpdates: message.updates }, - ); + ) + ) return; - } return { kind: "sceneNodePropsUpdate", targetNode: message.name, @@ -646,14 +635,14 @@ function useMessageHandler() { }; } // Shadowed variant: accumulate its pose in the shadow slot. - if (!targetsEffectiveVariant(message.name, message.owner ?? "")) { - viewer.sceneTreeActions.updateShadowedVariant( + if ( + viewer.sceneTreeActions.routeShadowedUpdate( message.name, - message.owner ?? "", + message.owner, { wxyz: message.wxyz }, - ); + ) + ) return; - } // All other nodes: write pose to mutable ref (no React re-render). const pose = viewerMutable.nodePoseData[message.name]; if (pose) { @@ -672,16 +661,13 @@ function useMessageHandler() { } case "SetPositionMessage": { if ( - message.name !== "" && - !targetsEffectiveVariant(message.name, message.owner ?? "") - ) { - viewer.sceneTreeActions.updateShadowedVariant( + viewer.sceneTreeActions.routeShadowedUpdate( message.name, - message.owner ?? "", + message.owner, { position: message.position }, - ); + ) + ) return; - } // Write pose to mutable ref (no React re-render). const pose = viewerMutable.nodePoseData[message.name]; if (pose) { @@ -700,16 +686,13 @@ function useMessageHandler() { } case "SetSceneNodeVisibilityMessage": { if ( - message.name !== "" && - !targetsEffectiveVariant(message.name, message.owner ?? "") - ) { - viewer.sceneTreeActions.updateShadowedVariant( + viewer.sceneTreeActions.routeShadowedUpdate( message.name, - message.owner ?? "", + message.owner, { visibility: message.visible }, - ); + ) + ) return; - } return { kind: "sceneNodeAttrUpdate", targetNode: message.name, @@ -768,28 +751,30 @@ function useMessageHandler() { // recursive: the server sends one message per same-scope descendant, // and the other scope's variants of these names must survive. case "RemoveSceneNodeMessage": { - const owner = message.owner ?? ""; - const wasEffective = targetsEffectiveVariant(message.name, owner); - viewer.sceneTreeActions.removeSceneNodeVariant(message.name, owner); + const outcome = viewer.sceneTreeActions.removeSceneNodeVariant( + message.name, + message.owner ?? "", + ); - // Clear skinned-mesh state for the removed node. Exact name only: + // Clear skinned-mesh state when the MOUNTED variant went away + // (removed or replaced by a promoted one). Exact name only: // descendants arrive as their own removal messages, and a surviving // other-scope variant of a descendant name must keep its state. - if (wasEffective) { + if (outcome === "removed-effective" || outcome === "promoted") { delete viewerMutable.skinnedMeshState[message.name]; } return; } // Set the drag-binding set for a particular scene node. case "SetSceneNodeDragBindingsMessage": { - if (!targetsEffectiveVariant(message.name, message.owner ?? "")) { - viewer.sceneTreeActions.updateShadowedVariant( + if ( + viewer.sceneTreeActions.routeShadowedUpdate( message.name, - message.owner ?? "", + message.owner, { dragBindings: [...message.bindings] }, - ); + ) + ) return; - } return { kind: "sceneNodeAttrUpdate", targetNode: message.name, @@ -797,14 +782,14 @@ function useMessageHandler() { }; } case "SetSceneNodeClickBindingsMessage": { - if (!targetsEffectiveVariant(message.name, message.owner ?? "")) { - viewer.sceneTreeActions.updateShadowedVariant( + if ( + viewer.sceneTreeActions.routeShadowedUpdate( message.name, - message.owner ?? "", + message.owner, { clickBindings: [...message.bindings] }, - ); + ) + ) return; - } return { kind: "sceneNodeAttrUpdate", targetNode: message.name, diff --git a/src/viser/client/src/SceneTree.tsx b/src/viser/client/src/SceneTree.tsx index 60f48da9d..0d6523b41 100644 --- a/src/viser/client/src/SceneTree.tsx +++ b/src/viser/client/src/SceneTree.tsx @@ -1194,12 +1194,6 @@ export function SceneNodeThreeObject(props: { name: string }) { getPointerXy(e.clientX, e.clientY), ); - // Echo the EFFECTIVE variant's owner: only the mounted - // variant is interactive, and the server routes the event - // to that scope's registry alone. - const clickedMessage = viewer.useSceneTree.get( - props.name, - )?.message; sendClicksThrottled({ type: "SceneNodeClickMessage", name: props.name, @@ -1216,10 +1210,12 @@ export function SceneNodeThreeObject(props: { name: string }) { ], screen_pos: [mouseVectorOpenCV.x, mouseVectorOpenCV.y], modifier: keyModifierFromEvent(e), - owner: - clickedMessage === undefined - ? "" - : ownerOf(clickedMessage), + // Echo the EFFECTIVE variant's owner: only the mounted + // variant is interactive, and the server routes the + // event to that scope's registry alone. + owner: ownerOf( + viewer.useSceneTree.get(props.name)?.message, + ), }); } } diff --git a/src/viser/client/src/SceneTreeState.test.ts b/src/viser/client/src/SceneTreeState.test.ts index c99583a94..b86b7559c 100644 --- a/src/viser/client/src/SceneTreeState.test.ts +++ b/src/viser/client/src/SceneTreeState.test.ts @@ -89,7 +89,7 @@ describe("variant slots and the display rule", () => { const { store, nodePoseData, actions } = setup(); const broadcastMsg = makeFrameMessage("/x", ""); - actions.addSceneNode(broadcastMsg); + expect(actions.addSceneNode(broadcastMsg)).toBe("effective"); nodePoseData["/x"] = { wxyz: [0, 0, 0, 1], position: [1, 2, 3], @@ -97,7 +97,7 @@ describe("variant slots and the display rule", () => { }; const clientMsg = makeFrameMessage("/x", "7"); - actions.addSceneNode(clientMsg); + expect(actions.addSceneNode(clientMsg)).toBe("effective"); const node = store.get("/x")!; expect(node.message).toBe(clientMsg); @@ -114,7 +114,7 @@ describe("variant slots and the display rule", () => { const broadcastMsg = makeFrameMessage("/x", ""); actions.addSceneNode(broadcastMsg); const anchorMsg = makeFrameMessage("/x", "7", true); - actions.addSceneNode(anchorMsg); + expect(actions.addSceneNode(anchorMsg)).toBe("shadowed"); const node = store.get("/x")!; expect(node.message).toBe(broadcastMsg); // Still effective. @@ -140,10 +140,17 @@ describe("variant slots and the display rule", () => { actions.addSceneNode(makeFrameMessage("/x", "")); actions.addSceneNode(makeFrameMessage("/x", "7")); // Shadows broadcast. - // Broadcast keeps updating while shadowed. - actions.updateShadowedVariant("/x", "", { position: [4, 5, 6] }); - - actions.removeSceneNodeVariant("/x", "7"); + // Broadcast keeps updating while shadowed; the router consumes the + // update (returns true) instead of letting it hit the effective path. + expect(actions.routeShadowedUpdate("/x", "", { position: [4, 5, 6] })).toBe( + true, + ); + // An update for the EFFECTIVE variant is not consumed. + expect( + actions.routeShadowedUpdate("/x", "7", { position: [0, 0, 9] }), + ).toBe(false); + + expect(actions.removeSceneNodeVariant("/x", "7")).toBe("promoted"); const node = store.get("/x")!; expect(node.message.owner).toBe(""); expect(node.shadowed).toBeUndefined(); diff --git a/src/viser/client/src/SceneTreeState.ts b/src/viser/client/src/SceneTreeState.ts index 7b85f71a4..02673e21b 100644 --- a/src/viser/client/src/SceneTreeState.ts +++ b/src/viser/client/src/SceneTreeState.ts @@ -40,9 +40,10 @@ export type ShadowedVariant = { /** Owner id stamped on scene messages: "" is the broadcast scope * (server.scene), anything else is a per-client scope. Old recordings - * predate the field; missing means broadcast. */ -export function ownerOf(message: SceneNodeMessage): string { - return (message as { owner?: string }).owner ?? ""; + * predate the field; a missing field -- or a missing MESSAGE, e.g. an + * interaction racing a node removal -- means broadcast. */ +export function ownerOf(message: SceneNodeMessage | undefined): string { + return (message as { owner?: string } | undefined)?.owner ?? ""; } function isVirtual(message: SceneNodeMessage): boolean { @@ -128,8 +129,35 @@ export function createSceneTreeActions( nodeRefFromName: { [name: string]: undefined | THREE.Object3D }, nodePoseData: NodePoseDataMap, ) { + // Number of names currently holding a shadowed variant. Shadowing only + // exists while a client has deliberately reused a server-owned name + // (typically zero for a whole session), so this lets the per-message + // routing check in `routeShadowedUpdate` collapse to one integer compare + // on the hot pose/visibility paths. + let shadowedSlotCount = 0; + + /** Remove `name` from its parent's `children` list, recording the change + * in `updates`. Shared by variant removal and recursive removal. */ + function dropFromParentChildren( + name: string, + updates: Record, + ) { + const parentName = name.split("/").slice(0, -1).join("/"); + const parentNode = store.get(parentName); + if (parentNode) { + updates[parentName] = { + ...parentNode, + children: parentNode.children.filter( + (child_name) => child_name !== name, + ), + }; + } + } + const actions = { - addSceneNode: (message: SceneNodeMessage) => { + /** Returns whether the added variant became the effective one for its + * name, or was parked in the shadow slot. */ + addSceneNode: (message: SceneNodeMessage): "effective" | "shadowed" => { const existingNode = store.get(message.name); // Cross-scope add: the name already has a variant from the OTHER @@ -140,6 +168,11 @@ export function createSceneTreeActions( existingNode !== undefined && ownerOf(existingNode.message) !== ownerOf(message) ) { + // Either branch below fills the (single) shadow slot; only count the + // slot when it was previously empty. If it was occupied, the parked + // variant belonged to the incoming message's own scope and is being + // replaced (a within-scope supersede that happens to be parked). + if (existingNode.shadowed === undefined) shadowedSlotCount++; if (variantRank(message) >= variantRank(existingNode.message)) { // Incoming variant shadows the current effective one. Snapshot the // effective variant's state (including its live pose) into the @@ -172,24 +205,24 @@ export function createSceneTreeActions( }, }); actions.computeEffectiveVisibility(message.name); - } else { - // Incoming variant is lower-ranked (e.g. a virtual anchor next to - // a real node): park it in the shadow slot, effective untouched. - store.set({ - [message.name]: { - ...existingNode, - shadowed: { - message, - clickBindings: [], - dragBindings: [], - wxyz: [1, 0, 0, 0], - position: [0, 0, 0], - visibility: true, - }, - }, - }); + return "effective"; } - return; + // Incoming variant is lower-ranked (e.g. a virtual anchor next to + // a real node): park it in the shadow slot, effective untouched. + store.set({ + [message.name]: { + ...existingNode, + shadowed: { + message, + clickBindings: [], + dragBindings: [], + wxyz: [1, 0, 0, 0], + position: [0, 0, 0], + visibility: true, + }, + }, + }); + return "shadowed"; } // Same-owner add (within-scope create or supersede), or a brand-new @@ -224,6 +257,7 @@ export function createSceneTreeActions( delete nodeRefFromName[message.name]; } store.set(updates); + return "effective"; }, /** Remove ONE scope's variant of a name. Scope-local by design: the @@ -233,18 +267,26 @@ export function createSceneTreeActions( * does not recurse. Removing the effective variant promotes the * shadowed one; a promoted VIRTUAL anchor inherits the departing * variant's pose (frozen-pose inheritance), so surviving children stay - * where they were instead of teleporting to identity. */ - removeSceneNodeVariant: (name: string, owner: string) => { + * where they were instead of teleporting to identity. + * + * The return value reports which disposition happened, so callers can + * act on it (e.g. clean per-node side state only when the mounted + * variant went away) without re-deriving the display-rule decision. */ + removeSceneNodeVariant: ( + name: string, + owner: string, + ): "removed-effective" | "promoted" | "removed-shadow" | "noop" => { const node = store.get(name); if (node === undefined) { console.log(`(OK) Skipping variant removal for ${name}`); - return; + return "noop"; } if (ownerOf(node.message) === owner) { const shadowed = node.shadowed; if (shadowed !== undefined) { // Promote the shadowed variant, with the state its scope's // messages have been accumulating while it was hidden. + shadowedSlotCount--; delete nodeRefFromName[name]; if (!isVirtual(shadowed.message)) { nodePoseData[name] = { @@ -266,7 +308,7 @@ export function createSceneTreeActions( }, }); actions.computeEffectiveVisibility(name); - return; + return "promoted"; } // Last variant: drop the entry (no recursion -- see docstring). const updates: Record = { @@ -274,61 +316,62 @@ export function createSceneTreeActions( }; delete nodeRefFromName[name]; delete nodePoseData[name]; - const parentName = name.split("/").slice(0, -1).join("/"); - const parentNode = store.get(parentName); - if (parentNode) { - updates[parentName] = { - ...parentNode, - children: parentNode.children.filter( - (child_name) => child_name !== name, - ), - }; - } + dropFromParentChildren(name, updates); store.set(updates); - return; + return "removed-effective"; } if (node.shadowed && ownerOf(node.shadowed.message) === owner) { + shadowedSlotCount--; store.set({ [name]: { ...node, shadowed: undefined } }); + return "removed-shadow"; } + return "noop"; }, - /** Merge state updates into the SHADOWED variant of a name, matched by - * owner. Used to route messages that target the non-effective variant; - * silently drops updates for owners with no parked variant (e.g. a - * remove raced the update). */ - updateShadowedVariant: ( + /** Route a node-keyed state update by owner: returns false when it + * targets the EFFECTIVE variant of `name` (or the scope-less root + * singleton), in which case the caller applies it through the normal + * effective-variant path; returns true when it was consumed here -- + * applied to the shadowed variant, or dropped because no variant of + * that owner exists (e.g. the update raced a removal). + * + * Shadowed state is mutated IN PLACE, with no store write: nothing + * subscribes to the shadow slot (only promotion reads it, via + * non-reactive store.get), and a server animating a shadowed node can + * stream pose updates at 60Hz -- reactive writes would notify every + * subscriber of the effective node per message for state that nothing + * renders. Promotion materializes the accumulated state into fresh + * store objects. */ + routeShadowedUpdate: ( name: string, - owner: string, + owner: string | undefined, updates: Partial> & { propsUpdates?: { [key: string]: any }; }, - ) => { + ): boolean => { + // Fast path: no shadowed variants exist anywhere (the common case for + // an entire session), so every update targets its effective variant. + if (shadowedSlotCount === 0) return false; + // The root ("") is a singleton across scopes; owner is ignored for it. + if (name === "") return false; const node = store.get(name); - const shadowed = node?.shadowed; + if (node === undefined) return false; + if (ownerOf(node.message) === (owner ?? "")) return false; + const shadowed = node.shadowed; if ( - node === undefined || - shadowed === undefined || - ownerOf(shadowed.message) !== owner + shadowed !== undefined && + ownerOf(shadowed.message) === (owner ?? "") ) { - return; + const { propsUpdates, ...rest } = updates; + Object.assign(shadowed, rest); + if (propsUpdates !== undefined) { + Object.assign( + shadowed.message.props as Record, + propsUpdates, + ); + } } - const { propsUpdates, ...rest } = updates; - store.set({ - [name]: { - ...node, - shadowed: { - ...shadowed, - ...rest, - message: - propsUpdates === undefined - ? shadowed.message - : ({ - ...shadowed.message, - props: { ...shadowed.message.props, ...propsUpdates }, - } as SceneNodeMessage), - }, - }, - }); + return true; }, removeSceneNode: (name: string) => { @@ -345,22 +388,14 @@ export function createSceneTreeActions( const updates: Record = {}; removeNames.forEach((removeName) => { + if (store.get(removeName)?.shadowed !== undefined) shadowedSlotCount--; updates[removeName] = undefined; delete nodeRefFromName[removeName]; delete nodePoseData[removeName]; }); // Remove node from parent's children list. - const parentName = name.split("/").slice(0, -1).join("/"); - const parentNode = store.get(parentName); - if (parentNode) { - updates[parentName] = { - ...parentNode, - children: parentNode.children.filter( - (child_name) => child_name !== name, - ), - }; - } + dropFromParentChildren(name, updates); store.set(updates); }, @@ -423,6 +458,9 @@ export function createSceneTreeActions( actions.removeSceneNode(child); } } + // The whole store returns to variant-free defaults below; /WorldAxes + // may carry a shadow slot that the loop above didn't visit. + shadowedSlotCount = 0; // Reset root and /WorldAxes to default state. const defaultState = makeDefaultSceneTreeState(); diff --git a/tests/e2e/test_cross_scope_handles.py b/tests/e2e/test_cross_scope_handles.py index 9f5e934f5..659ac1fbd 100644 --- a/tests/e2e/test_cross_scope_handles.py +++ b/tests/e2e/test_cross_scope_handles.py @@ -40,6 +40,7 @@ from .utils import ( canvas_center, find_free_port, + get_client_handle, wait_for_connection, wait_for_scene_node, wait_for_scene_node_hidden, @@ -59,25 +60,6 @@ """ -def get_client_handle( - server: viser.ViserServer, expected_count: int = 1, timeout: float = 10.0 -) -> viser.ClientHandle: - """Wait until ``expected_count`` clients are registered, return the newest. - - Client handles register on the first camera message, which can trail - the websocket handshake that ``wait_for_connection`` observes. - """ - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - clients = server.get_clients() - if len(clients) >= expected_count: - return clients[max(clients.keys())] - time.sleep(0.05) - raise TimeoutError( - f"Expected {expected_count} connected client(s) within {timeout}s." - ) - - def wait_for_node_position( page: Page, node_name: str, @@ -338,8 +320,14 @@ def _(_event: viser.SceneNodePointerEvent[viser.BoxHandle]) -> None: client_clicked.set() wait_for_scene_node(viser_page, "/dup_click") + # Wait until the client-scoped variant is the effective one (non-empty + # owner) before clicking. viser_page.wait_for_function( - JS_GET_EFFECTIVE_OWNER + "", + """(nodeName) => { + const tree = window.__viserSceneTree; + const node = tree && tree.getState()[nodeName]; + return node !== undefined && (node.message.owner ?? "") !== ""; + }""", arg="/dup_click", timeout=5_000, ) diff --git a/tests/e2e/test_cross_scope_visual.py b/tests/e2e/test_cross_scope_visual.py index c8286bca7..8f09a0ec0 100644 --- a/tests/e2e/test_cross_scope_visual.py +++ b/tests/e2e/test_cross_scope_visual.py @@ -25,7 +25,7 @@ import viser import viser._client_autobuild -from .utils import find_free_port, wait_for_connection, wait_for_server_ready +from .utils import center_mean, connect_client, find_free_port, wait_for_server_ready RED = (255, 0, 0) GREEN = (0, 255, 0) @@ -49,57 +49,29 @@ def own_server() -> Generator[viser.ViserServer, None, None]: server.stop() -def _connect_client(own_server: viser.ViserServer, browser: Browser): - captured: list[viser.ClientHandle] = [] - seen_ids = {c.client_id for c in own_server.get_clients().values()} - own_server.on_client_connect( - lambda client: ( - captured.append(client) if client.client_id not in seen_ids else None - ) - ) - context = browser.new_context() - page = context.new_page() - wait_for_connection(page, own_server.get_port()) - deadline = time.monotonic() + 10 - while not captured and time.monotonic() < deadline: - time.sleep(0.05) - assert captured, "client never connected" - client = captured[-1] - while client.camera._state.update_timestamp == 0.0 and ( - time.monotonic() < deadline - ): - time.sleep(0.05) - assert client.camera._state.update_timestamp != 0.0, "camera never synced" - return client, page, context - - -def _center_mean(img: np.ndarray) -> np.ndarray: - h, w = img.shape[:2] - return img[ - h // 2 - h // 8 : h // 2 + h // 8, - w // 2 - w // 8 : w // 2 + w // 8, - :3, - ].mean(axis=(0, 1)) - - def _capture_center(client: viser.ClientHandle) -> np.ndarray: img = client.get_render(height=96, width=128, timeout=30.0) - return _center_mean(img) + return center_mean(img) def _assert_dominant( client: viser.ClientHandle, color: tuple[int, int, int], label: str ) -> None: """The center patch's dominant channel must match `color`. Captures can - race the ~1-frame shadow remount, so retry briefly before failing.""" - deadline = time.monotonic() + 5.0 - center = _capture_center(client) - while time.monotonic() < deadline: + race the ~1-frame shadow remount, so retry briefly before failing. Every + capture is evaluated BEFORE the deadline check: a single software-WebGL + capture can exceed the whole retry budget under load, and a passing + frame must not be discarded just because it arrived late.""" + deadline = time.monotonic() + 10.0 + while True: + center = _capture_center(client) if int(np.argmax(center)) == int(np.argmax(color)) and center.max() > 60: return + if time.monotonic() > deadline: + raise AssertionError( + f"{label}: expected dominant {color}, captured {center}" + ) time.sleep(0.2) - center = _capture_center(client) - raise AssertionError(f"{label}: expected dominant {color}, captured {center}") def test_shadowing_pixels_round_trip( @@ -107,7 +79,7 @@ def test_shadowing_pixels_round_trip( ) -> None: """RED server box -> GREEN client variant shadows it -> server recolors its hidden variant BLUE (display unchanged) -> un-shadow reveals BLUE.""" - client, page, context = _connect_client(own_server, browser) + client, page, context = connect_client(own_server, browser) try: server_box = own_server.scene.add_box( "/box", color=RED, dimensions=(2.0, 2.0, 2.0) @@ -138,8 +110,8 @@ def test_two_clients_see_their_own_variant( ) -> None: """A shadowing variant is per-client: the shadowing client sees GREEN while a second client keeps seeing the server's RED.""" - client1, page1, context1 = _connect_client(own_server, browser) - client2, page2, context2 = _connect_client(own_server, browser) + client1, page1, context1 = connect_client(own_server, browser) + client2, page2, context2 = connect_client(own_server, browser) assert client1.client_id != client2.client_id try: own_server.scene.add_box("/box", color=RED, dimensions=(2.0, 2.0, 2.0)) @@ -162,7 +134,7 @@ def test_scope_local_cascade_child_stays_on_screen( """A client child under a broadcast parent remains VISIBLE (not just in the store) after the parent is removed, then disappears when its own scope removes it.""" - client, page, context = _connect_client(own_server, browser) + client, page, context = connect_client(own_server, browser) try: parent = own_server.scene.add_frame("/parent", show_axes=False) child = client.scene.add_box( diff --git a/tests/e2e/test_get_render_capture.py b/tests/e2e/test_get_render_capture.py index 301052fcc..eed2ae127 100644 --- a/tests/e2e/test_get_render_capture.py +++ b/tests/e2e/test_get_render_capture.py @@ -22,7 +22,12 @@ import viser import viser._client_autobuild -from .utils import find_free_port, wait_for_connection, wait_for_server_ready +from .utils import ( + center_mean, + connect_client, + find_free_port, + wait_for_server_ready, +) @pytest.fixture() @@ -42,37 +47,6 @@ def own_server() -> Generator[viser.ViserServer, None, None]: server.stop() -def _connect_client( - own_server: viser.ViserServer, browser: Browser -) -> tuple[viser.ClientHandle, object, object]: - captured: list[viser.ClientHandle] = [] - own_server.on_client_connect(lambda client: captured.append(client)) - context = browser.new_context() - page = context.new_page() - wait_for_connection(page, own_server.get_port()) - deadline = time.monotonic() + 10 - while not captured and time.monotonic() < deadline: - time.sleep(0.05) - assert captured, "client never connected" - client = captured[0] - # Wait for the first camera update so get_render() can read camera state. - while client.camera._state.update_timestamp == 0.0 and ( - time.monotonic() < deadline - ): - time.sleep(0.05) - assert client.camera._state.update_timestamp != 0.0, "camera never synced" - return client, page, context - - -def _center_mean(img: np.ndarray) -> np.ndarray: - h, w = img.shape[:2] - return img[ - h // 2 - h // 8 : h // 2 + h // 8, - w // 2 - w // 8 : w // 2 + w // 8, - :3, - ].mean(axis=(0, 1)) - - def test_get_render_reflects_prior_scene_updates( own_server: viser.ViserServer, browser: Browser ) -> None: @@ -81,7 +55,7 @@ def test_get_render_reflects_prior_scene_updates( down two different server buffers and the client needs a React commit before capturing) repeatedly, alternating colors so ANY stale frame fails the dominant-channel check.""" - client, page, context = _connect_client(own_server, browser) + client, page, context = connect_client(own_server, browser) try: # A box that fills the view center from the default camera pose. box = own_server.scene.add_box( @@ -93,7 +67,7 @@ def test_get_render_reflects_prior_scene_updates( img = client.get_render( height=96, width=128, transport_format="png", timeout=30.0 ) - center = _center_mean(img) + center = center_mean(img) expected_channel = int(np.argmax(color)) assert int(np.argmax(center)) == expected_channel, ( f"iteration {i}: set color {color} but captured center " @@ -115,7 +89,7 @@ def test_get_render_reflects_fresh_node_poses( The capture hook's defensive pose sweep plus the one-frame commit wait must cover it; a capture of the box at the default pose (origin) or the previous box's position fails the centroid side check.""" - client, page, context = _connect_client(own_server, browser) + client, page, context = connect_client(own_server, browser) try: h, w = 96, 128 @@ -174,7 +148,7 @@ def test_get_render_transport_formats_agree( on the center pixels, with corner pixels showing each format's background convention, and repeated solo captures (the same-frame fast path) staying stable. The default is JPEG.""" - client, page, context = _connect_client(own_server, browser) + client, page, context = connect_client(own_server, browser) try: own_server.scene.add_box( "/box", color=(0, 120, 255), dimensions=(2.0, 2.0, 2.0) @@ -186,7 +160,7 @@ def test_get_render_transport_formats_agree( # Stabilize first: capture until two consecutive frames agree. prev = None for _ in range(40): - cur = _center_mean( + cur = center_mean( client.get_render(height=h, width=w, transport_format="png", timeout=30) ) if prev is not None and np.allclose(cur, prev, atol=2.0): @@ -219,7 +193,7 @@ def test_get_render_transport_formats_agree( # Back-to-back solo captures (no interleaved scene updates) take the # same-frame capture path; they must stay correct and identical-ish. again = client.get_render(height=h, width=w, transport_format="png", timeout=30) - assert np.allclose(_center_mean(again), _center_mean(png), atol=3.0) + assert np.allclose(center_mean(again), center_mean(png), atol=3.0) finally: page.close() # type: ignore[attr-defined] context.close() # type: ignore[attr-defined] @@ -238,7 +212,7 @@ def test_get_render_does_not_leak_capture_state_into_splat_view( here as the capture's viewport size leaking across a frame boundary into the splat material (an in-page rAF probe can only ever see it if the same-frame repair did not happen).""" - client, page, context = _connect_client(own_server, browser) + client, page, context = connect_client(own_server, browser) try: rng = np.random.default_rng(0) n = 2000 @@ -303,7 +277,7 @@ def test_get_render_survives_hidden_unmounted_node_pose_update( too -- an undefined-only guard let the null through to a TypeError, failing the whole capture with a spurious "could not capture a frame" RuntimeError on a healthy client.""" - client, page, context = _connect_client(own_server, browser) + client, page, context = connect_client(own_server, browser) try: tc = own_server.scene.add_transform_controls("/gizmo") time.sleep(1.0) # Mount. diff --git a/tests/e2e/utils.py b/tests/e2e/utils.py index 039006445..fc5973dc0 100644 --- a/tests/e2e/utils.py +++ b/tests/e2e/utils.py @@ -4,8 +4,13 @@ import socket import time +from typing import TYPE_CHECKING -from playwright.sync_api import Locator, Page +import numpy as np +from playwright.sync_api import Browser, BrowserContext, Locator, Page + +if TYPE_CHECKING: + import viser # --------------------------------------------------------------------------- # Network utilities @@ -44,6 +49,66 @@ def find_gui_input(page: Page, label_text: str) -> Locator: return gui_row.locator("input:not([type='hidden'])") +def get_client_handle( + server: viser.ViserServer, expected_count: int = 1, timeout: float = 10.0 +) -> viser.ClientHandle: + """Wait until ``expected_count`` clients are registered, return the newest. + + Client handles register on the first camera message, which can trail the + websocket handshake that ``wait_for_connection`` observes. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + clients = server.get_clients() + if len(clients) >= expected_count: + return clients[max(clients.keys())] + time.sleep(0.05) + raise TimeoutError( + f"Expected {expected_count} connected client(s) within {timeout}s." + ) + + +def connect_client( + server: viser.ViserServer, browser: Browser +) -> tuple[viser.ClientHandle, Page, BrowserContext]: + """Open a fresh browser context on ``server`` and return the NEW client's + handle once its camera has synced (required before ``get_render``). + Safe to call repeatedly on one server: only a client that was not + already connected is returned.""" + captured: list = [] + seen_ids = {c.client_id for c in server.get_clients().values()} + server.on_client_connect( + lambda client: ( + captured.append(client) if client.client_id not in seen_ids else None + ) + ) + context = browser.new_context() + page = context.new_page() + wait_for_connection(page, server.get_port()) + deadline = time.monotonic() + 10 + while not captured and time.monotonic() < deadline: + time.sleep(0.05) + assert captured, "client never connected" + client = captured[-1] + while client.camera._state.update_timestamp == 0.0 and ( + time.monotonic() < deadline + ): + time.sleep(0.05) + assert client.camera._state.update_timestamp != 0.0, "camera never synced" + return client, page, context + + +def center_mean(img: np.ndarray) -> np.ndarray: + """Mean RGB of the center patch of a rendered frame (pixel-assertion + helper for get_render()-based tests).""" + h, w = img.shape[:2] + return img[ + h // 2 - h // 8 : h // 2 + h // 8, + w // 2 - w // 8 : w // 2 + w // 8, + :3, + ].mean(axis=(0, 1)) + + def wait_for_connection(page: Page, port: int) -> None: """Navigate to the viser server and wait for WebSocket connection. diff --git a/tests/infra_utils.py b/tests/infra_utils.py new file mode 100644 index 000000000..c819fe8d1 --- /dev/null +++ b/tests/infra_utils.py @@ -0,0 +1,33 @@ +"""Shared helpers for headless server-side tests (no browser). + +The synthetic-client construction here is the third home this pattern +needed (after inline copies in ``test_panel.py`` and +``test_get_render_latency.py``); new tests should import it from here. +""" + +from __future__ import annotations + +import asyncio + +import viser +from viser._viser import ClientHandle +from viser.infra._async_message_buffer import AsyncMessageBuffer +from viser.infra._infra import WebsockClientConnection, _ClientHandleState + + +def make_synthetic_client(server: viser.ViserServer, client_id: int) -> ClientHandle: + """In-process ClientHandle with a real per-client message buffer but no + websocket (mirrors how WebsockServer builds per-client state). Buffer + construction hops to the server's loop thread because + AsyncMessageBuffer's asyncio primitives bind to the running loop.""" + + async def _make_buffer() -> AsyncMessageBuffer: + return AsyncMessageBuffer(server._event_loop, persistent_messages=False) + + buffer = asyncio.run_coroutine_threadsafe( + _make_buffer(), server._event_loop + ).result(timeout=5.0) + conn = WebsockClientConnection( + client_id, _ClientHandleState(buffer, server._event_loop) + ) + return ClientHandle(conn, server) diff --git a/tests/test_scene_scopes.py b/tests/test_scene_scopes.py index 54784d4f7..9aebd6d34 100644 --- a/tests/test_scene_scopes.py +++ b/tests/test_scene_scopes.py @@ -15,7 +15,6 @@ from __future__ import annotations -import asyncio from typing import Generator import pytest @@ -24,8 +23,8 @@ import viser._client_autobuild from viser import _messages as m from viser._viser import ClientHandle -from viser.infra._async_message_buffer import AsyncMessageBuffer -from viser.infra._infra import WebsockClientConnection, _ClientHandleState + +from .infra_utils import make_synthetic_client @pytest.fixture() @@ -36,25 +35,6 @@ def server() -> Generator[viser.ViserServer, None, None]: server.stop() -def _make_synthetic_client(server: viser.ViserServer, client_id: int) -> ClientHandle: - """In-process ClientHandle with a real per-client message buffer but no - websocket (mirrors how WebsockServer builds per-client state; same - pattern as tests/test_panel.py). Buffer construction hops to the server's - loop thread because AsyncMessageBuffer's asyncio primitives bind to the - running loop.""" - - async def _make_buffer() -> AsyncMessageBuffer: - return AsyncMessageBuffer(server._event_loop, persistent_messages=False) - - buffer = asyncio.run_coroutine_threadsafe( - _make_buffer(), server._event_loop - ).result(timeout=5.0) - conn = WebsockClientConnection( - client_id, _ClientHandleState(buffer, server._event_loop) - ) - return ClientHandle(conn, server) - - def _client_buffer_messages(client: ClientHandle) -> list[m.Message]: return list( client._websock_connection._state.message_buffer.message_from_id.values() @@ -81,7 +61,7 @@ def test_owner_stamped_on_broadcast_messages(server: viser.ViserServer) -> None: def test_owner_stamped_on_client_messages(server: viser.ViserServer) -> None: - client = _make_synthetic_client(server, 3) + client = make_synthetic_client(server, 3) handle = client.scene.add_icosphere("/ball", radius=0.1, position=(1.0, 0.0, 0.0)) handle.visible = False handle.color = (10, 20, 30) @@ -101,7 +81,7 @@ def test_owner_stamped_on_client_messages(server: viser.ViserServer) -> None: def test_same_name_coexists_across_scopes(server: viser.ViserServer) -> None: - client = _make_synthetic_client(server, 0) + client = make_synthetic_client(server, 0) server_handle = server.scene.add_icosphere("/dup", radius=0.1) client_handle = client.scene.add_icosphere("/dup", radius=0.2) @@ -119,8 +99,8 @@ def test_same_name_coexists_across_scopes(server: viser.ViserServer) -> None: def test_same_name_across_clients_coexists(server: viser.ViserServer) -> None: - client0 = _make_synthetic_client(server, 0) - client1 = _make_synthetic_client(server, 1) + client0 = make_synthetic_client(server, 0) + client1 = make_synthetic_client(server, 1) h0 = client0.scene.add_icosphere("/own", radius=0.1) h1 = client1.scene.add_icosphere("/own", radius=0.2) @@ -142,7 +122,7 @@ def test_same_scope_supersede_still_works(server: viser.ViserServer) -> None: def test_virtual_anchors_created_per_scope(server: viser.ViserServer) -> None: - client = _make_synthetic_client(server, 0) + client = make_synthetic_client(server, 0) # Broadcast parent exists; the client's deep add still creates a # same-scope anchor chain (which does not shadow: anchors are virtual). @@ -187,7 +167,7 @@ def test_real_add_supersedes_virtual_anchor(server: viser.ViserServer) -> None: def test_cascade_is_scope_local(server: viser.ViserServer) -> None: - client = _make_synthetic_client(server, 0) + client = make_synthetic_client(server, 0) parent = server.scene.add_frame("/parent", show_axes=False) server_child = server.scene.add_icosphere("/parent/shared", radius=0.1) @@ -209,7 +189,7 @@ def test_cascade_is_scope_local(server: viser.ViserServer) -> None: def test_client_cascade_does_not_touch_broadcast(server: viser.ViserServer) -> None: - client = _make_synthetic_client(server, 0) + client = make_synthetic_client(server, 0) server.scene.add_frame("/parent", show_axes=False) server_child = server.scene.add_icosphere("/parent/shared", radius=0.1) @@ -247,7 +227,7 @@ def test_remove_messages_enumerate_descendants(server: viser.ViserServer) -> Non def test_client_scene_construction_sends_nothing(server: viser.ViserServer) -> None: - client = _make_synthetic_client(server, 0) + client = make_synthetic_client(server, 0) assert len(_client_buffer_messages(client)) == 0, ( "client SceneApi construction queued messages; it must not re-add " "/WorldAxes (or anything else) over the per-client connection" @@ -256,7 +236,7 @@ def test_client_scene_construction_sends_nothing(server: viser.ViserServer) -> N def test_client_world_axes_property_raises(server: viser.ViserServer) -> None: - client = _make_synthetic_client(server, 0) + client = make_synthetic_client(server, 0) with pytest.raises(AttributeError, match="server.scene.world_axes"): _ = client.scene.world_axes # The sanctioned per-client override: a client-scoped "/WorldAxes" frame From 1867bf0ddb2aaab4ee069dba107fe8a0cced3fdb Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Sun, 9 Aug 2026 18:22:15 +0000 Subject: [PATCH 11/27] Keep the virtual-anchor marker out of add_frame's public signature The _virtual parameter leaked into add_frame's signature (IDE completions, autodoc). Post-hoc assignment on the queued message would race the producer's flush, so the marker moves to an api-level flag: _ensure_ancestors_exist sets SceneApi._creating_virtual_anchors around its anchor loop, and _make stamps virtual=True on create messages queued while it is set. Race-free under the lifecycle lock, which serializes adds; the nested ensure calls the anchors trigger all hit the registered-parent fast path, so the flag needs no save/restore. --- src/viser/_scene_api.py | 30 +++++++++++++++++++++--------- src/viser/_scene_handles.py | 6 +++++- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/viser/_scene_api.py b/src/viser/_scene_api.py index 2a2e2b1d3..b27a15249 100644 --- a/src/viser/_scene_api.py +++ b/src/viser/_scene_api.py @@ -275,6 +275,12 @@ def __init__( str, TransformControlsHandle ] = {} self._handle_from_node_name: dict[str, SceneNodeHandle] = {} + self._creating_virtual_anchors = False + """True only while _ensure_ancestors_exist creates intermediate + frames; _make stamps `virtual=True` on create messages queued while + set. An api-level flag (rather than a parameter) keeps the marker + out of the public add_frame signature; the lifecycle lock makes it + race-free.""" if isinstance(owner, ViserServer): self._owner_id = "" """Opaque owner id stamped on every outgoing scene message: "" @@ -522,11 +528,21 @@ def _ensure_ancestors_exist(self, name: str) -> None: if parent == "" or parent in self._handle_from_node_name: return parts = name.split("/") - for i in range(2, len(parts)): # skip root ("") and the node itself - ancestor = "/".join(parts[:i]) - if ancestor not in self._handle_from_node_name: - # Recurses into _make under the reentrant lifecycle lock. - self.add_frame(ancestor, show_axes=False, _virtual=True) + # The anchors are ordinary add_frame() calls; the flag below makes + # _make stamp their create messages virtual, keeping the marker out + # of add_frame's public signature. Safe without save/restore + # subtleties: the lifecycle lock serializes adds, and the nested + # _ensure_ancestors_exist calls that add_frame triggers all hit the + # fast path above (ancestors are created parent-first). + self._creating_virtual_anchors = True + try: + for i in range(2, len(parts)): # skip root ("") and the node itself + ancestor = "/".join(parts[:i]) + if ancestor not in self._handle_from_node_name: + # Recurses into _make under the reentrant lifecycle lock. + self.add_frame(ancestor, show_axes=False) + finally: + self._creating_virtual_anchors = False def set_up_direction( self, @@ -1743,7 +1759,6 @@ def add_frame( wxyz: tuple[float, float, float, float] | np.ndarray = (1.0, 0.0, 0.0, 0.0), position: tuple[float, float, float] | np.ndarray = (0.0, 0.0, 0.0), visible: bool = True, - _virtual: bool = False, ) -> FrameHandle: """Add a coordinate frame to the scene. @@ -1785,9 +1800,6 @@ def add_frame( scale=scale, ), ) - # Internal: auto-created ancestor anchors are marked virtual so they - # yield to real variants in the client's display rule. - message.virtual = _virtual return FrameHandle._make(self, message, name, wxyz, position, visible) @deprecated_positional_shim diff --git a/src/viser/_scene_handles.py b/src/viser/_scene_handles.py index a29452159..03d35f0b7 100644 --- a/src/viser/_scene_handles.py +++ b/src/viser/_scene_handles.py @@ -329,8 +329,12 @@ def _make( had_drag=bool(old_handle._impl.drag_cb), ) - # Send message, stamped with this scope's owner id. + # Send message, stamped with this scope's owner id -- and marked + # virtual when this create is an auto-generated ancestor anchor + # (see SceneApi._creating_virtual_anchors). assert isinstance(message, _messages.Message) + if api._creating_virtual_anchors: + message.virtual = True # type: ignore[attr-defined] api._queue_scene_message(message) # Shallow copy is enough to decouple the handle from the queued From fc0bcc50400b2d31aa08236e685ad917eb02c333 Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Sun, 9 Aug 2026 22:07:42 +0000 Subject: [PATCH 12/27] Cleanup batch: pointer coexistence, dead-client unification, container guard, panel badges, docs Closes out the remaining follow-ups from the architecture review, all on this branch: Scene pointer coexistence (replaces cross-scope exclusivity): the client keeps ScenePointerEnableMessage filters PER OWNER and engages gestures on the union, so both scopes may register scene-level click/rect-select callbacks simultaneously -- one physical gesture fires both scopes' matching callbacks, and one scope clearing its filters never deactivates the other's. The registration-time exclusivity reach-ins (server clearing every client's registrations and vice versa) are deleted; the exclusivity unit and e2e contract tests are rewritten as coexistence tests. Dead-client behavior unified: SceneApi._get_client_handle now returns None like GuiApi._resolve_client (was KeyError), and dispatch drops events whose client vanished -- except TransformControlsEvent, whose client field is Optional by contract and still fires with None. Writes into a closed connection's buffer warn once per connection (AsyncMessageBuffer.push) instead of accumulating silently forever. GUI containers cannot span scopes: nesting a client-scope add inside a server-scope container context (or vice versa) raises with a clear message instead of silently landing the element at the other scope's root. Guarded per-thread and scoped to GuiApis of the SAME server, so independent ViserServers in one process are unaffected. Scene-tree panel: client-local nodes get a 'local' badge (with a shadowing-aware tooltip) and virtual anchors render dimmed/italic. Verified by DOM inspection and screenshot. Also: test_panel.py and test_floating_panel.py migrate to the shared synthetic-client/wait helpers (test_get_render_latency keeps its deliberately partial __new__-based variant), and DeprecatedAttributeShim.__getattr__ bails to a plain AttributeError during partial construction instead of recursing to RecursionError. Docs: 'Server and Client Scopes' section in conventions.rst and a new examples/03_interaction/08_per_client_scenes.py demonstrating shadowing overrides and scope-local removal. --- docs/source/conventions.rst | 33 ++++++++ .../03_interaction/08_per_client_scenes.py | 77 ++++++++++++++++++ src/viser/_backwards_compat_shims.py | 13 +++ src/viser/_gui_api.py | 58 +++++++++++-- src/viser/_messages.py | 15 +++- src/viser/_scene_api.py | 70 ++++++++-------- .../src/ControlPanel/SceneTreeTable.tsx | 36 ++++++++- src/viser/client/src/MessageHandler.tsx | 1 + src/viser/client/src/WebsocketMessages.ts | 15 +++- src/viser/client/src/pointer/gestures.ts | 45 +++++++++-- src/viser/infra/_async_message_buffer.py | 19 +++++ tests/e2e/test_cross_scope_handles.py | 81 ++++++++++++------- tests/e2e/test_floating_panel.py | 15 +--- tests/test_modifier_filtering.py | 79 ++++++++++-------- tests/test_panel.py | 26 ++---- tests/test_scene_scopes.py | 42 ++++++++++ 16 files changed, 470 insertions(+), 155 deletions(-) create mode 100644 examples/03_interaction/08_per_client_scenes.py diff --git a/docs/source/conventions.rst b/docs/source/conventions.rst index 8dfb93f93..895d46686 100644 --- a/docs/source/conventions.rst +++ b/docs/source/conventions.rst @@ -75,6 +75,38 @@ In ``viser``, all camera parameters use the **COLMAP/OpenCV convention**: **Conversion**: A simple **180° rotation around the local X-axis** converts between the two conventions. +Server and Client Scopes +------------------------ + +Scene and GUI elements can be created through two kinds of handles: + +- ``server.scene`` / ``server.gui``: **shared** elements, visible to every + connected client and replayed to clients that connect later. +- ``client.scene`` / ``client.gui`` (via :class:`~viser.ClientHandle`): + **per-client** elements, visible to one client only. Client state is + ephemeral -- it disappears when the connection closes, and a reconnecting + browser is a new client -- so per-client state should be (re)built in + :meth:`~viser.ViserServer.on_client_connect`. + +Each scene-tree name can hold one node from each scope. When both exist, +the client-scoped node **shadows** the shared one for that client: it is +the one rendered and the one that receives clicks and drags, while other +clients keep seeing the shared node. Updates to a shadowed shared node keep +accumulating invisibly; removing the client-scoped node reveals the shared +node again with its latest state. This makes per-client overrides of shared +elements a one-liner:: + + # Everyone sees this... + server.scene.add_box("/box", color=(255, 0, 0)) + # ...except this client, who now sees their own version instead: + client.scene.add_box("/box", color=(0, 255, 0)) + +Removal is **scope-local**: removing a node (or a whole subtree) through +one scope's handle never touches the other scope's nodes, even per-client +children named under a shared parent -- those stay, anchored at the +parent's last pose, until their own scope removes them. In the scene-tree +panel, per-client nodes are marked with a ``local`` badge. + ---- .. seealso:: @@ -82,5 +114,6 @@ In ``viser``, all camera parameters use the **COLMAP/OpenCV convention**: **Related Documentation** - :class:`~viser.ViserServer` for scene management + - :class:`~viser.ClientHandle` for per-client state - :func:`~viser.SceneApi.set_up_direction` for coordinate system configuration - :mod:`~viser.transforms` for transformation utilities diff --git a/examples/03_interaction/08_per_client_scenes.py b/examples/03_interaction/08_per_client_scenes.py new file mode 100644 index 000000000..973a8ad40 --- /dev/null +++ b/examples/03_interaction/08_per_client_scenes.py @@ -0,0 +1,77 @@ +"""Per-client scene state + +Mix shared (broadcast) scene elements with per-client ones, including +per-client overrides of shared nodes. + +**Features:** + +* :attr:`viser.ClientHandle.scene` for elements only one client sees +* Shadowing: a client-scoped node with a shared node's name replaces it for + that client only, and the shared node (with its latest state) returns when + the client-scoped variant is removed +* Scope-local removal: per-client annotations survive shared-node removal + until their own scope removes them +""" + +from __future__ import annotations + +import time + +import viser + +server = viser.ViserServer() + +# A shared box that every client sees, animated by the server. +shared_box = server.scene.add_box( + "/box", dimensions=(0.5, 0.5, 0.5), color=(200, 60, 60) +) + + +@server.on_client_connect +def _(client: viser.ClientHandle) -> None: + # Per-client GUI + scene state. Everything created through `client.` is + # visible to this client alone and is rebuilt here on reconnect (client + # state is ephemeral -- see the ClientHandle docs). + highlight = client.gui.add_checkbox("Highlight box (only me)", False) + annotate = client.gui.add_checkbox("Annotate box (only me)", False) + + highlight_handle: viser.BoxHandle | None = None + annotation_handle = None + + @highlight.on_update + def _(_) -> None: + nonlocal highlight_handle + if highlight.value: + # Same name as the shared box: this client-scoped variant + # SHADOWS the shared one for this client only. Other clients + # keep seeing the server's red box, and server updates keep + # accumulating in the hidden variant. + highlight_handle = client.scene.add_box( + "/box", dimensions=(0.55, 0.55, 0.55), color=(60, 200, 80) + ) + elif highlight_handle is not None: + # Un-shadow: the shared box reappears with its LATEST state. + highlight_handle.remove() + highlight_handle = None + + @annotate.on_update + def _(_) -> None: + nonlocal annotation_handle + if annotate.value: + # A per-client child under the shared node. If the server ever + # removes /box, this annotation survives (anchored where the box + # was) until this client removes it -- removal never reaches + # across scopes. + annotation_handle = client.scene.add_label( + "/box/note", text=f"client {client.client_id}'s note" + ) + elif annotation_handle is not None: + annotation_handle.remove() + annotation_handle = None + + +while True: + # Server-side animation of the shared box; shadowing clients don't see + # these updates until they un-shadow. + shared_box.position = (0.0, 0.0, 0.4 + 0.2 * (time.time() % 1.0)) + time.sleep(0.05) diff --git a/src/viser/_backwards_compat_shims.py b/src/viser/_backwards_compat_shims.py index 8547005f8..4d2542d19 100644 --- a/src/viser/_backwards_compat_shims.py +++ b/src/viser/_backwards_compat_shims.py @@ -103,6 +103,19 @@ class DeprecatedAttributeShim: `<=0.1.30`.""" def __getattr__(self, name: str) -> Any: + # During partial construction (or an attribute miss on a + # partially-torn-down object), `self.scene` / `self.gui` don't exist + # yet -- and looking them up would land back in THIS __getattr__, + # recursing until RecursionError. Bail out to a plain AttributeError + # instead, so init-ordering mistakes fail legibly. (Both attributes + # are plain instance attributes on ViserServer and ClientHandle, so + # __dict__ is the right place to check.) + if "scene" not in self.__dict__ or "gui" not in self.__dict__: + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}' " + "(object is partially constructed: scene/gui APIs not set up " + "yet)" + ) fixed_name = { # Map from old method names (viser v0.1.*) to new methods names. "reset_scene": "reset", diff --git a/src/viser/_gui_api.py b/src/viser/_gui_api.py index 3af75eac9..7b69a49f3 100644 --- a/src/viser/_gui_api.py +++ b/src/viser/_gui_api.py @@ -197,6 +197,11 @@ class _RootGuiContainer: _global_order_counter = 0 +_thread_container_context = threading.local() +"""Tracks, per thread, which GuiApi instance currently has an active +(non-root) container context. Used to make cross-scope container nesting an +error instead of a silent misplace -- see GuiApi._get_container_uuid.""" + def _apply_default_order(order: float | None) -> float: """Apply default ordering logic for GUI elements. @@ -236,9 +241,11 @@ class GuiApi: _target_container_from_thread_id: dict[int, str] """ID of container to put GUI elements into. Per-instance (NOT a shared - class attribute) -- otherwise a thread inside a ``with some_gui.add_folder()`` - block would leak that container target into a *different* GuiApi instance - (e.g. server.gui vs a client.gui) and raise KeyError on the foreign uuid.""" + class attribute): container targets never span GuiApi instances. A + cross-instance nesting attempt (``with server.gui.add_folder()`` around a + ``client.gui`` add, or vice versa) raises via _get_container_uuid's + thread-context check instead of silently landing at the other scope's + root.""" def __init__( self, @@ -648,12 +655,53 @@ async def _handle_command_trigger( ).add_done_callback(print_threadpool_errors) def _get_container_uuid(self) -> str: - """Get container ID associated with the current thread.""" + """Get container ID associated with the current thread. + + Raises when a container context from a DIFFERENT GuiApi (e.g. a + ``with server.gui.add_folder(...)`` block while adding through + ``client.gui``) is active on this thread: GUI containers cannot span + scopes, and silently placing the element at this scope's root -- + the historical behavior -- hid the mistake.""" + owner_api = getattr(_thread_container_context, "api", None) + if ( + owner_api is not None + and owner_api is not self + # Only guard scopes of the SAME server: two independent + # ViserServers in one process are unrelated worlds, and a + # container context on one has never affected (and should not + # constrain) adds on the other. + and owner_api._root_server() is self._root_server() + ): + raise RuntimeError( + "A GUI container context from a different scope is active on " + "this thread (e.g. `with server.gui.add_folder(...)` around " + "an add through `client.gui`, or vice versa). GUI containers " + "cannot span the server and client scopes; create the " + "container through the same handle that adds its contents." + ) return self._target_container_from_thread_id.get(threading.get_ident(), "root") + def _root_server(self): + """The ViserServer this GuiApi ultimately belongs to (itself for the + broadcast scope, the owning server for a client scope).""" + from ._viser import ViserServer + + return ( + self._owner + if isinstance(self._owner, ViserServer) + else self._owner._viser_server + ) + def _set_container_uuid(self, container_uuid: str) -> None: - """Set container ID associated with the current thread.""" + """Set container ID associated with the current thread, tracking + which GuiApi currently owns an active (non-root) container context + so cross-scope nesting fails loudly (see _get_container_uuid).""" self._target_container_from_thread_id[threading.get_ident()] = container_uuid + if container_uuid == "root": + if getattr(_thread_container_context, "api", None) is self: + _thread_container_context.api = None + else: + _thread_container_context.api = self def _next_layout_counter(self) -> int: """Bump and return the layout-update counter. THE single home of the diff --git a/src/viser/_messages.py b/src/viser/_messages.py index 8901cd476..bbf5e06c6 100644 --- a/src/viser/_messages.py +++ b/src/viser/_messages.py @@ -470,13 +470,20 @@ class ScenePointerEnableMessage(Message, include_in_scene_serialization=False): """Set the modifier-filter set for a scene pointer ``event_type``. An empty ``modifiers`` tuple disables all callbacks for that - ``event_type``. A non-empty tuple enables them, and the client uses - the filter list to gate gesture engagement: a pointerdown whose - held-modifier state doesn't match any filter is treated as if no - callback were registered (no rectangle drawn, no message sent).""" + ``event_type`` in the sending scope. A non-empty tuple enables them, + and the client uses the filter list to gate gesture engagement: a + pointerdown whose held-modifier state doesn't match any filter is + treated as if no callback were registered (no rectangle drawn, no + message sent). + + Filters are kept per ``owner`` on the client and engagement uses the + union across owners, so the broadcast scope and a client scope can + register pointer callbacks independently -- one scope clearing its + filters never deactivates the other's.""" event_type: ScenePointerEventType modifiers: Tuple[Optional[KeyModifier], ...] + owner: str = dataclasses.field(default="", init=False) @override def redundancy_key(self) -> str: diff --git a/src/viser/_scene_api.py b/src/viser/_scene_api.py index b27a15249..d8760c61b 100644 --- a/src/viser/_scene_api.py +++ b/src/viser/_scene_api.py @@ -384,9 +384,12 @@ def __init__( _messages.SceneNodeDragMessage, self._handle_node_drag ) # Deliberately NOT owner-scoped: scene pointer events are scene-level - # (no target node, no owner), and cross-scope arbitration is handled - # by the registration-time exclusivity in - # _register_scene_pointer_callback instead. + # (no target node). The client engages a gesture when the held + # modifiers match the UNION of both scopes' filters and sends ONE + # message; every scope's handler then dispatches its own matching + # registrations -- coexistence, with per-owner filter state on the + # client (ScenePointerEnableMessage.owner) so one scope's disable + # never deactivates the other's callbacks. self._websock_interface.register_handler( _messages.ScenePointerMessage, self._handle_scene_pointer_updates, @@ -3110,23 +3113,19 @@ def reset(self) -> None: # Clear the background image. self.set_background_image(image=None) - def _get_client_handle(self, client_id: ClientId) -> ClientHandle: - """Private helper for getting a client handle from its ID.""" + def _get_client_handle(self, client_id: ClientId) -> ClientHandle | None: + """Resolve the ClientHandle for a given client_id. Returns ``None`` + when the client disconnected between queueing and dispatch -- + callers early-return, dropping the event. Mirrors + ``GuiApi._resolve_client`` so the two APIs treat the same race the + same way.""" # Avoid circular imports. from ._viser import ViserServer - # Implementation-wise, note that MessageApi is never directly instantiated. - # Instead, it serves as a mixin/base class for either ViserServer, which - # maintains a registry of connected clients, or ClientHandle, which should - # only ever be dealing with its own client_id. if isinstance(self._owner, ViserServer): - handle = self._owner._connected_clients.get(client_id) - if handle is None: - raise KeyError(f"No connected client with id {client_id}.") - return handle - else: - assert client_id == self._owner.client_id - return self._owner + return self._owner._connected_clients.get(client_id) + assert client_id == self._owner.client_id + return self._owner async def _handle_transform_controls_updates( self, client_id: ClientId, message: _messages.TransformControlsUpdateMessage @@ -3184,6 +3183,10 @@ async def _fire_transform_controls_callbacks( phase: DragPhase, event_client: ClientHandle | None = None, ) -> None: + # Unlike the click/drag/pointer events (whose `client` field is + # non-Optional, so an unresolvable client drops the event), + # TransformControlsEvent.client is Optional by contract: gizmo + # lifecycle callbacks still fire when the client can't be resolved. event = TransformControlsEvent( client=event_client if event_client is not None @@ -3222,8 +3225,12 @@ async def _handle_node_click_updates( handle = self._handle_from_node_name.get(message.name, None) if handle is None or handle._impl.click_cb is None: return + client = self._get_client_handle(client_id) + if client is None: + # Client disconnected between queueing and dispatch; drop. + return event = SceneNodePointerEvent( - client=self._get_client_handle(client_id), + client=client, client_id=client_id, event="click", target=cast(_RaycastSupportedSceneNodeHandle, handle), @@ -3302,10 +3309,16 @@ async def _dispatch_drag_callbacks( if not matching: return - event = SceneNodeDragEvent( - client=event_client + client = ( + event_client if event_client is not None - else self._get_client_handle(client_id), + else self._get_client_handle(client_id) + ) + if client is None: + # Client disconnected between queueing and dispatch; drop. + return + event = SceneNodeDragEvent( + client=client, client_id=client_id, target=cast(_RaycastSupportedSceneNodeHandle, handle), phase=message.phase, @@ -3328,6 +3341,9 @@ async def _handle_scene_pointer_updates( if not self._scene_pointer_cb: return client = self._get_client_handle(client_id) + if client is None: + # Client disconnected between queueing and dispatch; drop. + return modifier = message.modifier # Build the typed event once for the actual gesture; the legacy @@ -3470,19 +3486,7 @@ def _register_scene_pointer_callback( ) -> Any: normalized_modifier = _messages._normalize_key_modifier(modifier) - from ._viser import ClientHandle, ViserServer - def decorator(func: Callable[[Any], None]) -> Callable[[Any], None]: - # Server-scope and client-scope share the same client-side - # enable toggle. Coexistence would let one scope's - # disable silently deactivate the other's callbacks; - # enforce exclusivity instead. - if isinstance(self._owner, ViserServer): - for client in self._owner.get_clients().values(): - client.scene._remove_all_pointer_callbacks() - elif isinstance(self._owner, ClientHandle): - self._owner._viser_server.scene._remove_all_pointer_callbacks() - self._scene_pointer_cb.append( _PointerCallbackEntry( callback=func, @@ -3513,7 +3517,7 @@ def _sync_scene_pointer_filters( } ), ) - self._websock_interface.queue_message( + self._queue_scene_message( _messages.ScenePointerEnableMessage( event_type=event_type, modifiers=modifiers ) diff --git a/src/viser/client/src/ControlPanel/SceneTreeTable.tsx b/src/viser/client/src/ControlPanel/SceneTreeTable.tsx index 554fbe1df..fdfed05bb 100644 --- a/src/viser/client/src/ControlPanel/SceneTreeTable.tsx +++ b/src/viser/client/src/ControlPanel/SceneTreeTable.tsx @@ -19,7 +19,7 @@ import { import { useDisclosure } from "@mantine/hooks"; import { useForm } from "@mantine/form"; import { ViewerContext } from "../ViewerContext"; -import { SceneNode } from "../SceneTreeState"; +import { ownerOf, SceneNode } from "../SceneTreeState"; import { shallowArrayEqual } from "../utils/shallowArrayEqual"; import { ScenePropDescriptor, @@ -27,6 +27,7 @@ import { } from "../WebsocketMessages"; import { parseToRgb, toMantineColor } from "../components/colorUtils"; import { + Badge, Box, Checkbox, Flex, @@ -612,6 +613,21 @@ const SceneTreeTableRow = React.memo(function SceneTreeTableRow(props: { props.nodeName, (node) => node?.message.type, ); + // Variant provenance for the effective node: client-local variants get a + // badge (they exist only on THIS client, possibly shadowing a server node + // of the same name), and virtual anchors -- auto-created ancestor frames + // that render nothing -- are de-emphasized. + const nodeIsClientLocal = + viewer.useSceneTree( + props.nodeName, + (node) => node !== undefined && ownerOf(node.message) !== "", + ) ?? false; + const nodeIsVirtual = + viewer.useSceneTree( + props.nodeName, + (node) => + (node?.message as { virtual?: boolean } | undefined)?.virtual ?? false, + ) ?? false; const expandable = (childrenName?.length ?? 0) > 0; const [expanded, { toggle: toggleExpanded }] = useDisclosure(false); @@ -735,11 +751,29 @@ const SceneTreeTableRow = React.memo(function SceneTreeTableRow(props: { whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", + ...(nodeIsVirtual && { opacity: 0.45, fontStyle: "italic" }), }} > / {props.nodeName.split("/").at(-1)} + {nodeIsClientLocal ? ( + + + local + + + ) : null} {overrideVisibility !== undefined ? ( >(); + + private unionFilter( + eventType: ScenePointerEventType, + ): (KeyModifier | null)[] | undefined { + const byOwner = this.filters.get(eventType); + if (byOwner === undefined) return undefined; + const out: (KeyModifier | null)[] = []; + for (const modifiers of byOwner.values()) out.push(...modifiers); + return out; + } /** Cleanup for window-level pointerup/pointercancel listeners * installed while a gesture is engaged. Null when idle. The * listeners catch releases that happen off the canvas -- a @@ -106,23 +122,35 @@ export class ScenePointerController { applyFiltersDelta( eventType: ScenePointerEventType, + owner: string, modifiers: readonly (KeyModifier | null)[], ): void { - if (modifiers.length === 0) this.filters.delete(eventType); - else this.filters.set(eventType, [...modifiers]); + const byOwner = this.filters.get(eventType); + if (modifiers.length === 0) { + if (byOwner !== undefined) { + byOwner.delete(owner); + if (byOwner.size === 0) this.filters.delete(eventType); + } + } else if (byOwner === undefined) { + this.filters.set(eventType, new Map([[owner, [...modifiers]]])); + } else { + byOwner.set(owner, [...modifiers]); + } this.hover.refresh(); } getFilter( eventType: ScenePointerEventType, ): readonly (KeyModifier | null)[] | undefined { - return this.filters.get(eventType); + return this.unionFilter(eventType); } anyFilterMatches(modifier: KeyModifier | null): boolean { - for (const list of this.filters.values()) { - for (const f of list) { - if (matchesModifierFilter(modifier, f)) return true; + for (const byOwner of this.filters.values()) { + for (const list of byOwner.values()) { + for (const f of list) { + if (matchesModifierFilter(modifier, f)) return true; + } } } return false; @@ -143,7 +171,8 @@ export class ScenePointerController { const input: DragInput = { button, modifier: args.modifier }; const eligible = new Set(); - for (const [eventType, modifiers] of this.filters) { + for (const eventType of this.filters.keys()) { + const modifiers = this.unionFilter(eventType)!; if ( button === "left" && modifiers.some((m) => matchesModifierFilter(args.modifier, m)) diff --git a/src/viser/infra/_async_message_buffer.py b/src/viser/infra/_async_message_buffer.py index bc29c4c84..e6f94fcb7 100644 --- a/src/viser/infra/_async_message_buffer.py +++ b/src/viser/infra/_async_message_buffer.py @@ -31,6 +31,8 @@ class AsyncMessageBuffer: window_duration_sec: float = 1.0 / 60.0 done: bool = False atomic_counter: int = 0 + _warned_push_after_done: bool = False + """One-shot latch for the dead-connection write warning in push().""" generator_cursors: Dict[int, int] = dataclasses.field(default_factory=dict) """Per-active-connection consumption cursors (client id -> last message id @@ -58,6 +60,23 @@ def push(self, message: Message) -> None: assert isinstance(message, Message) + # A done buffer has no producer left to drain it: for a per-client + # buffer this means the client disconnected, and anything pushed via + # a stale handle silently accumulates forever. Warn ONCE per buffer + # so the dead-handle write is diagnosable without spamming loops + # that keep animating a departed client's elements. + if self.done and not self._warned_push_after_done: + self._warned_push_after_done = True + import warnings + + warnings.warn( + f"Queued a {type(message).__name__} on a closed connection " + "(e.g. via a handle owned by a disconnected client); it will " + "never be delivered. Further messages on this connection " + "will be dropped silently.", + stacklevel=4, + ) + # Add message to buffer. redundancy_key = message.redundancy_key() diff --git a/tests/e2e/test_cross_scope_handles.py b/tests/e2e/test_cross_scope_handles.py index 659ac1fbd..a5d743a7a 100644 --- a/tests/e2e/test_cross_scope_handles.py +++ b/tests/e2e/test_cross_scope_handles.py @@ -12,8 +12,10 @@ - **Contract tests** lock in cross-scope behavior that any future redesign must preserve: per-client namespace isolation, the ephemeral lifecycle of - client-scoped elements across reconnects, and the deliberate cross-scope - exclusivity of scene pointer callbacks. + client-scoped elements across reconnects, and scene pointer callback + coexistence (both scopes may register; a gesture matching both scopes' + filters fires both scopes' callbacks; one scope's disable never + deactivates the other's). - **Variant/shadowing tests** cover the display rule end to end: a client variant shadows the server's and un-shadows with the server's LATEST @@ -200,57 +202,74 @@ def test_client_scope_elements_do_not_survive_reconnect( assert viser_page.evaluate(JS_GET_EFFECTIVE_OWNER, "/shared_box") == "" -def test_scene_pointer_callbacks_are_cross_scope_exclusive( +def test_scene_pointer_callbacks_coexist_across_scopes( viser_server: viser.ViserServer, viser_page: Page ) -> None: - """Scene pointer callbacks (scene-level on_click) enforce cross-scope - exclusivity: registering on the server scope tears down every client - scope's registrations, and vice versa. This is a deliberate workaround - for the shared client-side enable toggle -- pin it so the - action-at-a-distance stays visible.""" + """Scene pointer callbacks coexist across scopes: filters are kept per + owner on the frontend and gesture engagement uses the union, so both + scopes' registrations fire on one physical click and one scope's + disable never deactivates the other's. (This replaced the interim + cross-scope exclusivity rule.)""" client = get_client_handle(viser_server) + server_clicked = threading.Event() + client_clicked = threading.Event() + @client.scene.on_click() def _(_event: viser.SceneClickEvent) -> None: - pass - - assert len(client.scene._scene_pointer_cb) == 1 + client_clicked.set() @viser_server.scene.on_click() def _(_event: viser.SceneClickEvent) -> None: - pass + server_clicked.set() - # Server-scope registration reached into the client scope and removed - # its callback. + # Registration in one scope leaves the other's callbacks alone. assert len(viser_server.scene._scene_pointer_cb) == 1 - assert len(client.scene._scene_pointer_cb) == 0 + assert len(client.scene._scene_pointer_cb) == 1 - # And the reverse: a client-scope registration tears down the server's. - @client.scene.on_click() - def _(_event: viser.SceneClickEvent) -> None: - pass + time.sleep(0.5) # Let both enable messages reach the frontend. + cx, cy = canvas_center(viser_page) + viser_page.mouse.move(cx, cy) + viser_page.mouse.down() + viser_page.mouse.up() - assert len(client.scene._scene_pointer_cb) == 1 - assert len(viser_server.scene._scene_pointer_cb) == 0 + # One physical click, both scopes' callbacks. + assert server_clicked.wait(5.0), "server-scope scene click did not fire" + assert client_clicked.wait(5.0), "client-scope scene click did not fire" + + # One scope disabling its filters must not deactivate the other's: after + # the client scope clears, a click still reaches the server callback. + client.scene.remove_click_callback() + server_clicked.clear() + client_clicked.clear() + time.sleep(0.5) # Let the disable reach the frontend. + viser_page.mouse.move(cx, cy) + viser_page.mouse.down() + viser_page.mouse.up() + assert server_clicked.wait(5.0), ( + "server-scope scene click stopped firing after the client scope " + "cleared its own filters" + ) + time.sleep(0.3) + assert not client_clicked.is_set() def test_gui_container_context_does_not_span_scopes( viser_server: viser.ViserServer, viser_page: Page ) -> None: - """A ``with server.gui.add_folder(...)`` block does NOT capture elements - added through a client handle's GuiApi: the container context is - per-GuiApi-instance, so the button silently lands at the client's root. - Characterization -- if cross-scope nesting is ever supported (or made an - error), this should change deliberately.""" + """A ``with server.gui.add_folder(...)`` block cannot capture elements + added through a client handle's GuiApi: cross-scope container nesting + raises instead of silently landing the element at the other scope's + root (the historical behavior). Adds outside the block work normally.""" client = get_client_handle(viser_server) with viser_server.gui.add_folder("SrvFolder"): - stray = client.gui.add_button("StrayBtn") - - assert stray._impl.parent_container_id == "root" + with pytest.raises(RuntimeError, match="cannot span"): + client.gui.add_button("StrayBtn") - # The button still renders for the client (at the root, not the folder). - button = viser_page.get_by_role("button", name="StrayBtn") + # Outside the server's container context, client adds work normally. + client.gui.add_button("OkBtn") + button = viser_page.get_by_role("button", name="OkBtn") button.wait_for(state="visible", timeout=5_000) diff --git a/tests/e2e/test_floating_panel.py b/tests/e2e/test_floating_panel.py index dd7f14d81..24ada74f1 100644 --- a/tests/e2e/test_floating_panel.py +++ b/tests/e2e/test_floating_panel.py @@ -187,19 +187,6 @@ def test_resize_left_grip_keeps_right_edge_pinned(viser_page: Page) -> None: assert abs((after["x"] + after["width"]) - right_before) < 12 -def _wait_for_client(server: viser.ViserServer) -> viser.ClientHandle: - """Return the first connected client, polling briefly for it to register.""" - import time - - deadline = time.monotonic() + 5.0 - while time.monotonic() < deadline: - clients = server.get_clients() - if clients: - return next(iter(clients.values())) - time.sleep(0.05) - raise RuntimeError("no client connected within timeout") - - def test_notification_offset_clear_of_left_dock( viser_page: Page, viser_server: viser.ViserServer ) -> None: @@ -215,7 +202,7 @@ def test_notification_offset_clear_of_left_dock( panel = _panel_box(viser_page) # Raise a (non-auto-closing) notification from the server. - client = _wait_for_client(viser_server) + client = get_client_handle(viser_server) client.add_notification( "Docked test", "Should clear the GUI", auto_close_seconds=None ) diff --git a/tests/test_modifier_filtering.py b/tests/test_modifier_filtering.py index 36e650e34..88bca3b81 100644 --- a/tests/test_modifier_filtering.py +++ b/tests/test_modifier_filtering.py @@ -307,48 +307,59 @@ def _() -> None: @patch.object(viser._client_autobuild, "ensure_client_is_built", lambda: None) -def test_pointer_event_server_scope_clears_client_scope_and_vice_versa() -> None: - """Server-scope and per-client-scope ``on_pointer_event`` share the - same wire (the ``ScenePointerEnableMessage`` toggle on the client - side). Allowing both to register simultaneously would let one - scope's ``enable=False`` deactivate the other's callbacks. The API - enforces exclusivity: registering on one scope clears the other. - - This is asserted indirectly -- we don't fully spin up a client - handle here; we only verify the cross-scope cleanup hook is wired - by checking that the server-scope list is cleared via a fake - client whose ``scene._scene_pointer_cb`` we observe.""" - server = viser.ViserServer() - - # Stand up a minimal stub client that satisfies the cross-scope - # cleanup branch -- it just needs ``.scene._scene_pointer_cb`` and - # ``.scene._remove_all_pointer_callbacks`` to be present. - fake_client = MagicMock(name="fake-client") - fake_client.client_id = ClientId(0) - fake_client._viser_server = server - fake_client.scene._scene_pointer_cb = [] - - def _stub_remove_all(**_kwargs: object) -> None: - fake_client.scene._scene_pointer_cb.clear() +def test_pointer_event_scopes_coexist() -> None: + """Server-scope and per-client-scope pointer callbacks coexist: the + ``ScenePointerEnableMessage`` filters are kept per owner on the client + (gesture engagement uses the union), so registrations in one scope + never clear the other's, and each scope's enable messages ride its own + connection stamped with its own owner.""" + from .infra_utils import make_synthetic_client - fake_client.scene._remove_all_pointer_callbacks = MagicMock( - side_effect=_stub_remove_all - ) - server._connected_clients[ClientId(0)] = fake_client + server = viser.ViserServer() + client = make_synthetic_client(server, 5) - # Fake client populates its own list as if a client-scope - # registration had happened. - fake_client.scene._scene_pointer_cb.append(object()) - assert len(fake_client.scene._scene_pointer_cb) == 1 + @client.scene.on_click() + def _client_cb(event: viser.SceneClickEvent) -> None: + del event @server.scene.on_click() def _server_cb(event: viser.SceneClickEvent) -> None: del event - # Server-scope registration should have cleared the client-scope list. + # Both scopes keep their registrations. assert len(server.scene._scene_pointer_cb) == 1 - fake_client.scene._remove_all_pointer_callbacks.assert_called_once() - assert len(fake_client.scene._scene_pointer_cb) == 0 + assert len(client.scene._scene_pointer_cb) == 1 + + # Each scope's enable message is stamped with its own owner. + server_enables = [ + msg + for msg in server._websock_server._broadcast_buffer.message_from_id.values() + if isinstance(msg, _messages.ScenePointerEnableMessage) + ] + client_enables = [ + msg + for msg in ( + client._websock_connection._state.message_buffer.message_from_id.values() + ) + if isinstance(msg, _messages.ScenePointerEnableMessage) + ] + assert server_enables and all(msg.owner == "" for msg in server_enables) + assert client_enables and all(msg.owner == "5" for msg in client_enables) + + # One scope clearing its callbacks leaves the other's untouched; the + # disable rides that scope's connection only (an empty-modifiers enable + # coalesced into the same redundancy slot). + client.scene.remove_click_callback() + assert len(client.scene._scene_pointer_cb) == 0 + assert len(server.scene._scene_pointer_cb) == 1 + latest_client_enable = [ + msg + for msg in ( + client._websock_connection._state.message_buffer.message_from_id.values() + ) + if isinstance(msg, _messages.ScenePointerEnableMessage) + ][-1] + assert latest_client_enable.modifiers == () @patch.object(viser._client_autobuild, "ensure_client_is_built", lambda: None) diff --git a/tests/test_panel.py b/tests/test_panel.py index a82ce8d87..ac74f5ba5 100644 --- a/tests/test_panel.py +++ b/tests/test_panel.py @@ -9,7 +9,6 @@ from __future__ import annotations -import asyncio import threading import warnings from typing import Any @@ -384,31 +383,16 @@ def test_client_scoped_reset_does_not_touch_main_panel_placement() -> None: which the client's placement gate treats as a fresh deliberate command -- so the default CONTROL_PANEL_ID messages would clobber server-authored placement (e.g. undock a dock_left control panel) for that one client.""" - from viser._viser import ClientHandle - from viser.infra._async_message_buffer import AsyncMessageBuffer - from viser.infra._infra import WebsockClientConnection, _ClientHandleState + from .infra_utils import make_synthetic_client server = _make_server() try: server.gui.main_panel.dock_left() - # Synthetic in-process client connection: no websocket needed, we only - # inspect the outgoing per-client message buffer (mirrors how - # WebsockServer constructs the per-client state). Constructed ON the - # server's loop thread: AsyncMessageBuffer's asyncio.Event fields bind - # to the current event loop at construction on Python <= 3.9, and this - # test thread has none (production buffers are always built inside the - # loop, so only the test needs the hop). - async def _make_buffer() -> AsyncMessageBuffer: - return AsyncMessageBuffer(server._event_loop, persistent_messages=False) - - buffer = asyncio.run_coroutine_threadsafe( - _make_buffer(), server._event_loop - ).result(timeout=5.0) - conn = WebsockClientConnection( - 0, _ClientHandleState(buffer, server._event_loop) - ) - client = ClientHandle(conn, server) + # Synthetic in-process client connection: no websocket needed, we + # only inspect the outgoing per-client message buffer. + client = make_synthetic_client(server, client_id=0) + buffer = client._websock_connection._state.message_buffer client.gui.add_button("local") client.gui.reset() diff --git a/tests/test_scene_scopes.py b/tests/test_scene_scopes.py index 9aebd6d34..a9e872d62 100644 --- a/tests/test_scene_scopes.py +++ b/tests/test_scene_scopes.py @@ -244,3 +244,45 @@ def test_client_world_axes_property_raises(server: viser.ViserServer) -> None: override = client.scene.add_frame("/WorldAxes", show_axes=True) assert not override._impl.removed assert not server.scene.world_axes._impl.removed + + +# --------------------------------------------------------------------------- +# Cross-scope GUI containers + dead-client writes. +# --------------------------------------------------------------------------- + + +def test_gui_container_cannot_span_scopes(server: viser.ViserServer) -> None: + """Nesting a client-scope GUI add inside a server-scope container (or + vice versa) raises instead of silently landing the element at the other + scope's root.""" + client = make_synthetic_client(server, 0) + + with server.gui.add_folder("SrvFolder"): + with pytest.raises(RuntimeError, match="cannot span"): + client.gui.add_button("stray") + # Outside the context, adds work normally in both scopes. + client.gui.add_button("ok") + + with client.gui.add_folder("CliFolder"): + with pytest.raises(RuntimeError, match="cannot span"): + server.gui.add_button("stray") + server.gui.add_button("ok") + + +def test_dead_connection_write_warns_once(server: viser.ViserServer) -> None: + """Writes through handles owned by a disconnected client warn exactly + once per connection, instead of accumulating silently forever.""" + import warnings as warnings_module + + client = make_synthetic_client(server, 0) + handle = client.scene.add_icosphere("/mine", radius=0.1) + + # Simulate the disconnect teardown's buffer shutdown. + client._websock_connection._state.message_buffer.set_done() + + with warnings_module.catch_warnings(record=True) as caught: + warnings_module.simplefilter("always") + handle.position = (1.0, 0.0, 0.0) + handle.position = (2.0, 0.0, 0.0) # Second write: no second warning. + dead_warnings = [w for w in caught if "closed connection" in str(w.message)] + assert len(dead_warnings) == 1 From 78039291a161e132d52bc2898c385e8057f0a987 Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Mon, 10 Aug 2026 03:53:56 +0000 Subject: [PATCH 13/27] GUI containers: allow client elements inside server containers Cross-scope GUI container nesting is now directional instead of always raising. A client.gui add inside a `with server.gui.add_folder(...)` block nests in the server container -- the element's audience is a subset of the container's -- and renders inside the shared folder for that client only. The reverse (a server element inside a client container) still raises, since no other client could see the container. Cross-nested elements are the one deliberate exception to scope-local removal: removing the server container cascades into them (each remove is queued on the owning client's own connection), because an orphaned widget -- unlike a scene node, which keeps its pose -- has nowhere coherent to go. Plumbing: parent lookups go through a new _resolve_container_handle() that falls back to the server GuiApi's registry; a per-scope _handles_in_foreign_containers dict lets client.gui.reset() drain cross-nested elements the root-container walk can't reach, and lets the disconnect teardown detach them from the server's container tree bookkeeping-only (no messages on the closed buffer, so no dead-write warning when the server later removes the container). The add_form nested-form walk also resolves across scopes so a client form inside a server form is still rejected. --- docs/source/conventions.rst | 14 ++++ src/viser/_gui_api.py | 105 +++++++++++++++++++++----- src/viser/_gui_handles.py | 30 +++++--- src/viser/_viser.py | 14 ++++ tests/e2e/test_cross_scope_handles.py | 52 ++++++++----- tests/test_scene_scopes.py | 82 +++++++++++++++++--- 6 files changed, 242 insertions(+), 55 deletions(-) diff --git a/docs/source/conventions.rst b/docs/source/conventions.rst index 895d46686..c77840562 100644 --- a/docs/source/conventions.rst +++ b/docs/source/conventions.rst @@ -107,6 +107,20 @@ children named under a shared parent -- those stay, anchored at the parent's last pose, until their own scope removes them. In the scene-tree panel, per-client nodes are marked with a ``local`` badge. +GUI container nesting across scopes is **directional**: a ``client.gui`` +element may be added inside a ``server.gui`` container context (its +audience is a subset of the container's), rendering inside the shared +folder for that client only:: + + with server.gui.add_folder("Shared folder"): + client.gui.add_button("Only I see this") + +The reverse -- a ``server.gui`` element inside a ``client.gui`` container +-- raises, since no other client could see the container. Cross-nested +elements are the one exception to scope-local removal: removing the +server container also removes the client elements nested inside it (an +orphaned widget, unlike a scene node, has nowhere coherent to go). + ---- .. seealso:: diff --git a/src/viser/_gui_api.py b/src/viser/_gui_api.py index 7b69a49f3..3cf34378b 100644 --- a/src/viser/_gui_api.py +++ b/src/viser/_gui_api.py @@ -76,6 +76,7 @@ _colors_to_int_tuple, _CommandHandleState, _GuiButtonHandleState, + _GuiHandle, _GuiHandleState, _GuiInputHandle, _make_uuid, @@ -241,11 +242,11 @@ class GuiApi: _target_container_from_thread_id: dict[int, str] """ID of container to put GUI elements into. Per-instance (NOT a shared - class attribute): container targets never span GuiApi instances. A - cross-instance nesting attempt (``with server.gui.add_folder()`` around a - ``client.gui`` add, or vice versa) raises via _get_container_uuid's - thread-context check instead of silently landing at the other scope's - root.""" + class attribute). Cross-instance nesting is DIRECTIONAL, resolved by + _get_container_uuid's thread-context check: a ``client.gui`` add inside + a ``with server.gui.add_folder()`` block nests in the server container + (the element's audience is a subset of the container's), while the + reverse raises instead of silently landing at the other scope's root.""" def __init__( self, @@ -273,6 +274,12 @@ def __init__( "root": _RootGuiContainer({}) } self._modal_handle_from_uuid: dict[str, GuiModalHandle] = {} + # Elements of THIS scope that were nested inside another scope's + # container (client elements inside server containers -- the only + # allowed direction). They are parented in the server GuiApi's + # container tree, so this scope's reset() and disconnect teardown + # can't reach them through the root container walk; track them here. + self._handles_in_foreign_containers: dict[str, _GuiHandle[Any]] = {} self._panel_handle_from_uuid: dict[str, PanelHandle] = {} # Layout-update counter, bumped on every placement command (any panel) # and stamped onto the placement message (see @@ -657,30 +664,84 @@ async def _handle_command_trigger( def _get_container_uuid(self) -> str: """Get container ID associated with the current thread. - Raises when a container context from a DIFFERENT GuiApi (e.g. a - ``with server.gui.add_folder(...)`` block while adding through - ``client.gui``) is active on this thread: GUI containers cannot span - scopes, and silently placing the element at this scope's root -- - the historical behavior -- hid the mistake.""" + When a container context from a DIFFERENT GuiApi of the same server + is active on this thread, nesting is directional: a client-scope add + inside a server-scope container targets the server's container, while + the reverse raises -- silently placing the element at this scope's + root (the historical behavior) hid the mistake.""" owner_api = getattr(_thread_container_context, "api", None) if ( owner_api is not None and owner_api is not self - # Only guard scopes of the SAME server: two independent + # Only consider scopes of the SAME server: two independent # ViserServers in one process are unrelated worlds, and a # container context on one has never affected (and should not # constrain) adds on the other. and owner_api._root_server() is self._root_server() ): + from ._viser import ViserServer + + if isinstance(owner_api._owner, ViserServer) and not isinstance( + self._owner, ViserServer + ): + # Client element into a server container: allowed (the + # element's audience is a subset of the container's). The + # element nests under the SERVER scope's active container. + # This is the one deliberate exception to scope-local + # removal: the server container's teardown cascades into + # cross-nested client elements, because an orphaned widget + # -- unlike a scene node, which keeps its pose -- has + # nowhere coherent to go. + return owner_api._target_container_from_thread_id.get( + threading.get_ident(), "root" + ) + # Server (or other-client) element into a client container: + # every viewer outside that client scope couldn't see the + # container, so the element would dangle. Fail loudly. raise RuntimeError( - "A GUI container context from a different scope is active on " - "this thread (e.g. `with server.gui.add_folder(...)` around " - "an add through `client.gui`, or vice versa). GUI containers " - "cannot span the server and client scopes; create the " - "container through the same handle that adds its contents." + "A GUI container context from a client scope is active on " + "this thread, and elements with a broader audience cannot " + "nest inside it (they would dangle for every other client). " + "Client elements may nest inside server containers, but not " + "vice versa." ) return self._target_container_from_thread_id.get(threading.get_ident(), "root") + def _resolve_container_handle(self, container_uuid: str) -> GuiContainerProtocol: + """Resolve a container uuid to its handle, tolerating client + elements nested inside SERVER containers: the parent then lives in + the server GuiApi's registry rather than this one's.""" + handle = self._container_handle_from_uuid.get(container_uuid) + if handle is not None: + return handle + server_gui = self._root_server().gui + if server_gui is not self: + handle = server_gui._container_handle_from_uuid.get(container_uuid) + if handle is not None: + return handle + raise KeyError(container_uuid) + + def _release_cross_scope_nesting(self) -> None: + """Bookkeeping-only detach of this (client-scoped) GuiApi's elements + from the server containers they were nested in. Called on + disconnect: the connection's buffer is already closed, so no removal + messages are sent -- we just unhook the handles from the server's + container tree so a later server-side container removal doesn't + cascade a remove into a dead connection.""" + while self._handles_in_foreign_containers: + uuid, handle = self._handles_in_foreign_containers.popitem() + impl = handle._impl + if impl.removed: + continue + impl.removed = True + parent = self._root_server().gui._container_handle_from_uuid.get( + impl.parent_container_id + ) + if parent is not None: + parent._children.pop(uuid, None) + self._gui_input_handle_from_uuid.pop(uuid, None) + self._container_handle_from_uuid.pop(uuid, None) + def _root_server(self): """The ViserServer this GuiApi ultimately belongs to (itself for the broadcast scope, the owning server for a client scope).""" @@ -720,6 +781,11 @@ def reset(self) -> None: root_container = self._container_handle_from_uuid["root"] while root_container._children: next(iter(root_container._children.values())).remove() + # This scope's elements nested inside ANOTHER scope's containers + # (client elements in server containers) aren't reachable from this + # root; drain them explicitly. + while self._handles_in_foreign_containers: + next(iter(self._handles_in_foreign_containers.values())).remove() while self._modal_handle_from_uuid: next(iter(self._modal_handle_from_uuid.values())).close() # Panels are top-level entities (not under `root`), so drain them @@ -1051,7 +1117,12 @@ def add_form( #
is well-formed. container = self._get_container_uuid() while container != "root": - parent = self._container_handle_from_uuid.get(container) + # Resolve across scopes: a client-scope add_form() inside a + # server-scope form is just as invalid as a same-scope nesting. + try: + parent = self._resolve_container_handle(container) + except KeyError: + break if isinstance(parent, GuiFormHandle): raise ValueError( "Nested forms are not supported: add_form() was called " diff --git a/src/viser/_gui_handles.py b/src/viser/_gui_handles.py index da243989c..d24cec092 100644 --- a/src/viser/_gui_handles.py +++ b/src/viser/_gui_handles.py @@ -149,13 +149,18 @@ class _GuiButtonHandleState(_GuiHandleState[bool]): class _GuiHandle(Generic[T], AssignablePropsBase[_GuiHandleState]): def __init__(self, impl: _GuiHandleState[T]) -> None: super().__init__(impl=impl) - parent = self._impl.gui_api._container_handle_from_uuid[ - self._impl.parent_container_id - ] + gui_api = self._impl.gui_api + parent = gui_api._resolve_container_handle(self._impl.parent_container_id) parent._children[self._impl.uuid] = self + if self._impl.parent_container_id not in gui_api._container_handle_from_uuid: + # Client element nested inside a server container: the parent + # lives in the server GuiApi's registry, so this scope's reset() + # and disconnect teardown can't find it through the root + # container walk. Track it for those paths. + gui_api._handles_in_foreign_containers[self._impl.uuid] = self if isinstance(self, _GuiInputHandle): - self._impl.gui_api._gui_input_handle_from_uuid[self._impl.uuid] = self + gui_api._gui_input_handle_from_uuid[self._impl.uuid] = self @override def _queue_update(self, name: str, value: Any) -> None: @@ -177,8 +182,9 @@ def remove(self) -> None: gui_api = self._impl.gui_api gui_api._websock_interface.queue_message(GuiRemoveMessage(self._impl.uuid)) - parent = gui_api._container_handle_from_uuid[self._impl.parent_container_id] + parent = gui_api._resolve_container_handle(self._impl.parent_container_id) parent._children.pop(self._impl.uuid) + gui_api._handles_in_foreign_containers.pop(self._impl.uuid, None) if isinstance(self, _GuiInputHandle): gui_api._gui_input_handle_from_uuid.pop(self._impl.uuid) @@ -796,9 +802,9 @@ def __init__(self, _impl: _GuiHandleState[None]) -> None: self._tab_handles: list[GuiTabHandle] = [] def __post_init__(self) -> None: - parent = self._impl.gui_api._container_handle_from_uuid[ + parent = self._impl.gui_api._resolve_container_handle( self._impl.parent_container_id - ] + ) parent._children[self._impl.uuid] = self def remove(self) -> None: @@ -826,8 +832,9 @@ def remove(self) -> None: # client drops the whole entity via the remove message anyway). for tab in tuple(self._tab_handles): tab.remove() - parent = gui_api._container_handle_from_uuid[self._impl.parent_container_id] + parent = gui_api._resolve_container_handle(self._impl.parent_container_id) parent._children.pop(self._impl.uuid) + gui_api._handles_in_foreign_containers.pop(self._impl.uuid, None) @dataclasses.dataclass @@ -1351,9 +1358,9 @@ def __init__(self, _impl: _GuiHandleState[None]) -> None: super().__init__(impl=_impl) self._impl.gui_api._container_handle_from_uuid[self._impl.uuid] = self self._children = {} - parent = self._impl.gui_api._container_handle_from_uuid[ + parent = self._impl.gui_api._resolve_container_handle( self._impl.parent_container_id - ] + ) parent._children[self._impl.uuid] = self _container_id_restore: str | None = None @@ -1395,9 +1402,10 @@ def remove(self) -> None: gui_api._websock_interface.queue_message(GuiRemoveMessage(self._impl.uuid)) for child in tuple(self._children.values()): child.remove() - parent = gui_api._container_handle_from_uuid[self._impl.parent_container_id] + parent = gui_api._resolve_container_handle(self._impl.parent_container_id) parent._children.pop(self._impl.uuid) gui_api._container_handle_from_uuid.pop(self._impl.uuid) + gui_api._handles_in_foreign_containers.pop(self._impl.uuid, None) class GuiFormHandle(GuiFolderHandle): diff --git a/src/viser/_viser.py b/src/viser/_viser.py index c5af21695..f4613d14b 100644 --- a/src/viser/_viser.py +++ b/src/viser/_viser.py @@ -589,6 +589,14 @@ class ClientHandle(DeprecatedAttributeShim if not TYPE_CHECKING else object): server-scoped parent (e.g. per-client annotations under a shared frame); it survives the parent's removal, anchored at the parent's last pose, until this handle removes it. + + **GUI containers nest one way.** A client-scoped GUI element may be + added inside a server-scoped container context (``with + server.gui.add_folder(...): client.gui.add_button(...)``); it renders + inside the shared folder for this client only, and is removed along + with the folder. The reverse -- a server-scoped element inside a + client-scoped container -- raises, since no other client could see the + container. """ def __init__( @@ -1138,6 +1146,12 @@ async def _(conn: infra.WebsockClientConnection) -> None: self.gui._drop_uploads_from_client(cast(infra.ClientId, conn.client_id)) handle.gui._drop_uploads_from_client(cast(infra.ClientId, conn.client_id)) + # Unhook this client's GUI elements from any SERVER containers + # they were nested in (bookkeeping only; the connection's buffer + # is closed). Otherwise a later server-side container removal + # would cascade removes into a dead connection. + handle.gui._release_cross_scope_nesting() + # Drop any in-flight drag entries for this client; the # corresponding ``phase="end"`` will never arrive, so without # this the active-drag map leaks an entry per dropped drag and diff --git a/tests/e2e/test_cross_scope_handles.py b/tests/e2e/test_cross_scope_handles.py index a5d743a7a..e5e19053b 100644 --- a/tests/e2e/test_cross_scope_handles.py +++ b/tests/e2e/test_cross_scope_handles.py @@ -254,23 +254,41 @@ def _(_event: viser.SceneClickEvent) -> None: assert not client_clicked.is_set() -def test_gui_container_context_does_not_span_scopes( - viser_server: viser.ViserServer, viser_page: Page -) -> None: - """A ``with server.gui.add_folder(...)`` block cannot capture elements - added through a client handle's GuiApi: cross-scope container nesting - raises instead of silently landing the element at the other scope's - root (the historical behavior). Adds outside the block work normally.""" - client = get_client_handle(viser_server) - - with viser_server.gui.add_folder("SrvFolder"): - with pytest.raises(RuntimeError, match="cannot span"): - client.gui.add_button("StrayBtn") - - # Outside the server's container context, client adds work normally. - client.gui.add_button("OkBtn") - button = viser_page.get_by_role("button", name="OkBtn") - button.wait_for(state="visible", timeout=5_000) +def test_gui_container_nesting_is_directional(two_client_setup: dict) -> None: + """A ``with server.gui.add_folder(...)`` block CAN capture elements added + through a client handle's GuiApi: the client element renders inside the + shared folder for that client only, and dies with the folder. The + reverse (server element into a client container) raises instead of + silently landing the element at the other scope's root (the historical + behavior).""" + server = two_client_setup["server"] + page1: Page = two_client_setup["page1"] + page2: Page = two_client_setup["page2"] + client1 = two_client_setup["client1"] + + with server.gui.add_folder("SrvFolder") as folder: + client1.gui.add_button("MineBtn") + + # Client 1 sees its button inside the shared folder; client 2 sees the + # folder but no button. + button1 = page1.get_by_role("button", name="MineBtn") + button1.wait_for(state="visible", timeout=5_000) + page2.get_by_text("SrvFolder").wait_for(state="visible", timeout=5_000) + assert page2.get_by_role("button", name="MineBtn").count() == 0 + + # Removing the server folder cascades into the cross-nested client + # element (the one deliberate exception to scope-local removal). + folder.remove() + button1.wait_for(state="hidden", timeout=5_000) + + # Outside any server container context, client adds work normally... + client1.gui.add_button("OkBtn") + page1.get_by_role("button", name="OkBtn").wait_for(state="visible", timeout=5_000) + + # ...and the reverse nesting direction raises. + with client1.gui.add_folder("CliFolder"): + with pytest.raises(RuntimeError, match="not vice versa"): + server.gui.add_button("StrayBtn") # --------------------------------------------------------------------------- diff --git a/tests/test_scene_scopes.py b/tests/test_scene_scopes.py index a9e872d62..c9df4f7f5 100644 --- a/tests/test_scene_scopes.py +++ b/tests/test_scene_scopes.py @@ -251,24 +251,86 @@ def test_client_world_axes_property_raises(server: viser.ViserServer) -> None: # --------------------------------------------------------------------------- -def test_gui_container_cannot_span_scopes(server: viser.ViserServer) -> None: - """Nesting a client-scope GUI add inside a server-scope container (or - vice versa) raises instead of silently landing the element at the other - scope's root.""" +def test_gui_container_nesting_is_directional(server: viser.ViserServer) -> None: + """Client elements nest inside server containers (their audience is a + subset of the container's); the reverse raises instead of silently + landing the element at the other scope's root.""" client = make_synthetic_client(server, 0) - with server.gui.add_folder("SrvFolder"): - with pytest.raises(RuntimeError, match="cannot span"): - client.gui.add_button("stray") - # Outside the context, adds work normally in both scopes. - client.gui.add_button("ok") + with server.gui.add_folder("SrvFolder") as folder: + button = client.gui.add_button("mine") + # The client element is parented in the SERVER folder's subtree... + assert button._impl.parent_container_id == folder._impl.uuid + assert folder._children[button._impl.uuid] is button + # ...but tracked by the client scope for reset/disconnect teardown. + assert button._impl.uuid in client.gui._handles_in_foreign_containers + + # Server-container removal cascades into the cross-nested client element + # (the one deliberate exception to scope-local removal: an orphaned + # widget has nowhere coherent to go). The remove is queued on the + # CLIENT's own connection. + folder.remove() + assert button._impl.removed + assert button._impl.uuid not in client.gui._handles_in_foreign_containers + remove_uuids = { + msg.uuid + for msg in _client_buffer_messages(client) + if isinstance(msg, m.GuiRemoveMessage) + } + assert button._impl.uuid in remove_uuids + # The reverse direction still raises; outside the context, adds work + # normally. with client.gui.add_folder("CliFolder"): - with pytest.raises(RuntimeError, match="cannot span"): + with pytest.raises(RuntimeError, match="not vice versa"): server.gui.add_button("stray") server.gui.add_button("ok") +def test_client_gui_reset_drains_cross_nested_elements( + server: viser.ViserServer, +) -> None: + """client.gui.reset() reaches elements nested in server containers, which + the root-container walk alone would miss; the server container itself is + untouched.""" + client = make_synthetic_client(server, 0) + with server.gui.add_folder("SrvFolder") as folder: + button = client.gui.add_button("mine") + + client.gui.reset() + + assert button._impl.removed + assert button._impl.uuid not in folder._children + assert not folder._impl.removed + + +def test_disconnect_releases_cross_nested_elements( + server: viser.ViserServer, +) -> None: + """The disconnect teardown detaches cross-nested client elements from the + server's container tree without sending messages, so a later server-side + container removal doesn't cascade a remove into the dead connection.""" + import warnings as warnings_module + + client = make_synthetic_client(server, 0) + with server.gui.add_folder("SrvFolder") as folder: + button = client.gui.add_button("mine") + + # Simulate the disconnect teardown (buffer shutdown + release call). + client._websock_connection._state.message_buffer.set_done() + client.gui._release_cross_scope_nesting() + + assert button._impl.removed + assert button._impl.uuid not in folder._children + + # Removing the server folder afterwards is clean: no cascade into the + # dead connection, so no "closed connection" warning. + with warnings_module.catch_warnings(record=True) as caught: + warnings_module.simplefilter("always") + folder.remove() + assert not any("closed connection" in str(w.message) for w in caught) + + def test_dead_connection_write_warns_once(server: viser.ViserServer) -> None: """Writes through handles owned by a disconnected client warn exactly once per connection, instead of accumulating silently forever.""" From 59cce091161d91e2b6ed7d9606e26c8034d301dd Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Mon, 10 Aug 2026 04:10:04 +0000 Subject: [PATCH 14/27] Harden cross-scope GUI nesting: context-restore fix + test pyramid Extensive-testing pass over the directional GUI container nesting, which immediately paid for itself: exiting a client container context that was nested inside a server container context corrupted the thread's container marker. Symptoms: a server add later in the same server `with` block was misread as nesting inside a client container and raised, and the client scope kept targeting the server folder even after the server context exited. _set_container_uuid now detects a restore to a server-owned container uuid and hands the thread marker back to the server GuiApi instead of recording a foreign uuid as its own target. Both symptoms are pinned by regression tests verified to fail against the pre-fix code. Tests are organized as a pyramid with deliberate non-overlap: - tests/test_gui_cross_scope.py (new, 11 unit tests): message routing (client adds/removes on the client connection, never broadcast), nesting + teardown for every server container type (folder, tab, modal, 3D GUI container), rejected directions, cascade through client subtrees, foreign-tracking granularity (subtree root only), client.gui.reset() drain, cross-scope form rejection, and the bookkeeping-only disconnect release. The GUI tests formerly in test_scene_scopes.py moved here; buffer-inspection helpers moved to infra_utils for reuse. - e2e (browser-only behavior): the directional test now also proves DOM containment (collapsing the folder hides the client's button), and a new disconnect test exercises the real websocket teardown hook -- the unit suite can only call the release method directly -- plus warning-free folder removal afterwards and propagation to the remaining client. The two_client_setup fixture tolerates a test closing a context early. Visually verified via screenshots on two concurrent clients: nested button/subfolder render inside the shared folder card for their owner only, and cascade removal clears exactly the folder's subtree. --- src/viser/_gui_api.py | 27 ++- tests/e2e/test_cross_scope_handles.py | 49 +++- tests/infra_utils.py | 12 + tests/test_gui_cross_scope.py | 327 ++++++++++++++++++++++++++ tests/test_scene_scopes.py | 83 +------ 5 files changed, 413 insertions(+), 85 deletions(-) create mode 100644 tests/test_gui_cross_scope.py diff --git a/src/viser/_gui_api.py b/src/viser/_gui_api.py index 3cf34378b..8c810504f 100644 --- a/src/viser/_gui_api.py +++ b/src/viser/_gui_api.py @@ -756,8 +756,31 @@ def _root_server(self): def _set_container_uuid(self, container_uuid: str) -> None: """Set container ID associated with the current thread, tracking which GuiApi currently owns an active (non-root) container context - so cross-scope nesting fails loudly (see _get_container_uuid).""" - self._target_container_from_thread_id[threading.get_ident()] = container_uuid + so cross-scope nesting stays directional (see _get_container_uuid).""" + thread_id = threading.get_ident() + if ( + container_uuid != "root" + and container_uuid not in self._container_handle_from_uuid + ): + # The uuid belongs to the SERVER scope's registry: this is a + # client container context exiting back out into the server + # container context it was nested in (its __enter__ snapshot + # resolved to the server's active container). Hand the thread + # marker back to the server GuiApi instead of recording a + # foreign uuid as our own target -- doing the latter would make + # later server adds see a client context and raise, and would + # leave this scope targeting the server container even after + # the server's `with` block exits. + server_gui = self._root_server().gui + if ( + server_gui is not self + and container_uuid in server_gui._container_handle_from_uuid + ): + self._target_container_from_thread_id.pop(thread_id, None) + server_gui._target_container_from_thread_id[thread_id] = container_uuid + _thread_container_context.api = server_gui + return + self._target_container_from_thread_id[thread_id] = container_uuid if container_uuid == "root": if getattr(_thread_container_context, "api", None) is self: _thread_container_context.api = None diff --git a/tests/e2e/test_cross_scope_handles.py b/tests/e2e/test_cross_scope_handles.py index e5e19053b..cfa6e310c 100644 --- a/tests/e2e/test_cross_scope_handles.py +++ b/tests/e2e/test_cross_scope_handles.py @@ -124,8 +124,11 @@ def two_client_setup(browser: Browser) -> Generator[dict, None, None]: "client2": client2, } - context1.close() - context2.close() + for context in (context1, context2): + try: + context.close() + except Exception: + pass # A test may have closed it already (disconnect tests). server.stop() @@ -276,6 +279,13 @@ def test_gui_container_nesting_is_directional(two_client_setup: dict) -> None: page2.get_by_text("SrvFolder").wait_for(state="visible", timeout=5_000) assert page2.get_by_role("button", name="MineBtn").count() == 0 + # DOM containment, not just coexistence: collapsing the folder must hide + # the client's button; expanding brings it back. + page1.get_by_text("SrvFolder").click() + button1.wait_for(state="hidden", timeout=5_000) + page1.get_by_text("SrvFolder").click() + button1.wait_for(state="visible", timeout=5_000) + # Removing the server folder cascades into the cross-nested client # element (the one deliberate exception to scope-local removal). folder.remove() @@ -291,6 +301,41 @@ def test_gui_container_nesting_is_directional(two_client_setup: dict) -> None: server.gui.add_button("StrayBtn") +def test_disconnect_detaches_cross_nested_gui_elements( + two_client_setup: dict, +) -> None: + """A real websocket disconnect runs the cross-scope release hook: the + departed client's elements are detached from the server's container + tree, so removing the server folder afterwards is warning-free and + still propagates to the remaining client.""" + import warnings as warnings_module + + server = two_client_setup["server"] + page1: Page = two_client_setup["page1"] + page2: Page = two_client_setup["page2"] + client1 = two_client_setup["client1"] + + with server.gui.add_folder("SrvFolder") as folder: + client1.gui.add_button("MineBtn") + page1.get_by_role("button", name="MineBtn").wait_for(state="visible", timeout=5_000) + page2.get_by_text("SrvFolder").wait_for(state="visible", timeout=5_000) + + # Disconnect client 1 for real and wait for the server-side teardown. + page1.context.close() + deadline = time.time() + 5.0 + while client1.client_id in server._connected_clients: + assert time.time() < deadline, "client 1 never disconnected" + time.sleep(0.05) + + # The folder's cross-nested child was detached bookkeeping-only, so this + # removal must not try to message the dead connection. + with warnings_module.catch_warnings(record=True) as caught: + warnings_module.simplefilter("always") + folder.remove() + assert not any("closed connection" in str(w.message) for w in caught) + page2.get_by_text("SrvFolder").wait_for(state="hidden", timeout=5_000) + + # --------------------------------------------------------------------------- # Variant slots + display rule (shadowing). # --------------------------------------------------------------------------- diff --git a/tests/infra_utils.py b/tests/infra_utils.py index c819fe8d1..8d359216d 100644 --- a/tests/infra_utils.py +++ b/tests/infra_utils.py @@ -15,6 +15,18 @@ from viser.infra._infra import WebsockClientConnection, _ClientHandleState +def client_buffer_messages(client: ClientHandle) -> list: + """Messages currently in a client's per-connection buffer (post-GC).""" + return list( + client._websock_connection._state.message_buffer.message_from_id.values() + ) + + +def broadcast_messages(server: viser.ViserServer) -> list: + """Messages currently in the server's broadcast buffer (post-GC).""" + return list(server._websock_server._broadcast_buffer.message_from_id.values()) + + def make_synthetic_client(server: viser.ViserServer, client_id: int) -> ClientHandle: """In-process ClientHandle with a real per-client message buffer but no websocket (mirrors how WebsockServer builds per-client state). Buffer diff --git a/tests/test_gui_cross_scope.py b/tests/test_gui_cross_scope.py new file mode 100644 index 000000000..bd280bc02 --- /dev/null +++ b/tests/test_gui_cross_scope.py @@ -0,0 +1,327 @@ +"""Tests for cross-scope GUI container nesting. + +GUI container nesting across scopes is DIRECTIONAL: a ``client.gui`` element +added inside a ``with server.gui.`` context nests in the server +container (its audience is a subset of the container's), while the reverse +raises. Cross-nested elements are the one deliberate exception to +scope-local removal: server-container teardown cascades into them, and +``client.gui.reset()`` / disconnect teardown reach them through per-scope +foreign-nesting bookkeeping. + +Browser-facing behavior (rendering inside the folder, per-client visibility, +DOM containment) is covered by ``tests/e2e/test_cross_scope_handles.py``; +this file pins the Python-side wiring on a headless server with synthetic +in-process clients. +""" + +from __future__ import annotations + +import warnings as warnings_module +from typing import Generator + +import pytest + +import viser +import viser._client_autobuild +from viser import _messages as m + +from .infra_utils import ( + broadcast_messages, + client_buffer_messages, + make_synthetic_client, +) + + +@pytest.fixture() +def server() -> Generator[viser.ViserServer, None, None]: + viser._client_autobuild.ensure_client_is_built = lambda: None + server = viser.ViserServer(port=0, verbose=False) + yield server + server.stop() + + +def _add_uuids(messages: list) -> set[str]: + """uuids of GUI element-creation messages (they carry container_uuid).""" + return {msg.uuid for msg in messages if hasattr(msg, "container_uuid")} + + +def _remove_uuids(messages: list) -> set[str]: + return {msg.uuid for msg in messages if isinstance(msg, m.GuiRemoveMessage)} + + +# --------------------------------------------------------------------------- +# Where elements land: the allowed direction. +# --------------------------------------------------------------------------- + + +def test_client_element_nests_in_server_folder(server: viser.ViserServer) -> None: + """The core wiring: a client add inside a server folder context parents + into the server folder's subtree, is tracked as foreign by the client + scope, and its add message targets the folder but travels on the + CLIENT's own connection (never the broadcast buffer).""" + client = make_synthetic_client(server, 0) + + with server.gui.add_folder("SrvFolder") as folder: + button = client.gui.add_button("mine") + + assert button._impl.parent_container_id == folder._impl.uuid + assert folder._children[button._impl.uuid] is button + assert button._impl.uuid in client.gui._handles_in_foreign_containers + + client_adds = [ + msg + for msg in client_buffer_messages(client) + if getattr(msg, "uuid", None) == button._impl.uuid + and hasattr(msg, "container_uuid") + ] + assert len(client_adds) == 1 + assert client_adds[0].container_uuid == folder._impl.uuid + assert button._impl.uuid not in _add_uuids(broadcast_messages(server)) + + +def test_other_server_container_types_nest_and_teardown( + server: viser.ViserServer, +) -> None: + """The directional rule holds for the other server container context + types -- tabs, modals, and scene-node-backed 3D GUI containers -- and + each type's (distinct) teardown path also cascades into cross-nested + client elements.""" + client = make_synthetic_client(server, 0) + + tab_group = server.gui.add_tab_group() + tab = tab_group.add_tab("Tab") + with tab: + in_tab = client.gui.add_button("in tab") + assert in_tab._impl.parent_container_id == tab._id + assert tab._children[in_tab._impl.uuid] is in_tab + + modal = server.gui.add_modal("Modal") + with modal: + in_modal = client.gui.add_button("in modal") + assert in_modal._impl.parent_container_id == modal._uuid + assert modal._children[in_modal._impl.uuid] is in_modal + + container_3d = server.scene.add_3d_gui_container("/gui3d") + with container_3d: + in_3d = client.gui.add_button("in 3d container") + assert in_3d._impl.parent_container_id == container_3d._container_id + assert container_3d._children[in_3d._impl.uuid] is in_3d + + for handle in (in_tab, in_modal, in_3d): + assert handle._impl.uuid in client.gui._handles_in_foreign_containers + + tab_group.remove() + assert in_tab._impl.removed + modal.close() + assert in_modal._impl.removed + container_3d.remove() # Scene-node removal path (_on_remove). + assert in_3d._impl.removed + assert not client.gui._handles_in_foreign_containers + + +# --------------------------------------------------------------------------- +# The rejected directions. +# --------------------------------------------------------------------------- + + +def test_server_element_into_client_container_raises( + server: viser.ViserServer, +) -> None: + client = make_synthetic_client(server, 0) + + with client.gui.add_folder("CliFolder"): + with pytest.raises(RuntimeError, match="not vice versa"): + server.gui.add_button("stray") + # Outside the context, both scopes add at their own roots again. + assert server.gui.add_button("ok")._impl.parent_container_id == "root" + assert client.gui.add_button("ok")._impl.parent_container_id == "root" + + +def test_cross_client_nesting_raises(server: viser.ViserServer) -> None: + """One client's element cannot nest in another client's container: the + audiences are disjoint, not nested.""" + client_a = make_synthetic_client(server, 0) + client_b = make_synthetic_client(server, 1) + + with client_a.gui.add_folder("A's folder"): + with pytest.raises(RuntimeError): + client_b.gui.add_button("stray") + + +# --------------------------------------------------------------------------- +# Removal cascades. +# --------------------------------------------------------------------------- + + +def test_server_folder_removal_cascades_through_client_subtree( + server: viser.ViserServer, +) -> None: + """Removing a server folder removes its server children AND cross-nested + client subtrees (the one exception to scope-local removal). Each remove + message travels on its owner's connection.""" + client = make_synthetic_client(server, 0) + + with server.gui.add_folder("SrvFolder") as folder: + srv_child = server.gui.add_button("shared") + with client.gui.add_folder("CliFolder") as cli_folder: + leaf = client.gui.add_button("leaf") + + # Only the subtree ROOT is foreign-tracked: the leaf is an ordinary + # same-scope element parented under the client folder. + assert cli_folder._impl.parent_container_id == folder._impl.uuid + assert leaf._impl.parent_container_id == cli_folder._impl.uuid + assert cli_folder._impl.uuid in client.gui._handles_in_foreign_containers + assert leaf._impl.uuid not in client.gui._handles_in_foreign_containers + + folder.remove() + + assert srv_child._impl.removed + assert cli_folder._impl.removed + assert leaf._impl.removed + assert not client.gui._handles_in_foreign_containers + + client_removes = _remove_uuids(client_buffer_messages(client)) + broadcast_removes = _remove_uuids(broadcast_messages(server)) + assert {cli_folder._impl.uuid, leaf._impl.uuid} <= client_removes + assert {folder._impl.uuid, srv_child._impl.uuid} <= broadcast_removes + assert not {cli_folder._impl.uuid, leaf._impl.uuid} & broadcast_removes + + +def test_client_remove_detaches_without_touching_others( + server: viser.ViserServer, +) -> None: + """A client removing its own cross-nested element detaches it from the + server folder; the folder and a second client's element are untouched.""" + client_a = make_synthetic_client(server, 0) + client_b = make_synthetic_client(server, 1) + + with server.gui.add_folder("SrvFolder") as folder: + a_button = client_a.gui.add_button("a") + b_button = client_b.gui.add_button("b") + + a_button.remove() + + assert a_button._impl.removed + assert a_button._impl.uuid not in folder._children + assert a_button._impl.uuid not in client_a.gui._handles_in_foreign_containers + assert not folder._impl.removed + assert not b_button._impl.removed + assert folder._children[b_button._impl.uuid] is b_button + + +# --------------------------------------------------------------------------- +# reset(). +# --------------------------------------------------------------------------- + + +def test_client_reset_drains_cross_nested_subtree(server: viser.ViserServer) -> None: + """client.gui.reset() reaches cross-nested subtrees, which the root + container walk alone can't see; the server folder and other clients' + elements are untouched.""" + client_a = make_synthetic_client(server, 0) + client_b = make_synthetic_client(server, 1) + + with server.gui.add_folder("SrvFolder") as folder: + with client_a.gui.add_folder("CliFolder") as cli_folder: + leaf = client_a.gui.add_button("leaf") + b_button = client_b.gui.add_button("b") + + client_a.gui.reset() + + assert cli_folder._impl.removed + assert leaf._impl.removed + assert cli_folder._impl.uuid not in folder._children + assert not client_a.gui._handles_in_foreign_containers + assert not folder._impl.removed + assert not b_button._impl.removed + + +# --------------------------------------------------------------------------- +# Thread-context restore across nested cross-scope `with` blocks. +# --------------------------------------------------------------------------- + + +def test_server_adds_ok_after_nested_client_context_exits( + server: viser.ViserServer, +) -> None: + """Regression: exiting a client container context that was nested inside + a server container context must hand the thread marker back to the + server scope -- a server add afterwards used to be misread as nesting + inside a client container and raised.""" + client = make_synthetic_client(server, 0) + + with server.gui.add_folder("SrvFolder") as srv_folder: + with client.gui.add_folder("CliFolder"): + client.gui.add_button("leaf") + # Back in the server context: both scopes target the server folder. + srv_button = server.gui.add_button("shared") + cli_button = client.gui.add_button("mine") + + assert srv_button._impl.parent_container_id == srv_folder._impl.uuid + assert cli_button._impl.parent_container_id == srv_folder._impl.uuid + + +def test_no_stale_target_after_server_context_exits( + server: viser.ViserServer, +) -> None: + """Regression: the cross-scope restore must not record the server + folder's uuid in the CLIENT's own target map -- after the server's + `with` block exits, adds from both scopes land at their own roots.""" + client = make_synthetic_client(server, 0) + + with server.gui.add_folder("SrvFolder"): + with client.gui.add_folder("CliFolder"): + pass + + assert server.gui.add_button("s")._impl.parent_container_id == "root" + assert client.gui.add_button("c")._impl.parent_container_id == "root" + + +# --------------------------------------------------------------------------- +# Forms. +# --------------------------------------------------------------------------- + + +def test_form_nesting_rules_apply_across_scopes(server: viser.ViserServer) -> None: + """The no-nested-forms rule sees through scope boundaries: a client form + inside a server form is invalid, while a client form inside a plain + server folder is fine.""" + client = make_synthetic_client(server, 0) + + with server.gui.add_form("SrvForm"): + with pytest.raises(ValueError, match="Nested forms"): + client.gui.add_form("CliForm") + + with server.gui.add_folder("SrvFolder") as folder: + form = client.gui.add_form("CliForm") + assert form._impl.parent_container_id == folder._impl.uuid + + +# --------------------------------------------------------------------------- +# Disconnect teardown. +# --------------------------------------------------------------------------- + + +def test_disconnect_releases_cross_nested_elements( + server: viser.ViserServer, +) -> None: + """The disconnect teardown detaches cross-nested client elements from the + server's container tree without sending messages, so a later server-side + container removal doesn't cascade a remove into the dead connection.""" + client = make_synthetic_client(server, 0) + with server.gui.add_folder("SrvFolder") as folder: + button = client.gui.add_button("mine") + + # Simulate the disconnect teardown (buffer shutdown + release call). + client._websock_connection._state.message_buffer.set_done() + client.gui._release_cross_scope_nesting() + + assert button._impl.removed + assert button._impl.uuid not in folder._children + + # Removing the server folder afterwards is clean: no cascade into the + # dead connection, so no "closed connection" warning. + with warnings_module.catch_warnings(record=True) as caught: + warnings_module.simplefilter("always") + folder.remove() + assert not any("closed connection" in str(w.message) for w in caught) diff --git a/tests/test_scene_scopes.py b/tests/test_scene_scopes.py index c9df4f7f5..b22d96980 100644 --- a/tests/test_scene_scopes.py +++ b/tests/test_scene_scopes.py @@ -247,90 +247,11 @@ def test_client_world_axes_property_raises(server: viser.ViserServer) -> None: # --------------------------------------------------------------------------- -# Cross-scope GUI containers + dead-client writes. +# Dead-client writes. (Cross-scope GUI container tests live in +# tests/test_gui_cross_scope.py.) # --------------------------------------------------------------------------- -def test_gui_container_nesting_is_directional(server: viser.ViserServer) -> None: - """Client elements nest inside server containers (their audience is a - subset of the container's); the reverse raises instead of silently - landing the element at the other scope's root.""" - client = make_synthetic_client(server, 0) - - with server.gui.add_folder("SrvFolder") as folder: - button = client.gui.add_button("mine") - # The client element is parented in the SERVER folder's subtree... - assert button._impl.parent_container_id == folder._impl.uuid - assert folder._children[button._impl.uuid] is button - # ...but tracked by the client scope for reset/disconnect teardown. - assert button._impl.uuid in client.gui._handles_in_foreign_containers - - # Server-container removal cascades into the cross-nested client element - # (the one deliberate exception to scope-local removal: an orphaned - # widget has nowhere coherent to go). The remove is queued on the - # CLIENT's own connection. - folder.remove() - assert button._impl.removed - assert button._impl.uuid not in client.gui._handles_in_foreign_containers - remove_uuids = { - msg.uuid - for msg in _client_buffer_messages(client) - if isinstance(msg, m.GuiRemoveMessage) - } - assert button._impl.uuid in remove_uuids - - # The reverse direction still raises; outside the context, adds work - # normally. - with client.gui.add_folder("CliFolder"): - with pytest.raises(RuntimeError, match="not vice versa"): - server.gui.add_button("stray") - server.gui.add_button("ok") - - -def test_client_gui_reset_drains_cross_nested_elements( - server: viser.ViserServer, -) -> None: - """client.gui.reset() reaches elements nested in server containers, which - the root-container walk alone would miss; the server container itself is - untouched.""" - client = make_synthetic_client(server, 0) - with server.gui.add_folder("SrvFolder") as folder: - button = client.gui.add_button("mine") - - client.gui.reset() - - assert button._impl.removed - assert button._impl.uuid not in folder._children - assert not folder._impl.removed - - -def test_disconnect_releases_cross_nested_elements( - server: viser.ViserServer, -) -> None: - """The disconnect teardown detaches cross-nested client elements from the - server's container tree without sending messages, so a later server-side - container removal doesn't cascade a remove into the dead connection.""" - import warnings as warnings_module - - client = make_synthetic_client(server, 0) - with server.gui.add_folder("SrvFolder") as folder: - button = client.gui.add_button("mine") - - # Simulate the disconnect teardown (buffer shutdown + release call). - client._websock_connection._state.message_buffer.set_done() - client.gui._release_cross_scope_nesting() - - assert button._impl.removed - assert button._impl.uuid not in folder._children - - # Removing the server folder afterwards is clean: no cascade into the - # dead connection, so no "closed connection" warning. - with warnings_module.catch_warnings(record=True) as caught: - warnings_module.simplefilter("always") - folder.remove() - assert not any("closed connection" in str(w.message) for w in caught) - - def test_dead_connection_write_warns_once(server: viser.ViserServer) -> None: """Writes through handles owned by a disconnected client warn exactly once per connection, instead of accumulating silently forever.""" From 269335c0003f5d25dcba39ccc2aab8b4f2917981 Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Mon, 10 Aug 2026 06:05:21 +0000 Subject: [PATCH 15/27] Fix missing get_client_handle import in floating-panel e2e test The helper-dedup pass replaced the local _wait_for_client with the shared get_client_handle but dropped the import, so test_notification_offset_clear_of_left_dock failed with a NameError in CI shard 2. Slipped through locally because F821 is globally ignored (jaxtyping false positives); an --ignore-noqa F821 sweep over tests/ and src/ confirms this was the only undefined name. --- tests/e2e/test_floating_panel.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/e2e/test_floating_panel.py b/tests/e2e/test_floating_panel.py index 24ada74f1..75db0a1df 100644 --- a/tests/e2e/test_floating_panel.py +++ b/tests/e2e/test_floating_panel.py @@ -12,6 +12,8 @@ import viser +from .utils import get_client_handle + # Wide enough to stay above the mobile breakpoint (xs = 36em = 576px), so the # floating layout -- not the bottom sheet -- is used. _VIEWPORT: ViewportSize = {"width": 1280, "height": 720} From b8dc9d685e2fd615d2fb43be97e596f377e659a0 Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Mon, 10 Aug 2026 07:06:25 +0000 Subject: [PATCH 16/27] Fix 10 cross-scope regressions found by review + regression-hunt passes Every fix below is pinned by a test verified to fail against the pre-fix code (unit where possible, e2e where only a browser exhibits the behavior). Frontend: - Skinned-mesh bone state is keyed per (owner, name) variant. A single name-keyed entry let a shadowed server's bone stream corrupt the client's variant, and promotion deleted the promoted variant's state, freezing its bones. - RemoveSceneNodeMessage removes same-scope DESCENDANTS again (removeSceneNodeVariantSubtree). Current servers enumerate removes per descendant (recursion no-ops), but recordings from older servers send one remove per subtree; without recursion their descendants -- and their pose/ref/bone side state -- leaked forever. - Drag messages echo the owner captured at drag START. Re-deriving it per phase misrouted the final end after a mid-drag removal (ownerOf(undefined) is the broadcast scope), so client-scope on_drag end callbacks never fired. - Per-owner scene-pointer filters are cleared on (re)connect: owner ids are connection-scoped, so a stale entry could never be disabled and kept click gestures permanently engaged. - Batched scene updates are parked per (owner, name) and re-routed through routeShadowedUpdate at flush: a cross-scope add later in the same batch flips the effective variant, and name-keyed flushing wrote one scope's update onto the other scope's variant. Python: - reset()'s "/WorldAxes" carve-out is broadcast-only; a client-scoped override now resets away like any other per-client node. - Thread container-context markers are keyed per root server, so an inner `with` on an unrelated ViserServer no longer clobbers the first server's still-open context. - The cross-scope container restore hands the thread marker back only for uuids the SERVER scope owns (registry, or its current thread target for removed-while-open containers); a client's own dangling uuid stays client-local instead of poisoning the thread for both scopes. - _release_cross_scope_nesting tombstones released subtrees recursively; surviving references to descendants now warn like any dead handle instead of raising KeyError. - Message serialization iterates declared dataclass fields and deserialization assigns non-init fields after construction, so the public as_serializable_dict -> Message.deserialize round trip is lossless again for stamped scene messages (and `virtual` is always on the wire, as the generated TypeScript requires). --- src/viser/_gui_api.py | 107 ++++++++++----- src/viser/_scene_api.py | 5 +- src/viser/client/src/DragLayer.tsx | 8 +- src/viser/client/src/MessageHandler.tsx | 136 +++++++++++++------ src/viser/client/src/SceneTreeState.test.ts | 52 +++++++ src/viser/client/src/SceneTreeState.ts | 28 ++++ src/viser/client/src/ViewerContext.ts | 16 ++- src/viser/client/src/WebsocketInterface.tsx | 7 +- src/viser/client/src/dragUtils.ts | 7 + src/viser/client/src/mesh/SkinnedMesh.tsx | 19 ++- src/viser/client/src/pointer/gestures.ts | 14 +- src/viser/infra/_messages.py | 32 ++++- tests/e2e/test_cross_scope_handles.py | 142 ++++++++++++++++++++ tests/test_gui_cross_scope.py | 75 ++++++++++- tests/test_scene_scopes.py | 47 +++++++ 15 files changed, 596 insertions(+), 99 deletions(-) diff --git a/src/viser/_gui_api.py b/src/viser/_gui_api.py index 8c810504f..f451c8fb0 100644 --- a/src/viser/_gui_api.py +++ b/src/viser/_gui_api.py @@ -199,9 +199,20 @@ class _RootGuiContainer: _global_order_counter = 0 _thread_container_context = threading.local() -"""Tracks, per thread, which GuiApi instance currently has an active -(non-root) container context. Used to make cross-scope container nesting an -error instead of a silent misplace -- see GuiApi._get_container_uuid.""" +"""Tracks, per thread and per ViserServer, which GuiApi instance currently +has an active (non-root) container context (attribute ``api_by_server``, +keyed by ``id(root server)``). Per-server keying keeps independent +ViserServers in one process from clobbering each other's markers. Used to +make cross-scope container nesting directional instead of a silent +misplace -- see GuiApi._get_container_uuid.""" + + +def _thread_context_markers() -> dict[int, GuiApi]: + """The current thread's active-container markers, keyed by root server.""" + markers = getattr(_thread_container_context, "api_by_server", None) + if markers is None: + markers = _thread_container_context.api_by_server = {} + return markers def _apply_default_order(order: float | None) -> float: @@ -669,16 +680,11 @@ def _get_container_uuid(self) -> str: inside a server-scope container targets the server's container, while the reverse raises -- silently placing the element at this scope's root (the historical behavior) hid the mistake.""" - owner_api = getattr(_thread_container_context, "api", None) - if ( - owner_api is not None - and owner_api is not self - # Only consider scopes of the SAME server: two independent - # ViserServers in one process are unrelated worlds, and a - # container context on one has never affected (and should not - # constrain) adds on the other. - and owner_api._root_server() is self._root_server() - ): + # Markers are keyed by root server: two independent ViserServers in + # one process are unrelated worlds, and a container context on one + # has never affected (and should not constrain) adds on the other. + owner_api = _thread_context_markers().get(id(self._root_server())) + if owner_api is not None and owner_api is not self: from ._viser import ViserServer if isinstance(owner_api._owner, ViserServer) and not isinstance( @@ -730,17 +736,41 @@ def _release_cross_scope_nesting(self) -> None: cascade a remove into a dead connection.""" while self._handles_in_foreign_containers: uuid, handle = self._handles_in_foreign_containers.popitem() - impl = handle._impl - if impl.removed: - continue - impl.removed = True parent = self._root_server().gui._container_handle_from_uuid.get( - impl.parent_container_id + handle._impl.parent_container_id ) if parent is not None: parent._children.pop(uuid, None) - self._gui_input_handle_from_uuid.pop(uuid, None) - self._container_handle_from_uuid.pop(uuid, None) + self._tombstone_subtree(handle) + + def _tombstone_subtree(self, handle: Any) -> None: + """Recursively mark a cross-nested subtree removed and purge it from + this scope's registries WITHOUT queuing messages (the connection is + closed). Descendants must be tombstoned too: a surviving user + reference calling ``.remove()`` on one should get the ordinary + already-removed warning, not a KeyError from a parent that no + longer resolves. Mirrors the per-type drains in the remove() + implementations.""" + from ._gui_handles import GuiTabHandle + + if isinstance(handle, GuiTabHandle): + if handle.removed: + return + handle.removed = True + self._container_handle_from_uuid.pop(handle._id, None) + for child in tuple(handle._children.values()): + self._tombstone_subtree(child) + return + impl = handle._impl + if impl.removed: + return + impl.removed = True + self._gui_input_handle_from_uuid.pop(impl.uuid, None) + self._container_handle_from_uuid.pop(impl.uuid, None) + for tab in tuple(getattr(handle, "_tab_handles", ())): + self._tombstone_subtree(tab) + for child in tuple(getattr(handle, "_children", {}).values()): + self._tombstone_subtree(child) def _root_server(self): """The ViserServer this GuiApi ultimately belongs to (itself for the @@ -758,34 +788,47 @@ def _set_container_uuid(self, container_uuid: str) -> None: which GuiApi currently owns an active (non-root) container context so cross-scope nesting stays directional (see _get_container_uuid).""" thread_id = threading.get_ident() + markers = _thread_context_markers() + server_key = id(self._root_server()) if ( container_uuid != "root" and container_uuid not in self._container_handle_from_uuid ): - # The uuid belongs to the SERVER scope's registry: this is a - # client container context exiting back out into the server - # container context it was nested in (its __enter__ snapshot - # resolved to the server's active container). Hand the thread - # marker back to the server GuiApi instead of recording a + # A client-scoped GuiApi restoring a uuid the SERVER scope owns: + # this is a client container context exiting back out into the + # server container context it was nested in (its __enter__ + # snapshot resolved to the server's active container). Hand the + # thread marker back to the server GuiApi instead of recording a # foreign uuid as our own target -- doing the latter would make # later server adds see a client context and raise, and would # leave this scope targeting the server container even after # the server's `with` block exits. + # + # "Owned by the server scope" means: in the server's registry, + # OR exactly the server's current thread target -- the latter + # covers a server container REMOVED while the client context was + # open (the server's target can't change underneath us: a server + # container context can't even be entered while a client context + # marker is active). A dangling uuid matching neither is this + # scope's own removed-while-open container; it stays in our map, + # matching the single-scope behavior, and heals when the outer + # context exits to root. server_gui = self._root_server().gui - if ( - server_gui is not self - and container_uuid in server_gui._container_handle_from_uuid + if server_gui is not self and ( + container_uuid in server_gui._container_handle_from_uuid + or server_gui._target_container_from_thread_id.get(thread_id) + == container_uuid ): self._target_container_from_thread_id.pop(thread_id, None) server_gui._target_container_from_thread_id[thread_id] = container_uuid - _thread_container_context.api = server_gui + markers[server_key] = server_gui return self._target_container_from_thread_id[thread_id] = container_uuid if container_uuid == "root": - if getattr(_thread_container_context, "api", None) is self: - _thread_container_context.api = None + if markers.get(server_key) is self: + del markers[server_key] else: - _thread_container_context.api = self + markers[server_key] = self def _next_layout_counter(self) -> int: """Bump and return the layout-update counter. THE single home of the diff --git a/src/viser/_scene_api.py b/src/viser/_scene_api.py index d8760c61b..d697383b2 100644 --- a/src/viser/_scene_api.py +++ b/src/viser/_scene_api.py @@ -3103,7 +3103,10 @@ def reset(self) -> None: # Remove all scene nodes. handles = list(self._handle_from_node_name.values()) for handle in handles: - if handle.name == "/WorldAxes": + # The broadcast scope keeps its default world-axes handle; a + # client-scoped "/WorldAxes" is an ordinary per-client override + # and resets away like everything else. + if handle.name == "/WorldAxes" and self._owner_id == "": continue # Skip handles already removed by cascading. if handle._impl.removed: diff --git a/src/viser/client/src/DragLayer.tsx b/src/viser/client/src/DragLayer.tsx index 2eba0da78..cb1403ed0 100644 --- a/src/viser/client/src/DragLayer.tsx +++ b/src/viser/client/src/DragLayer.tsx @@ -228,9 +228,10 @@ function DragLayerActive({ children }: { children?: React.ReactNode }) { end_screen_pos: [endScreenPos.x, endScreenPos.y], button: activeDrag.input.button, modifier: activeDrag.input.modifier, - // Echo the effective variant's owner so the server routes the drag - // to exactly one scope's registry. - owner: ownerOf(viewer.useSceneTree.get(activeDrag.nodeName)?.message), + // Echo the drag-start owner (frozen in ActiveDragState) so every + // phase routes to the same scope's registry -- including the final + // ``end`` sent after the node was removed mid-drag. + owner: activeDrag.owner, }; }, [ @@ -567,6 +568,7 @@ function DragLayerActive({ children }: { children?: React.ReactNode }) { // point. activeDragRef.current = { nodeName, + owner: ownerOf(viewer.useSceneTree.get(nodeName)?.message), instanceIndex, targetObj, pointerId, diff --git a/src/viser/client/src/MessageHandler.tsx b/src/viser/client/src/MessageHandler.tsx index 953aa1552..1b03277ca 100644 --- a/src/viser/client/src/MessageHandler.tsx +++ b/src/viser/client/src/MessageHandler.tsx @@ -5,7 +5,7 @@ import * as THREE from "three"; import { TextureLoader } from "three"; import { toMantineColor } from "./components/colorUtils"; -import { ViewerContext } from "./ViewerContext"; +import { ViewerContext, skinnedMeshStateKey } from "./ViewerContext"; import { FileTransferPart, FileTransferStartDownload, @@ -206,7 +206,9 @@ function useMessageHandler() { // bone update from the second loop onward. A real remove + re-add // deletes the entry first, so a NEW component still claims a fresh // object and the same-name re-add race stays protected. - const state = (viewerMutable.skinnedMeshState[message.name] ??= { + const state = (viewerMutable.skinnedMeshState[ + skinnedMeshStateKey(message.owner, message.name) + ] ??= { initialized: false, claimed: false, poses: [], @@ -446,23 +448,28 @@ function useMessageHandler() { // dereference throws inside the per-frame batch loop and drops the rest // of the batch. case "SetBoneOrientationMessage": { - const pose = - viewerMutable.skinnedMeshState[message.name]?.poses[ - message.bone_index + // Keyed per variant: a bone update for a SHADOWED variant + // accumulates in that variant's own entry instead of corrupting + // the effective one. + const state = + viewerMutable.skinnedMeshState[ + skinnedMeshStateKey(message.owner, message.name) ]; + const pose = state?.poses[message.bone_index]; if (pose === undefined) break; pose.wxyz = message.wxyz; - viewerMutable.skinnedMeshState[message.name].dirty = true; + state.dirty = true; break; } case "SetBonePositionMessage": { - const pose = - viewerMutable.skinnedMeshState[message.name]?.poses[ - message.bone_index + const state = + viewerMutable.skinnedMeshState[ + skinnedMeshStateKey(message.owner, message.name) ]; + const pose = state?.poses[message.bone_index]; if (pose === undefined) break; pose.position = message.position; - viewerMutable.skinnedMeshState[message.name].dirty = true; + state.dirty = true; break; } case "SetCameraLookAtMessage": { @@ -748,21 +755,24 @@ function useMessageHandler() { } return; } - // Remove one scope's variant of a scene node. Scope-local, and NOT - // recursive: the server sends one message per same-scope descendant, - // and the other scope's variants of these names must survive. + // Remove one scope's variant of a scene node, plus its same-scope + // descendants (needed for recordings from older servers, which sent a + // single remove per subtree; current servers enumerate descendants, + // making the recursion a no-op). Other scopes' variants survive. case "RemoveSceneNodeMessage": { - const outcome = viewer.sceneTreeActions.removeSceneNodeVariant( + const owner = message.owner ?? ""; + for (const removed of viewer.sceneTreeActions.removeSceneNodeVariantSubtree( message.name, - message.owner ?? "", - ); - - // Clear skinned-mesh state when the MOUNTED variant went away - // (removed or replaced by a promoted one). Exact name only: - // descendants arrive as their own removal messages, and a surviving - // other-scope variant of a descendant name must keep its state. - if (outcome === "removed-effective" || outcome === "promoted") { - delete viewerMutable.skinnedMeshState[message.name]; + owner, + )) { + // Whatever the disposition, the removed VARIANT is gone; drop its + // own bone-state entry. Other variants of the name (including a + // just-promoted one) keep theirs. + if (removed.outcome !== "noop") { + delete viewerMutable.skinnedMeshState[ + skinnedMeshStateKey(owner, removed.name) + ]; + } } return; } @@ -1044,30 +1054,61 @@ export function FrameSynchronizedMessageHandler() { // - attrUpdates: top-level SceneNode attributes (wxyz, position, visibility, etc.) // - propsUpdates: message.props fields (batched_wxyzs, colors, etc.) // - guiUpdates: GUI component property updates - const attrUpdates: { [name: string]: Partial } = {}; - const propsUpdates: { [name: string]: { [key: string]: any } } = {}; + // + // Scene updates are parked per (owner, name) and RE-ROUTED through + // routeShadowedUpdate at flush time: a cross-scope add later in the + // SAME batch can flip a name's effective variant after an update was + // parked, and flushing by name alone would then write one scope's + // update onto the other scope's variant. (With a single scope per + // name -- the common case -- the re-route is the shadowedSlotCount + // fast path and flushing is unchanged.) + const attrUpdates: { + [ownerAndName: string]: { + name: string; + owner: string; + updates: Partial; + }; + } = {}; + const propsUpdates: { + [ownerAndName: string]: { + name: string; + owner: string; + updates: { [key: string]: any }; + }; + } = {}; const guiUpdates: { uuid: string; updates: { [key: string]: any } }[] = []; for (const msg of processBatch) { + const msgOwner: string = (msg as { owner?: string }).owner ?? ""; const result = handleMessage(msg); if (result === undefined) continue; switch (result.kind) { case "sceneNodeAttrUpdate": { - const existing = attrUpdates[result.targetNode]; + const key = `${msgOwner}\u0000${result.targetNode}`; + const existing = attrUpdates[key]; if (existing) { - Object.assign(existing, result.updates); + Object.assign(existing.updates, result.updates); } else { - attrUpdates[result.targetNode] = { ...result.updates }; + attrUpdates[key] = { + name: result.targetNode, + owner: msgOwner, + updates: { ...result.updates }, + }; } break; } case "sceneNodePropsUpdate": { - const existing = propsUpdates[result.targetNode]; + const key = `${msgOwner}\u0000${result.targetNode}`; + const existing = propsUpdates[key]; if (existing) { - Object.assign(existing, result.propsUpdates); + Object.assign(existing.updates, result.propsUpdates); } else { - propsUpdates[result.targetNode] = { ...result.propsUpdates }; + propsUpdates[key] = { + name: result.targetNode, + owner: msgOwner, + updates: { ...result.propsUpdates }, + }; } break; } @@ -1081,30 +1122,43 @@ export function FrameSynchronizedMessageHandler() { const mergedUpdates: { [name: string]: SceneNode } = {}; // Merge attribute-level updates (wxyz, position, visibility, etc.). - for (const [k, v] of Object.entries(attrUpdates)) { - const currentNode = viewer.useSceneTree.get(k); + for (const { name, owner, updates } of Object.values(attrUpdates)) { + // The effective variant may have flipped since parking; consume + // into the shadow slot (or drop) instead of merging if so. + if (viewer.sceneTreeActions.routeShadowedUpdate(name, owner, updates)) + continue; + const currentNode = viewer.useSceneTree.get(name); if (currentNode === undefined) { - console.log(`(OK) Tried to update non-existent scene node ${k}`); + console.log(`(OK) Tried to update non-existent scene node ${name}`); continue; } - mergedUpdates[k] = { ...currentNode, ...v }; + mergedUpdates[name] = { + ...(mergedUpdates[name] ?? currentNode), + ...updates, + }; } // Merge props-level updates (batched_wxyzs, colors, etc.). - for (const [k, v] of Object.entries(propsUpdates)) { - const currentNode = viewer.useSceneTree.get(k); + for (const { name, owner, updates } of Object.values(propsUpdates)) { + if ( + viewer.sceneTreeActions.routeShadowedUpdate(name, owner, { + propsUpdates: updates, + }) + ) + continue; + const currentNode = viewer.useSceneTree.get(name); if (currentNode === undefined) { - console.log(`(OK) Tried to update non-existent scene node ${k}`); + console.log(`(OK) Tried to update non-existent scene node ${name}`); continue; } - const node = mergedUpdates[k] || currentNode; - mergedUpdates[k] = { + const node = mergedUpdates[name] || currentNode; + mergedUpdates[name] = { ...node, message: { ...node.message, props: { ...node.message.props, - ...v, + ...updates, }, } as SceneNodeMessage, }; diff --git a/src/viser/client/src/SceneTreeState.test.ts b/src/viser/client/src/SceneTreeState.test.ts index b86b7559c..01c9335b2 100644 --- a/src/viser/client/src/SceneTreeState.test.ts +++ b/src/viser/client/src/SceneTreeState.test.ts @@ -215,3 +215,55 @@ describe("variant slots and the display rule", () => { expect(node.shadowed?.message.owner).toBe(""); }); }); + +describe("removeSceneNodeVariantSubtree", () => { + it("removes same-scope descendants of a single non-enumerated remove (old recordings)", () => { + // Recordings from servers predating per-descendant remove enumeration + // contain ONE RemoveSceneNodeMessage per subtree; the store must sweep + // descendants (and their side state) itself. + const { store, nodeRefFromName, nodePoseData, actions } = setup(); + for (const name of ["/p", "/p/a", "/p/a/b"]) { + actions.addSceneNode(makeFrameMessage(name, "")); + nodeRefFromName[name] = new THREE.Object3D(); + nodePoseData[name] = { + wxyz: [1, 0, 0, 0], + position: [0, 0, 0], + poseUpdateState: "updated", + }; + } + + const outcomes = actions.removeSceneNodeVariantSubtree("/p", ""); + + for (const name of ["/p", "/p/a", "/p/a/b"]) { + expect(store.get(name)).toBeUndefined(); + expect(nodeRefFromName[name]).toBeUndefined(); + expect(nodePoseData[name]).toBeUndefined(); + } + expect(outcomes.map((o) => o.outcome)).toEqual([ + "removed-effective", + "removed-effective", + "removed-effective", + ]); + }); + + it("stays scope-local: other-owner descendants survive and shadowed ones promote", () => { + const { store, actions } = setup(); + actions.addSceneNode(makeFrameMessage("/p", "")); + actions.addSceneNode(makeFrameMessage("/p/shared", "")); + const clientVariant = makeFrameMessage("/p/shared", "7"); + actions.addSceneNode(clientVariant); // Shadows the broadcast one. + actions.addSceneNode(makeFrameMessage("/p/mine", "7")); + + const outcomes = actions.removeSceneNodeVariantSubtree("/p", ""); + const byName = Object.fromEntries(outcomes.map((o) => [o.name, o.outcome])); + + expect(byName["/p"]).toBe("removed-effective"); + // The broadcast variant of /p/shared was PARKED (client shadows it); + // removing it leaves the client variant effective. + expect(byName["/p/shared"]).toBe("removed-shadow"); + expect(store.get("/p/shared")!.message).toBe(clientVariant); + // The client-only descendant is untouched by the broadcast sweep. + expect(byName["/p/mine"]).toBe("noop"); + expect(store.get("/p/mine")).toBeDefined(); + }); +}); diff --git a/src/viser/client/src/SceneTreeState.ts b/src/viser/client/src/SceneTreeState.ts index 02673e21b..63b6e9065 100644 --- a/src/viser/client/src/SceneTreeState.ts +++ b/src/viser/client/src/SceneTreeState.ts @@ -328,6 +328,34 @@ export function createSceneTreeActions( return "noop"; }, + /** Remove one scope's variants of `name` AND its same-scope descendants. + * Current servers enumerate one RemoveSceneNodeMessage per descendant + * (the recursion then no-ops on the later messages), but recordings from + * older servers contain a single non-enumerated remove per subtree -- + * without the recursion their descendants would linger forever, along + * with their pose/ref side state. Still scope-local: other-owner + * variants of descendant names are untouched. Returns the disposition + * per name so callers can clean per-variant side state. */ + removeSceneNodeVariantSubtree: ( + name: string, + owner: string, + ): { + name: string; + outcome: "removed-effective" | "promoted" | "removed-shadow" | "noop"; + }[] => { + // Collect before removing: children lists die with their nodes. + const names: string[] = []; + function collect(nodeName: string) { + names.push(nodeName); + store.get(nodeName)?.children.forEach(collect); + } + collect(name); + return names.map((n) => ({ + name: n, + outcome: actions.removeSceneNodeVariant(n, owner), + })); + }, + /** Route a node-keyed state update by owner: returns false when it * targets the EFFECTIVE variant of `name` (or the scope-less root * singleton), in which case the caller applies it through the normal diff --git a/src/viser/client/src/ViewerContext.ts b/src/viser/client/src/ViewerContext.ts index 84607b872..73e77c06f 100644 --- a/src/viser/client/src/ViewerContext.ts +++ b/src/viser/client/src/ViewerContext.ts @@ -78,9 +78,12 @@ export type ViewerMutable = { rootWxyzAtCapture: [number, number, number, number]; } | null; - // Skinned mesh state. + // Skinned mesh state, keyed PER VARIANT via skinnedMeshStateKey(owner, + // name): each scope's variant of a name owns independent bone state, so + // bone updates for a shadowed variant accumulate without corrupting the + // effective one, and promotion finds the promoted variant's state intact. skinnedMeshState: { - [name: string]: { + [ownerAndName: string]: { initialized: boolean; // True once a mounted SkinnedMesh instance has claimed this entry. // Entries can be recreated without a remount (FilePlayback's loop and @@ -102,6 +105,15 @@ export type ViewerMutable = { nodePoseData: NodePoseDataMap; }; +/** skinnedMeshState key for one scope's variant of a scene node. Owners are + * "" (broadcast) or a client id, so NUL can't collide with a real owner. */ +export function skinnedMeshStateKey( + owner: string | undefined, + name: string, +): string { + return `${owner ?? ""}\u0000${name}`; +} + export type ViewerContextContents = { // Non-mutable state. messageSource: "websocket" | "file_playback" | "embed"; diff --git a/src/viser/client/src/WebsocketInterface.tsx b/src/viser/client/src/WebsocketInterface.tsx index 4cfc4ec69..01fbe60b3 100644 --- a/src/viser/client/src/WebsocketInterface.tsx +++ b/src/viser/client/src/WebsocketInterface.tsx @@ -62,12 +62,17 @@ export function WebsocketMessageProducer() { // so this transient state isn't reset for us. viewerMutable.messageQueue.length = 0; viewerMutable.firstMessageBatch = true; - // Skinned-mesh pose buffers are keyed by node name on the mutable ref, + // Skinned-mesh pose buffers are keyed by variant on the mutable ref, // which persists across reconnects; drop them so they don't leak (and // so stale bone state doesn't apply to the fresh scene). for (const key of Object.keys(viewerMutable.skinnedMeshState)) { delete viewerMutable.skinnedMeshState[key]; } + // Scene-pointer filters are keyed per owner, and owner ids are + // connection-scoped: entries from the previous connection can never + // be disabled again, so drop them all before the replay re-enables + // the live ones. + viewer.interaction.scenePointer.clearFilters(); // Clear any render request left in flight from the previous connection. // Message handling is gated on this being "ready", and a stale request // would otherwise render once against the fresh scene (its response is diff --git a/src/viser/client/src/dragUtils.ts b/src/viser/client/src/dragUtils.ts index a88c15e4d..3f218a377 100644 --- a/src/viser/client/src/dragUtils.ts +++ b/src/viser/client/src/dragUtils.ts @@ -216,6 +216,13 @@ export function motionExceedsThreshold( * are updated in place on every pointermove. */ export type ActiveDragState = { nodeName: string; + /** Owner of the effective variant this drag started on, frozen at + * drag-start. Every phase of the drag echoes THIS owner: re-deriving it + * from the live store would misroute the final ``end`` when the node is + * removed (ownerOf(undefined) is the broadcast "") or when the effective + * variant flips mid-drag -- the scope that received ``start`` must be + * the one that receives ``end``. */ + owner: string; /** Frozen at drag-start. Non-null for batched scene nodes (meshes, * GLBs, axes); ``null`` otherwise. */ instanceIndex: number | null; diff --git a/src/viser/client/src/mesh/SkinnedMesh.tsx b/src/viser/client/src/mesh/SkinnedMesh.tsx index d909ac769..7c9d43ec2 100644 --- a/src/viser/client/src/mesh/SkinnedMesh.tsx +++ b/src/viser/client/src/mesh/SkinnedMesh.tsx @@ -3,7 +3,11 @@ import * as THREE from "three"; import { ViserStandardMeshMaterial, ShadowSkinnedMesh } from "./MeshUtils"; import { SkinnedMeshMessage } from "../WebsocketMessages"; import { OutlinesIfHovered } from "../OutlinesIfHovered"; -import { ViewerContext, ViewerMutable } from "../ViewerContext"; +import { + ViewerContext, + ViewerMutable, + skinnedMeshStateKey, +} from "../ViewerContext"; import { useFrame } from "@react-three/fiber"; import { normalizeScale } from "../utils/normalizeScale"; @@ -112,12 +116,17 @@ export const SkinnedMesh = React.forwardRef< // Get mutable once. const viewerMutable = viewer.mutable.current; + // Bone state is keyed per VARIANT: this mounted instance renders exactly + // one scope's variant, and must never read/claim the entry of a same-name + // variant from the other scope. + const stateKey = skinnedMeshStateKey(message.owner, message.name); + // Clean up geometry and skeleton when they change (they're created together). React.useEffect(() => { // The state entry can be deleted while this component is still mounted // (subtree-prefix removal in MessageHandler, reconnect clearing in // WebsocketInterface), so guard reads like the bone-message handlers do. - const state = viewerMutable.skinnedMeshState[message.name]; + const state = viewerMutable.skinnedMeshState[stateKey]; if (state !== undefined) { state.initialized = false; state.claimed = true; @@ -137,14 +146,14 @@ export const SkinnedMesh = React.forwardRef< // run, or a new mount) can take the entry over. if ( ownedEntryRef.current !== null && - viewerMutable.skinnedMeshState[message.name] === ownedEntryRef.current + viewerMutable.skinnedMeshState[stateKey] === ownedEntryRef.current ) { ownedEntryRef.current.claimed = false; } if (skeleton) skeleton.dispose(); if (geometry) geometry.dispose(); }; - }, [skeleton, geometry, message.name, viewerMutable.skinnedMeshState]); + }, [skeleton, geometry, stateKey, viewerMutable.skinnedMeshState]); // Check if we should render a shadow mesh. const shadowOpacity = @@ -159,7 +168,7 @@ export const SkinnedMesh = React.forwardRef< // useFrame (priority -100000) earlier in the same rAF tick, while this // subscriber is still registered. R3F's subscriber loop has no try/catch, // so throwing here would skip the remaining subscribers and gl.render. - const state = viewerMutable.skinnedMeshState[message.name]; + const state = viewerMutable.skinnedMeshState[stateKey]; if (state === undefined) return; // Only one live instance may drive an entry. Normally the init effect // claims it; but FilePlayback can recreate the entry WITHOUT a remount diff --git a/src/viser/client/src/pointer/gestures.ts b/src/viser/client/src/pointer/gestures.ts index 75c1327f6..6b6338348 100644 --- a/src/viser/client/src/pointer/gestures.ts +++ b/src/viser/client/src/pointer/gestures.ts @@ -291,11 +291,21 @@ export class ScenePointerController { this.removeWindowListeners(); } - resetForTest(): void { - this.cancelAny(); + /** Drop every scope's filters. Called on (re)connect: owner ids are + * connection-scoped (a reconnected browser is a NEW client id), so any + * surviving per-owner entry is unreachable garbage that would keep its + * event type permanently engaged -- no disable for that owner can ever + * arrive. Broadcast-scope enables replay from the persistent buffer + * right after. */ + clearFilters(): void { this.filters.clear(); this.hover.refresh(); } + + resetForTest(): void { + this.cancelAny(); + this.clearFilters(); + } } type NodeCandidate = { diff --git a/src/viser/infra/_messages.py b/src/viser/infra/_messages.py index 45a40aabc..a4d8cb333 100644 --- a/src/viser/infra/_messages.py +++ b/src/viser/infra/_messages.py @@ -222,12 +222,18 @@ def as_serializable_dict( Otherwise, arrays are inlined as memoryviews in the returned dict.""" message_type = type(self) hints = get_type_hints_cached(message_type) - # Filter to type-hinted fields only -- excludes dynamic attributes - # like cached values that shouldn't be serialized. + # Iterate DECLARED dataclass fields (not vars(self)): non-init + # defaulted fields -- e.g. the scene messages' owner/virtual stamps + # -- live on the class until assigned, so vars() would silently omit + # them from the wire even though the generated TypeScript declares + # them as required. The hints filter still excludes anything + # unannotated. out = { - k: _prepare_for_serialization(v, hints[k], binary_buffers) - for k, v in vars(self).items() - if k in hints + name: _prepare_for_serialization( + getattr(self, name), hints[name], binary_buffers + ) + for name in message_type.__dataclass_fields__ + if name in hints } out["type"] = message_type.__name__ return out @@ -254,7 +260,21 @@ def deserialize(cls, message: bytes) -> Message: # a blanket recursive traversal of the entire message tree. message_type = cls._subclass_from_type_string()[cast(str, mapping.pop("type"))] message_kwargs = message_type._from_serializable_dict(mapping) - return message_type(**message_kwargs) + # Non-init fields (e.g. the scene messages' owner/virtual stamps, + # declared init=False so defaulted fields can follow subclasses' + # positional ones on Python 3.8) can't be passed to __init__; strip + # them out and assign after construction so the serialize -> + # deserialize round trip stays lossless. + fields = message_type.__dataclass_fields__ + non_init = { + k: message_kwargs.pop(k) + for k in list(message_kwargs) + if k in fields and not fields[k].init + } + message = message_type(**message_kwargs) + for k, v in non_init.items(): + setattr(message, k, v) + return message @classmethod @functools.lru_cache(maxsize=100) diff --git a/tests/e2e/test_cross_scope_handles.py b/tests/e2e/test_cross_scope_handles.py index cfa6e310c..8959da06c 100644 --- a/tests/e2e/test_cross_scope_handles.py +++ b/tests/e2e/test_cross_scope_handles.py @@ -568,3 +568,145 @@ def test_world_axes_server_state_deterministic_for_new_client( context.close() server.stop() + + +# --------------------------------------------------------------------------- +# Regression tests: owner routing + per-connection frontend state. +# --------------------------------------------------------------------------- + + +def test_drag_end_routes_to_owner_after_mid_drag_removal( + viser_server: viser.ViserServer, viser_page: Page +) -> None: + """The final drag ``end`` echoes the owner captured at drag START. + Regression: it was re-derived from the live store per phase, so removing + the node mid-drag stamped the end with the broadcast owner ("") and the + client scope's ``on_drag`` end callback never fired.""" + import viser as viser_module + + client = get_client_handle(viser_server) + client.camera.position = (0.0, 0.0, 4.0) + client.camera.look_at = (0.0, 0.0, 0.0) + + box = client.scene.add_box("/dragme", dimensions=(4.0, 4.0, 0.2)) + started = threading.Event() + ended = threading.Event() + + @box.on_drag("left") + def _(event: viser_module.SceneNodeDragEvent) -> None: + if event.phase == "start": + started.set() + elif event.phase == "end": + ended.set() + + wait_for_scene_node(viser_page, "/dragme") + cx, cy = canvas_center(viser_page) + viser_page.mouse.move(cx, cy) + viser_page.mouse.down() + viser_page.mouse.move(cx + 80, cy + 40, steps=10) + assert started.wait(timeout=5.0), "drag start never reached the client scope" + + # Remove the node MID-DRAG: the frontend synthesizes the end phase, which + # must still route to the client scope that received the start. + box.remove() + assert ended.wait(timeout=5.0), "drag end was misrouted after removal" + viser_page.mouse.up() + + +def test_pointer_filters_cleared_on_reconnect( + viser_server: viser.ViserServer, viser_page: Page +) -> None: + """Per-owner scene-pointer filters are connection-scoped. Regression: an + IN-PLACE reconnect (worker retry, no page reload) left the previous + client's filter entry behind forever -- its owner id can never send a + disable -- keeping click gestures engaged.""" + client = get_client_handle(viser_server) + old_id = client.client_id + + @client.scene.on_pointer_event(event_type="click") + def _(event) -> None: + pass + + viser_page.wait_for_function( + "() => window.__viserPointer?.hasSceneClickFilter() === true", + timeout=5_000, + ) + + # Kick the connection; the page's worker auto-reconnects WITHOUT a + # reload, so all frontend state survives except what the reconnect + # path deliberately resets. The browser comes back as a new client id, + # and nothing re-registers a pointer callback -- so no filter may + # survive the reconnect. + viser_server._websock_server.disconnect_all_clients() + deadline = time.time() + 10.0 + while not any(cid != old_id for cid in viser_server.get_clients().keys()): + assert time.time() < deadline, "browser never reconnected" + time.sleep(0.05) + + viser_page.wait_for_function( + "() => window.__viserPointer?.hasSceneClickFilter() === false", + timeout=5_000, + ) + + +def test_skinned_mesh_bone_state_is_variant_scoped( + viser_server: viser.ViserServer, viser_page: Page +) -> None: + """Bone state is keyed per (owner, name) variant. Regression: a single + name-keyed entry let a shadowed server's bone stream corrupt the + client's variant, and promotion deleted the promoted variant's state.""" + import numpy as np + + client = get_client_handle(viser_server) + + def add_skinned(scene_api): + return scene_api.add_mesh_skinned( + "/skin", + vertices=np.array( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + dtype=np.float32, + ), + faces=np.array([[0, 1, 2]], dtype=np.uint32), + bone_wxyzs=((1.0, 0.0, 0.0, 0.0), (1.0, 0.0, 0.0, 0.0)), + bone_positions=((0.0, 0.0, 0.0), (1.0, 0.0, 0.0)), + skin_weights=np.array( + [[1.0, 0.0], [0.5, 0.5], [0.0, 1.0]], dtype=np.float32 + ), + ) + + server_mesh = add_skinned(viser_server.scene) + wait_for_scene_node(viser_page, "/skin") + + # Client-scoped variant shadows the server's. + add_skinned(client.scene) + js_entry = """ + (owner) => { + const state = window.__viserMutable?.skinnedMeshState; + if (!state) return null; + const entry = state[`${owner}\\u0000/skin`]; + return entry ? entry.poses[1].position : null; + } + """ + client_owner = str(client.client_id) + viser_page.wait_for_function( + f"() => ({js_entry})('') !== null && ({js_entry})('{client_owner}') !== null", + timeout=5_000, + ) + + # A bone update from the (shadowed) server scope lands in the SERVER + # variant's entry; the client's stays at its initial pose. + server_mesh.bones[1].position = (5.0, 6.0, 7.0) + viser_page.wait_for_function( + f"() => String(({js_entry})('')) === '5,6,7'", + timeout=5_000, + ) + assert viser_page.evaluate(js_entry, client_owner) == [1, 0, 0] + + # Promotion: removing the client variant keeps the server variant's + # accumulated bone state (entry survives; client entry is dropped). + client.scene._handle_from_node_name["/skin"].remove() + viser_page.wait_for_function( + f"() => ({js_entry})('{client_owner}') === null", + timeout=5_000, + ) + assert viser_page.evaluate(js_entry, "") == [5, 6, 7] diff --git a/tests/test_gui_cross_scope.py b/tests/test_gui_cross_scope.py index bd280bc02..6ada22d3c 100644 --- a/tests/test_gui_cross_scope.py +++ b/tests/test_gui_cross_scope.py @@ -305,19 +305,26 @@ def test_form_nesting_rules_apply_across_scopes(server: viser.ViserServer) -> No def test_disconnect_releases_cross_nested_elements( server: viser.ViserServer, ) -> None: - """The disconnect teardown detaches cross-nested client elements from the - server's container tree without sending messages, so a later server-side - container removal doesn't cascade a remove into the dead connection.""" + """The disconnect teardown detaches cross-nested client subtrees from the + server's container tree without sending messages -- including + DESCENDANTS of the cross-nested root, so surviving user references get + the ordinary already-removed warning instead of a KeyError.""" client = make_synthetic_client(server, 0) with server.gui.add_folder("SrvFolder") as folder: - button = client.gui.add_button("mine") + with client.gui.add_folder("CliFolder") as cli_folder: + leaf = client.gui.add_button("leaf") # Simulate the disconnect teardown (buffer shutdown + release call). client._websock_connection._state.message_buffer.set_done() client.gui._release_cross_scope_nesting() - assert button._impl.removed - assert button._impl.uuid not in folder._children + assert cli_folder._impl.removed + assert leaf._impl.removed + assert cli_folder._impl.uuid not in folder._children + + # A surviving reference to a DESCENDANT degrades gracefully. + with pytest.warns(UserWarning, match="already removed"): + leaf.remove() # Removing the server folder afterwards is clean: no cascade into the # dead connection, so no "closed connection" warning. @@ -325,3 +332,59 @@ def test_disconnect_releases_cross_nested_elements( warnings_module.simplefilter("always") folder.remove() assert not any("closed connection" in str(w.message) for w in caught) + + +def test_second_server_context_does_not_break_first( + server: viser.ViserServer, +) -> None: + """Regression: container-context markers are per-server, so an inner + `with` block on an unrelated ViserServer must not clobber the first + server's still-open context.""" + server_b = viser.ViserServer(port=0, verbose=False) + try: + client = make_synthetic_client(server, 0) + with server.gui.add_folder("FA") as fa: + with server_b.gui.add_folder("FB"): + pass + # Server A's context is still active for both of its scopes. + cli_button = client.gui.add_button("x") + srv_button = server.gui.add_button("y") + assert cli_button._impl.parent_container_id == fa._impl.uuid + assert srv_button._impl.parent_container_id == fa._impl.uuid + finally: + server_b.stop() + + +def test_client_scope_dangling_restore_stays_client_local( + server: viser.ViserServer, +) -> None: + """Regression: a CLIENT container removed while an inner client context + is open is a same-scope affair -- the dangling uuid must not be handed + to the server's target map (which would poison the thread for both + scopes permanently). After the outer context exits, both scopes add at + their roots again.""" + client = make_synthetic_client(server, 0) + + with client.gui.add_folder("Outer") as outer: + with client.gui.add_folder("Inner"): + outer.remove() + + assert server.gui.add_button("s")._impl.parent_container_id == "root" + assert client.gui.add_button("c")._impl.parent_container_id == "root" + + +def test_removed_server_container_restore_does_not_poison_thread( + server: viser.ViserServer, +) -> None: + """Regression: if the server container is removed while a nested client + context is open, the client context's exit must still hand the thread + marker back cleanly -- afterwards neither scope spuriously raises and + adds land at their own roots.""" + client = make_synthetic_client(server, 0) + + with server.gui.add_folder("F") as f: + with client.gui.add_folder("C"): + f.remove() + + assert server.gui.add_button("s")._impl.parent_container_id == "root" + assert client.gui.add_button("c")._impl.parent_container_id == "root" diff --git a/tests/test_scene_scopes.py b/tests/test_scene_scopes.py index b22d96980..9f68020ed 100644 --- a/tests/test_scene_scopes.py +++ b/tests/test_scene_scopes.py @@ -246,6 +246,53 @@ def test_client_world_axes_property_raises(server: viser.ViserServer) -> None: assert not server.scene.world_axes._impl.removed +def test_reset_world_axes_skip_is_broadcast_only(server: viser.ViserServer) -> None: + """Regression: reset()'s "/WorldAxes" carve-out protects the broadcast + scope's default handle only -- a client-scoped override is an ordinary + per-client node and must reset away with everything else.""" + client = make_synthetic_client(server, 0) + override = client.scene.add_frame("/WorldAxes", show_axes=True) + + client.scene.reset() + assert override._impl.removed + assert "/WorldAxes" not in client.scene._handle_from_node_name + + server.scene.reset() + assert not server.scene.world_axes._impl.removed + + +def test_stamped_messages_roundtrip_through_serialization( + server: viser.ViserServer, +) -> None: + """Regression: the owner/virtual stamps are non-init dataclass fields, + which (a) vars()-based serialization silently omitted until first + assignment and (b) ``Message.deserialize`` choked on (they aren't + ``__init__`` parameters). The public serialize -> deserialize round trip + must stay lossless, stamps included.""" + import msgspec + + from viser.infra import Message + + client = make_synthetic_client(server, 3) + client.scene.add_frame("/rt/leaf", show_axes=False) # Anchor + real node. + + messages = _client_buffer_messages(client) + assert len(messages) > 0 + for msg in messages: + serialized = msg.as_serializable_dict() + # The stamps are always on the wire (the generated TypeScript + # declares them as required fields). + assert serialized["owner"] == "3" + if hasattr(msg, "virtual"): + assert "virtual" in serialized + + decoded = Message.deserialize(msgspec.msgpack.encode(serialized)) + assert type(decoded) is type(msg) + assert decoded.owner == msg.owner # type: ignore[attr-defined] + if hasattr(msg, "virtual"): + assert decoded.virtual == msg.virtual # type: ignore[attr-defined] + + # --------------------------------------------------------------------------- # Dead-client writes. (Cross-scope GUI container tests live in # tests/test_gui_cross_scope.py.) From 0921a010e57083afd8a40885d3ca13658f626869 Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Mon, 10 Aug 2026 07:26:41 +0000 Subject: [PATCH 17/27] Simplify pass over the regression fixes Applied from a four-angle cleanup review (reuse, simplification, efficiency, altitude); no behavior changes intended, all suites green. - Container-context restore reworked at the right depth: `with` blocks now snapshot (owning GuiApi, uuid) via _snapshot_container_context and restore via _restore_container_context, so _set_container_uuid loses the registry/thread-target heuristic entirely -- a dangling uuid restores into the scope that owned it by construction. Context-owner markers move off the module-level threading.local onto the server GuiApi instance (per-server scoping falls out of which instance holds the dict). - _tombstone_subtree collapses to one path over the two duck-typed branches. - One definition of the wire field set: wire_field_names() is shared by Message.as_serializable_dict and the TypeScript interface generator (generated output is byte-identical); deserialize's non-init split uses a cached per-class name tuple instead of scanning every message's kwargs. - Frontend: variantKey() is the single (owner, name) key helper (skinned mesh state + batch parking); parking collapses to ??= + Object.assign and derives owners via ownerOf; collectSubtreeNames is shared by variant-subtree and whole-node removal; removeSceneNodeVariantSubtree returns just the removed names and no-ops silently on already-removed subtrees (the per-descendant remove storm previously logged once per name). - Tests: drop a redundant module alias import and a hand-rolled reconnect poll the following wait already implies. --- src/viser/_gui_api.py | 127 ++++++++----------- src/viser/_gui_handles.py | 22 ++-- src/viser/_scene_handles.py | 4 +- src/viser/client/src/MessageHandler.tsx | 56 +++----- src/viser/client/src/SceneTreeState.test.ts | 22 ++-- src/viser/client/src/SceneTreeState.ts | 63 ++++----- src/viser/client/src/ViewerContext.ts | 13 +- src/viser/client/src/mesh/SkinnedMesh.tsx | 8 +- src/viser/infra/_messages.py | 38 ++++-- src/viser/infra/_typescript_interface_gen.py | 13 +- tests/e2e/test_cross_scope_handles.py | 18 +-- 11 files changed, 168 insertions(+), 216 deletions(-) diff --git a/src/viser/_gui_api.py b/src/viser/_gui_api.py index f451c8fb0..361c17ac8 100644 --- a/src/viser/_gui_api.py +++ b/src/viser/_gui_api.py @@ -198,22 +198,6 @@ class _RootGuiContainer: _global_order_counter = 0 -_thread_container_context = threading.local() -"""Tracks, per thread and per ViserServer, which GuiApi instance currently -has an active (non-root) container context (attribute ``api_by_server``, -keyed by ``id(root server)``). Per-server keying keeps independent -ViserServers in one process from clobbering each other's markers. Used to -make cross-scope container nesting directional instead of a silent -misplace -- see GuiApi._get_container_uuid.""" - - -def _thread_context_markers() -> dict[int, GuiApi]: - """The current thread's active-container markers, keyed by root server.""" - markers = getattr(_thread_container_context, "api_by_server", None) - if markers is None: - markers = _thread_container_context.api_by_server = {} - return markers - def _apply_default_order(order: float | None) -> float: """Apply default ordering logic for GUI elements. @@ -270,6 +254,13 @@ def __init__( self._owner = owner """Entity that owns this API.""" self._target_container_from_thread_id = {} + # Which GuiApi owns each thread's active (non-root) container + # context. Only the SERVER-scope instance's dict is consulted -- + # storing the marker there scopes it per server for free, so + # contexts on unrelated ViserServers can't interfere. Used to make + # cross-scope container nesting directional instead of a silent + # misplace (see _get_container_uuid). + self._context_owner_from_thread_id: dict[int, GuiApi] = {} self._thread_executor = thread_executor self._event_loop = event_loop @@ -680,10 +671,9 @@ def _get_container_uuid(self) -> str: inside a server-scope container targets the server's container, while the reverse raises -- silently placing the element at this scope's root (the historical behavior) hid the mistake.""" - # Markers are keyed by root server: two independent ViserServers in - # one process are unrelated worlds, and a container context on one - # has never affected (and should not constrain) adds on the other. - owner_api = _thread_context_markers().get(id(self._root_server())) + owner_api = self._root_server().gui._context_owner_from_thread_id.get( + threading.get_ident() + ) if owner_api is not None and owner_api is not self: from ._viser import ViserServer @@ -753,23 +743,20 @@ def _tombstone_subtree(self, handle: Any) -> None: implementations.""" from ._gui_handles import GuiTabHandle - if isinstance(handle, GuiTabHandle): - if handle.removed: - return - handle.removed = True - self._container_handle_from_uuid.pop(handle._id, None) - for child in tuple(handle._children.values()): - self._tombstone_subtree(child) - return - impl = handle._impl - if impl.removed: + # Tabs keep their tombstone/uuid on the handle itself; everything + # else keeps them on `_impl`. + is_tab = isinstance(handle, GuiTabHandle) + state = handle if is_tab else handle._impl + if state.removed: return - impl.removed = True - self._gui_input_handle_from_uuid.pop(impl.uuid, None) - self._container_handle_from_uuid.pop(impl.uuid, None) - for tab in tuple(getattr(handle, "_tab_handles", ())): - self._tombstone_subtree(tab) - for child in tuple(getattr(handle, "_children", {}).values()): + state.removed = True + uuid = handle._id if is_tab else handle._impl.uuid + self._gui_input_handle_from_uuid.pop(uuid, None) + self._container_handle_from_uuid.pop(uuid, None) + for child in ( + *getattr(handle, "_tab_handles", ()), + *tuple(getattr(handle, "_children", {}).values()), + ): self._tombstone_subtree(child) def _root_server(self): @@ -788,47 +775,39 @@ def _set_container_uuid(self, container_uuid: str) -> None: which GuiApi currently owns an active (non-root) container context so cross-scope nesting stays directional (see _get_container_uuid).""" thread_id = threading.get_ident() - markers = _thread_context_markers() - server_key = id(self._root_server()) - if ( - container_uuid != "root" - and container_uuid not in self._container_handle_from_uuid - ): - # A client-scoped GuiApi restoring a uuid the SERVER scope owns: - # this is a client container context exiting back out into the - # server container context it was nested in (its __enter__ - # snapshot resolved to the server's active container). Hand the - # thread marker back to the server GuiApi instead of recording a - # foreign uuid as our own target -- doing the latter would make - # later server adds see a client context and raise, and would - # leave this scope targeting the server container even after - # the server's `with` block exits. - # - # "Owned by the server scope" means: in the server's registry, - # OR exactly the server's current thread target -- the latter - # covers a server container REMOVED while the client context was - # open (the server's target can't change underneath us: a server - # container context can't even be entered while a client context - # marker is active). A dangling uuid matching neither is this - # scope's own removed-while-open container; it stays in our map, - # matching the single-scope behavior, and heals when the outer - # context exits to root. - server_gui = self._root_server().gui - if server_gui is not self and ( - container_uuid in server_gui._container_handle_from_uuid - or server_gui._target_container_from_thread_id.get(thread_id) - == container_uuid - ): - self._target_container_from_thread_id.pop(thread_id, None) - server_gui._target_container_from_thread_id[thread_id] = container_uuid - markers[server_key] = server_gui - return self._target_container_from_thread_id[thread_id] = container_uuid + markers = self._root_server().gui._context_owner_from_thread_id if container_uuid == "root": - if markers.get(server_key) is self: - del markers[server_key] + if markers.get(thread_id) is self: + del markers[thread_id] else: - markers[server_key] = self + markers[thread_id] = self + + def _snapshot_container_context(self) -> tuple[GuiApi, str]: + """Snapshot the active container context for a `with` block to + restore on exit: the OWNING GuiApi and its current target uuid. + Carrying the owner explicitly -- instead of re-deriving it from a + bare uuid at exit time -- keeps the restore correct even when the + snapshot container has been removed while the block was open (a + dangling uuid restores into the scope that owned it, exactly like a + removed-while-open container always has within a single scope). + Raises for disallowed nesting directions, via _get_container_uuid.""" + container_uuid = self._get_container_uuid() + owner_api = self._root_server().gui._context_owner_from_thread_id.get( + threading.get_ident() + ) + return (owner_api if owner_api is not None else self, container_uuid) + + def _restore_container_context(self, snapshot: tuple[GuiApi, str]) -> None: + """Restore an `__enter__`-time snapshot on `__exit__`. When the + snapshot belongs to another scope (a client container context nested + inside a server one), our own thread target -- set for the block + that is now exiting -- is dropped so later adds can't resolve + against it once the owning scope's context also exits.""" + owner_api, container_uuid = snapshot + if owner_api is not self: + self._target_container_from_thread_id.pop(threading.get_ident(), None) + owner_api._set_container_uuid(container_uuid) def _next_layout_counter(self) -> int: """Bump and return the layout-update counter. THE single home of the diff --git a/src/viser/_gui_handles.py b/src/viser/_gui_handles.py index d24cec092..a962bb3df 100644 --- a/src/viser/_gui_handles.py +++ b/src/viser/_gui_handles.py @@ -845,7 +845,7 @@ class GuiTabHandle: _id: str # Used as container ID of children. _label: str _icon: IconName | None - _container_id_restore: str | None = None + _container_id_restore: tuple[GuiApi, str] | None = None _children: dict[str, SupportsRemoveProtocol] = dataclasses.field( default_factory=dict ) @@ -871,14 +871,18 @@ def __enter__(self) -> GuiTabHandle: "This GuiTabHandle is already active as a context; it cannot " "be re-entered inside itself." ) - self._container_id_restore = self._parent._impl.gui_api._get_container_uuid() + self._container_id_restore = ( + self._parent._impl.gui_api._snapshot_container_context() + ) self._parent._impl.gui_api._set_container_uuid(self._id) return self def __exit__(self, *args) -> None: del args assert self._container_id_restore is not None - self._parent._impl.gui_api._set_container_uuid(self._container_id_restore) + self._parent._impl.gui_api._restore_container_context( + self._container_id_restore + ) self._container_id_restore = None def __post_init__(self) -> None: @@ -1363,7 +1367,7 @@ def __init__(self, _impl: _GuiHandleState[None]) -> None: ) parent._children[self._impl.uuid] = self - _container_id_restore: str | None = None + _container_id_restore: tuple[GuiApi, str] | None = None def __enter__(self) -> Self: if self._container_id_restore is not None: @@ -1375,14 +1379,14 @@ def __enter__(self) -> Self: "This GuiFolderHandle is already active as a context; it " "cannot be re-entered inside itself." ) - self._container_id_restore = self._impl.gui_api._get_container_uuid() + self._container_id_restore = self._impl.gui_api._snapshot_container_context() self._impl.gui_api._set_container_uuid(self._impl.uuid) return self def __exit__(self, *args) -> None: del args assert self._container_id_restore is not None - self._impl.gui_api._set_container_uuid(self._container_id_restore) + self._impl.gui_api._restore_container_context(self._container_id_restore) self._container_id_restore = None def remove(self) -> None: @@ -1510,21 +1514,21 @@ class GuiModalHandle: _gui_api: GuiApi _uuid: str # Used as container ID of children. - _container_uuid_restore: str | None = None + _container_uuid_restore: tuple[GuiApi, str] | None = None _children: dict[str, SupportsRemoveProtocol] = dataclasses.field( default_factory=dict ) closed: bool = False def __enter__(self) -> GuiModalHandle: - self._container_uuid_restore = self._gui_api._get_container_uuid() + self._container_uuid_restore = self._gui_api._snapshot_container_context() self._gui_api._set_container_uuid(self._uuid) return self def __exit__(self, *args) -> None: del args assert self._container_uuid_restore is not None - self._gui_api._set_container_uuid(self._container_uuid_restore) + self._gui_api._restore_container_context(self._container_uuid_restore) self._container_uuid_restore = None def __post_init__(self) -> None: diff --git a/src/viser/_scene_handles.py b/src/viser/_scene_handles.py index 03d35f0b7..89ef3aea7 100644 --- a/src/viser/_scene_handles.py +++ b/src/viser/_scene_handles.py @@ -1791,14 +1791,14 @@ def __init__(self, impl: _SceneNodeHandleState, gui_api: GuiApi, container_id: s self._gui_api._container_handle_from_uuid[self._container_id] = self def __enter__(self) -> Gui3dContainerHandle: - self._container_id_restore = self._gui_api._get_container_uuid() + self._container_id_restore = self._gui_api._snapshot_container_context() self._gui_api._set_container_uuid(self._container_id) return self def __exit__(self, *args) -> None: del args assert self._container_id_restore is not None - self._gui_api._set_container_uuid(self._container_id_restore) + self._gui_api._restore_container_context(self._container_id_restore) self._container_id_restore = None @override diff --git a/src/viser/client/src/MessageHandler.tsx b/src/viser/client/src/MessageHandler.tsx index 1b03277ca..95d08337b 100644 --- a/src/viser/client/src/MessageHandler.tsx +++ b/src/viser/client/src/MessageHandler.tsx @@ -5,7 +5,7 @@ import * as THREE from "three"; import { TextureLoader } from "three"; import { toMantineColor } from "./components/colorUtils"; -import { ViewerContext, skinnedMeshStateKey } from "./ViewerContext"; +import { ViewerContext, variantKey } from "./ViewerContext"; import { FileTransferPart, FileTransferStartDownload, @@ -20,7 +20,7 @@ import { useFrame, useThree } from "@react-three/fiber"; import { Button, Progress } from "@mantine/core"; import { IconCheck, IconDownload } from "@tabler/icons-react"; import { computeT_threeworld_world } from "./WorldTransformUtils"; -import { rootNodeTemplate, SceneNode } from "./SceneTreeState"; +import { ownerOf, rootNodeTemplate, SceneNode } from "./SceneTreeState"; import { applyGuiConfigUpdate } from "./ControlPanel/GuiState"; import { GaussianSplatsContext } from "./Splatting/GaussianSplatsHelpers"; @@ -207,7 +207,7 @@ function useMessageHandler() { // deletes the entry first, so a NEW component still claims a fresh // object and the same-name re-add race stays protected. const state = (viewerMutable.skinnedMeshState[ - skinnedMeshStateKey(message.owner, message.name) + variantKey(message.owner, message.name) ] ??= { initialized: false, claimed: false, @@ -453,7 +453,7 @@ function useMessageHandler() { // the effective one. const state = viewerMutable.skinnedMeshState[ - skinnedMeshStateKey(message.owner, message.name) + variantKey(message.owner, message.name) ]; const pose = state?.poses[message.bone_index]; if (pose === undefined) break; @@ -464,7 +464,7 @@ function useMessageHandler() { case "SetBonePositionMessage": { const state = viewerMutable.skinnedMeshState[ - skinnedMeshStateKey(message.owner, message.name) + variantKey(message.owner, message.name) ]; const pose = state?.poses[message.bone_index]; if (pose === undefined) break; @@ -761,18 +761,13 @@ function useMessageHandler() { // making the recursion a no-op). Other scopes' variants survive. case "RemoveSceneNodeMessage": { const owner = message.owner ?? ""; - for (const removed of viewer.sceneTreeActions.removeSceneNodeVariantSubtree( + // Each removed variant's bone-state entry dies with it; other + // variants of the name (including a just-promoted one) keep theirs. + for (const name of viewer.sceneTreeActions.removeSceneNodeVariantSubtree( message.name, owner, )) { - // Whatever the disposition, the removed VARIANT is gone; drop its - // own bone-state entry. Other variants of the name (including a - // just-promoted one) keep theirs. - if (removed.outcome !== "noop") { - delete viewerMutable.skinnedMeshState[ - skinnedMeshStateKey(owner, removed.name) - ]; - } + delete viewerMutable.skinnedMeshState[variantKey(owner, name)]; } return; } @@ -1080,36 +1075,23 @@ export function FrameSynchronizedMessageHandler() { []; for (const msg of processBatch) { - const msgOwner: string = (msg as { owner?: string }).owner ?? ""; const result = handleMessage(msg); if (result === undefined) continue; switch (result.kind) { case "sceneNodeAttrUpdate": { - const key = `${msgOwner}\u0000${result.targetNode}`; - const existing = attrUpdates[key]; - if (existing) { - Object.assign(existing.updates, result.updates); - } else { - attrUpdates[key] = { - name: result.targetNode, - owner: msgOwner, - updates: { ...result.updates }, - }; - } + const owner = ownerOf(msg as { owner?: string }); + const entry = (attrUpdates[ + variantKey(owner, result.targetNode) + ] ??= { name: result.targetNode, owner, updates: {} }); + Object.assign(entry.updates, result.updates); break; } case "sceneNodePropsUpdate": { - const key = `${msgOwner}\u0000${result.targetNode}`; - const existing = propsUpdates[key]; - if (existing) { - Object.assign(existing.updates, result.propsUpdates); - } else { - propsUpdates[key] = { - name: result.targetNode, - owner: msgOwner, - updates: { ...result.propsUpdates }, - }; - } + const owner = ownerOf(msg as { owner?: string }); + const entry = (propsUpdates[ + variantKey(owner, result.targetNode) + ] ??= { name: result.targetNode, owner, updates: {} }); + Object.assign(entry.updates, result.propsUpdates); break; } case "guiUpdate": diff --git a/src/viser/client/src/SceneTreeState.test.ts b/src/viser/client/src/SceneTreeState.test.ts index 01c9335b2..61ad2b857 100644 --- a/src/viser/client/src/SceneTreeState.test.ts +++ b/src/viser/client/src/SceneTreeState.test.ts @@ -232,18 +232,17 @@ describe("removeSceneNodeVariantSubtree", () => { }; } - const outcomes = actions.removeSceneNodeVariantSubtree("/p", ""); + const removedNames = actions.removeSceneNodeVariantSubtree("/p", ""); for (const name of ["/p", "/p/a", "/p/a/b"]) { expect(store.get(name)).toBeUndefined(); expect(nodeRefFromName[name]).toBeUndefined(); expect(nodePoseData[name]).toBeUndefined(); } - expect(outcomes.map((o) => o.outcome)).toEqual([ - "removed-effective", - "removed-effective", - "removed-effective", - ]); + expect(removedNames).toEqual(["/p", "/p/a", "/p/a/b"]); + // Later per-descendant remove messages (current servers enumerate them) + // no-op silently. + expect(actions.removeSceneNodeVariantSubtree("/p/a", "")).toEqual([]); }); it("stays scope-local: other-owner descendants survive and shadowed ones promote", () => { @@ -254,16 +253,13 @@ describe("removeSceneNodeVariantSubtree", () => { actions.addSceneNode(clientVariant); // Shadows the broadcast one. actions.addSceneNode(makeFrameMessage("/p/mine", "7")); - const outcomes = actions.removeSceneNodeVariantSubtree("/p", ""); - const byName = Object.fromEntries(outcomes.map((o) => [o.name, o.outcome])); + const removedNames = actions.removeSceneNodeVariantSubtree("/p", ""); - expect(byName["/p"]).toBe("removed-effective"); - // The broadcast variant of /p/shared was PARKED (client shadows it); - // removing it leaves the client variant effective. - expect(byName["/p/shared"]).toBe("removed-shadow"); + // Both broadcast variants went away: /p entirely, /p/shared's PARKED + // copy (the client variant shadows it and stays effective). + expect(removedNames).toEqual(["/p", "/p/shared"]); expect(store.get("/p/shared")!.message).toBe(clientVariant); // The client-only descendant is untouched by the broadcast sweep. - expect(byName["/p/mine"]).toBe("noop"); expect(store.get("/p/mine")).toBeDefined(); }); }); diff --git a/src/viser/client/src/SceneTreeState.ts b/src/viser/client/src/SceneTreeState.ts index 63b6e9065..6e28d00ce 100644 --- a/src/viser/client/src/SceneTreeState.ts +++ b/src/viser/client/src/SceneTreeState.ts @@ -42,8 +42,8 @@ export type ShadowedVariant = { * (server.scene), anything else is a per-client scope. Old recordings * predate the field; a missing field -- or a missing MESSAGE, e.g. an * interaction racing a node removal -- means broadcast. */ -export function ownerOf(message: SceneNodeMessage | undefined): string { - return (message as { owner?: string } | undefined)?.owner ?? ""; +export function ownerOf(message: { owner?: string } | undefined): string { + return message?.owner ?? ""; } function isVirtual(message: SceneNodeMessage): boolean { @@ -136,6 +136,19 @@ export function createSceneTreeActions( // on the hot pose/visibility paths. let shadowedSlotCount = 0; + /** Pre-order names of `name`'s subtree, collected BEFORE any removal: + * children lists die with their nodes. Shared by variant-subtree and + * whole-node removal. */ + function collectSubtreeNames(name: string): string[] { + const names: string[] = []; + function collect(nodeName: string) { + names.push(nodeName); + store.get(nodeName)?.children.forEach(collect); + } + collect(name); + return names; + } + /** Remove `name` from its parent's `children` list, recording the change * in `updates`. Shared by variant removal and recursive removal. */ function dropFromParentChildren( @@ -276,11 +289,11 @@ export function createSceneTreeActions( name: string, owner: string, ): "removed-effective" | "promoted" | "removed-shadow" | "noop" => { + // Absent names are routine, not an anomaly: servers enumerate one + // remove per descendant, and the subtree recursion (see + // removeSceneNodeVariantSubtree) has usually removed them already. const node = store.get(name); - if (node === undefined) { - console.log(`(OK) Skipping variant removal for ${name}`); - return "noop"; - } + if (node === undefined) return "noop"; if (ownerOf(node.message) === owner) { const shadowed = node.shadowed; if (shadowed !== undefined) { @@ -334,26 +347,14 @@ export function createSceneTreeActions( * older servers contain a single non-enumerated remove per subtree -- * without the recursion their descendants would linger forever, along * with their pose/ref side state. Still scope-local: other-owner - * variants of descendant names are untouched. Returns the disposition - * per name so callers can clean per-variant side state. */ - removeSceneNodeVariantSubtree: ( - name: string, - owner: string, - ): { - name: string; - outcome: "removed-effective" | "promoted" | "removed-shadow" | "noop"; - }[] => { - // Collect before removing: children lists die with their nodes. - const names: string[] = []; - function collect(nodeName: string) { - names.push(nodeName); - store.get(nodeName)?.children.forEach(collect); - } - collect(name); - return names.map((n) => ({ - name: n, - outcome: actions.removeSceneNodeVariant(n, owner), - })); + * variants of descendant names are untouched. Returns the names whose + * variant actually went away, so callers can clean per-variant side + * state. */ + removeSceneNodeVariantSubtree: (name: string, owner: string): string[] => { + if (store.get(name) === undefined) return []; + return collectSubtreeNames(name).filter( + (n) => actions.removeSceneNodeVariant(n, owner) !== "noop", + ); }, /** Route a node-keyed state update by owner: returns false when it @@ -404,15 +405,7 @@ export function createSceneTreeActions( removeSceneNode: (name: string) => { // Remove this scene node and all children. - const removeNames: string[] = []; - function findChildrenRecursive(nodeName: string) { - removeNames.push(nodeName); - const node = store.get(nodeName); - if (node) { - node.children.forEach(findChildrenRecursive); - } - } - findChildrenRecursive(name); + const removeNames = collectSubtreeNames(name); const updates: Record = {}; removeNames.forEach((removeName) => { diff --git a/src/viser/client/src/ViewerContext.ts b/src/viser/client/src/ViewerContext.ts index 73e77c06f..45fb6cdbf 100644 --- a/src/viser/client/src/ViewerContext.ts +++ b/src/viser/client/src/ViewerContext.ts @@ -78,7 +78,7 @@ export type ViewerMutable = { rootWxyzAtCapture: [number, number, number, number]; } | null; - // Skinned mesh state, keyed PER VARIANT via skinnedMeshStateKey(owner, + // Skinned mesh state, keyed PER VARIANT via variantKey(owner, // name): each scope's variant of a name owns independent bone state, so // bone updates for a shadowed variant accumulate without corrupting the // effective one, and promotion finds the promoted variant's state intact. @@ -105,12 +105,11 @@ export type ViewerMutable = { nodePoseData: NodePoseDataMap; }; -/** skinnedMeshState key for one scope's variant of a scene node. Owners are - * "" (broadcast) or a client id, so NUL can't collide with a real owner. */ -export function skinnedMeshStateKey( - owner: string | undefined, - name: string, -): string { +/** Composite key for one scope's variant of a scene node, used wherever + * per-variant side state lives in a flat map (skinnedMeshState, the message + * batcher's parked updates). Owners are "" (broadcast) or a client id, so + * NUL can't collide with a real owner. */ +export function variantKey(owner: string | undefined, name: string): string { return `${owner ?? ""}\u0000${name}`; } diff --git a/src/viser/client/src/mesh/SkinnedMesh.tsx b/src/viser/client/src/mesh/SkinnedMesh.tsx index 7c9d43ec2..a59adfcf3 100644 --- a/src/viser/client/src/mesh/SkinnedMesh.tsx +++ b/src/viser/client/src/mesh/SkinnedMesh.tsx @@ -3,11 +3,7 @@ import * as THREE from "three"; import { ViserStandardMeshMaterial, ShadowSkinnedMesh } from "./MeshUtils"; import { SkinnedMeshMessage } from "../WebsocketMessages"; import { OutlinesIfHovered } from "../OutlinesIfHovered"; -import { - ViewerContext, - ViewerMutable, - skinnedMeshStateKey, -} from "../ViewerContext"; +import { ViewerContext, ViewerMutable, variantKey } from "../ViewerContext"; import { useFrame } from "@react-three/fiber"; import { normalizeScale } from "../utils/normalizeScale"; @@ -119,7 +115,7 @@ export const SkinnedMesh = React.forwardRef< // Bone state is keyed per VARIANT: this mounted instance renders exactly // one scope's variant, and must never read/claim the entry of a same-name // variant from the other scope. - const stateKey = skinnedMeshStateKey(message.owner, message.name); + const stateKey = variantKey(message.owner, message.name); // Clean up geometry and skeleton when they change (they're created together). React.useEffect(() => { diff --git a/src/viser/infra/_messages.py b/src/viser/infra/_messages.py index a4d8cb333..681ebb80f 100644 --- a/src/viser/infra/_messages.py +++ b/src/viser/infra/_messages.py @@ -14,6 +14,7 @@ Dict, List, Optional, + Tuple, Type, TypeVar, Union, @@ -170,6 +171,25 @@ def get_type_hints_cached(cls: Type[Any]) -> Dict[str, Any]: return get_type_hints(cls) # type: ignore +@functools.lru_cache(maxsize=None) +def wire_field_names(cls: Type[Any]) -> Tuple[str, ...]: + """Names of the fields a message type puts on the wire: declared, + type-hinted dataclass fields, in declaration order. THE single + definition of the wire field set -- the serializer and the TypeScript + interface generator must never disagree on it. Declared fields (not + ``vars()``) so that non-init defaulted fields (e.g. the scene messages' + owner/virtual stamps) are included even before first assignment.""" + hints = get_type_hints_cached(cls) + return tuple(f.name for f in dataclasses.fields(cls) if f.name in hints) + + +@functools.lru_cache(maxsize=None) +def _non_init_field_names(cls: Type[Any]) -> Tuple[str, ...]: + """Dataclass fields excluded from ``__init__`` (assigned post-construction + on deserialization). Empty for most message types.""" + return tuple(f.name for f in dataclasses.fields(cls) if not f.init) + + class Message(abc.ABC): """Base message type for server/client communication.""" @@ -222,18 +242,11 @@ def as_serializable_dict( Otherwise, arrays are inlined as memoryviews in the returned dict.""" message_type = type(self) hints = get_type_hints_cached(message_type) - # Iterate DECLARED dataclass fields (not vars(self)): non-init - # defaulted fields -- e.g. the scene messages' owner/virtual stamps - # -- live on the class until assigned, so vars() would silently omit - # them from the wire even though the generated TypeScript declares - # them as required. The hints filter still excludes anything - # unannotated. out = { name: _prepare_for_serialization( getattr(self, name), hints[name], binary_buffers ) - for name in message_type.__dataclass_fields__ - if name in hints + for name in wire_field_names(message_type) } out["type"] = message_type.__name__ return out @@ -264,12 +277,13 @@ def deserialize(cls, message: bytes) -> Message: # declared init=False so defaulted fields can follow subclasses' # positional ones on Python 3.8) can't be passed to __init__; strip # them out and assign after construction so the serialize -> - # deserialize round trip stays lossless. - fields = message_type.__dataclass_fields__ + # deserialize round trip stays lossless. The per-class name tuple is + # cached: most message types have none, and this runs per inbound + # message. non_init = { k: message_kwargs.pop(k) - for k in list(message_kwargs) - if k in fields and not fields[k].init + for k in _non_init_field_names(message_type) + if k in message_kwargs } message = message_type(**message_kwargs) for k, v in non_init.items(): diff --git a/src/viser/infra/_typescript_interface_gen.py b/src/viser/infra/_typescript_interface_gen.py index 9723eb5bb..f020dbe9f 100644 --- a/src/viser/infra/_typescript_interface_gen.py +++ b/src/viser/infra/_typescript_interface_gen.py @@ -21,7 +21,7 @@ except ImportError: LiteralAlt = Literal # type: ignore -from ._messages import Message +from ._messages import Message, wire_field_names _raw_type_mapping = { bool: "boolean", @@ -252,13 +252,10 @@ def generate_typescript_interfaces(message_cls: Type[Message]) -> str: out_lines.append(f"export interface {cls.__name__} " + "{") out_lines.append(f' type: "{cls.__name__}";') - field_names = set([f.name for f in dataclasses.fields(cls)]) # type: ignore - for name, typ in get_type_hints(cls, include_extras=True).items(): - if name in field_names: - typ = _get_ts_type(typ) - else: - continue - out_lines.append(f" {name}: {typ};") + # Same field set (and order) the serializer puts on the wire. + hints = get_type_hints(cls, include_extras=True) + for name in wire_field_names(cls): + out_lines.append(f" {name}: {_get_ts_type(hints[name])};") out_lines.append("}") out_lines.append("") diff --git a/tests/e2e/test_cross_scope_handles.py b/tests/e2e/test_cross_scope_handles.py index 8959da06c..29a969402 100644 --- a/tests/e2e/test_cross_scope_handles.py +++ b/tests/e2e/test_cross_scope_handles.py @@ -582,8 +582,6 @@ def test_drag_end_routes_to_owner_after_mid_drag_removal( Regression: it was re-derived from the live store per phase, so removing the node mid-drag stamped the end with the broadcast owner ("") and the client scope's ``on_drag`` end callback never fired.""" - import viser as viser_module - client = get_client_handle(viser_server) client.camera.position = (0.0, 0.0, 4.0) client.camera.look_at = (0.0, 0.0, 0.0) @@ -593,7 +591,7 @@ def test_drag_end_routes_to_owner_after_mid_drag_removal( ended = threading.Event() @box.on_drag("left") - def _(event: viser_module.SceneNodeDragEvent) -> None: + def _(event: viser.SceneNodeDragEvent) -> None: if event.phase == "start": started.set() elif event.phase == "end": @@ -621,7 +619,6 @@ def test_pointer_filters_cleared_on_reconnect( client's filter entry behind forever -- its owner id can never send a disable -- keeping click gestures engaged.""" client = get_client_handle(viser_server) - old_id = client.client_id @client.scene.on_pointer_event(event_type="click") def _(event) -> None: @@ -634,18 +631,13 @@ def _(event) -> None: # Kick the connection; the page's worker auto-reconnects WITHOUT a # reload, so all frontend state survives except what the reconnect - # path deliberately resets. The browser comes back as a new client id, - # and nothing re-registers a pointer callback -- so no filter may - # survive the reconnect. + # path deliberately resets. Nothing re-registers a pointer callback, so + # no filter may survive -- and since only the reconnect path clears + # filters, this wait doubles as the reconnect wait. viser_server._websock_server.disconnect_all_clients() - deadline = time.time() + 10.0 - while not any(cid != old_id for cid in viser_server.get_clients().keys()): - assert time.time() < deadline, "browser never reconnected" - time.sleep(0.05) - viser_page.wait_for_function( "() => window.__viserPointer?.hasSceneClickFilter() === false", - timeout=5_000, + timeout=10_000, ) From 6f90c40a2554fdebbba4ac64c03d7c185520117a Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Mon, 10 Aug 2026 08:01:02 +0000 Subject: [PATCH 18/27] Address final review pass: disconnect drag drain, task-local contexts Five findings from a second full-PR review, the two correctness ones pinned by tests verified to fail pre-fix: - Disconnect teardown drains in-flight drags from BOTH scopes. Owner-scoped dispatch routes drags on client-scoped nodes to the client's own SceneApi, so draining only the server's map silently skipped the synthesized on_drag_end for exactly the per-client nodes this PR introduces (e2e: real mid-drag disconnect). - GUI container-context ownership moved from thread-keyed state to a ContextVar. Async callbacks interleave on one event-loop thread; a `with` block suspended at an await leaked its marker into unrelated callbacks, turning previously-safe interleavings into spurious cross-scope RuntimeErrors. Each asyncio task runs in a copied Context, so the marker is now visible only inside the block; the copy-on-write dict keeps sibling tasks from observing each other's mutations. - The dead-connection push warning exempts removal messages (releasing a departed client's elements in on_client_disconnect is ordinary cleanup, and the buffer is already closed by then) and no longer claims further messages are "dropped silently" (they never were). - Buffer-inspection helpers deduplicated: test_scene_scopes and test_modifier_filtering now import them from tests/infra_utils instead of keeping private copies. - SceneTreeTable rows fold three per-row store subscriptions (message type, client-local badge, virtual dimming) into one tuple-valued selector with shallow equality. --- src/viser/_gui_api.py | 65 +++++++++++++------ src/viser/_viser.py | 13 ++-- .../src/ControlPanel/SceneTreeTable.tsx | 31 ++++----- src/viser/infra/_async_message_buffer.py | 17 +++-- tests/e2e/test_cross_scope_handles.py | 41 ++++++++++++ tests/test_gui_cross_scope.py | 28 ++++++++ tests/test_modifier_filtering.py | 16 ++--- tests/test_scene_scopes.py | 44 +++++++------ 8 files changed, 180 insertions(+), 75 deletions(-) diff --git a/src/viser/_gui_api.py b/src/viser/_gui_api.py index 361c17ac8..d5e45d0c2 100644 --- a/src/viser/_gui_api.py +++ b/src/viser/_gui_api.py @@ -12,6 +12,7 @@ from asyncio import AbstractEventLoop from collections.abc import Mapping from concurrent.futures import ThreadPoolExecutor +from contextvars import ContextVar from pathlib import Path from typing import ( TYPE_CHECKING, @@ -196,6 +197,20 @@ class _RootGuiContainer: _children: dict[str, SupportsRemoveProtocol] +_context_owner_by_server: ContextVar[Dict[int, "GuiApi"]] = ContextVar( + "viser_gui_context_owner_by_server", default={} +) +"""Which GuiApi owns the active (non-root) container context, keyed by +``id()`` of the owning server's GuiApi. A ContextVar rather than +thread-keyed state: asyncio callbacks interleave on one event-loop thread, +and a ``with`` block suspended at an ``await`` must not leak its container +context into unrelated callbacks -- each asyncio task runs in a copied +Context, so only code inside the block sees the marker. Sync code keeps +working like before (a thread has its own implicit Context). Values are +treated as IMMUTABLE -- every update installs a fresh dict -- because +copied Contexts share the mapping object itself.""" + + _global_order_counter = 0 @@ -254,13 +269,6 @@ def __init__( self._owner = owner """Entity that owns this API.""" self._target_container_from_thread_id = {} - # Which GuiApi owns each thread's active (non-root) container - # context. Only the SERVER-scope instance's dict is consulted -- - # storing the marker there scopes it per server for free, so - # contexts on unrelated ViserServers can't interfere. Used to make - # cross-scope container nesting directional instead of a silent - # misplace (see _get_container_uuid). - self._context_owner_from_thread_id: dict[int, GuiApi] = {} self._thread_executor = thread_executor self._event_loop = event_loop @@ -667,13 +675,12 @@ def _get_container_uuid(self) -> str: """Get container ID associated with the current thread. When a container context from a DIFFERENT GuiApi of the same server - is active on this thread, nesting is directional: a client-scope add - inside a server-scope container targets the server's container, while - the reverse raises -- silently placing the element at this scope's - root (the historical behavior) hid the mistake.""" - owner_api = self._root_server().gui._context_owner_from_thread_id.get( - threading.get_ident() - ) + is active in the current Context, nesting is directional: a + client-scope add inside a server-scope container targets the + server's container, while the reverse raises -- silently placing the + element at this scope's root (the historical behavior) hid the + mistake.""" + owner_api = self._context_owner() if owner_api is not None and owner_api is not self: from ._viser import ViserServer @@ -770,18 +777,36 @@ def _root_server(self): else self._owner._viser_server ) + def _context_owner(self) -> GuiApi | None: + """The GuiApi owning the current Context's active (non-root) + container context on this API's server, if any.""" + return _context_owner_by_server.get().get(id(self._root_server().gui)) + + def _set_context_owner(self, owner: GuiApi | None) -> None: + """Install (or clear, with None) this server's context-owner marker + in the current Context. Copy-on-write: copied Contexts (sibling + asyncio tasks) share the mapping object, so it is never mutated.""" + key = id(self._root_server().gui) + current = _context_owner_by_server.get() + if owner is None: + if key in current: + updated = dict(current) + del updated[key] + _context_owner_by_server.set(updated) + else: + _context_owner_by_server.set({**current, key: owner}) + def _set_container_uuid(self, container_uuid: str) -> None: """Set container ID associated with the current thread, tracking which GuiApi currently owns an active (non-root) container context so cross-scope nesting stays directional (see _get_container_uuid).""" thread_id = threading.get_ident() self._target_container_from_thread_id[thread_id] = container_uuid - markers = self._root_server().gui._context_owner_from_thread_id if container_uuid == "root": - if markers.get(thread_id) is self: - del markers[thread_id] + if self._context_owner() is self: + self._set_context_owner(None) else: - markers[thread_id] = self + self._set_context_owner(self) def _snapshot_container_context(self) -> tuple[GuiApi, str]: """Snapshot the active container context for a `with` block to @@ -793,9 +818,7 @@ def _snapshot_container_context(self) -> tuple[GuiApi, str]: removed-while-open container always has within a single scope). Raises for disallowed nesting directions, via _get_container_uuid.""" container_uuid = self._get_container_uuid() - owner_api = self._root_server().gui._context_owner_from_thread_id.get( - threading.get_ident() - ) + owner_api = self._context_owner() return (owner_api if owner_api is not None else self, container_uuid) def _restore_container_context(self, snapshot: tuple[GuiApi, str]) -> None: diff --git a/src/viser/_viser.py b/src/viser/_viser.py index f4613d14b..ecc3ad4fd 100644 --- a/src/viser/_viser.py +++ b/src/viser/_viser.py @@ -1155,13 +1155,18 @@ async def _(conn: infra.WebsockClientConnection) -> None: # Drop any in-flight drag entries for this client; the # corresponding ``phase="end"`` will never arrive, so without # this the active-drag map leaks an entry per dropped drag and - # ``on_drag_end`` is silently skipped. The popped handle is - # passed in explicitly so the synthesized end events can still - # resolve ``event.client`` without the client being publicly - # listed. + # ``on_drag_end`` is silently skipped. BOTH scopes: owner-scoped + # dispatch routes drags on client-scoped nodes to the client's + # own SceneApi, so its map needs the same drain as the server's. + # The popped handle is passed in explicitly so the synthesized + # end events can still resolve ``event.client`` without the + # client being publicly listed. await self.scene._drop_active_drags_for_client( cast(infra.ClientId, conn.client_id), event_client=handle ) + await handle.scene._drop_active_drags_for_client( + cast(infra.ClientId, conn.client_id), event_client=handle + ) await self._dispatch_client_callbacks(disconnect_cbs, handle) # Start the server. diff --git a/src/viser/client/src/ControlPanel/SceneTreeTable.tsx b/src/viser/client/src/ControlPanel/SceneTreeTable.tsx index fdfed05bb..d32a36be3 100644 --- a/src/viser/client/src/ControlPanel/SceneTreeTable.tsx +++ b/src/viser/client/src/ControlPanel/SceneTreeTable.tsx @@ -609,25 +609,22 @@ const SceneTreeTableRow = React.memo(function SceneTreeTableRow(props: { (node) => node?.children, shallowArrayEqual, ); - const messageType = viewer.useSceneTree( + // One subscription for the row's message-derived facts (scenes can hold + // thousands of rows; every extra per-row selector multiplies the + // subscriber walk on each store write). Variant provenance: client-local + // variants get a badge (they exist only on THIS client, possibly + // shadowing a server node of the same name), and virtual anchors -- + // auto-created ancestor frames that render nothing -- are de-emphasized. + const [messageType, nodeIsClientLocal, nodeIsVirtual] = viewer.useSceneTree( props.nodeName, - (node) => node?.message.type, - ); - // Variant provenance for the effective node: client-local variants get a - // badge (they exist only on THIS client, possibly shadowing a server node - // of the same name), and virtual anchors -- auto-created ancestor frames - // that render nothing -- are de-emphasized. - const nodeIsClientLocal = - viewer.useSceneTree( - props.nodeName, - (node) => node !== undefined && ownerOf(node.message) !== "", - ) ?? false; - const nodeIsVirtual = - viewer.useSceneTree( - props.nodeName, - (node) => + (node) => + [ + node?.message.type, + node !== undefined && ownerOf(node.message) !== "", (node?.message as { virtual?: boolean } | undefined)?.virtual ?? false, - ) ?? false; + ] as const, + shallowArrayEqual, + ); const expandable = (childrenName?.length ?? 0) > 0; const [expanded, { toggle: toggleExpanded }] = useDisclosure(false); diff --git a/src/viser/infra/_async_message_buffer.py b/src/viser/infra/_async_message_buffer.py index e6f94fcb7..a9e634683 100644 --- a/src/viser/infra/_async_message_buffer.py +++ b/src/viser/infra/_async_message_buffer.py @@ -64,16 +64,23 @@ def push(self, message: Message) -> None: # buffer this means the client disconnected, and anything pushed via # a stale handle silently accumulates forever. Warn ONCE per buffer # so the dead-handle write is diagnosable without spamming loops - # that keep animating a departed client's elements. - if self.done and not self._warned_push_after_done: + # that keep animating a departed client's elements. Removal messages + # are exempt: releasing elements of a departed client (e.g. inside + # on_client_disconnect, which runs after the buffer is closed) is + # ordinary cleanup, and a remove on a dead connection is a benign + # no-op rather than a leak in the making. + if ( + self.done + and not self._warned_push_after_done + and message.lifecycle_phase != "remove" + ): self._warned_push_after_done = True import warnings warnings.warn( f"Queued a {type(message).__name__} on a closed connection " - "(e.g. via a handle owned by a disconnected client); it will " - "never be delivered. Further messages on this connection " - "will be dropped silently.", + "(e.g. via a handle owned by a disconnected client, or after " + "the server stopped); it will never be delivered.", stacklevel=4, ) diff --git a/tests/e2e/test_cross_scope_handles.py b/tests/e2e/test_cross_scope_handles.py index 29a969402..ff6a1801e 100644 --- a/tests/e2e/test_cross_scope_handles.py +++ b/tests/e2e/test_cross_scope_handles.py @@ -702,3 +702,44 @@ def add_skinned(scene_api): timeout=5_000, ) assert viser_page.evaluate(js_entry, "") == [5, 6, 7] + + +def test_disconnect_mid_drag_fires_end_for_client_scope( + two_client_setup: dict, +) -> None: + """Regression: the disconnect teardown drained in-flight drags only from + the server SceneApi, but owner-scoped dispatch routes drags on + client-scoped nodes to the CLIENT's own scope -- so their synthesized + ``phase="end"`` (which lets apps release per-drag state) never fired.""" + server = two_client_setup["server"] + page1: Page = two_client_setup["page1"] + client1 = two_client_setup["client1"] + + client1.camera.position = (0.0, 0.0, 4.0) + client1.camera.look_at = (0.0, 0.0, 0.0) + box = client1.scene.add_box("/dragme", dimensions=(4.0, 4.0, 0.2)) + started = threading.Event() + ended = threading.Event() + + @box.on_drag("left") + def _(event: viser.SceneNodeDragEvent) -> None: + if event.phase == "start": + started.set() + elif event.phase == "end": + ended.set() + + wait_for_scene_node(page1, "/dragme") + cx, cy = canvas_center(page1) + page1.mouse.move(cx, cy) + page1.mouse.down() + page1.mouse.move(cx + 80, cy + 40, steps=10) + assert started.wait(timeout=5.0), "drag start never reached the client scope" + + # Disconnect mid-drag: the wire "end" will never arrive, so the + # teardown must synthesize it for the CLIENT scope's registry. + page1.context.close() + deadline = time.time() + 5.0 + while client1.client_id in server._connected_clients: + assert time.time() < deadline, "client 1 never disconnected" + time.sleep(0.05) + assert ended.wait(timeout=5.0), "synthesized drag end never fired" diff --git a/tests/test_gui_cross_scope.py b/tests/test_gui_cross_scope.py index 6ada22d3c..cacb7f4a3 100644 --- a/tests/test_gui_cross_scope.py +++ b/tests/test_gui_cross_scope.py @@ -261,6 +261,34 @@ def test_server_adds_ok_after_nested_client_context_exits( assert cli_button._impl.parent_container_id == srv_folder._impl.uuid +def test_container_context_is_task_local(server: viser.ViserServer) -> None: + """Async callbacks interleave on ONE event-loop thread, each running in + a copied Context. Regression: a thread-keyed context marker made a + `with` block suspended at an await leak into unrelated callbacks -- a + sibling task's server add spuriously raised the cross-scope error. + Contexts copied before the block entered must see no marker.""" + import contextvars + + client_a = make_synthetic_client(server, 0) + client_b = make_synthetic_client(server, 1) + sibling = contextvars.copy_context() # Stands in for another asyncio task. + + folder = client_a.gui.add_folder("F") + folder.__enter__() + try: + # Inside the block, cross-scope nesting is live... + inside = client_a.gui.add_button("in") + assert inside._impl.parent_container_id == folder._impl.uuid + # ...but the sibling context is unaffected: no spurious raise, no + # cross-scope capture. + srv = sibling.run(server.gui.add_button, "s") + assert srv._impl.parent_container_id == "root" + other = sibling.run(client_b.gui.add_button, "b") + assert other._impl.parent_container_id == "root" + finally: + folder.__exit__(None, None, None) + + def test_no_stale_target_after_server_context_exits( server: viser.ViserServer, ) -> None: diff --git a/tests/test_modifier_filtering.py b/tests/test_modifier_filtering.py index 88bca3b81..5cf5cf032 100644 --- a/tests/test_modifier_filtering.py +++ b/tests/test_modifier_filtering.py @@ -313,7 +313,11 @@ def test_pointer_event_scopes_coexist() -> None: (gesture engagement uses the union), so registrations in one scope never clear the other's, and each scope's enable messages ride its own connection stamped with its own owner.""" - from .infra_utils import make_synthetic_client + from .infra_utils import ( + broadcast_messages, + client_buffer_messages, + make_synthetic_client, + ) server = viser.ViserServer() client = make_synthetic_client(server, 5) @@ -333,14 +337,12 @@ def _server_cb(event: viser.SceneClickEvent) -> None: # Each scope's enable message is stamped with its own owner. server_enables = [ msg - for msg in server._websock_server._broadcast_buffer.message_from_id.values() + for msg in broadcast_messages(server) if isinstance(msg, _messages.ScenePointerEnableMessage) ] client_enables = [ msg - for msg in ( - client._websock_connection._state.message_buffer.message_from_id.values() - ) + for msg in client_buffer_messages(client) if isinstance(msg, _messages.ScenePointerEnableMessage) ] assert server_enables and all(msg.owner == "" for msg in server_enables) @@ -354,9 +356,7 @@ def _server_cb(event: viser.SceneClickEvent) -> None: assert len(server.scene._scene_pointer_cb) == 1 latest_client_enable = [ msg - for msg in ( - client._websock_connection._state.message_buffer.message_from_id.values() - ) + for msg in client_buffer_messages(client) if isinstance(msg, _messages.ScenePointerEnableMessage) ][-1] assert latest_client_enable.modifiers == () diff --git a/tests/test_scene_scopes.py b/tests/test_scene_scopes.py index 9f68020ed..507573196 100644 --- a/tests/test_scene_scopes.py +++ b/tests/test_scene_scopes.py @@ -22,9 +22,12 @@ import viser import viser._client_autobuild from viser import _messages as m -from viser._viser import ClientHandle -from .infra_utils import make_synthetic_client +from .infra_utils import ( + broadcast_messages, + client_buffer_messages, + make_synthetic_client, +) @pytest.fixture() @@ -35,27 +38,17 @@ def server() -> Generator[viser.ViserServer, None, None]: server.stop() -def _client_buffer_messages(client: ClientHandle) -> list[m.Message]: - return list( - client._websock_connection._state.message_buffer.message_from_id.values() - ) - - -def _broadcast_messages(server: viser.ViserServer) -> list[m.Message]: - return list(server._websock_server._broadcast_buffer.message_from_id.values()) - - # --------------------------------------------------------------------------- # Owner stamping. # --------------------------------------------------------------------------- -def test_owner_stamped_on_broadcast_messages(server: viser.ViserServer) -> None: +def test_owner_stamped_onbroadcast_messages(server: viser.ViserServer) -> None: handle = server.scene.add_icosphere("/ball", radius=0.1, position=(1.0, 0.0, 0.0)) handle.visible = False handle.color = (10, 20, 30) - for msg in _broadcast_messages(server): + for msg in broadcast_messages(server): if hasattr(msg, "owner"): assert msg.owner == "", f"{type(msg).__name__} not broadcast-stamped" @@ -68,7 +61,7 @@ def test_owner_stamped_on_client_messages(server: viser.ViserServer) -> None: handle.remove() scene_messages = [ - msg for msg in _client_buffer_messages(client) if hasattr(msg, "owner") + msg for msg in client_buffer_messages(client) if hasattr(msg, "owner") ] assert len(scene_messages) > 0 for msg in scene_messages: @@ -134,7 +127,7 @@ def test_virtual_anchors_created_per_scope(server: viser.ViserServer) -> None: creates = { msg.name: msg - for msg in _client_buffer_messages(client) + for msg in client_buffer_messages(client) if isinstance(msg, m._CreateSceneNodeMessage) } assert creates["/parent"].virtual is True @@ -154,7 +147,7 @@ def test_real_add_supersedes_virtual_anchor(server: viser.ViserServer) -> None: # The real frame's create message is not virtual. creates = [ msg - for msg in _broadcast_messages(server) + for msg in broadcast_messages(server) if isinstance(msg, m._CreateSceneNodeMessage) and msg.name == "/a" ] assert len(creates) == 1 # Redundancy key replaced the anchor's create. @@ -215,7 +208,7 @@ def test_remove_messages_enumerate_descendants(server: viser.ViserServer) -> Non # After GC/redundancy, creates are gone; a remove tombstone per name. removed_names = { msg.name - for msg in _broadcast_messages(server) + for msg in broadcast_messages(server) if isinstance(msg, m.RemoveSceneNodeMessage) } assert {"/p", "/p/a", "/p/a/b"} <= removed_names @@ -228,7 +221,7 @@ def test_remove_messages_enumerate_descendants(server: viser.ViserServer) -> Non def test_client_scene_construction_sends_nothing(server: viser.ViserServer) -> None: client = make_synthetic_client(server, 0) - assert len(_client_buffer_messages(client)) == 0, ( + assert len(client_buffer_messages(client)) == 0, ( "client SceneApi construction queued messages; it must not re-add " "/WorldAxes (or anything else) over the per-client connection" ) @@ -276,7 +269,7 @@ def test_stamped_messages_roundtrip_through_serialization( client = make_synthetic_client(server, 3) client.scene.add_frame("/rt/leaf", show_axes=False) # Anchor + real node. - messages = _client_buffer_messages(client) + messages = client_buffer_messages(client) assert len(messages) > 0 for msg in messages: serialized = msg.as_serializable_dict() @@ -316,3 +309,14 @@ def test_dead_connection_write_warns_once(server: viser.ViserServer) -> None: handle.position = (2.0, 0.0, 0.0) # Second write: no second warning. dead_warnings = [w for w in caught if "closed connection" in str(w.message)] assert len(dead_warnings) == 1 + + # Removal messages are exempt: releasing a departed client's elements + # (e.g. inside on_client_disconnect, which runs after the buffer is + # closed) is ordinary cleanup, not a leak in the making. + client2 = make_synthetic_client(server, 1) + handle2 = client2.scene.add_icosphere("/theirs", radius=0.1) + client2._websock_connection._state.message_buffer.set_done() + with warnings_module.catch_warnings(record=True) as caught: + warnings_module.simplefilter("always") + handle2.remove() + assert not any("closed connection" in str(w.message) for w in caught) From a22b2ad13896066f63b8c61b19aa23f892cdd555 Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Mon, 10 Aug 2026 08:43:05 +0000 Subject: [PATCH 19/27] Fix review pass 3 findings: per-name owner routing, teardown races - routeShadowedUpdate always performs the per-name owner check. The global shadow-count fast path returned early whenever no shadow slot existed anywhere, so a stale other-scope update (e.g. a write through a handle whose variant was just removed) was applied to the surviving scope's variant instead of being dropped -- teleporting a client's node from a broadcast message. The counter is deleted outright; the routing cost is one map lookup per node-keyed message. Pinned by a store unit test verified to fail against the counter version. - The disconnect teardown's cross-scope release now tombstones a subtree BEFORE detaching it from the server parent, and the remove() paths' registry/parent pops tolerate entries the (unlocked, bookkeeping-only) teardown already purged -- a user thread racing disconnect with element.remove() previously could hit KeyError from the non-defaulted pops or the strict parent resolve. --- src/viser/_gui_api.py | 6 ++++- src/viser/_gui_handles.py | 30 ++++++++++++++------- src/viser/client/src/SceneTreeState.test.ts | 22 +++++++++++++++ src/viser/client/src/SceneTreeState.ts | 22 --------------- 4 files changed, 48 insertions(+), 32 deletions(-) diff --git a/src/viser/_gui_api.py b/src/viser/_gui_api.py index d5e45d0c2..52124e528 100644 --- a/src/viser/_gui_api.py +++ b/src/viser/_gui_api.py @@ -733,12 +733,16 @@ def _release_cross_scope_nesting(self) -> None: cascade a remove into a dead connection.""" while self._handles_in_foreign_containers: uuid, handle = self._handles_in_foreign_containers.popitem() + # Tombstone BEFORE detaching from the server parent: a user + # thread racing us with handle.remove() then bails at the + # already-removed check instead of finding a half-detached + # tree (its registry pops are tolerant of ours regardless). + self._tombstone_subtree(handle) parent = self._root_server().gui._container_handle_from_uuid.get( handle._impl.parent_container_id ) if parent is not None: parent._children.pop(uuid, None) - self._tombstone_subtree(handle) def _tombstone_subtree(self, handle: Any) -> None: """Recursively mark a cross-nested subtree removed and purge it from diff --git a/src/viser/_gui_handles.py b/src/viser/_gui_handles.py index a962bb3df..09c296626 100644 --- a/src/viser/_gui_handles.py +++ b/src/viser/_gui_handles.py @@ -182,12 +182,18 @@ def remove(self) -> None: gui_api = self._impl.gui_api gui_api._websock_interface.queue_message(GuiRemoveMessage(self._impl.uuid)) - parent = gui_api._resolve_container_handle(self._impl.parent_container_id) - parent._children.pop(self._impl.uuid) + # Tolerant detach: a disconnect teardown racing this remove() may + # have already purged the parent and these registry entries + # (bookkeeping-only, no lock). + try: + parent = gui_api._resolve_container_handle(self._impl.parent_container_id) + parent._children.pop(self._impl.uuid, None) + except KeyError: + pass gui_api._handles_in_foreign_containers.pop(self._impl.uuid, None) if isinstance(self, _GuiInputHandle): - gui_api._gui_input_handle_from_uuid.pop(self._impl.uuid) + gui_api._gui_input_handle_from_uuid.pop(self._impl.uuid, None) class _GuiInputHandle( @@ -832,8 +838,11 @@ def remove(self) -> None: # client drops the whole entity via the remove message anyway). for tab in tuple(self._tab_handles): tab.remove() - parent = gui_api._resolve_container_handle(self._impl.parent_container_id) - parent._children.pop(self._impl.uuid) + try: + parent = gui_api._resolve_container_handle(self._impl.parent_container_id) + parent._children.pop(self._impl.uuid, None) + except KeyError: + pass # Parent already torn down by a disconnect race. gui_api._handles_in_foreign_containers.pop(self._impl.uuid, None) @@ -911,7 +920,7 @@ def remove(self) -> None: for child in tuple(self._children.values()): child.remove() - self._parent._impl.gui_api._container_handle_from_uuid.pop(self._id) + self._parent._impl.gui_api._container_handle_from_uuid.pop(self._id, None) # The control panel's fixed uuid, shared with the client (CONTROL_PANEL_ID in @@ -1406,9 +1415,12 @@ def remove(self) -> None: gui_api._websock_interface.queue_message(GuiRemoveMessage(self._impl.uuid)) for child in tuple(self._children.values()): child.remove() - parent = gui_api._resolve_container_handle(self._impl.parent_container_id) - parent._children.pop(self._impl.uuid) - gui_api._container_handle_from_uuid.pop(self._impl.uuid) + try: + parent = gui_api._resolve_container_handle(self._impl.parent_container_id) + parent._children.pop(self._impl.uuid, None) + except KeyError: + pass # Parent already torn down by a disconnect race. + gui_api._container_handle_from_uuid.pop(self._impl.uuid, None) gui_api._handles_in_foreign_containers.pop(self._impl.uuid, None) diff --git a/src/viser/client/src/SceneTreeState.test.ts b/src/viser/client/src/SceneTreeState.test.ts index 61ad2b857..fa1b9eb91 100644 --- a/src/viser/client/src/SceneTreeState.test.ts +++ b/src/viser/client/src/SceneTreeState.test.ts @@ -263,3 +263,25 @@ describe("removeSceneNodeVariantSubtree", () => { expect(store.get("/p/mine")).toBeDefined(); }); }); + +describe("routeShadowedUpdate", () => { + it("drops a stale other-scope update even when no shadow slot exists", () => { + // Server adds /a, a client shadows it, then the server variant is + // removed (no shadow slot anywhere anymore). A late broadcast-owned + // update -- e.g. a write through a stale server handle -- must be + // consumed (dropped), NOT applied to the client's surviving variant. + // Regression: a global shadow-count fast path skipped the per-name + // owner check when the count was zero. + const { store, actions } = setup(); + actions.addSceneNode(makeFrameMessage("/a", "")); + const clientMsg = makeFrameMessage("/a", "7"); + actions.addSceneNode(clientMsg); + actions.removeSceneNodeVariant("/a", ""); // Parked broadcast copy dies. + + const consumed = actions.routeShadowedUpdate("/a", "", { + position: [9, 9, 9], + }); + expect(consumed).toBe(true); + expect(store.get("/a")!.message).toBe(clientMsg); + }); +}); diff --git a/src/viser/client/src/SceneTreeState.ts b/src/viser/client/src/SceneTreeState.ts index 6e28d00ce..3b6fa935a 100644 --- a/src/viser/client/src/SceneTreeState.ts +++ b/src/viser/client/src/SceneTreeState.ts @@ -129,13 +129,6 @@ export function createSceneTreeActions( nodeRefFromName: { [name: string]: undefined | THREE.Object3D }, nodePoseData: NodePoseDataMap, ) { - // Number of names currently holding a shadowed variant. Shadowing only - // exists while a client has deliberately reused a server-owned name - // (typically zero for a whole session), so this lets the per-message - // routing check in `routeShadowedUpdate` collapse to one integer compare - // on the hot pose/visibility paths. - let shadowedSlotCount = 0; - /** Pre-order names of `name`'s subtree, collected BEFORE any removal: * children lists die with their nodes. Shared by variant-subtree and * whole-node removal. */ @@ -181,11 +174,6 @@ export function createSceneTreeActions( existingNode !== undefined && ownerOf(existingNode.message) !== ownerOf(message) ) { - // Either branch below fills the (single) shadow slot; only count the - // slot when it was previously empty. If it was occupied, the parked - // variant belonged to the incoming message's own scope and is being - // replaced (a within-scope supersede that happens to be parked). - if (existingNode.shadowed === undefined) shadowedSlotCount++; if (variantRank(message) >= variantRank(existingNode.message)) { // Incoming variant shadows the current effective one. Snapshot the // effective variant's state (including its live pose) into the @@ -299,7 +287,6 @@ export function createSceneTreeActions( if (shadowed !== undefined) { // Promote the shadowed variant, with the state its scope's // messages have been accumulating while it was hidden. - shadowedSlotCount--; delete nodeRefFromName[name]; if (!isVirtual(shadowed.message)) { nodePoseData[name] = { @@ -334,7 +321,6 @@ export function createSceneTreeActions( return "removed-effective"; } if (node.shadowed && ownerOf(node.shadowed.message) === owner) { - shadowedSlotCount--; store.set({ [name]: { ...node, shadowed: undefined } }); return "removed-shadow"; } @@ -378,9 +364,6 @@ export function createSceneTreeActions( propsUpdates?: { [key: string]: any }; }, ): boolean => { - // Fast path: no shadowed variants exist anywhere (the common case for - // an entire session), so every update targets its effective variant. - if (shadowedSlotCount === 0) return false; // The root ("") is a singleton across scopes; owner is ignored for it. if (name === "") return false; const node = store.get(name); @@ -409,7 +392,6 @@ export function createSceneTreeActions( const updates: Record = {}; removeNames.forEach((removeName) => { - if (store.get(removeName)?.shadowed !== undefined) shadowedSlotCount--; updates[removeName] = undefined; delete nodeRefFromName[removeName]; delete nodePoseData[removeName]; @@ -479,10 +461,6 @@ export function createSceneTreeActions( actions.removeSceneNode(child); } } - // The whole store returns to variant-free defaults below; /WorldAxes - // may carry a shadow slot that the loop above didn't visit. - shadowedSlotCount = 0; - // Reset root and /WorldAxes to default state. const defaultState = makeDefaultSceneTreeState(); store.set({ From 92194a74a281d6b515160f5c244682ce8070c390 Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Mon, 10 Aug 2026 08:48:58 +0000 Subject: [PATCH 20/27] Fix CI: batched visibility recompute broke in the parking refactor The simplify pass rekeyed the batch-parking tables to (owner, name) entries of shape {name, owner, updates}, but the post-flush effective-visibility recompute loop still read them in the old name -> partial shape: its composite-string key is not a node name and '"visibility" in entry' never matched, so computeEffectiveVisibility stopped running for batched visibility changes -- caught by CI's test_bug_global_visibility and test_bug_hover_visibility (an `in` check is invisible to the type checker). Both pass again. Also fixes the two pyright CI errors: the ContextVar annotation used un-imported typing.Dict (never evaluated at runtime under future annotations, so tests passed), and deserialize()'s local shadowed the `message: bytes` parameter. --- src/viser/_gui_api.py | 2 +- src/viser/client/src/MessageHandler.tsx | 6 +++--- src/viser/infra/_messages.py | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/viser/_gui_api.py b/src/viser/_gui_api.py index 52124e528..1301addc2 100644 --- a/src/viser/_gui_api.py +++ b/src/viser/_gui_api.py @@ -197,7 +197,7 @@ class _RootGuiContainer: _children: dict[str, SupportsRemoveProtocol] -_context_owner_by_server: ContextVar[Dict[int, "GuiApi"]] = ContextVar( +_context_owner_by_server: ContextVar[dict[int, GuiApi]] = ContextVar( "viser_gui_context_owner_by_server", default={} ) """Which GuiApi owns the active (non-root) container context, keyed by diff --git a/src/viser/client/src/MessageHandler.tsx b/src/viser/client/src/MessageHandler.tsx index 95d08337b..f8407bb55 100644 --- a/src/viser/client/src/MessageHandler.tsx +++ b/src/viser/client/src/MessageHandler.tsx @@ -1204,9 +1204,9 @@ export function FrameSynchronizedMessageHandler() { // Recompute effective visibility for nodes whose visibility changed. // This needs to be done after updates are applied. - for (const [nodeName, nodeState] of Object.entries(attrUpdates)) { - if ("visibility" in nodeState) { - viewer.sceneTreeActions.computeEffectiveVisibility(nodeName); + for (const { name, updates } of Object.values(attrUpdates)) { + if ("visibility" in updates) { + viewer.sceneTreeActions.computeEffectiveVisibility(name); } } diff --git a/src/viser/infra/_messages.py b/src/viser/infra/_messages.py index 681ebb80f..e25b03f10 100644 --- a/src/viser/infra/_messages.py +++ b/src/viser/infra/_messages.py @@ -285,10 +285,10 @@ def deserialize(cls, message: bytes) -> Message: for k in _non_init_field_names(message_type) if k in message_kwargs } - message = message_type(**message_kwargs) + decoded = message_type(**message_kwargs) for k, v in non_init.items(): - setattr(message, k, v) - return message + setattr(decoded, k, v) + return decoded @classmethod @functools.lru_cache(maxsize=100) From 3469a932afa375501bcc6177a79feef02e080735 Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Mon, 10 Aug 2026 09:03:13 +0000 Subject: [PATCH 21/27] Harden new cross-scope e2e tests against CI timing Three CI-only failures in the new tests, none a product bug: - The drag tests set camera.position=(0,0,4) and look_at=origin, a view parallel to the SERVER-DEFAULT +Z up direction -- whether look_at succeeded depended on racing the browser's camera-up sync, and CI's slower runners lost the race (ValueError from the degeneracy guard). Up is now set explicitly. - The skinned-mesh bone waits ran at 5s; CI runs two xdist workers with two chromium instances on two cores, and multi-message round trips can exceed 5s under that contention. Widened to 10s. --- tests/e2e/test_cross_scope_handles.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/e2e/test_cross_scope_handles.py b/tests/e2e/test_cross_scope_handles.py index ff6a1801e..058f963bc 100644 --- a/tests/e2e/test_cross_scope_handles.py +++ b/tests/e2e/test_cross_scope_handles.py @@ -583,6 +583,10 @@ def test_drag_end_routes_to_owner_after_mid_drag_removal( the node mid-drag stamped the end with the broadcast owner ("") and the client scope's ``on_drag`` end callback never fired.""" client = get_client_handle(viser_server) + # Up must be set explicitly: the (0, 0, 4) -> origin view is parallel to + # the server-default +Z up, and whether the browser's camera sync has + # already replaced that default is a race. + client.camera.up_direction = (0.0, 1.0, 0.0) client.camera.position = (0.0, 0.0, 4.0) client.camera.look_at = (0.0, 0.0, 0.0) @@ -682,7 +686,7 @@ def add_skinned(scene_api): client_owner = str(client.client_id) viser_page.wait_for_function( f"() => ({js_entry})('') !== null && ({js_entry})('{client_owner}') !== null", - timeout=5_000, + timeout=10_000, ) # A bone update from the (shadowed) server scope lands in the SERVER @@ -690,7 +694,7 @@ def add_skinned(scene_api): server_mesh.bones[1].position = (5.0, 6.0, 7.0) viser_page.wait_for_function( f"() => String(({js_entry})('')) === '5,6,7'", - timeout=5_000, + timeout=10_000, ) assert viser_page.evaluate(js_entry, client_owner) == [1, 0, 0] @@ -699,7 +703,7 @@ def add_skinned(scene_api): client.scene._handle_from_node_name["/skin"].remove() viser_page.wait_for_function( f"() => ({js_entry})('{client_owner}') === null", - timeout=5_000, + timeout=10_000, ) assert viser_page.evaluate(js_entry, "") == [5, 6, 7] @@ -715,6 +719,7 @@ def test_disconnect_mid_drag_fires_end_for_client_scope( page1: Page = two_client_setup["page1"] client1 = two_client_setup["client1"] + client1.camera.up_direction = (0.0, 1.0, 0.0) client1.camera.position = (0.0, 0.0, 4.0) client1.camera.look_at = (0.0, 0.0, 0.0) box = client1.scene.add_box("/dragme", dimensions=(4.0, 4.0, 0.2)) From 45a7d03d1c24f4224b46d3a3ae7e55e3b676a0df Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Mon, 10 Aug 2026 09:49:51 +0000 Subject: [PATCH 22/27] Fix review pass 4: wire-order across mid-batch flips, warning hygiene - Extract the per-batch parked scene-update tables into batchedSceneUpdates.ts and drain a name's parked entries before any add/variant-remove for it is handled. An add is applied at receive time while updates park until flush, so a pre-flip parked update previously flushed AFTER newer post-flip updates that were consumed into the shadow slot at receive time -- rewinding the shadowed variant's accumulated state to a stale value (visible after promotion). The module is unit-tested against the real store actions, including the exact inversion sequence. - Flush now reports which visibility changes actually merged into an effective variant; the post-batch effective-visibility recompute uses that instead of scanning all parked entries (which recomputed full subtrees for shadow-consumed no-ops). - remove()'s empty interaction-bindings emits are wrapped in a new AsyncMessageBuffer.sanctioned_dead_writes() scope so cleanup from on_client_disconnect doesn't trip the dead-connection write warning (the messages aren't lifecycle_phase="remove", so the existing exemption missed them). Pinned by extending the existing warning test with a click callback -- fails pre-fix. - Parent-removal cascades skip children a concurrent disconnect teardown already tombstoned instead of warning "already removed" for an internal race. - variantKey moves from ViewerContext.ts to SceneTreeState.ts (re- exported) so store-level modules don't pull in UI imports. --- src/viser/_gui_handles.py | 21 ++- src/viser/_scene_handles.py | 16 +- src/viser/client/src/MessageHandler.tsx | 119 +++++---------- src/viser/client/src/SceneTreeState.test.ts | 59 ++++++++ src/viser/client/src/SceneTreeState.ts | 8 + src/viser/client/src/ViewerContext.ts | 8 +- src/viser/client/src/batchedSceneUpdates.ts | 156 ++++++++++++++++++++ src/viser/infra/_async_message_buffer.py | 23 ++- tests/test_scene_scopes.py | 6 +- 9 files changed, 311 insertions(+), 105 deletions(-) create mode 100644 src/viser/client/src/batchedSceneUpdates.ts diff --git a/src/viser/_gui_handles.py b/src/viser/_gui_handles.py index 09c296626..64df338eb 100644 --- a/src/viser/_gui_handles.py +++ b/src/viser/_gui_handles.py @@ -103,6 +103,17 @@ class SupportsRemoveProtocol(Protocol): def remove(self) -> None: ... +def _cascade_remove(child: SupportsRemoveProtocol) -> None: + """Remove a child as part of a parent's removal cascade, silently + skipping children that a concurrent disconnect teardown already + tombstoned -- remove() would warn "already removed" for a purely + internal race the user did not cause.""" + impl = getattr(child, "_impl", child) # Tabs carry `removed` directly. + if getattr(impl, "removed", False): + return + child.remove() + + class GuiPropsProtocol(Protocol): order: float @@ -837,7 +848,7 @@ def remove(self) -> None: # skips for a removed group (props_setattr would reject the write; the # client drops the whole entity via the remove message anyway). for tab in tuple(self._tab_handles): - tab.remove() + _cascade_remove(tab) try: parent = gui_api._resolve_container_handle(self._impl.parent_container_id) parent._children.pop(self._impl.uuid, None) @@ -919,7 +930,7 @@ def remove(self) -> None: self._parent._rebuild_tab_props() for child in tuple(self._children.values()): - child.remove() + _cascade_remove(child) self._parent._impl.gui_api._container_handle_from_uuid.pop(self._id, None) @@ -1332,7 +1343,7 @@ def remove(self) -> None: # message anyway). gui_api._panel_handle_from_uuid.pop(self._impl.uuid) for tab in tuple(self._tab_handles): - tab.remove() + _cascade_remove(tab) class MainPanelHandle(_PlacementMixin): @@ -1414,7 +1425,7 @@ def remove(self) -> None: gui_api = self._impl.gui_api gui_api._websock_interface.queue_message(GuiRemoveMessage(self._impl.uuid)) for child in tuple(self._children.values()): - child.remove() + _cascade_remove(child) try: parent = gui_api._resolve_container_handle(self._impl.parent_container_id) parent._children.pop(self._impl.uuid, None) @@ -1560,7 +1571,7 @@ def close(self) -> None: GuiCloseModalMessage(self._uuid), ) for child in tuple(self._children.values()): - child.remove() + _cascade_remove(child) self._gui_api._container_handle_from_uuid.pop(self._uuid) self._gui_api._modal_handle_from_uuid.pop(self._uuid) diff --git a/src/viser/_scene_handles.py b/src/viser/_scene_handles.py index 89ef3aea7..e017883a8 100644 --- a/src/viser/_scene_handles.py +++ b/src/viser/_scene_handles.py @@ -476,12 +476,16 @@ def _remove_locked(self) -> None: # dispatch is about to snapshot -- losing the user's required # on_drag_end. drag_active = api._is_drag_active_for(node_name) - _queue_empty_interaction_bindings( - api, - node_name, - had_click=len(impl.click_cb) > 0, - had_drag=had_drag, - ) + # These emits are part of the removal: on a dead per-client + # buffer (remove() from on_client_disconnect) they are benign + # no-ops and must not trip the dead-connection write warning. + with api._websock_interface.get_message_buffer().sanctioned_dead_writes(): + _queue_empty_interaction_bindings( + api, + node_name, + had_click=len(impl.click_cb) > 0, + had_drag=had_drag, + ) # Clear AFTER both emits (the snapshots above key them): if an # emit raises mid-remove, the handle keeps its callback state, so # a RETRY re-emits everything -- clearing first left a retry diff --git a/src/viser/client/src/MessageHandler.tsx b/src/viser/client/src/MessageHandler.tsx index f8407bb55..f457e2bbe 100644 --- a/src/viser/client/src/MessageHandler.tsx +++ b/src/viser/client/src/MessageHandler.tsx @@ -5,6 +5,7 @@ import * as THREE from "three"; import { TextureLoader } from "three"; import { toMantineColor } from "./components/colorUtils"; +import { createParkedSceneUpdates } from "./batchedSceneUpdates"; import { ViewerContext, variantKey } from "./ViewerContext"; import { FileTransferPart, @@ -1046,52 +1047,44 @@ export function FrameSynchronizedMessageHandler() { // Handle all messages and accumulate batched updates. // Three kinds of updates are accumulated and applied as single setState calls: - // - attrUpdates: top-level SceneNode attributes (wxyz, position, visibility, etc.) - // - propsUpdates: message.props fields (batched_wxyzs, colors, etc.) + // - parked scene updates: SceneNode attributes (wxyz, visibility, + // ...) and message.props fields, per (owner, name) -- see + // batchedSceneUpdates.ts for the parking/routing semantics. // - guiUpdates: GUI component property updates - // - // Scene updates are parked per (owner, name) and RE-ROUTED through - // routeShadowedUpdate at flush time: a cross-scope add later in the - // SAME batch can flip a name's effective variant after an update was - // parked, and flushing by name alone would then write one scope's - // update onto the other scope's variant. (With a single scope per - // name -- the common case -- the re-route is the shadowedSlotCount - // fast path and flushing is unchanged.) - const attrUpdates: { - [ownerAndName: string]: { - name: string; - owner: string; - updates: Partial; - }; - } = {}; - const propsUpdates: { - [ownerAndName: string]: { - name: string; - owner: string; - updates: { [key: string]: any }; - }; - } = {}; + const parked = createParkedSceneUpdates( + viewer.useSceneTree, + viewer.sceneTreeActions, + ); const guiUpdates: { uuid: string; updates: { [key: string]: any } }[] = []; for (const msg of processBatch) { + // An add or variant remove flips the name's variant topology at + // receive time; parked updates for it must land first to keep + // wire order (see the drainFor contract). + if ( + isSceneNodeMessage(msg) || + msg.type === "RemoveSceneNodeMessage" + ) { + parked.drainFor(msg.name); + } const result = handleMessage(msg); if (result === undefined) continue; switch (result.kind) { case "sceneNodeAttrUpdate": { - const owner = ownerOf(msg as { owner?: string }); - const entry = (attrUpdates[ - variantKey(owner, result.targetNode) - ] ??= { name: result.targetNode, owner, updates: {} }); - Object.assign(entry.updates, result.updates); + parked.parkAttr( + ownerOf(msg as { owner?: string }), + result.targetNode, + result.updates, + ); break; } case "sceneNodePropsUpdate": { - const owner = ownerOf(msg as { owner?: string }); - const entry = (propsUpdates[ - variantKey(owner, result.targetNode) - ] ??= { name: result.targetNode, owner, updates: {} }); - Object.assign(entry.updates, result.propsUpdates); + parked.parkProps( + ownerOf(msg as { owner?: string }), + result.targetNode, + result.propsUpdates, + ); break; } case "guiUpdate": @@ -1101,51 +1094,7 @@ export function FrameSynchronizedMessageHandler() { } // Apply all accumulated scene tree updates in a single set(). - const mergedUpdates: { [name: string]: SceneNode } = {}; - - // Merge attribute-level updates (wxyz, position, visibility, etc.). - for (const { name, owner, updates } of Object.values(attrUpdates)) { - // The effective variant may have flipped since parking; consume - // into the shadow slot (or drop) instead of merging if so. - if (viewer.sceneTreeActions.routeShadowedUpdate(name, owner, updates)) - continue; - const currentNode = viewer.useSceneTree.get(name); - if (currentNode === undefined) { - console.log(`(OK) Tried to update non-existent scene node ${name}`); - continue; - } - mergedUpdates[name] = { - ...(mergedUpdates[name] ?? currentNode), - ...updates, - }; - } - - // Merge props-level updates (batched_wxyzs, colors, etc.). - for (const { name, owner, updates } of Object.values(propsUpdates)) { - if ( - viewer.sceneTreeActions.routeShadowedUpdate(name, owner, { - propsUpdates: updates, - }) - ) - continue; - const currentNode = viewer.useSceneTree.get(name); - if (currentNode === undefined) { - console.log(`(OK) Tried to update non-existent scene node ${name}`); - continue; - } - const node = mergedUpdates[name] || currentNode; - mergedUpdates[name] = { - ...node, - message: { - ...node.message, - props: { - ...node.message.props, - ...updates, - }, - } as SceneNodeMessage, - }; - } - + const { mergedUpdates, visibilityNames } = parked.flush(); if (Object.keys(mergedUpdates).length > 0) { viewer.useSceneTree.set(mergedUpdates); } @@ -1202,12 +1151,12 @@ export function FrameSynchronizedMessageHandler() { }); } - // Recompute effective visibility for nodes whose visibility changed. - // This needs to be done after updates are applied. - for (const { name, updates } of Object.values(attrUpdates)) { - if ("visibility" in updates) { - viewer.sceneTreeActions.computeEffectiveVisibility(name); - } + // Recompute effective visibility for nodes whose visibility change + // actually merged into an effective variant (updates consumed into + // a shadow slot don't affect what renders). This needs to be done + // after updates are applied. + for (const name of visibilityNames) { + viewer.sceneTreeActions.computeEffectiveVisibility(name); } // A render request that arrived with no messages in front of it has diff --git a/src/viser/client/src/SceneTreeState.test.ts b/src/viser/client/src/SceneTreeState.test.ts index fa1b9eb91..3fe950719 100644 --- a/src/viser/client/src/SceneTreeState.test.ts +++ b/src/viser/client/src/SceneTreeState.test.ts @@ -285,3 +285,62 @@ describe("routeShadowedUpdate", () => { expect(store.get("/a")!.message).toBe(clientMsg); }); }); + +describe("parked batch updates (batchedSceneUpdates)", () => { + it("keeps wire order across a mid-batch variant flip", async () => { + // Batch: [visibility(false, server), client add (flip), visibility(true, + // server)]. Msg 1 parks (server variant effective at receive time); msg 3 + // is consumed into the shadow slot at receive time. Without draining the + // parked entries at the flip, the STALE parked false would overwrite the + // newer true at flush. + const { createParkedSceneUpdates } = await import("./batchedSceneUpdates"); + const { store, actions } = setup(); + actions.addSceneNode(makeFrameMessage("/x", "")); + const parked = createParkedSceneUpdates(store, actions); + + // Msg 1: server visibility=false. Effective at receive time -> parks. + expect(actions.routeShadowedUpdate("/x", "", { visibility: false })).toBe( + false, + ); + parked.parkAttr("", "/x", { visibility: false }); + + // Msg 2: client add flips the effective variant. MessageHandler drains + // parked entries for the name BEFORE the add. + parked.drainFor("/x"); + actions.addSceneNode(makeFrameMessage("/x", "7")); + + // Msg 3: server visibility=true. Server variant now shadowed -> consumed + // into the shadow slot at receive time. + expect(actions.routeShadowedUpdate("/x", "", { visibility: true })).toBe( + true, + ); + + const { mergedUpdates, visibilityNames } = parked.flush(); + store.set(mergedUpdates); + + // The server variant's accumulated state must reflect the LAST wire + // value (true), and no effective-visibility recompute is owed (nothing + // merged into the effective variant). + expect(store.get("/x")!.shadowed!.visibility).toBe(true); + expect(visibilityNames).toEqual([]); + + // Promotion materializes that state: remove the client variant and the + // server node comes back visible. + actions.removeSceneNodeVariant("/x", "7"); + expect(store.get("/x")!.visibility).toBe(true); + }); + + it("reports merged visibility changes for effective-variant recompute", async () => { + const { createParkedSceneUpdates } = await import("./batchedSceneUpdates"); + const { store, actions } = setup(); + actions.addSceneNode(makeFrameMessage("/y", "")); + const parked = createParkedSceneUpdates(store, actions); + + parked.parkAttr("", "/y", { visibility: false }); + const { mergedUpdates, visibilityNames } = parked.flush(); + store.set(mergedUpdates); + + expect(store.get("/y")!.visibility).toBe(false); + expect(visibilityNames).toEqual(["/y"]); + }); +}); diff --git a/src/viser/client/src/SceneTreeState.ts b/src/viser/client/src/SceneTreeState.ts index 3b6fa935a..035b23fed 100644 --- a/src/viser/client/src/SceneTreeState.ts +++ b/src/viser/client/src/SceneTreeState.ts @@ -42,6 +42,14 @@ export type ShadowedVariant = { * (server.scene), anything else is a per-client scope. Old recordings * predate the field; a missing field -- or a missing MESSAGE, e.g. an * interaction racing a node removal -- means broadcast. */ +/** Composite key for one scope's variant of a scene node, used wherever + * per-variant side state lives in a flat map (skinnedMeshState, the message + * batcher's parked updates). Owners are "" (broadcast) or a client id, so + * NUL can't collide with a real owner. */ +export function variantKey(owner: string | undefined, name: string): string { + return `${owner ?? ""}\u0000${name}`; +} + export function ownerOf(message: { owner?: string } | undefined): string { return message?.owner ?? ""; } diff --git a/src/viser/client/src/ViewerContext.ts b/src/viser/client/src/ViewerContext.ts index 45fb6cdbf..8b6147e3f 100644 --- a/src/viser/client/src/ViewerContext.ts +++ b/src/viser/client/src/ViewerContext.ts @@ -105,13 +105,7 @@ export type ViewerMutable = { nodePoseData: NodePoseDataMap; }; -/** Composite key for one scope's variant of a scene node, used wherever - * per-variant side state lives in a flat map (skinnedMeshState, the message - * batcher's parked updates). Owners are "" (broadcast) or a client id, so - * NUL can't collide with a real owner. */ -export function variantKey(owner: string | undefined, name: string): string { - return `${owner ?? ""}\u0000${name}`; -} +export { variantKey } from "./SceneTreeState"; export type ViewerContextContents = { // Non-mutable state. diff --git a/src/viser/client/src/batchedSceneUpdates.ts b/src/viser/client/src/batchedSceneUpdates.ts new file mode 100644 index 000000000..8436b4b13 --- /dev/null +++ b/src/viser/client/src/batchedSceneUpdates.ts @@ -0,0 +1,156 @@ +import { + createSceneTreeActions, + SceneNode, + variantKey, +} from "./SceneTreeState"; +import { KeyedStore } from "./store"; +import { SceneNodeMessage } from "./WebsocketMessages"; + +type SceneTreeActions = ReturnType; + +/** Per-batch parking tables for scene-node updates. + * + * Attribute and props updates are parked per (owner, name) during a message + * batch and applied in merged form at flush, so a 60Hz stream costs one + * store write per batch instead of one per message. Parked entries are + * RE-ROUTED through `routeShadowedUpdate` when they land: a cross-scope add + * later in the SAME batch can flip a name's effective variant after an + * update was parked, and flushing by name alone would then write one + * scope's update onto the other scope's variant. + * + * Wire-order invariant: an add/remove is applied at receive time while + * updates park until flush, so the caller MUST `drainFor(name)` before + * applying any message that can flip `name`'s variant topology. Without the + * drain, a pre-flip parked update would flush AFTER post-flip updates that + * were consumed into the shadow slot at receive time, overwriting newer + * state with older state. + */ +export interface ParkedSceneUpdates { + parkAttr(owner: string, name: string, updates: Partial): void; + parkProps(owner: string, name: string, updates: { [key: string]: any }): void; + /** Apply and clear all parked entries for `name` immediately, before a + * topology-flipping message (add / variant remove) for it is handled. */ + drainFor(name: string): void; + /** Apply all remaining parked entries. Returns the merged store updates + * (for a single set()) and the names whose visibility actually merged + * into an effective variant (whose effective visibility must be + * recomputed after the set()). */ + flush(): { + mergedUpdates: { [name: string]: SceneNode }; + visibilityNames: string[]; + }; +} + +export function createParkedSceneUpdates( + store: KeyedStore, + actions: SceneTreeActions, +): ParkedSceneUpdates { + const attrUpdates: { + [ownerAndName: string]: { + name: string; + owner: string; + updates: Partial; + }; + } = {}; + const propsUpdates: { + [ownerAndName: string]: { + name: string; + owner: string; + updates: { [key: string]: any }; + }; + } = {}; + + /** Route one attr entry to the shadow slot, or merge it into `out`. + * Returns true when a visibility change merged into the effective + * variant. */ + function applyAttr( + entry: { name: string; owner: string; updates: Partial }, + out: { [name: string]: SceneNode }, + ): boolean { + if (actions.routeShadowedUpdate(entry.name, entry.owner, entry.updates)) + return false; + const currentNode = out[entry.name] ?? store.get(entry.name); + if (currentNode === undefined) { + console.log(`(OK) Tried to update non-existent scene node ${entry.name}`); + return false; + } + out[entry.name] = { ...currentNode, ...entry.updates }; + return "visibility" in entry.updates; + } + + function applyProps( + entry: { name: string; owner: string; updates: { [key: string]: any } }, + out: { [name: string]: SceneNode }, + ): void { + if ( + actions.routeShadowedUpdate(entry.name, entry.owner, { + propsUpdates: entry.updates, + }) + ) + return; + const currentNode = out[entry.name] ?? store.get(entry.name); + if (currentNode === undefined) { + console.log(`(OK) Tried to update non-existent scene node ${entry.name}`); + return; + } + out[entry.name] = { + ...currentNode, + message: { + ...currentNode.message, + props: { + ...currentNode.message.props, + ...entry.updates, + }, + } as SceneNodeMessage, + }; + } + + return { + parkAttr: (owner, name, updates) => { + const entry = (attrUpdates[variantKey(owner, name)] ??= { + name, + owner, + updates: {}, + }); + Object.assign(entry.updates, updates); + }, + parkProps: (owner, name, updates) => { + const entry = (propsUpdates[variantKey(owner, name)] ??= { + name, + owner, + updates: {}, + }); + Object.assign(entry.updates, updates); + }, + drainFor: (name) => { + // Rare path (a cross-scope flip mid-batch): immediate store writes + // are fine, and required -- the topology change lands right after. + for (const [key, entry] of Object.entries(attrUpdates)) { + if (entry.name !== name) continue; + delete attrUpdates[key]; + const out: { [n: string]: SceneNode } = {}; + const recompute = applyAttr(entry, out); + if (Object.keys(out).length > 0) store.set(out); + if (recompute) actions.computeEffectiveVisibility(name); + } + for (const [key, entry] of Object.entries(propsUpdates)) { + if (entry.name !== name) continue; + delete propsUpdates[key]; + const out: { [n: string]: SceneNode } = {}; + applyProps(entry, out); + if (Object.keys(out).length > 0) store.set(out); + } + }, + flush: () => { + const mergedUpdates: { [name: string]: SceneNode } = {}; + const visibilityNames: string[] = []; + for (const entry of Object.values(attrUpdates)) { + if (applyAttr(entry, mergedUpdates)) visibilityNames.push(entry.name); + } + for (const entry of Object.values(propsUpdates)) { + applyProps(entry, mergedUpdates); + } + return { mergedUpdates, visibilityNames }; + }, + }; +} diff --git a/src/viser/infra/_async_message_buffer.py b/src/viser/infra/_async_message_buffer.py index a9e634683..568f327c5 100644 --- a/src/viser/infra/_async_message_buffer.py +++ b/src/viser/infra/_async_message_buffer.py @@ -1,10 +1,11 @@ from __future__ import annotations import asyncio +import contextlib import dataclasses import threading from asyncio.events import AbstractEventLoop -from typing import AsyncGenerator, Callable, Dict, List, Sequence +from typing import AsyncGenerator, Callable, Dict, Generator, List, Sequence from ._messages import Message @@ -34,6 +35,10 @@ class AsyncMessageBuffer: _warned_push_after_done: bool = False """One-shot latch for the dead-connection write warning in push().""" + _sanctioned_dead_writes: int = 0 + """Nesting depth of ``sanctioned_dead_writes()`` scopes; nonzero + suppresses the dead-connection write warning.""" + generator_cursors: Dict[int, int] = dataclasses.field(default_factory=dict) """Per-active-connection consumption cursors (client id -> last message id that connection's window generator has drained). Written by each generator @@ -43,6 +48,21 @@ class AsyncMessageBuffer: pending?" event is not a consumption watermark, since a backpressured client's cursor can sit arbitrarily far behind it.""" + @contextlib.contextmanager + def sanctioned_dead_writes(self) -> Generator[None, None, None]: + """Suppress the dead-connection write warning for pushes inside this + scope. For cleanup emits that removal paths perform on behalf of the + user (e.g. the empty interaction-bindings broadcasts in a scene + node's ``remove()``): on a dead buffer they are benign no-ops, same + as the removal messages themselves. Depth is a plain int (GIL-atomic + += / -=); a concurrent unsanctioned push slipping through unwarned + is acceptable for a best-effort diagnostic.""" + self._sanctioned_dead_writes += 1 + try: + yield + finally: + self._sanctioned_dead_writes -= 1 + def remove_from_buffer(self, match_fn: Callable[[Message], bool]) -> None: """Remove messages that match some condition.""" @@ -73,6 +93,7 @@ def push(self, message: Message) -> None: self.done and not self._warned_push_after_done and message.lifecycle_phase != "remove" + and self._sanctioned_dead_writes == 0 ): self._warned_push_after_done = True import warnings diff --git a/tests/test_scene_scopes.py b/tests/test_scene_scopes.py index 507573196..bb3ed6df3 100644 --- a/tests/test_scene_scopes.py +++ b/tests/test_scene_scopes.py @@ -312,9 +312,13 @@ def test_dead_connection_write_warns_once(server: viser.ViserServer) -> None: # Removal messages are exempt: releasing a departed client's elements # (e.g. inside on_client_disconnect, which runs after the buffer is - # closed) is ordinary cleanup, not a leak in the making. + # closed) is ordinary cleanup, not a leak in the making. The click + # callback matters: remove() then also emits empty interaction-bindings + # messages (not lifecycle_phase="remove"), which must be covered by the + # same exemption. client2 = make_synthetic_client(server, 1) handle2 = client2.scene.add_icosphere("/theirs", radius=0.1) + handle2.on_click(lambda _: None) client2._websock_connection._state.message_buffer.set_done() with warnings_module.catch_warnings(record=True) as caught: warnings_module.simplefilter("always") From ab4e289e4f29d61fd0055b103b568899848105ec Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Mon, 10 Aug 2026 10:01:00 +0000 Subject: [PATCH 23/27] Observe wire pose, not the render loop, in the coexistence e2e wait test_same_name_coexists_across_different_clients timed out twice in a row on CI's shard 1 (both Python versions) while passing locally: its wait read the mounted three.js object's position, which is applied by a useFrame hook and therefore needs requestAnimationFrame ticks -- the only render-loop-dependent wait in the suite, stalled past the timeout by CI's software-GL + two-worker contention. The helper now waits on store presence (message-delivery proof) and nodePoseData (written synchronously at message-handling time); the applier path stays covered by the visual/pixel tests. Also adds the new store-level randomized display-rule oracle test (300 seeded rounds against a plain-JS model of the documented semantics). --- src/viser/client/src/SceneTreeState.test.ts | 130 ++++++++++++++++++++ tests/e2e/test_cross_scope_handles.py | 26 ++-- 2 files changed, 146 insertions(+), 10 deletions(-) diff --git a/src/viser/client/src/SceneTreeState.test.ts b/src/viser/client/src/SceneTreeState.test.ts index 3fe950719..d9605cfe8 100644 --- a/src/viser/client/src/SceneTreeState.test.ts +++ b/src/viser/client/src/SceneTreeState.test.ts @@ -344,3 +344,133 @@ describe("parked batch updates (batchedSceneUpdates)", () => { expect(visibilityNames).toEqual(["/y"]); }); }); + +describe("randomized display-rule oracle", () => { + // Deterministic PRNG so failures reproduce from the logged round index. + function mulberry32(seed: number) { + return () => { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + } + + const rank = (owner: string, virtual: boolean) => + (virtual ? 0 : 2) + (owner !== "" ? 1 : 0); + + type ModelVariant = { virtual: boolean; visibility: boolean }; + + it("random op sequences match the documented semantics", async () => { + const { createParkedSceneUpdates } = await import("./batchedSceneUpdates"); + const NAMES = ["/n1", "/n2"]; + const OWNERS = ["", "7"]; + + for (let round = 0; round < 300; round++) { + const rand = mulberry32(round + 1); + const { store, actions } = setup(); + const parked = createParkedSceneUpdates(store, actions); + // Oracle: per name, per owner, the variant's state; plus which owner + // is effective. Mirrors ONLY the documented rules (display rule, + // fresh-on-add, snapshot-on-shadow, promotion, virtual force-true). + const model: { + [name: string]: { + variants: { [owner: string]: ModelVariant }; + effective: string | undefined; + }; + } = { "/n1": { variants: {}, effective: undefined }, + "/n2": { variants: {}, effective: undefined } }; + const opLog: string[] = []; + + for (let i = 0; i < 12; i++) { + const name = NAMES[Math.floor(rand() * NAMES.length)]; + const owner = OWNERS[Math.floor(rand() * OWNERS.length)]; + const m = model[name]; + const r = rand(); + if (r < 0.45) { + // Add (sometimes virtual, as anchors are on the wire). + const virtual = rand() < 0.25; + opLog.push(`add(${name}, ${owner || "server"}, virtual=${virtual})`); + parked.drainFor(name); + actions.addSceneNode(makeFrameMessage(name, owner, virtual)); + if (m.effective !== undefined && m.effective !== owner) { + const eff = m.variants[m.effective]; + if (rank(owner, virtual) >= rank(m.effective, eff.virtual)) { + m.variants[owner] = { virtual, visibility: true }; + m.effective = owner; // Old effective keeps its state, parked. + } else { + m.variants[owner] = { virtual, visibility: true }; // Parked fresh. + } + } else { + const prev = m.variants[owner]; + m.variants[owner] = { + virtual, + // Same-owner supersede preserves visibility; new name is true. + visibility: m.effective === owner ? prev.visibility : true, + }; + m.effective = owner; + } + } else if (r < 0.7) { + opLog.push(`removeVariant(${name}, ${owner || "server"})`); + parked.drainFor(name); + actions.removeSceneNodeVariant(name, owner); + if (m.variants[owner] !== undefined) { + delete m.variants[owner]; + if (m.effective === owner) { + const other = Object.keys(m.variants)[0]; + m.effective = other; + if (other !== undefined && m.variants[other].virtual) { + m.variants[other].visibility = true; // Promotion force-true. + } + } + } + } else { + // Visibility update, exactly as MessageHandler routes it. Skipped + // for virtual variants (no wire path sends these). + if (m.variants[owner]?.virtual) continue; + const visible = rand() < 0.5; + opLog.push(`visibility(${name}, ${owner || "server"}, ${visible})`); + if ( + !actions.routeShadowedUpdate(name, owner, { visibility: visible }) + ) { + parked.parkAttr(owner, name, { visibility: visible }); + } + if (m.variants[owner] !== undefined) { + m.variants[owner].visibility = visible; + } + } + } + + const { mergedUpdates } = parked.flush(); + store.set(mergedUpdates); + + for (const name of NAMES) { + const m = model[name]; + const node = store.get(name); + const ctx = `round ${round}: ${opLog.join(" ; ")} -- ${name}`; + if (m.effective === undefined) { + expect(node, ctx).toBeUndefined(); + continue; + } + expect(node, ctx).toBeDefined(); + const eff = m.variants[m.effective]; + expect(node!.message.owner ?? "", ctx).toBe(m.effective); + expect(!!node!.message.virtual, ctx).toBe(eff.virtual); + expect(node!.visibility ?? true, ctx).toBe(eff.visibility); + const shadowOwner = Object.keys(m.variants).find( + (o) => o !== m.effective, + ); + if (shadowOwner === undefined) { + expect(node!.shadowed, ctx).toBeUndefined(); + } else { + const sh = m.variants[shadowOwner]; + expect(node!.shadowed, ctx).toBeDefined(); + expect(node!.shadowed!.message.owner ?? "", ctx).toBe(shadowOwner); + expect(!!node!.shadowed!.message.virtual, ctx).toBe(sh.virtual); + expect(node!.shadowed!.visibility, ctx).toBe(sh.visibility); + } + } + } + }); +}); diff --git a/tests/e2e/test_cross_scope_handles.py b/tests/e2e/test_cross_scope_handles.py index 058f963bc..5e97d7915 100644 --- a/tests/e2e/test_cross_scope_handles.py +++ b/tests/e2e/test_cross_scope_handles.py @@ -66,20 +66,26 @@ def wait_for_node_position( page: Page, node_name: str, position: tuple[float, float, float], - timeout: int = 5_000, + timeout: int = 10_000, ) -> None: - """Wait until a node's three.js local position matches ``position``.""" + """Wait until a node's latest wire pose matches ``position``. + + Observes ``nodePoseData`` (written synchronously when the message is + handled), NOT the mounted three.js object: the object's position is + applied by a ``useFrame`` hook and needs requestAnimationFrame ticks, + which stall for seconds under CI's software-GL + xdist contention (the + only repeated CI failures in this suite were exactly that stall). The + applier path is covered by the visual/pixel tests.""" + wait_for_scene_node(page, node_name, timeout=timeout) # Delivery proof. page.wait_for_function( """([nodeName, expected]) => { - const m = window.__viserMutable; - if (!m || !m.nodeRefFromName) return false; - const obj = m.nodeRefFromName[nodeName]; - if (!obj) return false; - const p = obj.position; + const pose = window.__viserMutable?.nodePoseData?.[nodeName]; + if (!pose) return false; + const p = pose.position; return ( - Math.abs(p.x - expected[0]) < 1e-4 && - Math.abs(p.y - expected[1]) < 1e-4 && - Math.abs(p.z - expected[2]) < 1e-4 + Math.abs(p[0] - expected[0]) < 1e-4 && + Math.abs(p[1] - expected[1]) < 1e-4 && + Math.abs(p[2] - expected[2]) < 1e-4 ); }""", arg=[node_name, list(position)], From bb98058501bbb31f595b335e19880369a9dc8d6f Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Mon, 10 Aug 2026 10:03:09 +0000 Subject: [PATCH 24/27] Format SceneTreeState.test.ts (prettier gate) --- src/viser/client/src/SceneTreeState.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/viser/client/src/SceneTreeState.test.ts b/src/viser/client/src/SceneTreeState.test.ts index d9605cfe8..1b4426d91 100644 --- a/src/viser/client/src/SceneTreeState.test.ts +++ b/src/viser/client/src/SceneTreeState.test.ts @@ -379,8 +379,10 @@ describe("randomized display-rule oracle", () => { variants: { [owner: string]: ModelVariant }; effective: string | undefined; }; - } = { "/n1": { variants: {}, effective: undefined }, - "/n2": { variants: {}, effective: undefined } }; + } = { + "/n1": { variants: {}, effective: undefined }, + "/n2": { variants: {}, effective: undefined }, + }; const opLog: string[] = []; for (let i = 0; i < 12; i++) { From 54cfa759228dcdc83c2891cef26ebc011c3edc60 Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Mon, 10 Aug 2026 10:06:21 +0000 Subject: [PATCH 25/27] Add cross-version recording fixture + server-registry leak test - tests/e2e/assets/pre_scope_recording.viser: a recording serialized by actual pre-refactor code (the branch's merge-base, via a worktree) -- no owner/virtual stamps on any message. The new playback e2e pins the deserialization defaults, client-side parent-anchor fallback, poses, and visibility for the old format end to end. - test_disconnect_cycles_leave_no_server_residue: 20 connect / cross-nest / disconnect cycles must leave the server GUI registries and host container at baseline size. --- tests/e2e/assets/pre_scope_recording.viser | Bin 0 -> 853 bytes tests/e2e/test_old_recording_playback.py | 89 +++++++++++++++++++++ tests/test_gui_cross_scope.py | 35 ++++++++ 3 files changed, 124 insertions(+) create mode 100644 tests/e2e/assets/pre_scope_recording.viser create mode 100644 tests/e2e/test_old_recording_playback.py diff --git a/tests/e2e/assets/pre_scope_recording.viser b/tests/e2e/assets/pre_scope_recording.viser new file mode 100644 index 0000000000000000000000000000000000000000..26188255cccaecab8d400b9559c1d536c81e7de6 GIT binary patch literal 853 zcmV-b1FHPf3;+NC0000ewJ-f((+eFM0G4HcLNKkS1Hq9k0I@$)t|sY+kncX>qnk)@ znRJh(B($<&Z1`?Mb}GeAY5z=n?!o?sctGj4Szpdc0_EaFVAAgMAQPFCZt?O^1@i*)LNa^n%T&Uf?Q9ebB@{9#_g2P9 zB9qcSW8GlMCBjZ?KAPZN&o1QZIUu*$4lI&#kwFng1XLniv*@mzs7kryzEs1#AEzf( zM5+KI0V>x)E*Y;?*_t@5P#n8J_)>=^>sX^8-cE0G+VnkeyDf-QzrFVwyHs8 zg&SCPUjC_U4l2kxd~Pgd_N8-x+#2OQ`k+lWakEx3N@P5}K~m1@r8C8)(;lyqoKbRo z7j1E;e&j^YJ+V+Ro(zPl;$p-*rec`4ak|K`xUj(Zrg}^@DW^GY5F(Z9mez?0mcBSN zpemNLa^pFO&w@_XI=4Jv0;NCtxHP%czoQDM{(f7UY>$(SzNP7BMZL<&um{iXd~^P1YU5 zaR&~`>0?vUq>F8jaFReX{2^*Wkw0}!3t_F;oomCW(>Z{+lp!E|*JavYK+!{Tx5Yt< zL51wG#biPc&*GkpRDonR)7^$Ym56zWguN{YFYq?PIVY1j41VG*C_ThF9m?nfvSL}> z89+$EAp`{=0pQrk7el*Vs;5+#_o8&gs-*S9+=M5E2P8J&OB%On*^rpa_5MEuVKG*2 z#K>XZ3#403;CP`@QHwsakhP3;d`7I%OQ>bEOH4MNnS_Z)GJqt4;2D%6)3jlIuAp>q fQ^354BV~C=eWlSbnqha32?qA`K&9o(^sNg6tUZ=> literal 0 HcmV?d00001 diff --git a/tests/e2e/test_old_recording_playback.py b/tests/e2e/test_old_recording_playback.py new file mode 100644 index 000000000..b93cec678 --- /dev/null +++ b/tests/e2e/test_old_recording_playback.py @@ -0,0 +1,89 @@ +"""E2E: recordings serialized BEFORE the (owner, name) identity refactor +still play back correctly. + +``assets/pre_scope_recording.viser`` was generated by actual pre-refactor +code (the branch's merge-base) -- its messages carry no ``owner`` / +``virtual`` stamps, so this pins the deserialization defaults and the +client-side parent-anchor fallback for the old format end to end. +""" + +from __future__ import annotations + +import functools +import http.server +import threading +from pathlib import Path + +from playwright.sync_api import Page + +import viser + +from .utils import find_free_port, wait_for_scene_node, wait_for_scene_node_hidden + +_ASSETS = Path(__file__).parent / "assets" + + +class _CORSHandler(http.server.SimpleHTTPRequestHandler): + """The recording is served from a different origin than the viewer.""" + + def end_headers(self) -> None: + self.send_header("Access-Control-Allow-Origin", "*") + super().end_headers() + + def log_message(self, *args) -> None: + pass # Keep pytest output clean. + + +def test_pre_scope_recording_plays_back( + viser_server: viser.ViserServer, page: Page +) -> None: + file_port = find_free_port() + httpd = http.server.ThreadingHTTPServer( + ("127.0.0.1", file_port), + functools.partial(_CORSHandler, directory=str(_ASSETS)), + ) + threading.Thread(target=httpd.serve_forever, daemon=True).start() + try: + page.goto( + f"http://localhost:{viser_server.get_port()}/" + f"?playbackPath=http://127.0.0.1:{file_port}/pre_scope_recording.viser" + ) + + # Every node from the old recording, including the auto-created + # ancestors of the deep add (the old format has no server-sent + # virtual anchors; the client-side makeParents fallback covers it). + for name in ( + "/root_frame", + "/root_frame/box", + "/root_frame/box/label", + "/lines", + "/hidden_box", + "/deep", + "/deep/nested", + "/deep/nested/sphere", + ): + wait_for_scene_node(page, name, timeout=15_000) + + # Un-stamped messages must resolve to the broadcast scope. + owners = page.evaluate( + """() => { + const state = window.__viserSceneTree.getState(); + return ["/root_frame", "/lines", "/deep/nested/sphere"].map( + (n) => state[n].message.owner ?? "", + ); + }""" + ) + assert owners == ["", "", ""] + + # Recorded poses and visibility land. + page.wait_for_function( + """() => { + const pose = + window.__viserMutable?.nodePoseData?.["/root_frame"]; + return pose && Math.abs(pose.position[0] - 0.5) < 1e-4; + }""", + timeout=15_000, + ) + wait_for_scene_node_hidden(page, "/hidden_box", timeout=15_000) + finally: + httpd.shutdown() diff --git a/tests/test_gui_cross_scope.py b/tests/test_gui_cross_scope.py index cacb7f4a3..f200c0ce7 100644 --- a/tests/test_gui_cross_scope.py +++ b/tests/test_gui_cross_scope.py @@ -416,3 +416,38 @@ def test_removed_server_container_restore_does_not_poison_thread( assert server.gui.add_button("s")._impl.parent_container_id == "root" assert client.gui.add_button("c")._impl.parent_container_id == "root" + + +def test_disconnect_cycles_leave_no_server_residue( + server: viser.ViserServer, +) -> None: + """Repeated connect / cross-nest / disconnect cycles must not accumulate + entries in the server-side GUI registries or the host container's + children (leak check for the release path).""" + folder = server.gui.add_folder("Host") + gui = server.gui + + def registry_sizes() -> tuple[int, int, int]: + return ( + len(gui._container_handle_from_uuid), + len(gui._gui_input_handle_from_uuid), + len(folder._children), + ) + + baseline = registry_sizes() + for i in range(20): + client = make_synthetic_client(server, i) + with folder: + with client.gui.add_folder(f"F{i}"): + client.gui.add_button(f"b{i}") + client.gui.add_slider( + f"s{i}", min=0.0, max=1.0, step=0.1, initial_value=0.5 + ) + client.scene.add_frame(f"/c{i}") + + # Disconnect teardown. + client._websock_connection._state.message_buffer.set_done() + client.gui._release_cross_scope_nesting() + + assert registry_sizes() == baseline, f"registry residue after cycle {i}" + assert len(client.gui._handles_in_foreign_containers) == 0 From 10fa1344124c9bdc724dd290116ebb23684e9470 Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Mon, 10 Aug 2026 10:35:45 +0000 Subject: [PATCH 26/27] Address review pass 5 + harden drag e2e tests for CI contention - sanctioned_dead_writes adjusts its depth under buffer_lock (the docstring's GIL-atomicity claim for augmented assignment was false; a lost update would have wedged the counter and silently disabled the dead-write diagnostic buffer-wide). - batchedSceneUpdates keeps a parked-names set so the per-add/remove drainFor is O(1) when nothing is parked for that name, instead of scanning both tables per topology message (mixed update+rebuild batches were O(adds x parked)). - The reconnect e2e waits on the effective owner flipping to the client instead of a (0,0,0) pose -- which passed vacuously on the freshly-initialized default pose before the shadowing add applied. - Both cross-scope drag e2e tests wait for the mounted mesh and two animation frames before synthesizing mouse input; CI's contended software-GL runner reached mouse-down before the box was raycastable ("drag start never reached the client scope", 3.12 shard 1). - Comments: document that same-owner supersede deliberately skips re-ranking against the shadow slot (wire can't downgrade a name to a virtual anchor), mirror that note in the oracle model, and state that _cascade_remove narrows rather than closes its race window. --- src/viser/_gui_handles.py | 7 ++++- src/viser/client/src/SceneTreeState.test.ts | 5 ++++ src/viser/client/src/SceneTreeState.ts | 4 +++ src/viser/client/src/batchedSceneUpdates.ts | 8 ++++++ src/viser/infra/_async_message_buffer.py | 13 +++++---- tests/e2e/test_cross_scope_handles.py | 31 ++++++++++++++++++--- 6 files changed, 58 insertions(+), 10 deletions(-) diff --git a/src/viser/_gui_handles.py b/src/viser/_gui_handles.py index 64df338eb..9667b5a4f 100644 --- a/src/viser/_gui_handles.py +++ b/src/viser/_gui_handles.py @@ -107,7 +107,12 @@ def _cascade_remove(child: SupportsRemoveProtocol) -> None: """Remove a child as part of a parent's removal cascade, silently skipping children that a concurrent disconnect teardown already tombstoned -- remove() would warn "already removed" for a purely - internal race the user did not cause.""" + internal race the user did not cause. + + Best-effort: the unlocked check narrows the race window rather than + closing it (a teardown landing between this check and the removed-guard + inside remove() still warns, harmlessly). Closing it would need a lock + shared across all GUI removal paths, which the design avoids.""" impl = getattr(child, "_impl", child) # Tabs carry `removed` directly. if getattr(impl, "removed", False): return diff --git a/src/viser/client/src/SceneTreeState.test.ts b/src/viser/client/src/SceneTreeState.test.ts index 1b4426d91..1fea51b45 100644 --- a/src/viser/client/src/SceneTreeState.test.ts +++ b/src/viser/client/src/SceneTreeState.test.ts @@ -411,6 +411,11 @@ describe("randomized display-rule oracle", () => { // Same-owner supersede preserves visibility; new name is true. visibility: m.effective === owner ? prev.visibility : true, }; + // Stays effective WITHOUT re-ranking against the shadow slot, + // matching addSceneNode: the wire never downgrades an existing + // in-scope name to a virtual anchor, so the only sequence where + // this would matter (real -> virtual same-owner supersede while + // shadowing a real other-scope variant) is unreachable live. m.effective = owner; } } else if (r < 0.7) { diff --git a/src/viser/client/src/SceneTreeState.ts b/src/viser/client/src/SceneTreeState.ts index 035b23fed..95bdd2287 100644 --- a/src/viser/client/src/SceneTreeState.ts +++ b/src/viser/client/src/SceneTreeState.ts @@ -236,6 +236,10 @@ export function createSceneTreeActions( // Same-owner add (within-scope create or supersede), or a brand-new // name. `...existingNode` carries any shadow slot across a supersede. + // Deliberately NOT re-ranked against the shadow slot: the wire never + // downgrades an existing in-scope name to a virtual anchor (anchors + // are only sent for names missing in that scope), so a supersede + // keeps this variant effective unconditionally. const parentName = message.name.split("/").slice(0, -1).join("/"); const parentNode = store.get(parentName); diff --git a/src/viser/client/src/batchedSceneUpdates.ts b/src/viser/client/src/batchedSceneUpdates.ts index 8436b4b13..62d1a721d 100644 --- a/src/viser/client/src/batchedSceneUpdates.ts +++ b/src/viser/client/src/batchedSceneUpdates.ts @@ -59,6 +59,11 @@ export function createParkedSceneUpdates( updates: { [key: string]: any }; }; } = {}; + // Names with at least one parked entry, so the per-add/remove drainFor + // call is O(1) in the common case (nothing parked for that name) instead + // of scanning both tables. Never pruned on drain; a stale member only + // costs one extra scan. + const parkedNames = new Set(); /** Route one attr entry to the shadow slot, or merge it into `out`. * Returns true when a visibility change merged into the effective @@ -107,6 +112,7 @@ export function createParkedSceneUpdates( return { parkAttr: (owner, name, updates) => { + parkedNames.add(name); const entry = (attrUpdates[variantKey(owner, name)] ??= { name, owner, @@ -115,6 +121,7 @@ export function createParkedSceneUpdates( Object.assign(entry.updates, updates); }, parkProps: (owner, name, updates) => { + parkedNames.add(name); const entry = (propsUpdates[variantKey(owner, name)] ??= { name, owner, @@ -123,6 +130,7 @@ export function createParkedSceneUpdates( Object.assign(entry.updates, updates); }, drainFor: (name) => { + if (!parkedNames.has(name)) return; // Rare path (a cross-scope flip mid-batch): immediate store writes // are fine, and required -- the topology change lands right after. for (const [key, entry] of Object.entries(attrUpdates)) { diff --git a/src/viser/infra/_async_message_buffer.py b/src/viser/infra/_async_message_buffer.py index 568f327c5..851cb8091 100644 --- a/src/viser/infra/_async_message_buffer.py +++ b/src/viser/infra/_async_message_buffer.py @@ -54,14 +54,17 @@ def sanctioned_dead_writes(self) -> Generator[None, None, None]: scope. For cleanup emits that removal paths perform on behalf of the user (e.g. the empty interaction-bindings broadcasts in a scene node's ``remove()``): on a dead buffer they are benign no-ops, same - as the removal messages themselves. Depth is a plain int (GIL-atomic - += / -=); a concurrent unsanctioned push slipping through unwarned - is acceptable for a best-effort diagnostic.""" - self._sanctioned_dead_writes += 1 + as the removal messages themselves. The depth is adjusted under + ``buffer_lock`` so concurrent scopes can't lose an update and wedge + the counter; a concurrent unsanctioned push slipping through + unwarned is still acceptable for a best-effort diagnostic.""" + with self.buffer_lock: + self._sanctioned_dead_writes += 1 try: yield finally: - self._sanctioned_dead_writes -= 1 + with self.buffer_lock: + self._sanctioned_dead_writes -= 1 def remove_from_buffer(self, match_fn: Callable[[Message], bool]) -> None: """Remove messages that match some condition.""" diff --git a/tests/e2e/test_cross_scope_handles.py b/tests/e2e/test_cross_scope_handles.py index 5e97d7915..471dc1534 100644 --- a/tests/e2e/test_cross_scope_handles.py +++ b/tests/e2e/test_cross_scope_handles.py @@ -196,10 +196,16 @@ def test_client_scope_elements_do_not_survive_reconnect( "/shared_box", dimensions=(1.0, 1.0, 1.0), position=(2.0, 0.0, 0.0) ) client.scene.add_icosphere("/client_sphere", radius=0.3) - # A client variant shadowing a server name. + # A client variant shadowing a server name. Wait on the EFFECTIVE OWNER + # flipping to the client -- a (0, 0, 0) pose wait would pass vacuously on + # the store's freshly-initialized default pose before the shadowing add + # has even been processed. client.scene.add_box("/shared_box", dimensions=(0.5, 0.5, 0.5)) wait_for_scene_node(viser_page, "/client_sphere") - wait_for_node_position(viser_page, "/shared_box", (0.0, 0.0, 0.0)) + viser_page.wait_for_function( + f"() => ({JS_GET_EFFECTIVE_OWNER})('/shared_box') === '{client.client_id}'", + timeout=10_000, + ) viser_server._websock_server.disconnect_all_clients() @@ -581,6 +587,23 @@ def test_world_axes_server_state_deterministic_for_new_client( # --------------------------------------------------------------------------- +def _wait_drag_ready(page: Page) -> None: + """The drag raycasts against the RENDERED scene: wait for the mounted + mesh (store presence precedes the object existing for the raycaster), + then for two animation frames (proof the render loop is producing + frames, so pointer events will be processed). Both lag by seconds under + CI's software-GL contention.""" + wait_for_scene_node(page, "/dragme") + page.wait_for_function( + "() => window.__viserMutable?.nodeRefFromName?.['/dragme'] != null", + timeout=15_000, + ) + page.evaluate( + """() => new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)))""" + ) + + def test_drag_end_routes_to_owner_after_mid_drag_removal( viser_server: viser.ViserServer, viser_page: Page ) -> None: @@ -607,7 +630,7 @@ def _(event: viser.SceneNodeDragEvent) -> None: elif event.phase == "end": ended.set() - wait_for_scene_node(viser_page, "/dragme") + _wait_drag_ready(viser_page) cx, cy = canvas_center(viser_page) viser_page.mouse.move(cx, cy) viser_page.mouse.down() @@ -739,7 +762,7 @@ def _(event: viser.SceneNodeDragEvent) -> None: elif event.phase == "end": ended.set() - wait_for_scene_node(page1, "/dragme") + _wait_drag_ready(page1) cx, cy = canvas_center(page1) page1.mouse.move(cx, cy) page1.mouse.down() From ed7fdbcd18612cbff1b25b9e35b185c88a06e971 Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Mon, 10 Aug 2026 11:40:33 +0000 Subject: [PATCH 27/27] Drop redundant store wait in _wait_drag_ready (pass 6 cleanup) The nodeRefFromName wait is strictly stronger than store presence. --- tests/e2e/test_cross_scope_handles.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/e2e/test_cross_scope_handles.py b/tests/e2e/test_cross_scope_handles.py index 471dc1534..845b8def1 100644 --- a/tests/e2e/test_cross_scope_handles.py +++ b/tests/e2e/test_cross_scope_handles.py @@ -593,7 +593,6 @@ def _wait_drag_ready(page: Page) -> None: then for two animation frames (proof the render loop is producing frames, so pointer events will be processed). Both lag by seconds under CI's software-GL contention.""" - wait_for_scene_node(page, "/dragme") page.wait_for_function( "() => window.__viserMutable?.nodeRefFromName?.['/dragme'] != null", timeout=15_000,