fix(sdk): release reserved player CRDT entity slot on every disconnect - #9516
fix(sdk): release reserved player CRDT entity slot on every disconnect#9516LautaroPetaccio wants to merge 4 commits into
Conversation
`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.
|
Windows and Mac build successful in Unity Cloud! You can find a link to the downloadable artifact below. |
|
Slack notification sent to #explorer-ext-contributions for external review. |
decentraland-bot
left a comment
There was a problem hiding this comment.
STEP 1 — Context & scope
- Loaded
CLAUDE.md,docs/README.md, anddocs/multiplayer.mdfor the multiplayer/SDK CRDT lifecycle context. - Reviewed the diff and checked out the PR head locally.
- Changed files:
PlayerCRDTEntitiesHandlerSystem.csandPlayerCRDTEntitiesHandlerSystemShould.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
left a comment
There was a problem hiding this comment.
STEP 1 — Context & scope
- Loaded
CLAUDE.md,docs/README.md, anddocs/multiplayer.md. - Reviewed the diff and checked out the PR head locally.
- Changed files:
PlayerCRDTEntitiesHandlerSystem.csandPlayerCRDTEntitiesHandlerSystemShould.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
| // `= 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!; |
There was a problem hiding this comment.
[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.
| // `= 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
left a comment
There was a problem hiding this comment.
STEP 1 — Context & scope
- Loaded
CLAUDE.md,docs/README.md, anddocs/multiplayer.mdfor the multiplayer/SDK CRDT lifecycle context. - Reviewed the diff for
PlayerCRDTEntitiesHandlerSystem.csandPlayerCRDTEntitiesHandlerSystemShould.csand 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
| // `= 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!; |
There was a problem hiding this comment.
[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;.
| // `= 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
left a comment
There was a problem hiding this comment.
STEP 1 — Context & scope
- Loaded
CLAUDE.md,docs/README.md, anddocs/multiplayer.mdfor the multiplayer/SDK CRDT lifecycle context. - Reviewed the diff for
PlayerCRDTEntitiesHandlerSystem.csandPlayerCRDTEntitiesHandlerSystemShould.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
| // `= 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!; |
There was a problem hiding this comment.
[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;.
| // `= 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.
c3b4984 to
8233c8d
Compare
|
Claude finished @LautaroPetaccio's task in 1m 16s —— View job Security-focused dependency & workflow review
Step 1 — Dependency changesNone.
Checked and confirmed absent from the diff:
The Step W — Workflow and prompt filesNot applicable. Nothing under Prior review findings — statusThe
Summary table
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 ( DEPENDENCY_REVIEW: PASS |
What
Fixes a permanent reserved-entity slot leak in
PlayerCRDTEntitiesHandlerSystemthat silently hides later-joining players from every scene for the rest of the session.AddPlayerCRDTEntityreserves a CRDT entity id unconditionally (ReserveNextFreeEntity()), butFreeReservedEntity(...)was called only inside theif (playerCRDTEntity is { AssignedToScene: true, SceneFacade: not null })branch ofRemoveComponent. TheWorld.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:
HIDDEN_SPAWN_POSITION = (int.MinValue, 0, int.MinValue)(RemoteEntities.CreateCharacter), soscenesCache.TryGetByParcelfinds nothing and the player is in no scene until their first movement packet arrives — disconnecting in that window leaked;newSceneIsValidcheck;RemoteEntities.ForceRemoveAllmarks every tracked wallet for deletion at once, most of them not inside a live scene at that instant.There was no recovery path:
FreeReservedEntityhas exactly one caller andClearReservedEntities()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-1andAddPlayerCRDTEntitybailed out silently (if (crdtEntityId == -1) return;, no log). From that point on, every newly connected remote player was exposed to no scene at all — noPlayerIdentityData, noTransform, 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
Lleaked slots, only224 - Lslots 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
AssignedToSceneis false, so the exact production incidence cannot be quantified from the code alone.Changes
FreeReservedEntitymoved out of theAssignedToSceneguard so the slot is released whenevernoLongerExistsis true, i.e. paired with theWorld.Remove<PlayerCRDTEntity>that was already there — matching where the slot is allocated.RemovePlayerFromScenestill runs under the original guard, unchanged.ReportHub.LogWarning(GetReportData(), ...)(the codebase convention for systems; the category resolves from this class's own[LogCategory(ReportCategory.PLAYER_SDK_DATA)]).AddPlayerCRDTEntitycan 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.FreeReservedEntityis now idempotent — it returns early if the slot is already free. It previously decrementedcurrentReservedEntitiesCountunconditionally. See the double-free analysis below.Double-free analysis
A double free would be worse than the leak, so this was checked explicitly.
FreeReservedEntityidempotent? No. For an in-range id it setreservedEntities[i] = falseand decrementedcurrentReservedEntitiesCountunconditionally, so a repeated release would drift the counter (potentially negative) and break thecurrentReservedEntitiesCount == reservedEntities.Lengthfast path. It was already a safe no-op for out-of-range ids: the local player'sPLAYER_ENTITY(1) maps to1 - 32 = -31and returns early, so the local player never touches the pool — including via theRemoveComponentquery, which does not excludePlayerComponent. This PR makes it idempotent for in-range ids too, so a hypothetical repeated release is a no-op.CRDTEntity.Idreach 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 guardedWorld.Remove<PlayerCRDTEntity>(entity), andnoLongerExistsistrueat both — and the only — call sites of the privateRemoveComponent. The two[Query]entry points are mutually exclusive by construction (RemoveComponentOnPlayerDisconnectrequiresDeleteEntityIntention;RemoveComponentrequiresNone(DeleteEntityIntention)), and both requirePlayerCRDTEntity, which is removed in the same call, so neither can match the entity again.AddPlayerCRDTEntitycannot re-add whileDeleteEntityIntentionis present (None(DeleteEntityIntention)), so any later re-add is a fresh reservation. A repo-wide grep confirmsPlayerCRDTEntityis added/removed only in this system.FreeReservedEntityhad exactly one caller (the one that was moved) andClearReservedEntities()only the constructor.World.Remove<PlayerCRDTEntity>would already double-remove; this change adds no new exposure, and the idempotency guard makes the second release harmless.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.PlayerCRDTEntitiesHandlerSystemis constructed once perGlobalWorld, andGlobalWorldis not recreated on a realm switch:RealmController.UnloadCurrentRealmAsyncdestroys only realm/scene-definition entities (CLEAR_QUERY, which does not match player entities) and callsGlobalWorld.Clear(), which only clearsCameraSamplingData/RealmSamplingData. So there is no existing per-system realm-change reset hook.RemoteEntities.ForceRemoveAll(2nd operation of the realm-change sequence inRealmNavigationContainer, viaRemoveRemoteEntitiesTeleportOperation) does not destroy entities — it addsDeleteEntityIntentionthroughworld.AddOrGet. Entities are destroyed later byDestroyEntitiesSystem, which is inSyncedPreRenderingSystemGroup(start ofPostLateUpdate), while this system is inPresentationSystemGroup(end ofPreLateUpdate) — earlier in the same frame. This system is also not[ThrottlingEnabled]whereasDestroyEntitiesSystemis, so the handler cannot be skipped in a frame where the destroyer runs.PreLateUpdateand start-of-PostLateUpdateof the same frame; the teleport sequence runs on UniTask continuations (defaultPlayerLoopTiming.Update), which is before both.IFinalizeWorldSystem.FinalizeComponents(in Query), collected inGlobalWorld.FinalizeWorldSystemsand invoked fromUnloadCurrentRealmAsync. WiringClearReservedEntities()there is not clearly safe, so I did not guess: it is documented as running "right before World destruction", it is handed the realmCLEAR_QUERY(unrelated to players), and it can run while livePlayerCRDTEntitycomponents still hold pool ids — the local player always, and remote entities whoseDeleteEntityIntentionhas 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
PlayerCRDTEntitycomponents, 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):
RemoveComponent,FreeReservedEntity,ReserveNextFreeEntity,ClearReservedEntitiesandAddPlayerCRDTEntity— repo-wide grep shows all are private to this single file;FreeReservedEntityhad exactly one caller,ClearReservedEntitiesonly the constructor.noLongerExistsis the correct condition: it istrueat both call sites, and each of those immediately removes the component, so it cannot be true while the component is still live.PlayerCRDTEntity;RemoteEntitiesnever destroys the global entity directly, it only marksDeleteEntityIntention;RealmController.CLEAR_QUERYdoes not match player entities. So no path destroys a player entity behind this system's back within a frame.PresentationSystemGroup= end ofPreLateUpdate,PreRenderingSystemGroup= start ofPostLateUpdate, so this system observesDeleteEntityIntentionbeforeDestroyEntitiesSystemdestroys the entity.ReportHub.LogWarning(ReportData, object)exists inDCL.Diagnostics(already imported by this file);GetReportData()isprotected internalonBaseUnityLoopSystemand resolves the category from this class's[LogCategory(ReportCategory.PLAYER_SDK_DATA)];ReportHubis already used elsewhere in the sameDCL.Multiplayerassembly, so no asmdef change is needed.PlayerCRDTEntitiesHandlerSystemShould.cs), so two regression tests were added there: a single disconnect while outside any scene must return the id to the pool, and224 + 8connect/disconnect cycles outside any scene must never exhaust the pool (each cycle must reuseOTHER_PLAYER_ENTITIES_FROM). By inspection both should fail onmain— the first because the replacement player would getOTHER_PLAYER_ENTITIES_FROM + 1, the second at its second cycle for the same reason and, past cycle 224, with noPlayerCRDTEntityassigned at all. I could not run them to confirm that (see below), so treat it as reasoning, not as an observed result.cscwithout project references. The only diagnostics are missing-type/namespace ones (CS0518/CS0246/CS0234/CS0103, including the pre-existingReportCategoryin the class attribute). Zero syntax diagnostics. This is a parse check, not a build.NOT verified locally — please exercise in CI / the editor:
UnitySystemTestBaseharness and the conventions already used in the same file, but they have not been run in the editor or in CI.Manual repro for a reviewer:
PlayerIdentityData+Transform.ForceRemoveAllremoves every tracked wallet at once.main: from that point on, every newly joining player never appears in the scene'sPlayerIdentityData+Transformquery — 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