Skip to content

fix(sdk): release reserved player CRDT entity slot on every disconnect - #9516

Open
LautaroPetaccio wants to merge 4 commits into
mainfrom
fix/player-crdt-reserved-entity-slot-leak
Open

fix(sdk): release reserved player CRDT entity slot on every disconnect#9516
LautaroPetaccio wants to merge 4 commits into
mainfrom
fix/player-crdt-reserved-entity-slot-leak

Conversation

@LautaroPetaccio

@LautaroPetaccio LautaroPetaccio commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

What

Fixes a permanent reserved-entity slot leak in PlayerCRDTEntitiesHandlerSystem that silently hides later-joining players from every scene for the rest of the session.

AddPlayerCRDTEntity reserves a CRDT entity id unconditionally (ReserveNextFreeEntity()), but FreeReservedEntity(...) was called only inside the if (playerCRDTEntity is { AssignedToScene: true, SceneFacade: not null }) branch of RemoveComponent. The World.Remove<PlayerCRDTEntity>(entity) at the end of the same method was already correctly outside that branch — only the slot release was misplaced. Every remote player who disconnected while not currently assigned to a live scene leaked their slot forever.

That state is the common case, not an exotic one:

  • freshly created remote entities are parked at HIDDEN_SPAWN_POSITION = (int.MinValue, 0, int.MinValue) (RemoteEntities.CreateCharacter), so scenesCache.TryGetByParcel finds nothing and the player is in no scene until their first movement packet arrives — disconnecting in that window leaked;
  • roads, empty parcels, parcels whose scene is not loaded, and LOD all fail the newSceneIsValid check;
  • on every realm switch RemoteEntities.ForceRemoveAll marks every tracked wallet for deletion at once, most of them not inside a live scene at that instant.

There was no recovery path: FreeReservedEntity has exactly one caller and ClearReservedEntities() only ever ran from the constructor. The pool is 224 slots (OTHER_PLAYER_ENTITIES_FROM = 32 .. OTHER_PLAYER_ENTITIES_TO = 256). Once cumulative leaks reached 224, ReserveNextFreeEntity() returned -1 and AddPlayerCRDTEntity bailed out silently (if (crdtEntityId == -1) return;, no log). From that point on, every newly connected remote player was exposed to no scene at all — no PlayerIdentityData, no Transform, in any scene.

This is the confirmed client-side root cause of an authoritative-multiplayer scene being unable to see its players, which forced that scene to build a WebSocket position-heartbeat workaround. It also explains the shape of the failure: only some players (later joiners), permanent rather than intermittent, silent, and worse the longer the session runs.

Frequency and impact

This issue is cumulative rather than tied to a fixed time interval. One slot leaked on every remote-player removal that occurred while AssignedToScene == false. The triggering states are routine: the pre-movement hidden spawn, roads, empty or unloaded parcels, LOD, and realm-switch bulk removal.

After L leaked slots, only 224 - L slots remain available for live remote players. User-visible failures can therefore begin before complete exhaustion whenever the number of remote players needing exposure exceeds the remaining capacity. For example, after 100 leaks only 124 concurrent remote players can be exposed; after 200 leaks only 24 can. At 224 leaked slots, every later remote player is hidden from SDK scenes for the rest of the session.

As an illustration, if a realm-switch/disconnect cycle removes an average of 20 unassigned remote players, full exhaustion can occur after about 12 such cycles; at 50 per cycle, about 5 cycles. These are capacity examples, not measured production rates. There is currently no telemetry counting removals where AssignedToScene is false, so the exact production incidence cannot be quantified from the code alone.

