Per-client scene/GUI scopes: variant shadowing and scope-local removal - #758
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ownerfield (""= broadcast, client id otherwise), stamped server-side; scene creates may also bevirtual(auto-generated ancestor anchors, sent unconditionally so orphan subtrees stay anchored).localbadges and dims virtual anchors.Verification (ongoing; four review passes so far)
Migration notes
owner/virtualfields (defaulted for old recordings; verified against pre-change.viserfiles).add_*with an existing name in the same scope still replaces; the same name in a different scope now coexists instead of clobbering.