feat: per-realm scene-listener AoI, reassignable in place - #39
Conversation
Two limits of the scene listener as shipped: it observed a single realm, and its parcel set was fixed for the connection's lifetime. An authoritative server needs neither — it cohosts scenes from several realms, and loads and unloads them while it runs. The announced AoI becomes a repeated SceneListenerAoi, one entry per realm, on both the handshake and the new SceneListenerUpdate. SceneListenerState holds ParcelsByRealm plus one union of covering cell keys; Observes(realm, parcel) replaces the realm compare and parcel lookup that the simulation did separately. Parcels only mean anything inside a realm — every world numbers its own from 0,0 — so two cohosted worlds share both grid cells and parcel indices, and neither filter is redundant. The Σ-area budget spans the whole announcement, so extra realms buy no extra area, and a realm may appear only once. SceneListenerUpdateHandler swaps in a whole new descriptor rather than mutating one, keeping SceneListenerState immutable for its readers. The simulation re-reads it each tick, so a new AoI takes effect on the next one: subjects inside it are joined like any newly visible peer, and subjects dropped from it age out through the ordinary stale-view sweep, exactly as for a player who walks out of range. It rides the shared discrete-event bucket, and a malformed AoI disconnects with INVALID_SCENE_LISTENER_FIELD without partially applying. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7qjELVNiLBaqmn75jGknw
…lms are replaceable A player sending SceneListenerUpdate was dropped ahead of the rate limiter so it could not spend the discrete-event budget — and then logged a warning per packet, the one unthrottled thing left on the path. Count it under the listener's forbidden-message metric instead, like every other dropped message. The design spec and the choke-point doc still said the realm was fixed for the connection's lifetime; the update replaces realms with the rest of the AoI and a test already pins that. What is fixed is the role. Also pin that a subject dropped by a reassignment is swept out with PlayerLeft on the ordinary stale-view path, which the docs promise but nothing tested. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AT5fSeGp16vwqAMymSMS8A
The protocol drops the reserved fields: no listener ever shipped on the single-realm handshake, so `aoi` takes field 2 outright. Generated code and the spec's excerpt follow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The committed bindings put SceneListenerHandshakeRequest.aoi on field 2; the protocol reserves 2 and 3 and puts it on 4, so a client generated from it announced into a field this server never read and every listener handshake was rejected as an empty AoI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The AoI's second pass now pays for itself — it sizes each parcel set from the area the first measured, and takes the cell cover from each rect as a cell range instead of four probes per parcel: same cover, 3x less allocation, ~10x faster at the cap. MaxParcels charges per realm too, so 4096 single-parcel realms are no longer a legal 1.6 MB announcement. Also: FieldValidator returns the finished descriptor so neither handler builds one, unbounded realm-name joins are out of log arguments, and multi-realm filtering is tested through the simulation rather than only the validator. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Mikhail Agapov <mikhail.agapov@decentraland.org>
…nnect Lower PeerTimeoutMs to 5s, split the sweep constant into a 1s cadence and 3s threshold, and clear snapshot/grid entries on the lifecycle event so the sweep starts at disconnect. Take the seqlock write side in ClearActive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
decentraland-bot
left a comment
There was a problem hiding this comment.
Review: per-realm scene-listener AoI, reassignable in place
Verdict: APPROVE — no P0 or P1 issues found. This is a well-crafted, thoroughly tested extension to the scene-listener protocol.
What the PR does
Converts the scene listener's area of interest from a single-realm, immutable-at-connect parcel set to a per-realm (Dictionary<string, HashSet<int>>) descriptor that can be swapped in place via the new SceneListenerUpdate message. The change is motivated by an authoritative server cohosting scenes from several worlds — since every world numbers its parcels from 0,0, a flat parcel set cannot distinguish them, and the per-IP/wallet-unique session limits forbid one connection per world.
Other notable changes:
- Two-phase disconnect:
ClearActive+SpatialGrid.Removenow runs immediately on theDisconnectedlifecycle event (phase 1), so the subject exits every AoI query on the next tick; identity/slot cleanup still waits for theDisconnectionCleanTimeoutMsgrace window (phase 2). - Stale-view sweep rework: replaces
SWEEP_INTERVAL = 100withVIEW_STALE_TICKS = 60+SWEEP_CHECK_INTERVAL = 20, giving a documented 3.05–4.0 s eviction bound. PeerTimeoutMsreduced from 30 s to 5 s — see P2 note below.- Cell-cover optimization:
SceneListenerCellMappernow operates per-rect at O(cells) instead of per-parcel at O(4×parcels).
Security audit
- Rate-limiter ordering ✅ —
SceneListenerUpdateHandlerapplies auth → role check →DiscreteEventRateLimiter→ expensiveFieldValidatorvalidation/expansion. The O(Σ rect area) work runs only after a token is consumed. - Budget validation ✅ — cumulative budget (
long) accumulatesREALM_BUDGET_COST + Σ rect areasacross all realms, checked incrementally. Two-pass validation (price before expand) prevents hostile payloads from buying expansion work on their way to rejection. No integer overflow possible. - Input validation completeness ✅ — empty AoI, empty realm, repeated realm, empty rect list, inverted rects, out-of-bounds coordinates, and over-budget announcements are all rejected with disconnect.
- Player sending SceneListenerUpdate ✅ — two-layer defense:
IsForbiddenForSceneListenerpasses it (not a listener message), but the handler drops at theSceneListener is not { }check before the rate limiter, so it cannot spend a player's discrete-event budget. - Thread safety ✅ — both the update handler (writer) and the simulation tick (reader) of
PeerState.SceneListenerrun on the owning worker thread. Theis { } listenercapture inSimulateTickgives a consistent reference for the entire tick. Reference assignment is atomic on .NET regardless. - Seqlock in
SnapshotBoard.ClearActive✅ — protocol is correct (odd/even version,Thread.MemoryBarrierbefore closing even write). Single-writer invariant is maintained:ClearActiveis called only from the owning worker thread. - Realm name injection ✅ — names are non-empty, length-capped, duplicate-rejected, and used only as dictionary keys and
string.Equalscomparisons. No injection surface.
Consumer impact
- Protocol change:
SceneListenerHandshakeRequestfield 2 changed fromstring realmtorepeated SceneListenerAoi aoi— wire-incompatible, but the PR description confirms nothing shipped the old shape. Protocol side tracked in decentraland/protocol#469. - bevy-explorer: consumer PR linked (decentraland/bevy-explorer#1137).
- unity-explorer: only has the generated proto file (
PulseClient.gen.cs), which would need regeneration; no runtime usage of the scene-listener handshake path found.
Test coverage
Excellent. Key new test files:
SceneListenerUpdateHandlerTests.cs(221 lines) — valid/invalid updates, role check, rate limiting, realm replacement.PeerSimulationTests.TwoPhaseDisconnect.cs(226 lines) — phase-1 instant visibility drop, phase-2 deferred cleanup, sweep timing.- Extended
FieldValidatorTestswith multi-realm, budget-spanning, realm-overhead, repeated-realm, and cell-cover tests. PeerSimulationTests.SceneListenerextended with AoI reassignment, per-realm filtering, realm-set replacement, and sweep-bound tests.
P2 findings (non-blocking)
All minor — none block merge.
1. [P2] Misleading memory-model comment in SnapshotBoard.ClearActive
src/DCLPulse/Peers/Simulation/SnapshotBoard.cs
The comment says "Volatile.Write alone only prevents reordering of that store with later stores — on ARM, preceding stores could still be reordered past it." This is incorrect: .NET's Volatile.Write has release semantics on ARM64 (stlr), which already prevents preceding stores from being reordered past it. The Thread.MemoryBarrier() is therefore belt-and-suspenders — correct but redundant. The comment could mislead a future maintainer into removing the Volatile.Write instead of the truly redundant barrier. Consider correcting the comment.
2. [P2] PeerTimeoutMs reduced from 30 s to 5 s
src/DCLPulse/Transport/ENetTransportOptions.cs
The flat deadline means any network stall longer than 5 s drops the peer. The doc acknowledges the tradeoff and the development override to 300 s. Worth verifying this has been load-tested with mobile clients, which can experience >5 s stalls on cell networks. The reconnect flow absorbs it, but operators should be aware the default is now aggressive.
3. [P2] SceneListenerState exposes mutable collection types
src/DCLPulse/Peers/SceneListenerState.cs
ParcelsByRealm is Dictionary<string, HashSet<int>> and CellKeys is long[] — both mutable despite the class being documented as immutable. No current reader mutates them and the single-writer/single-reader threading model prevents corruption, but IReadOnlyDictionary<string, IReadOnlySet<int>> and ReadOnlyMemory<long> would enforce the contract at the type level. Low priority — consistent with the pre-existing pattern.
Git conventions ✅
- PR title:
feat: per-realm scene-listener AoI, reassignable in place— semantic commit format. - Branch:
feat/scene-listener-aoi-update— matches.
CI ✅
All checks pass: build (linux/win/osx), tests (637/637).
Reviewed by Jarvis 🤖 · Requested by mikhail-dcl via GitHub
Part of decentraland/sdk-multiplayer-server#132 — rollout plan in this comment. Protocol side: decentraland/protocol#469.
Two limits of the scene listener as shipped: it observed a single realm, and its parcel set was fixed for the connection's lifetime. An authoritative server needs neither — it cohosts scenes from several realms (every world numbers its parcels from 0,0, so two cohosted worlds share both grid cells and parcel indices), and it loads and unloads scenes while it runs. One connection per realm is ruled out by the wallet-unique session rule and the per-IP listener cap.
repeated SceneListenerAoi, one entry per realm, on both the handshake and the newSceneListenerUpdate.SceneListenerStateholdsParcelsByRealmplus one union of covering cell keys;Observes(realm, parcel)replaces the separate realm compare and parcel lookup. The Σ-area budget spans the whole announcement, and a realm may appear only once.SceneListenerUpdateHandlerswaps in a whole new descriptor rather than mutating one. The simulation re-reads it each tick, so a new AoI takes effect on the next one; dropped subjects age out through the ordinary stale-view sweep (now pinned by a test). It rides the shared discrete-event bucket; a malformed AoI disconnects withINVALID_SCENE_LISTENER_FIELD(19) without partially applying. A player sending it is counted under the listener's forbidden-message metric rather than logged per packet.Consumer: bevy-explorer decentraland/bevy-explorer#1137 (one listener per engine, one AoI entry per hosted realm, updates coalesced to one per second).
Verified:
dotnet test637/637.🤖 Generated with Claude Code