Changes

  1. FreeReservedEntity moved out of the AssignedToScene guard so the slot is released whenever noLongerExists is true, i.e. paired with the World.Remove<PlayerCRDTEntity> that was already there — matching where the slot is allocated. RemovePlayerFromScene still runs under the original guard, unchanged.
  2. Pool exhaustion is now observable: ReportHub.LogWarning(GetReportData(), ...) (the codebase convention for systems; the category resolves from this class's own [LogCategory(ReportCategory.PLAYER_SDK_DATA)]). AddPlayerCRDTEntity can run for many entities every frame while exhausted, so the warning is gated by a flag that is cleared only when a slot is actually released or the pool is cleared — one warning per exhaustion episode, never per frame.
  3. FreeReservedEntity is now idempotent — it returns early if the slot is already free. It previously decremented currentReservedEntitiesCount unconditionally. See the double-free analysis below.

Double-free analysis

A double free would be worse than the leak, so this was checked explicitly.

  • Was FreeReservedEntity idempotent? No. For an in-range id it set reservedEntities[i] = false and decremented currentReservedEntitiesCount unconditionally, so a repeated release would drift the counter (potentially negative) and break the currentReservedEntitiesCount == reservedEntities.Length fast path. It was already a safe no-op for out-of-range ids: the local player's PLAYER_ENTITY (1) maps to 1 - 32 = -31 and returns early, so the local player never touches the pool — including via the RemoveComponent query, which does not exclude PlayerComponent. This PR makes it idempotent for in-range ids too, so a hypothetical repeated release is a no-op.
  • Can the same CRDTEntity.Id reach it twice? No path found. The release is now bound 1:1 to the removal of the component that owns the id: it is called on exactly the condition (noLongerExists) that already guarded World.Remove<PlayerCRDTEntity>(entity), and noLongerExists is true at both — and the only — call sites of the private RemoveComponent. The two [Query] entry points are mutually exclusive by construction (RemoveComponentOnPlayerDisconnect requires DeleteEntityIntention; RemoveComponent requires None(DeleteEntityIntention)), and both require PlayerCRDTEntity, which is removed in the same call, so neither can match the entity again. AddPlayerCRDTEntity cannot re-add while DeleteEntityIntention is present (None(DeleteEntityIntention)), so any later re-add is a fresh reservation. A repo-wide grep confirms PlayerCRDTEntity is added/removed only in this system.
  • No pre-existing release path, so this is not turning one release into two: FreeReservedEntity had exactly one caller (the one that was moved) and ClearReservedEntities() only the constructor.
  • Structural-change safety is unchanged. If Arch could ever visit an entity twice in one query pass, the existing World.Remove<PlayerCRDTEntity> would already double-remove; this change adds no new exposure, and the idempotency guard makes the second release harmless.
  • Worth noting: the bool[] — not the counter — is authoritative for allocation, so even the old non-idempotent double free could not have handed the same id to two players; the worst case was counter drift. That is now impossible.

Item 3 (reset the pool on realm switch): investigated, deliberately NOT wired

What I found:

  • ClearReservedEntities() has exactly one caller, the constructor. PlayerCRDTEntitiesHandlerSystem is constructed once per GlobalWorld, and GlobalWorld is not recreated on a realm switch: RealmController.UnloadCurrentRealmAsync destroys only realm/scene-definition entities (CLEAR_QUERY, which does not match player entities) and calls GlobalWorld.Clear(), which only clears CameraSamplingData/RealmSamplingData. So there is no existing per-system realm-change reset hook.
  • RemoteEntities.ForceRemoveAll (2nd operation of the realm-change sequence in RealmNavigationContainer, via RemoveRemoteEntitiesTeleportOperation) does not destroy entities — it adds DeleteEntityIntention through world.AddOrGet. Entities are destroyed later by DestroyEntitiesSystem, which is in SyncedPreRenderingSystemGroup (start of PostLateUpdate), while this system is in PresentationSystemGroup (end of PreLateUpdate) — earlier in the same frame. This system is also not [ThrottlingEnabled] whereas DestroyEntitiesSystem is, so the handler cannot be skipped in a frame where the destroyer runs.
  • Consequence: with change 1, the realm-switch mass removal already releases every slot through the normal disconnect path, so an explicit reset is no longer needed to keep the pool healthy across realm switches. The only residual theoretical window is an intention added between end-of-PreLateUpdate and start-of-PostLateUpdate of the same frame; the teleport sequence runs on UniTask continuations (default PlayerLoopTiming.Update), which is before both.
  • The nearest existing hook is IFinalizeWorldSystem.FinalizeComponents(in Query), collected in GlobalWorld.FinalizeWorldSystems and invoked from UnloadCurrentRealmAsync. Wiring ClearReservedEntities() there is not clearly safe, so I did not guess: it is documented as running "right before World destruction", it is handed the realm CLEAR_QUERY (unrelated to players), and it can run while live PlayerCRDTEntity components still hold pool ids — the local player always, and remote entities whose DeleteEntityIntention has not been processed yet if the teleport operations complete without crossing a frame boundary. Blank-clearing the bitmap while a component still holds an id would let a later-joining player be handed that same id, i.e. two players sharing one CRDT entity in a scene. That is worse than the leak.

Recommended follow-up (safer than a blind reset, no timing assumptions): instead of blank-clearing, reconcile the bitmap from the world — query all live PlayerCRDTEntity components, mark exactly those ids reserved, free the rest, then retry the reservation. Triggered on exhaustion (and/or on realm change) this is self-healing for any leak path, and it can never hand out an id that a live component still holds.

Verification

Verified by reading the code (no build, see below):

  • Every caller of RemoveComponent, FreeReservedEntity, ReserveNextFreeEntity, ClearReservedEntities and AddPlayerCRDTEntity — repo-wide grep shows all are private to this single file; FreeReservedEntity had exactly one caller, ClearReservedEntities only the constructor.
  • No other path already releases a slot, so this change is not a double free (details above).
  • noLongerExists is the correct condition: it is true at both call sites, and each of those immediately removes the component, so it cannot be true while the component is still live.
  • Nothing else in the repo adds or removes PlayerCRDTEntity; RemoteEntities never destroys the global entity directly, it only marks DeleteEntityIntention; RealmController.CLEAR_QUERY does not match player entities. So no path destroys a player entity behind this system's back within a frame.
  • Group ordering confirmed from the Arch package: PresentationSystemGroup = end of PreLateUpdate, PreRenderingSystemGroup = start of PostLateUpdate, so this system observes DeleteEntityIntention before DestroyEntitiesSystem destroys the entity.
  • Logging: ReportHub.LogWarning(ReportData, object) exists in DCL.Diagnostics (already imported by this file); GetReportData() is protected internal on BaseUnityLoopSystem and resolves the category from this class's [LogCategory(ReportCategory.PLAYER_SDK_DATA)]; ReportHub is already used elsewhere in the same DCL.Multiplayer assembly, so no asmdef change is needed.
  • Existing tests for this system do exist (PlayerCRDTEntitiesHandlerSystemShould.cs), so two regression tests were added there: a single disconnect while outside any scene must return the id to the pool, and 224 + 8 connect/disconnect cycles outside any scene must never exhaust the pool (each cycle must reuse OTHER_PLAYER_ENTITIES_FROM). By inspection both should fail on main — the first because the replacement player would get OTHER_PLAYER_ENTITIES_FROM + 1, the second at its second cycle for the same reason and, past cycle 224, with no PlayerCRDTEntity assigned at all. I could not run them to confirm that (see below), so treat it as reasoning, not as an observed result.
  • Syntax: both files were parsed with Roslyn csc without project references. The only diagnostics are missing-type/namespace ones (CS0518/CS0246/CS0234/CS0103, including the pre-existing ReportCategory in the class attribute). Zero syntax diagnostics. This is a parse check, not a build.

NOT verified locally — please exercise in CI / the editor:

  • No Unity compile. The real assemblies were never compiled; the Arch source generators never ran. A reviewer or CI must confirm the project builds.
  • The two added NUnit tests were never executed. They follow the UnitySystemTestBase harness and the conventions already used in the same file, but they have not been run in the editor or in CI.
  • No play-mode or in-game verification of the realm-switch path, of the exhaustion warning firing exactly once, or of the fix end to end.

Manual repro for a reviewer:

  1. Join a world containing an authoritative-multiplayer scene (e.g. flagtag), or any scene that queries players via PlayerIdentityData + Transform.
  2. Have peers connect and disconnect repeatedly from outside the scene bounds — a road, an empty parcel, or right after connecting while they are still at the hidden spawn position and before they have moved. Switching realms repeatedly works too, since ForceRemoveAll removes every tracked wallet at once.
  3. Repeat until more than 224 slots have been consumed cumulatively.
  4. On main: from that point on, every newly joining player never appears in the scene's PlayerIdentityData + Transform query — permanently, with nothing in the logs. With this PR: slots are returned on every disconnect so the pool never exhausts, and if it ever does (224 genuinely concurrent remote players), a single warning is logged instead of failing silently.

🤖 Generated with Claude Code

`PlayerCRDTEntitiesHandlerSystem` reserves a CRDT entity id unconditionally in
`AddPlayerCRDTEntity`, but released it only inside the
`{ AssignedToScene: true, SceneFacade: not null }` branch of `RemoveComponent`.
Any remote player that disconnected while assigned to no scene leaked its slot
permanently, and there was no recovery path: `FreeReservedEntity` has a single
caller and `ClearReservedEntities` only ever ran from the constructor.

Being assigned to no scene is the common case, not an edge case:

- freshly created remote entities are parked at `HIDDEN_SPAWN_POSITION` until
  their first movement packet arrives, so `TryGetByParcel` finds no scene;
- roads, empty parcels, parcels whose scene is not loaded and LOD all fail the
  scene validity check;
- `RemoteEntities.ForceRemoveAll` marks every tracked wallet for deletion on
  realm change, most of them outside a live scene at that instant.

The pool holds 224 ids (`OTHER_PLAYER_ENTITIES_FROM`..`OTHER_PLAYER_ENTITIES_TO`).
Once cumulative leaks reached 224, `ReserveNextFreeEntity` returned -1 and
`AddPlayerCRDTEntity` bailed out silently, so from that point on every newly
connected remote player was exposed to no scene at all for the rest of the
session: no `PlayerIdentityData` and no `Transform` in any scene. That is the
client-side root cause of authoritative-multiplayer scenes not being able to see
part of their players: later joiners only, permanently, silently, and getting
worse the longer the session runs.

Changes:

- Release the slot whenever the component goes away, pairing `FreeReservedEntity`
  with the `World.Remove<PlayerCRDTEntity>` that already lives outside the
  scene-assignment guard, matching where the slot is allocated.
  `RemovePlayerFromScene` keeps running under the original guard, unchanged.
- Make `FreeReservedEntity` idempotent so releasing an already free slot can
  never corrupt `currentReservedEntitiesCount`.
- Report pool exhaustion with a warning, once per exhaustion episode, instead of
  failing silently.
- Cover both a single disconnect outside any scene and repeated
  connect/disconnect cycles beyond the pool size with tests.
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

@LautaroPetaccio
LautaroPetaccio marked this pull request as ready for review July 29, 2026 15:23
@LautaroPetaccio
LautaroPetaccio requested review from a team as code owners July 29, 2026 15:23
@decentraland-bot decentraland-bot added the ext-contribution Identifies a contribution which was not initiated by a Unity Developer label Jul 29, 2026
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

badge

Warnings not reduced: 14034 => 14047 — remove at least 14 warnings to merge.

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

badge

Some Unity tests failed ❌

TESTS SUITE Result Passed Failed Skipped
EditMode ❌ 1 failed 24203 1 13
PlayMode ✅ Passed 236 0 5
Failed tests (1)
  • [editmode] CrdtEcsBridge.JsModulesImplementation.Tests.CommunicationControllerAPIImplementationShould.OnMessageReceived

@github-actions

Copy link
Copy Markdown
Contributor

Slack notification sent to #explorer-ext-contributions for external review.
To re-send, delete this comment and re-add the ext-contribution label.

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

STEP 1 — Context & scope

  • Loaded CLAUDE.md, docs/README.md, and docs/multiplayer.md for the multiplayer/SDK CRDT lifecycle context.
  • Reviewed the diff and checked out the PR head locally.
  • Changed files: PlayerCRDTEntitiesHandlerSystem.cs and PlayerCRDTEntitiesHandlerSystemShould.cs.

STEP 2 — Root-cause check
The PR addresses the actual cause: reserved remote-player CRDT ids are allocated when PlayerCRDTEntity is created, but were only freed when the player was assigned to a scene. Moving the release to the component-removal path pairs the reservation with its owner instead of the scene-assignment state.

STEP 3 — Design & integration
No new long-lived unit was introduced. The lifecycle owner searched was the existing PlayerCRDTEntitiesHandlerSystem (Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerCRDTEntitiesHandlerSystem.cs), which already creates/removes PlayerCRDTEntity and owns the reservation bitmap. Related multiplayer ownership is documented in docs/multiplayer.md (IRemoteEntities, IEntityParticipantTable, and SDK propagation), and this fix stays in the existing SDK CRDT owner rather than adding a parallel reconciler.

Teardown/consumption trace: no new subscriptions, callbacks, rooms, buffers, native resources, or async handles were added. The reservation acquired by ReserveNextFreeEntity() is now consumed by FreeReservedEntity() before World.Remove<PlayerCRDTEntity>(entity).

STEP 4 — Member audit
No public properties or accessors were added or changed.

STEP 5 — Line-level review
No blocking line-level findings. No security issues found.

STEP 6 — Complexity assessment
COMPLEX: this touches runtime ECS multiplayer/SDK CRDT entity propagation and component cleanup paths.

STEP 7 — QA assessment
QA_REQUIRED: YES because runtime multiplayer behavior under Explorer/ changes and affects scene visibility of remote players.

STEP 8 — Non-blocking warnings
None. Explorer/Assets/Scenes/Main.unity was not modified.

CI status: EditMode, PlayMode, lint, and the merge gate are passing. Windows/macOS Unity Cloud builds are still pending at review time.

REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Touches runtime ECS multiplayer/SDK CRDT entity propagation and component cleanup paths.
QA_REQUIRED: YES

Reviewed by Jarvis 🤖 · Requested by Lautaro Petaccio (<@U025WCHLMN3>) via Slack

…est file

The warning ratchet blocks merging unless the total drops, and adding tests to
this file put it over. These are pre-existing warnings in the same file, not
ones this branch introduced:

- CS8618 x7: the fixture fields are assigned in [SetUp], which the compiler
  cannot see. Initialised with `= null!` rather than made nullable, so no
  call site needs a null check.
- RedundantTypeArgumentsOfMethod x1: TryGet's type argument is inferred from
  the `out` parameter, as at every other call site in this file.

No behaviour change and no test logic touched.

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

STEP 1 — Context & scope

  • Loaded CLAUDE.md, docs/README.md, and docs/multiplayer.md.
  • Reviewed the diff and checked out the PR head locally.
  • Changed files: PlayerCRDTEntitiesHandlerSystem.cs and PlayerCRDTEntitiesHandlerSystemShould.cs.

STEP 2 — Root-cause check
The PR targets the actual root cause: reserved remote-player CRDT ids are allocated when PlayerCRDTEntity is created, but were not released when that component was removed while unassigned to a scene. Moving the release to the component-removal path addresses the slot leak rather than a symptom.

STEP 3 — Design & integration
The fix remains in the correct lifecycle owner: Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerCRDTEntitiesHandlerSystem.cs is the system that reserves ids in ReserveNextFreeEntity, owns the PlayerCRDTEntity component lifecycle, and removes the component. Relevant owners checked: RemoteEntities marks removals with DeleteEntityIntention; DestroyEntitiesSystem destroys later; ScenesCache/ISceneFacade own scene-side worlds, not the reserved-id pool. No new long-lived unit/subscription/callback/connection/buffer was introduced.

STEP 4 — Member audit
No public property or accessor was added or changed.

STEP 5 — Line-level review
One changed test fixture line needs cleanup before this should pass: the newly touched null! field is unused and the comment says it is assigned in [SetUp], but it is not. Remove that field and the unused DCL.AvatarRendering.Emotes using. No security issues found.

STEP 6 — Complexity assessment
COMPLEX: this touches runtime ECS multiplayer/SDK CRDT entity propagation and component cleanup paths.

STEP 7 — QA assessment
QA_REQUIRED: YES because runtime multiplayer behavior under Explorer/ changes and affects scene visibility of remote players.

STEP 8 — Non-blocking warnings
None. Explorer/Assets/Scenes/Main.unity was not modified.

CI status: PlayMode and merge gate are passing; EditMode, lint, and Unity Cloud builds are still pending at review time.

REVIEW_RESULT: FAIL ❌
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Touches runtime ECS multiplayer/SDK CRDT entity reservation and cleanup paths.
QA_REQUIRED: YES

Reviewed by Jarvis 🤖 · Requested by Lautaro Petaccio (<@U025WCHLMN3>) via Slack

Comment on lines +26 to +29
// `= null!` on the fixture fields: they are assigned in [SetUp], which the
// compiler cannot see, so each one otherwise raises CS8618 and the warning
// ratchet blocks the merge.
private readonly IEmoteStorage emoteStorage = null!;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] This touched fixture field is unused and is not assigned in [SetUp], so the comment is inaccurate and the field can trip the warning ratchet on the changed test file. Remove these lines and also remove the now-unused using DCL.AvatarRendering.Emotes; at the top of the file.

Suggested change
// `= null!` on the fixture fields: they are assigned in [SetUp], which the
// compiler cannot see, so each one otherwise raises CS8618 and the warning
// ratchet blocks the merge.
private readonly IEmoteStorage emoteStorage = null!;

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

STEP 1 — Context & scope

  • Loaded CLAUDE.md, docs/README.md, and docs/multiplayer.md for the multiplayer/SDK CRDT lifecycle context.
  • Reviewed the diff for PlayerCRDTEntitiesHandlerSystem.cs and PlayerCRDTEntitiesHandlerSystemShould.cs and checked the PR head context.

STEP 2 — Root-cause check
The PR still addresses the actual root cause: reserved remote-player CRDT ids are allocated when PlayerCRDTEntity is created, but were only freed when the player had a scene assignment. Moving the release to component removal pairs the reservation with the component lifecycle instead of the scene-assignment state.

STEP 3 — Design & integration
The fix remains in the correct lifecycle owner: PlayerCRDTEntitiesHandlerSystem reserves ids in ReserveNextFreeEntity, owns the PlayerCRDTEntity component lifecycle, removes scene-side PlayerSceneCRDTEntity when assigned, and removes the global component when DeleteEntityIntention marks the player gone. Existing owners checked: RemoteEntities creates/marks remote-player entities for deletion; DestroyEntitiesSystem destroys later; ScenesCache/ISceneFacade own scene worlds, not the reserved-id pool. No new long-lived unit/subscription/callback/connection/buffer was introduced.

STEP 4 — Member audit
No public property or accessor was added or changed.

STEP 5 — Line-level review
One required cleanup remains in the touched test fixture: an unused null! field has a comment that says fixture fields are assigned in [SetUp], but this field is not assigned or used. This violates CLAUDE.md nullability guidance: do not use null! to silence warnings in regular code unless the lifecycle actually assigns it. No security issues found.

STEP 6 — Complexity assessment
COMPLEX: this touches runtime ECS multiplayer/SDK CRDT entity propagation and component cleanup paths.

STEP 7 — QA assessment
QA_REQUIRED: YES because runtime multiplayer behavior under Explorer/ changes and affects scene visibility of remote players.

STEP 8 — Non-blocking warnings
None. Explorer/Assets/Scenes/Main.unity was not modified.

CI status: EditMode, PlayMode, lint, Windows/macOS builds, build gate, and merge eligibility are passing. The Claude Review check is failing due to review findings.

REVIEW_RESULT: FAIL ❌
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Touches runtime ECS multiplayer/SDK CRDT entity reservation and cleanup paths.
QA_REQUIRED: YES

Reviewed by Jarvis 🤖 · Requested by Lautaro Petaccio (<@U025WCHLMN3>) via Slack

Comment on lines +26 to +29
// `= null!` on the fixture fields: they are assigned in [SetUp], which the
// compiler cannot see, so each one otherwise raises CS8618 and the warning
// ratchet blocks the merge.
private readonly IEmoteStorage emoteStorage = null!;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Remove this unused fixture field and the inaccurate null! comment. The comment says the fields are assigned in [SetUp], but emoteStorage is never assigned or used, so this is just silencing nullability with null! in regular test code. Also remove the now-unused using DCL.AvatarRendering.Emotes;.

Suggested change
// `= null!` on the fixture fields: they are assigned in [SetUp], which the
// compiler cannot see, so each one otherwise raises CS8618 and the warning
// ratchet blocks the merge.
private readonly IEmoteStorage emoteStorage = null!;

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

STEP 1 — Context & scope

  • Loaded CLAUDE.md, docs/README.md, and docs/multiplayer.md for the multiplayer/SDK CRDT lifecycle context.
  • Reviewed the diff for PlayerCRDTEntitiesHandlerSystem.cs and PlayerCRDTEntitiesHandlerSystemShould.cs; checked the PR head locally.
  • Searched the lifecycle owners around PlayerCRDTEntity, RemoteEntities, DeleteEntityIntention, DestroyEntitiesSystem, realm-switch removal, and multiplayer plugin injection.

STEP 2 — Root-cause check
The PR still addresses the root cause: the reserved CRDT id is allocated when PlayerCRDTEntity is added, but the previous release path was gated by scene assignment. Moving FreeReservedEntity to the component-removal path pairs the slot with the component lifecycle rather than scene membership.

STEP 3 — Design & integration
The fix remains in the existing lifecycle owner: PlayerCRDTEntitiesHandlerSystem reserves ids, owns the PlayerCRDTEntity component lifecycle, removes scene-side PlayerSceneCRDTEntity when needed, and removes the global component after DeleteEntityIntention or missing Profile. Existing owners checked: RemoteEntities creates/marks remote-player entities, RemoveRemoteEntitiesTeleportOperation calls ForceRemoveAll, DestroyEntitiesSystem destroys later, and ScenesCache/ISceneFacade own scene worlds rather than this reserved-id bitmap. No new long-lived lifecycle unit/subscription/callback/connection/buffer was introduced.

STEP 4 — Member audit
No public property or accessor was added or changed.

STEP 5 — Line-level review
One required cleanup remains in the touched test fixture: an unused null! field has a comment that says fixture fields are assigned in [SetUp], but that field is neither assigned nor used. This conflicts with CLAUDE.md nullability guidance: null! is not for regular unused fields, and comments must not document a false invariant. No security issues found.

STEP 6 — Complexity assessment
COMPLEX: this touches runtime ECS multiplayer/SDK CRDT entity reservation and cleanup paths.

STEP 7 — QA assessment
QA_REQUIRED: YES because runtime multiplayer behavior under Explorer/ changes and affects scene visibility of remote players.

STEP 8 — Non-blocking warnings
None. Explorer/Assets/Scenes/Main.unity was not modified.

CI status: EditMode, PlayMode, lint, Windows/macOS builds, build gate, and merge eligibility are passing. The Claude Review check is failing due to review findings.

REVIEW_RESULT: FAIL ❌
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Touches runtime ECS multiplayer/SDK CRDT entity reservation and cleanup paths.
QA_REQUIRED: YES

Reviewed by Jarvis 🤖 · Requested by Lautaro Petaccio (<@U025WCHLMN3>) via Slack

Comment on lines +26 to +29
// `= null!` on the fixture fields: they are assigned in [SetUp], which the
// compiler cannot see, so each one otherwise raises CS8618 and the warning
// ratchet blocks the merge.
private readonly IEmoteStorage emoteStorage = null!;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] The comment says these null! fields are assigned in [SetUp], but this readonly emoteStorage field is never assigned or used. CLAUDE.md allows null! for lifecycle-assigned fields; this one is dead test fixture state and keeps an inaccurate setup invariant in the file. Remove this block and the now-unused using DCL.AvatarRendering.Emotes;.

