feat: scene listener — receive-only parcel-scoped observers - #29
feat: scene listener — receive-only parcel-scoped observers#29mikhail-dcl wants to merge 31 commits into
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Regenerate ClientMessage with the new scene_listener_handshake oneof case (= 8) and a round-trip envelope test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add SimulateSceneListenerObserver + CollectSceneListenerSubjects and thread positionalOnly through the shared pipeline so listeners get parcel-exact, TIER_0, positional-only fan-out with no snapshot of their own. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add --scene-listener-parcels flag that connects receive-only, logs the positional stream, and never sends after handshake. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The listener now also subscribes to EmoteStarted/EmoteStopped/ PlayerProfileVersionAnnounced and logs any receipt as a LEAK line, so the "0 received" tally is evidence rather than true by construction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prevents an already-AUTHENTICATED player from converting itself into a listener in place, which would strand its snapshot/grid state as a ghost. Adds coverage for the gate and mid-emote-join emote suppression; aligns the spec's failure-path wording with the no-response implementation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Value-checked TryRemove so the duplicate-session eviction's delayed cleanup can't delete the wallet's rebound mapping. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move the shared handshake pipeline (admission gate, attempt throttle, auth, ban, replay, promotion, response/rejection) into a template-method base class; handlers now override only what differs.
Now that the base is the only caller, fold the auth-chain parsing/validation into a private method taking AuthChainValidator directly; drop the wrapper class and its DI registration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SimulateTick now only walks lifecycle gates and dispatches to SimulatePlayerObserver / SimulateSceneListenerObserver; the tail both paths duplicated (view map, per-subject processing, resync drop, sweep) moves into a shared ProcessCollectedSubjects. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # src/DCLPulse/Peers/Simulation/PeerSimulation.cs # src/Protocol/Generated/PulseClient.cs
|
Wire change follow-up: the scene-listener handshake now announces its area of interest as Motivation: an explicit index list ballooned the handshake packet for large scenes; rects keep it compact. The cap is now the Σ of nominal rect areas ≤ Spec ( |
Replace parcel_indices with inclusive parcel-coordinate rects (sint32) so big scenes fit in small handshake packets. The server validates rect bounds and a sum-of-areas budget (MaxParcels, raised to 4096) before expanding to the internal parcel set; everything downstream of the handshake is unchanged. Test client announces x:z / x1:z1..x2:z2 specs. Includes a 311-parcel real-scene fixture proving the cover -> expansion round-trip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # src/DCLPulse/Metrics/MetricsSnapshot.cs # src/DCLPulseTestClient/ClientOptions.cs # src/DCLPulseTestClient/Program.cs
…tests Main's WebTransport test fixtures construct 8-bucket counters; the envelope on this branch has SceneListenerHandshake = 8, so the Prometheus formatter read out of bounds. Bump all seven test constructions to 9. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Parameterless ctor sizes the array to max defined value + 1, so the count can no longer go stale when the message envelopes grow. Explicit count ctor kept for special cases; Program.cs and all test call sites migrated off the hardcoded literals. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
decentraland-bot
left a comment
There was a problem hiding this comment.
Review summary
Thanks for the detailed design notes and test coverage. I found two P1 issues that should be addressed before merge.
Findings
-
P1 — Breaking protocol change is not coordinated with the existing client package
The PR changed
SceneListenerHandshakeRequestto requireparcel_rects(src/Protocol/Generated/PulseClient.cs,src/DCLPulse/Messaging/Hardening/FieldValidator.cs:132rejects whenParcelRects.Count == 0). However, the currentdecentraland/dcl-pulse-clientconsumer still builds scene-listener handshakes withparcelIndices(src/client/handshake.ts,src/protocol/messages.ts). With this server, an existing client will send noparcel_rectsand be rejected asINVALID_HANDSHAKE_FIELD.Please either keep a backwards-compatible migration path on the server for the old
parcel_indicesshape, or coordinate/update/release the client package before this server change lands and document the required version. -
P1 — Player handshakes are still accepted after authentication and can re-key a live peer
HandshakeHandlerBase.CanBeginHandshake()defaults totrue(src/DCLPulse/Messaging/HandshakeHandlerBase.cs:130), and the normalHandshakeHandlerdoes not override it. An alreadyAUTHENTICATEDpeer can send another playerHANDSHAKE; the shared pipeline replacespeers[from]and callsidentityBoard.Set(from, wallet)without removing the previous wallet’s reverse mapping. If the second handshake uses a different wallet,IdentityBoardcan retain a staleoldWallet -> same PeerIndexentry whilewalletsByPeerIds[from]now points to the new wallet.Please gate the normal player handshake to
PENDING_AUTHas well (or explicitly remove the old identity mapping before any supported re-auth flow), and add regression tests for an authenticated peer sending a second handshake. -
P2 — Initial-state emote mask loses optional presence
HandshakeHandler.SeedInitialState()maps an omittedEmoteMaskto0and passes it as present (src/DCLPulse/Messaging/HandshakeHandler.cs:90-92). The liveEmoteStartHandlerpreserves absence withint? mask = emoteStart.HasMask ? emoteStart.Mask : null. Reconnect/initial-state emotes can therefore serializemask = 0where live emotes omit the field. Please preserve presence withint? emoteMask = initialState.HasEmoteMask ? initialState.EmoteMask : null.
Security review
No hardcoded secrets or obvious injection issues found. The authenticated re-handshake issue above is the main auth/lifecycle security concern.
Consumer impact
The scene-listener protocol surface is public. I found an existing downstream consumer (decentraland/dcl-pulse-client) still using the old parcel-index handshake shape, so this needs a compatibility or rollout plan before merge.
CI
All current checks are passing.
Reviewed by Jarvis 🤖 · Requested by Mikhail Agapov (<@U04A7TYN13L>) via Slack
|
Re Finding 1 (P1 — "Breaking protocol change is not coordinated with the existing client package"): not applicable. The Pulse protocol is pre-GA and has no released external consumers whose wire compatibility must be preserved. A server-side back-compat shim for the old 🤖 Generated with Claude Code |
…sk presence Finding 2 (P1): HandshakeHandler now overrides CanBeginHandshake to require PENDING_AUTH, matching SceneListenerHandshakeHandler. An already-AUTHENTICATED peer can no longer re-key a live session in place (which left a stale oldWallet -> PeerIndex reverse mapping in IdentityBoard). Genuine reconnects arrive on a fresh PeerIndex, so they are unaffected. Regression test added. Finding 3 (P2): SeedInitialState preserves emote-mask absence (int? = null) instead of collapsing an omitted mask to a present 0, matching EmoteStartHandler. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
decentraland-bot
left a comment
There was a problem hiding this comment.
Review Summary
Thoroughly reviewed the full 5800-line diff across 55 changed files. This is a well-designed, well-tested feature addition. The previous review's P1 findings (re-handshake on authenticated peer, emote mask presence) have been properly fixed, and the IdentityBoard race condition fix is correct. No P0 or P1 issues remain — approving.
Previous findings — resolved
| # | Previous finding | Resolution |
|---|---|---|
| 1 | P1 — Breaking protocol change (parcel_indices → parcel_rects) |
Author confirmed pre-GA, coordinated lockstep with dcl-pulse-client. No released external consumers. Accepted. |
| 2 | P1 — Re-handshake on authenticated peer | ✅ Fixed. Both HandshakeHandler and SceneListenerHandshakeHandler now override CanBeginHandshake() to require PENDING_AUTH. New test Handle_SecondHandshakeOnAuthenticatedPeer_IsDroppedAndDoesNotRekey covers the regression. |
| 3 | P2 — EmoteMask presence | ✅ Fixed. SeedInitialState now uses int? emoteMask = initialState.HasEmoteMask ? initialState.EmoteMask : null, matching the live EmoteStartHandler semantics. New tests Handle_EmoteInitialStateWithoutMask_LeavesMaskNull and Handle_EmoteInitialStateWithMask_PreservesMask cover both paths. |
New findings
[P2] CanBeginHandshake base default is now dead code with a stale doc comment
HandshakeHandlerBase.CanBeginHandshake() defaults to true with a comment saying "the player flow re-authenticates regardless." But the player handler now overrides to require PENDING_AUTH, so the default is never exercised. The comment is misleading — a future maintainer reading the base class would believe re-auth is intended.
Consider either:
- Making
CanBeginHandshakeabstract(forces future handlers to decide explicitly), or - Changing the default to
PENDING_AUTH(safe-by-default) and updating the comment.
[P2] Design spec proto snippet is stale
docs/superpowers/specs/2026-07-07-scene-listener-design.md ("Protocol changes" section) still shows:
repeated int32 parcel_indices = 3;but the actual wire protocol now uses repeated ParcelRect parcel_rects = 3 (as correctly reflected in CLAUDE.md and the generated C#). The spec should be updated to match.
Security review
No security issues found. Specifically verified:
- Auth pipeline: Scene-listener handshakes flow through the same ECDSA auth-chain validation, ban-list check (before replay cache), and anti-replay guard as player handshakes. No bypass path.
- PENDING_AUTH gate: Both handlers require
PENDING_AUTH, preventing an authenticated peer from converting itself in place (which would leave ghost SnapshotBoard/SpatialGrid entries). - IdentityBoard race fix:
TryRemove(KeyValuePair<string, PeerIndex>)is the correctConcurrentDictionaryoverload for value-checked atomic removal — the evicted peer's delayed cleanup can no longer delete the live wallet→peer binding. - Post-auth choke point:
IsForbiddenForSceneListenerdrops all non-Resyncmessages from listeners, counted via metrics. No disconnect on violation (degrades to noise, not churn). Parcel set immutable by construction. - Input validation:
ValidateSceneListenerHandshakevalidates rect well-formedness (min ≤ max), coordinate bounds (IsValidCoordinatefor corners — sufficient since the valid region is rectangular), and nominal area budget withlongarithmetic (no int overflow). Budget enforced before expansion to prevent CPU/memory DoS. - No hardcoded secrets, no new network exposure.
Consumer impact
The ParcelRect-based scene-listener handshake is a new protocol surface with no GA consumers. The dcl-pulse-client adaptation is coordinated in lockstep. No backward-compatibility concern.
Architecture assessment
- Template method pattern (
HandshakeHandlerBase): Well-motivated — the shared pipeline (attempt throttle → auth-chain parse → ban check → replay guard → promote → evict duplicate → register identity → respond) is security-critical and shouldn't be duplicated. The hooks (GetAuthChain,TryAuthorize,OnAuthenticated,LogAccepted) are clean extension points. - Peer variant, not separate subsystem: Correct choice — reusing the observer pipeline (views, diffs, resync, sweeps) avoids duplicating ~500 lines of battle-tested machinery. The
SceneListenerdescriptor onPeerStatecleanly signals the variant. positionalOnlyflag: Threading a boolean throughProcessVisibleSubjectsis pragmatic for exactly two paths (player/listener). The gates are placed precisely at the three emission points (emote start on new subject, emote start on existing subject, profile announcement). Emote stop suppression is implicit and correct — sinceEmoteStartedis never sent to listeners,view.LastSentEmotestays null, so the stop path is a no-op.- Parcel-to-cell mapping: Computed once at handshake, O(1) per cell per tick — efficient. The covering-cell over-approximation (1–4 cells per 16m parcel on a 100-unit grid) is harmless because the simulation filters parcel-exact.
EnumCountersparameterless ctor:DeriveBucketCountfrom max defined value + 1 prevents the bucket count from going stale when newClientMessagevariants are added. Good defensive change.
Test coverage
404/404 tests passing. New test suites provide excellent coverage:
SceneListenerHandshakeHandlerTests— accept, reject (over-cap, empty, bad auth), PENDING_AUTH gate, duplicate eviction, no subject registrationPeerSimulationTests.SceneListener— parcel-exact filtering, cross-realm invisibility, emote/profile suppression, teleport delivery, sweep-based PlayerLeft, resync servedSceneListenerMessagePolicyTests— every forbidden message type dropped, Resync allowed, metric countingFieldValidatorTests— rect validation (valid, inverted, out-of-bounds, over-cap, overlapping dedup, origin-crossing)SceneListenerRealSceneTests— 311-parcel real scene with greedy rect cover, server-side expansion reproduces exact footprintIdentityBoardTests.Remove_OldPeerAfterWalletRebound_KeepsLiveForwardMapping— regression test for the race fixHandshakeHandlerTests— re-handshake drop, eviction cleanup chain, emote mask presence
CI status
Tests ✅ | Linux build ✅ | macOS build ✅ | Win build pending
Reviewed by Jarvis 🤖 · Requested by mikhail-dcl via GitHub
| /// (the player flow re-authenticates regardless). Scene listeners override to require | ||
| /// PENDING_AUTH so an already-authenticated peer can't convert itself in place. | ||
| /// </summary> | ||
| protected virtual bool CanBeginHandshake(PeerState existingState) => true; |
There was a problem hiding this comment.
[P2] Stale default + doc comment. Both concrete handlers now override this to PENDING_AUTH, making the true default dead code. The comment says "the player flow re-authenticates regardless" — no longer accurate after the fix. Consider making this abstract (forces future handlers to decide) or changing the default to PENDING_AUTH (safe-by-default).
| ```proto | ||
| message SceneListenerHandshakeRequest { | ||
| bytes auth_chain = 1; // same signed-fetch headers JSON as HandshakeRequest | ||
| string realm = 2; // required; same rules as TeleportRequest.realm |
There was a problem hiding this comment.
[P2] Stale proto snippet. This section still shows repeated int32 parcel_indices = 3 but the actual wire format is now repeated ParcelRect parcel_rects = 3 (as documented correctly in CLAUDE.md). Worth updating to match.
There was a problem hiding this comment.
Fixed in e08b5d8: the spec now shows the ParcelRect/parcel_rects wire format (sint32), the Σ-area budget, cap 4096, and rect validation/expansion — matching CLAUDE.md and the implementation.
|
|
||
| // Value-checked removal: after a duplicate-session eviction the wallet is already | ||
| // rebound to the replacement peer, and the evicted peer's delayed cleanup must not | ||
| // delete that live mapping. |
There was a problem hiding this comment.
👍 Excellent fix. The TryRemove(KeyValuePair<string, PeerIndex>) overload is exactly the right tool — atomically removes only if the value still matches, so a late cleanup of an evicted peer can never delete the live wallet binding. The regression test (Remove_OldPeerAfterWalletRebound_KeepsLiveForwardMapping) pins it well.
The player HandshakeHandler now overrides CanBeginHandshake to require PENDING_AUTH (470c403), so the base default's comment claiming the player flow re-authenticates regardless was stale. Comment-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- HandshakeHandlerBase.CanBeginHandshake is now abstract, removing the dead `true` default (both concrete handlers override it) and forcing future handlers to make the admission decision explicitly. - Update the scene-listener design spec to the parcel-rect wire format: ParcelRect/parcel_rects proto, sint32 coords, Σ-area budget, cap 4096, rect validation + expansion — the spec had lagged behind CLAUDE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
What
A new scene-listener client type: authenticates via a dedicated
SCENE_LISTENER_HANDSHAKE(same ECDSA auth chain as players), announces an immutable parcel set as its area of interest, never sends state updates, and receives only the positional stream (PlayerJoined/PlayerLeft/PlayerStateDelta/PlayerStateFull/Teleported) for players inside those parcels. Intended for scene/world services that need presence awareness.Design
SnapshotBoard/SpatialGridregistration, so players can't see listeners; aPENDING_AUTHgate prevents an authenticated player from converting itself in placeRESYNC_REQUESTis silently dropped and counted; the parcel set is immutable by constructionSceneListener:MaxParcels(default 256, reject over-cap),pulse.scene_listener.*metricsHandshakeHandlerBasepipeline for both handshakes;SimulateTicksplit into player/listener pathsIdentityBoardlosing the live wallet binding when an evicted peer's cleanup runsRequires
Companion
@dcl/protocolcommitb875338(SceneListenerHandshakeRequest,ClientMessage.scene_listener_handshake = 8).Verification
docs/superpowers/specs/2026-07-07-scene-listener-design.md🤖 Generated with Claude Code