fix: keep checked-out GLTF clones alive when AssetPreLoadCache clears - #9799
fix: keep checked-out GLTF clones alive when AssetPreLoadCache clears#9799alejandro-jimenez-dcl wants to merge 2 commits into
Conversation
## Problem Two coupled Sentry issues in the same sessions: UNITY-EXPLORER-PHE — `EcsSystemException [FinalizeGltfContainerLoadingSystem]` wrapping an NRE on a destroyed Root GameObject while consuming a successfully-resolved promise — and UNITY-EXPLORER-P8N — "`AssetPromise ... is already consumed`" thrown every subsequent frame once the NRE aborts `FinalizeLoading` between `TryConsume` and `State = Finished` (~48 events / 17 users past week combined; scene content also silently vanishes for already-finished containers). ## Root cause Global-cache/per-scene-lifecycle mismatch → use-after-free. `AssetPreLoadCache` is a global cache, but `CleanUpAssetPreLoadSystem` is registered per scene world: ANY scene teardown calls `Clear()`, which disposed every live checked-out clone (`GltfTemplate.Copies`) — destroying the Roots of clones other scenes had placed in-world or were still holding in unconsumed promise results. The consume side then NREs on the dead Root (PHE), and the abort leaves a consumed-promise/`Loading` component that throws "already consumed" forever (P8N). Timeline fits: the clone-on-request mechanism landed in #9001 (2026-06-19); PHE first appeared v0.158. ## Fix (~30 LOC, two files) 1. `AssetPreLoadCache.Clear()` no longer disposes checked-out clones — clones are owned by the containers that checked them out; once the template entry is removed, their normal release path routes them into the shared pool, where LRU `Unload` disposes them (no leak). The now-dead `Copies` tracking is removed. 2. `FinalizeGltfContainerLoadingSystem`: enforce "consumed ⇒ terminal state" — after a successful consume, a destroyed `Root` finishes the component with `FinishedWithError` (the exact contract failed loads already use, loudly logged) instead of throwing, so the known destroyed-Root NRE can no longer arm the P8N cascade (an exception thrown for a different reason between consume and the terminal state would still abort mid-block). ## Test - `FinalizeGltfContainerLoadingSystemShould.FinalizeWithErrorWhenAssetRootDestroyed` — reproduces both Sentry issues deterministically at the pin (first update throws the NRE, second throws "already consumed"). - New `AssetPreLoadCacheShould.KeepCheckedOutClonesAliveOnClear` — a checked-out clone's Root must survive `Clear()`; template dereference still received. ## Validation Windows Unity 6000.4 EditMode lane at the pin: RED FAIL 2/2 as intended (EcsSystemException on the destroyed GameObject; clone Root destroyed by Clear) / GREEN PASS 2/2. Fixes #9451 Fixes #9531 Related: #8866, #8291 (older auto-filed dupes of the same system exception), #9001 (context: the clone-on-request mechanism) Includes inspection-warning cleanup in all touched files.
decentraland-bot
left a comment
There was a problem hiding this comment.
PR Review: fix: keep checked-out GLTF clones alive when AssetPreLoadCache clears
STEP 1 — Context & Scope
Docs loaded: CLAUDE.md, docs/README.md
Subsystem: Asset loading pipeline — AssetPreLoadCache (global cache), GltfContainerAssetsCache (LRU pool), FinalizeGltfContainerLoadingSystem (promise consumer)
Changed files (9):
AssetPreLoadCache.cs— core fix: remove clone tracking/disposal from cacheGltfContainerAssetsCache.cs— simplify dereference pathFinalizeGltfContainerLoadingSystem.cs— destroyed-Root guardFinalizeGltfContainerLoadingSystemShould.cs— new test + inspection cleanupAssetPreLoadCacheShould.cs— new test file- Meta/asmref files for the new test folder
Surrounding files read in full context:
CleanUpAssetPreLoadSystem.cs— the caller ofClear()(registered per scene world viaIFinalizeWorldSystem)CleanUpGltfContainerSystem.cs— entity cleanup callsDereferenceResetGltfContainerSystem.cs— component reset callsDereferenceIGltfContainerAssetsCache.cs— interface contractGltfContainerAsset.cs—Dispose()implementation (deref + destroy Root)Utils.cs—TryDuplicateGltfAssetFromTemplate(clones acquire their own ref onAssetData)StaticContainer.cs— global cache constructionAssetPreLoadPlugin.cs— plugin registration
STEP 2 — Root-cause check
Problem: AssetPreLoadCache is global (created once in StaticContainer), but CleanUpAssetPreLoadSystem is registered per scene world. Any scene teardown calls Clear(), which disposed every live checked-out clone via the Copies list — destroying Roots of clones owned by other live scenes.
Does the diff fix the cause or a symptom? The diff fixes the cause:
- Primary fix (
AssetPreLoadCache.Clear): removesCopiestracking entirely and stops disposing checked-out clones. Clones are now exclusively owned by the containers that checked them out, released throughDereference. - Resilience fix (
FinalizeGltfContainerLoadingSystem): adds a destroyed-Root guard after promise consumption. This handles the broader class of destroyed-Root scenarios (cacheUnload/Remove, etc.) and is correctly scoped — it only fires after the promise is consumed and ensures the component reaches a terminal state.
The primary fix is not a null-check workaround or exception swallow — it corrects the ownership model. The resilience fix is belt-and-suspenders for a real edge case (the comment accurately describes the scenario).
PASS ✅
STEP 3 — Design & integration
No new long-lived units introduced. The diff removes code (the Copies list, ReleaseGltfInstance method) and adds a guard in an existing system.
Owner search — clone lifecycle:
| Lifecycle moment | Owner | File |
|---|---|---|
| Clone creation | AssetPreLoadCache.TryGetGltfInstance → called from GltfContainerAssetsCache.TryGet |
AssetPreLoadCache.cs:49, GltfContainerAssetsCache.cs:67 |
| Clone usage | FinalizeGltfContainerLoadingSystem (consumes promise, parents Root) |
FinalizeGltfContainerLoadingSystem.cs:74 |
| Clone release on entity destroy | CleanUpGltfContainerSystem.DestroyGLTFContainer → cache.Dereference(key, asset) |
CleanUpGltfContainerSystem.cs:75 |
| Clone release on component reset | ResetGltfContainerSystem.TryReleaseAsset → cache.Dereference(key, asset) |
ResetGltfContainerSystem.cs:69 |
| Clone disposal in Dereference | GltfContainerAssetsCache.Dereference: if template still cached → asset.Dispose() directly; if not → pool for LRU eviction |
GltfContainerAssetsCache.cs:99-104 |
| Template release on cache clear | AssetPreLoadCache.Clear → gltfCache.Dereference(key, template, handleAssetLoad: false) |
AssetPreLoadCache.cs:112 |
Post-Clear flow verified: After Clear() removes the template entry, ContainsGltf(key) returns false. When a container later calls Dereference, the clone enters the shared LRU pool (not disposed immediately). The pool's Unload eventually disposes it. No leak.
handleAssetLoad: false in Clear() is correct — it prevents the circular check against ContainsGltf during clearing.
Teardown trace: No new subscriptions, callbacks, or event hookups are introduced. The removed Copies list was the only clone-tracking mechanism, and it is fully excised.
PASS ✅
STEP 4 — Member audit
| Member | Change | Consumers | Assessment |
|---|---|---|---|
ReleaseGltfInstance (public) |
Removed | Was called only by GltfContainerAssetsCache.Dereference (1 caller) |
✅ Safe removal — sole caller now calls asset.Dispose() directly |
GltfTemplate.Copies (private) |
Removed | Was used only within AssetPreLoadCache (add in TryGetGltfInstance, remove in ReleaseGltfInstance, iterate in Clear) |
✅ Clean removal, no orphan refs (rg confirms zero remaining .Copies references) |
TryAdd<T> null check |
Added guard | Existing callers unchanged | ✅ Defensive hardening — T is unconstrained generic, null is legitimate |
TryGet<T> [MaybeNullWhen(false)] |
Annotation added | Existing callers unchanged | ✅ Correct nullability improvement |
No new public members. PASS ✅
STEP 5 — Line-level review
Pass A — Blocking-issue categories:
No P0 or P1 issues found.
-
Code quality (CLAUDE.md standards): The new
ReportHub.LogErrorat line 92 uses string interpolation ($"..."). This allocates, but it is inside a consumed-promise guard that fires at most once per entity lifecycle — not a hot path. Acceptable. -
Pattern matching improvement: The diff replaces repeated
result.Asset!null-forgiving operators withresult.Asset is not { } assetpattern matching. This eliminates 8!operators with no justifying comment (CLAUDE.md §Nullable). Net improvement. -
Null guard in
Dereference: The existingRoot == nullguard atGltfContainerAssetsCache.cs:96correctly handles destroyed-Root clones that arrive atDereference— they are silently dropped (no crash, no re-pooling). The newasset.Dispose()at line 102 only runs whenRoot != null(the guard above returns early otherwise). No double-free risk.
Pass B — Design, encapsulation & resource smells:
- No construction/DI issues. No new classes, no new dependencies.
- Naming: All existing — no new types or members to name.
- Comments: The three new block comments accurately describe what the annotated code does/guarantees (ownership contract, cache-clear rationale, destroyed-Root recovery). They do not narrate external behavior.
Security review: No security issues found. The error log exposes asset name and hash — these are public content identifiers, not sensitive data.
STEP 6 — Complexity
COMPLEX — Touches the asset loading pipeline, object pooling, cache management, and resource cleanup paths. Changes cross-cutting ownership semantics between a global cache and per-scene lifecycle systems.
STEP 7 — QA assessment
QA_REQUIRED: YES — Modifies runtime GLTF container loading and cache management. Affects what users see (scene content can silently vanish without this fix). The primary scenario to verify: multi-scene navigation where one scene tears down while another has active GLTF containers.
STEP 8 — Non-blocking warnings
None. Main scene is not modified.
STEP 9 — Verdict
REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Modifies cross-cutting ownership semantics between global AssetPreLoadCache, per-scene cleanup systems, and the GltfContainerAssetsCache LRU pool
QA_REQUIRED: YES
Reviewed by Jarvis 🤖 · Requested by decentraland-bot via GitHub
|
🔍 Jarvis reviewed this PR and found no blocking issues, but assessed it as complex — human DEV review is still required before merging. |
|
PR #9799, run #32262032543 Builds: Windows change, Windows baseline, macOS change, macOS baseline How to read this table
Intel Core i5
Exception breakdown
Apple M1
|
Reverts the inspection-warning cleanup that rode along with the fix (MaybeNullWhen attribute, TryAdd null guard, test null-safety refactors) so the PR diff contains only the fix and its tests.
alejandro-jimenez-dcl
left a comment
There was a problem hiding this comment.
Approved
Problem
Two coupled Sentry issues in the same sessions: UNITY-EXPLORER-PHE -
EcsSystemException [FinalizeGltfContainerLoadingSystem]wrapping an NRE on a destroyedRoot GameObject while consuming a successfully-resolved promise - and UNITY-EXPLORER-P8N -
"
AssetPromise ... is already consumed" thrown every subsequent frame once the NRE abortsFinalizeLoadingbetweenTryConsumeandState = Finished(~48 events / 17 users pastweek combined; scene content also silently vanishes for already-finished containers).
Root cause
Global-cache/per-scene-lifecycle mismatch → use-after-free.
AssetPreLoadCacheis a globalcache, but
CleanUpAssetPreLoadSystemis registered per scene world: ANY scene teardowncalls
Clear(), which disposed every live checked-out clone (GltfTemplate.Copies) -destroying the Roots of clones other scenes had placed in-world or were still holding in
unconsumed promise results. The consume side then NREs on the dead Root (PHE), and the
abort leaves a consumed-promise/
Loadingcomponent that throws "already consumed" forever(P8N). Timeline fits: the clone-on-request mechanism landed in #9001 (2026-06-19); PHE
first appeared v0.158.
Fix (~30 LOC, two files)
AssetPreLoadCache.Clear()no longer disposes checked-out clones - clones are owned bythe containers that checked them out; once the template entry is removed, their normal
release path routes them into the shared pool, where LRU
Unloaddisposes them (noleak). The now-dead
Copiestracking is removed.FinalizeGltfContainerLoadingSystem: enforce "consumed ⇒ terminal state" - after asuccessful consume, a destroyed
Rootfinishes the component withFinishedWithError(the exact contract failed loads already use, loudly logged) instead of throwing, so the
known destroyed-Root NRE can no longer arm the P8N cascade (an exception thrown for a
different reason between consume and the terminal state would still abort mid-block).
Test
FinalizeGltfContainerLoadingSystemShould.FinalizeWithErrorWhenAssetRootDestroyed-reproduces both Sentry issues deterministically at the pin (first update throws the NRE,
second throws "already consumed").
AssetPreLoadCacheShould.KeepCheckedOutClonesAliveOnClear- a checked-out clone'sRoot must survive
Clear(); template dereference still received.Validation
Windows Unity 6000.4 EditMode lane at the pin: RED FAIL 2/2 as intended (EcsSystemException
on the destroyed GameObject; clone Root destroyed by Clear) / GREEN PASS 2/2.
Fixes #9451
Fixes #9531
Related: #8866, #8291 (older auto-filed dupes of the same system exception), #9001
(context: the clone-on-request mechanism)