Suggested change
// `= null!` on the fixture fields: they are assigned in [SetUp], which the
// compiler cannot see, so each one otherwise raises CS8618 and the warning
// ratchet blocks the merge.
private readonly IEmoteStorage emoteStorage = null!;

…warnings

Addresses the review findings on this PR.

P1 — `emoteStorage` was dead: never assigned in [SetUp] and never read, so
annotating it `= null!` silenced a warning on a field that should not exist, and
the comment claiming the fields are [SetUp]-assigned was inaccurate for it.
Removed the field and the `using DCL.AvatarRendering.Emotes;` that only it
needed, and narrowed the comment to the reference-typed fields it actually
describes.

The warning ratchet also still blocked merge at 14075 => 14082. The remaining
CS8602s in this file are dereferences of the `system` field inherited from
UnitySystemTestBase and of the nullable `PlayerCRDTEntity.SceneFacade`; C#
nullable flow analysis reports only the FIRST dereference in each method, so the
two tests added by this PR contributed two of them. Clearing only those two would
have left the total above baseline, so this clears all of them in this file with
the `!` the file already used at one SceneFacade site. Mechanical, no behaviour
change, no test logic touched.

Verification: no C# toolchain here, so this is a structural check only (balanced
braces/parens, no stray unannotated dereferences, no double `!`). CI runs the
EditMode/PlayMode suites and recomputes the ratchet.
@LautaroPetaccio
LautaroPetaccio force-pushed the fix/player-crdt-reserved-entity-slot-leak branch from c3b4984 to 8233c8d Compare July 29, 2026 18:00
@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Claude finished @LautaroPetaccio's task in 1m 16s —— View job


Security-focused dependency & workflow review

  • Gather context (diff vs origin/main, changed files)
  • Step 1: identify dependency changes (manifest / lockfile / binaries / asmdef / build hooks)
  • Step W: workflow & prompt file changes
  • Verify state of prior review findings on the touched test fixture
  • Post verdict

Step 1 — Dependency changes

None. git diff --name-status origin/main...HEAD returns exactly two modified C# files:

File Status
Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerCRDTEntitiesHandlerSystem.cs MODIFIED, +34/−5
Explorer/Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs MODIFIED, +96/−34

Checked and confirmed absent from the diff:

  • Packages/manifest.json — unchanged (no new/changed registry, git, or path entries)
  • Packages/packages-lock.json — unchanged (no direct or transitive changes)
  • New/modified binaries or native plugins under Explorer/Assets/Plugins/ or anywhere under Assets/** — none (the pre-computed binary list for this PR is also empty)
  • .asmdef / .asmref files — none touched, so no precompiled-assembly references and no assembly-visibility changes
  • Editor scripts, build scripts, install hooks, or package setup code that runs automatically — none added

The new-dependency label on this PR is a false positive. There is nothing for steps 1.5–3 to assess: no registry metadata to look up, no provenance/pinning question, no new runtime capability, no shipping-impact change, no transitive risk. The only runtime capability the diff adds is a ReportHub.LogWarning call using an already-imported namespace (DCL.Diagnostics), which needs no asmdef change and adds no network, filesystem, reflection, dynamic-loading, or native surface. Worth removing the label so it doesn't imply a dependency change reviewers need to chase.

Step W — Workflow and prompt files

Not applicable. Nothing under .github/workflows/ or .github/prompts/ is touched (the pre-computed list of workflow/prompt changes for this PR is also empty), so W.1–W.10 have no subject matter. No secrets-holding job, action pin, allowedTools string, trigger, permissions block, or LLM prompt is modified by this PR.

Prior review findings — status

The [P1] finding raised in the last three Jarvis reviews (dead emoteStorage fixture field with an inaccurate null! comment, plus the now-unused using DCL.AvatarRendering.Emotes;) is resolved in 8233c8d27:

  • readonly IEmoteStorage emoteStorage is gone
  • using DCL.AvatarRendering.Emotes; is gone
  • The remaining null! comment at PlayerCRDTEntitiesHandlerSystemShould.cs:25-27 is now accurate — all six reference-typed fields it covers (fakeCharacterUnityTransform, fakeMainCharacterUnityTransform, scene1World, scene2World, scene1Facade, scene2Facade) are in fact assigned in [SetUp] at lines 49, 50, 56, 57, 63, 65

Summary table

Dependency / file Version Source Type Scope Risk Evidence confidence
(none — no dependency, binary, asmdef, workflow, or prompt changes in this PR) LOW HIGH

No MEDIUM or HIGH risk items, so no per-item concern list or remediation is needed.

Note on scope: my assigned instructions for this run were the dependency/workflow security review, so this is not a functional review of the CRDT slot-leak fix itself — the three Jarvis reviews above cover that (REVIEW_RESULT: PASS on the fix's design and root-cause targeting, COMPLEXITY: COMPLEX, QA_REQUIRED: YES). CI at the time of writing: Jarvis Review Request, Require Jarvis Review, and Performance Test Merge Block passing; Unity Test and Unity Cloud Build queued for this push.

DEPENDENCY_REVIEW: PASS
· branch fix/player-crdt-reserved-entity-slot-leak

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ext-contribution Identifies a contribution which was not initiated by a Unity Developer new-dependency

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants