Skip to content

Per-client scene/GUI scopes: variant shadowing and scope-local removal - #758

Merged
brentyi merged 27 commits into
mainfrom
scene-gui-scopes
Aug 11, 2026
Merged

Per-client scene/GUI scopes: variant shadowing and scope-local removal#758
brentyi merged 27 commits into
mainfrom
scene-gui-scopes

Conversation

@brentyi

@brentyi brentyi commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Scene + GUI handle architecture: unified (owner, name) identity

Implements the redesign discussed in #692 and #741: scene nodes and GUI elements are identified by (owner, name) rather than name alone, making server (broadcast) and per-client scopes fully independent.

Core semantics

  • Variant slots: each scene-tree name holds at most one variant per scope. The client renders the effective variant by the display rule: real client > real broadcast > virtual client > virtual broadcast. The non-effective variant is parked in a shadow slot, keeps accumulating its scope's updates, and is promoted with that state when the effective variant is removed.
  • Owner stamping: every scene/GUI message carries an owner field ("" = broadcast, client id otherwise), stamped server-side; scene creates may also be virtual (auto-generated ancestor anchors, sent unconditionally so orphan subtrees stay anchored).
  • Scope-local cascades: removal recursion never crosses scopes; a client's children survive a broadcast parent's removal, anchored at the parent's frozen pose (and vice versa).
  • Pointer/click/drag callbacks coexist per scope with per-owner filters; the effective variant's scope receives dispatches. GUI containers support client-elements-into-server-containers nesting (released on disconnect); the reverse direction raises.
  • Panel placement stays write-only per spec §8; scene-tree panel shows local badges and dims virtual anchors.

Verification (ongoing; four review passes so far)

  • ~35 discriminating regression tests (each verified to fail against the pre-fix code) across pytest, vitest, and Playwright e2e; full e2e suite (403 tests) run locally: only failures were two visibility tests whose bug is fixed below.
  • Three independent regression-hunt agent sweeps + four adversarial review passes. Notable fixes that came out of them: old-recording compatibility, drag-end owner capture, pointer-filter reset on reconnect, skinned-mesh per-variant bone state, batched-visibility recompute (caught by CI), wire-order across mid-batch variant flips (parked updates vs. receive-time shadow routing), disconnect-vs-remove teardown races, and a shadow-routing fast path that could apply stale broadcast writes to a client's variant.
  • Randomized multi-threaded stress soak (adds/removes/overrides/disconnect storms across scopes with invariant checks) running clean.
  • Visual confirmation via headless Chromium screenshot rounds (shadowing, un-shadow with latest state, badges, anchored survival, GUI cross-nesting).

Migration notes

  • Wire protocol: scene/GUI messages gain owner/virtual fields (defaulted for old recordings; verified against pre-change .viser files).
  • add_* with an existing name in the same scope still replaces; the same name in a different scope now coexists instead of clobbering.

brentyi added 27 commits August 8, 2026 19:33
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
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'.
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).
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.
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.
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.
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.
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.
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.
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).
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.
…r 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.
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.
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.
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.
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).
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.
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.
- 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.
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.
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.
- 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.
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).
- 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.
- 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.
The nodeRefFromName wait is strictly stronger than store presence.
@brentyi
brentyi merged commit fc4da00 into main Aug 11, 2026
23 checks passed
@brentyi
brentyi deleted the scene-gui-scopes branch August 11, 2026 08:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant