Skip to content

fix: evict only the changed GLTF on LSD hot reload instead of draining every cache - #9667

Merged
dalkia merged 7 commits into
devfrom
opti/lsd-scoped-gltf-eviction
Aug 11, 2026
Merged

fix: evict only the changed GLTF on LSD hot reload instead of draining every cache#9667
dalkia merged 7 commits into
devfrom
opti/lsd-scoped-gltf-eviction

Conversation

@dalkia

@dalkia dalkia commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Pull Request Description

What does this PR change?

Resolves the long-standing TODO in LocalSceneDevelopmentController: the sdk-commands dev server's UpdateModel websocket message already names the exact GLTF that changed (src + path-derived hash), but the client discarded it and force-drained every cache on each hot reload.

This PR consumes that message. When a .glb/.gltf is saved during local scene development (raw-GLTF mode):

  • Evict only the changed model — its parsed import (GltfLoadCache) and its pooled container instances (GltfContainerAssetsCache) — via a new ICacheCleaner.EvictGltfModel(hash, src).
  • Every other cache stays warm across the reload: unchanged models are served from the pool without re-downloading, re-parsing or re-instantiating (meshes, colliders, materials).

Everything else keeps today's conservative behavior:

  • Non-model changes (textures, code, scene.json) arrive as a coarse updateScene carrying only the scene id — we can't know what went stale (e.g. a .gltf's external texture), so the full drain remains.
  • --local-ab mode: the model lives in the asset-bundle caches, so scoped eviction is skipped. Guarded by IsRawGltfModel, which mirrors the exact key composition the container cache uses (AssetBundleManifestVersionOrFailed.ComposeCacheKey(hash) == hash), so the guard can't drift from the real cache keying.

New primitives: IStreamableCache.Remove(key) (default no-op; real implementation in RefCountStreamableCacheBase mirrors Unload scoped to one key, honoring refcounts) and IGltfContainerAssetsCache.Remove(key).

Performance (measured in editor, LSD, dev vs this branch)

Genesis Plaza central-plaza (70 parcels, 390 GLBs, 1.13M triangles):

Two timings are reported, because they diverge badly on a scene this size:

  • "Reload reported done" — when the client considers the reload finished (SceneLoadingConcluded). This only tracks GLTFs declared in the scene's first ticks, so it fires while most content is still streaming in.
  • "All content back" — when scene content stats (triangles/bodies) return to the pre-reload baseline, cross-checked visually. This is what the creator actually experiences.

(Memory budget was disabled during measurement so budget-driven eviction/throttling wouldn't pollute the numbers.)

Reload reported done All content back World during reload
dev — .glb save ~5s ~15–26s fully torn down: a 1KB model save empties the plaza (no ground, no buildings) and re-streams everything
this branch — .glb save (1KB / 3.5MB / 4.2MB model) 3.0s / 3.2s / 3.5s ≈ when reported done (baseline stats at the first poll, ≤12–15s incl. polling granularity) stays standing; only the edited model re-streams
plain reload (both) ~5s ~20–35s torn down (unchanged behavior)

Numbers re-measured after the content-hash resolution fix, i.e. with the edited model genuinely evicted and re-downloaded — model size adds ~0.5s at most against the local dev server. Eviction correctness was verified with a real content swap (replacing one model's bytes with a different model): the new content shows after the reload.

On heavy scenes the win is qualitative: a model save no longer wipes the world for ~20s of visible popping — the scene is whole ~5s after hitting save, regardless of how big the scene or the edited model is.

Test Instructions

Steps (standard run):

metaforge explorer run 9667

Run against a local scene (npm run start in any SDK7 scene, connect with the local-scene-development launch args, without --local-ab).

Expected result:

  1. Save/touch a .glb in the scene's assets → the scene reloads noticeably faster than on dev, and the edited model shows the new content.
    IE:
  • For Genesis Plaza, make yourself a copy of BenchStreet.glb and BushPot.glb on a separate folder than the one you are running the build from (so you dont accidentlaly trigger reloading)
  • Then just rename and copy. IE: Rename bench street to bush, and step over the bench street. Doing it like that, you will get bushes instead of benches.
  • Remember, the important part is that scene reload is fast now, given that you just changed a glb
  1. Edit a texture or scene.json → reload behaves exactly as on dev (full drain), changes show up.

Additional Testing Notes

  • Edge to verify: with --local-ab, a .glb save must still do the full drain (scoped eviction is guarded off).
  • A .gltf that references an external texture: editing the texture triggers full drain (correct); editing the .gltf itself takes the scoped path.
  • Genesis City / worlds are untouched — the new branch only runs under localSceneDevelopment.

Quality Checklist

  • Changes have been tested locally
  • Documentation has been updated (if required)
  • Performance impact has been considered
  • For SDK features: Test scene is included

🤖 Generated with Claude Code

…ng every cache

The dev server's UpdateModel websocket message already names the exact
model that changed (src + path-derived hash), but the client discarded
it and force-drained every cache on each reload. Consume it: in raw-GLTF
local development, evict just that model (parsed import + pooled
container instances) and keep every other cache warm across the reload.

Non-model changes (textures, code, scene.json) still arrive as a coarse
updateScene with no file information, so they keep the conservative full
drain, as does --local-ab mode where the model lives in the asset-bundle
caches (guarded via ComposeCacheKey, mirroring the container cache key).

Measured on a real scene (editor, LSD): GLB save reload 2.4s -> 1.5s
median, with the win growing on heavy models (3.4s -> 1.6s) since
unchanged assets no longer re-download, re-parse or re-instantiate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dalkia
dalkia requested review from a team as code owners August 10, 2026 10:39
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

🚦 CI Status

Build

Windows and Mac build successful in Unity Cloud! You can find a link to the downloadable artifact below.

Name Link
Commit 79cc500
Logs https://github.com/decentraland/unity-explorer/actions/runs/31427515649
Download Windows https://github.com/decentraland/unity-explorer/suites/85246190229/artifacts/9079172432
Download Windows S3 https://explorer-artifacts.decentraland.org/@dcl/unity-explorer/branch/opti/lsd-scoped-gltf-eviction/pr-24809-79cc500/Decentraland_windows64.zip
Download Mac https://github.com/decentraland/unity-explorer/suites/85246190229/artifacts/9079149122
Download Mac S3 https://explorer-artifacts.decentraland.org/@dcl/unity-explorer/branch/opti/lsd-scoped-gltf-eviction/pr-24809-79cc500/Decentraland_macos.zip
Built on 2026-08-10T20:55:01Z

Lint

Warnings count reduced: 13676 => 13659

Warnings/errors in files changed by this PR (20)
Assets/DCL/ResourcesUnloading/CacheCleaner.cs:171  CSharpWarnings::CS8600  Converting null literal or possible null value into non-nullable type
Assets/DCL/ResourcesUnloading/CacheCleaner.cs:171  CSharpWarnings::CS8602  Dereference of a possibly null reference
Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Asset/Cache/GltfContainerAssetsCache.cs:31  CollectionNeverUpdated.Global  Content of collection 'IrrecoverableFailures' is never updated
Assets/DCL/LOD/Tests/EditMode/ResolveISSLODSystemShould.cs:37  InconsistentNaming  Name 'ResolveISSLODSystemShould' does not match rule 'members_should_be_pascal_case'. Suggested name is 'ResolveIsslodSystemShould'.
Assets/DCL/ResourcesUnloading/CacheCleaner.cs:176  InconsistentNaming  Name 'TA' does not match rule 'members_should_be_pascal_case'. Suggested name is 'Ta'.
Assets/DCL/ResourcesUnloading/CacheCleaner.cs:176  InconsistentNaming  Name 'TI' does not match rule 'members_should_be_pascal_case'. Suggested name is 'Ti'.
Assets/DCL/ResourcesUnloading/CacheCleaner.cs:58  InconsistentNaming  Name 'unlimitedFPSBudget' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'unlimitedFpsBudget'.
Assets/DCL/ResourcesUnloading/CacheCleaner.cs:126  ParameterHidesMember  Parameter 'assetBundleCache' hides field 'DCL.ResourcesUnloading.CacheCleaner.assetBundleCache'
Assets/DCL/ResourcesUnloading/CacheCleaner.cs:144  ParameterHidesMember  Parameter 'audioClipsCache' hides field 'DCL.ResourcesUnloading.CacheCleaner.audioClipsCache'
Assets/DCL/ResourcesUnloading/CacheCleaner.cs:129  ParameterHidesMember  Parameter 'gltfContainerAssetsCache' hides field 'DCL.ResourcesUnloading.CacheCleaner.gltfContainerAssetsCache'
Assets/DCL/ResourcesUnloading/CacheCleaner.cs:132  ParameterHidesMember  Parameter 'gltfLoadCache' hides field 'DCL.ResourcesUnloading.CacheCleaner.gltfLoadCache'
Assets/DCL/ResourcesUnloading/CacheCleaner.cs:165  ParameterHidesMember  Parameter 'jsSourcesCache' hides field 'DCL.ResourcesUnloading.CacheCleaner.jsSourcesCache'
Assets/DCL/ResourcesUnloading/CacheCleaner.cs:159  ParameterHidesMember  Parameter 'profileCache' hides field 'DCL.ResourcesUnloading.CacheCleaner.profileCache'
Assets/DCL/ResourcesUnloading/CacheCleaner.cs:138  ParameterHidesMember  Parameter 'texturesCache' hides field 'DCL.ResourcesUnloading.CacheCleaner.texturesCache'
Assets/DCL/ResourcesUnloading/CacheCleaner.cs:135  ParameterHidesMember  Parameter 'wearableAssetsCache' hides field 'DCL.ResourcesUnloading.CacheCleaner.wearableAssetsCache'
Assets/DCL/Infrastructure/SceneLifeCycle/ECSReloadScene.cs:10  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/Infrastructure/SceneLifeCycle/LocalSceneDevelopment/LocalSceneDevelopmentController.cs:5  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/ResourcesUnloading/CacheCleaner.cs:17  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Asset/Cache/GltfContainerAssetsCache.cs:30  UnusedAutoPropertyAccessor.Global  Auto-property accessor 'OngoingRequests.get' is never used
Assets/DCL/ResourcesUnloading/CacheCleaner.cs:32  UnusedMember.Local  Constant 'NFT_SHAPE_UNLOAD_CHUNK' is never used

Tests

All Unity tests passed ✅

TESTS SUITE Result Passed Failed Skipped
EditMode ✅ Passed 24607 0 13
PlayMode ✅ Passed 236 0 5

@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.

PR Review — opti: evict only the changed GLTF on LSD hot reload instead of draining every cache

Step 2 — Root-cause check: PASS ✅

The problem is real: the sdk-commands dev server's UpdateModel websocket message already names the exact GLTF that changed (src + hash), but the client discarded this information and force-drained every cache on each hot reload. The diff fixes the cause (ignoring the per-model message) rather than a symptom: it consumes the UpdateModel payload, evicts only the named asset, and leaves every other cache warm. The conservative full-drain fallback is correctly preserved for all cases where scoped eviction is unsafe (non-model changes, --local-ab mode, missing model info).

Step 3 — Design & integration: PASS ✅

Lifecycle owner search:

  • ChangedGltfModel — a readonly struct carrying two strings from the websocket message to the reload logic. Not a long-lived unit; it's a data carrier with no lifecycle of its own. Appropriate.
  • ICacheCleaner.EvictGltfModel — placed on CacheCleaner, which already owns references to gltfContainerAssetsCache and gltfLoadCache and knows how to UnloadCache(). This is the natural home for scoped cache eviction. ✅
  • IStreamableCache.Remove / IGltfContainerAssetsCache.Remove — added to the existing cache interfaces that already own Unload(). Remove mirrors Unload scoped to one key — same owner, same abstraction level. ✅
  • IsRawGltfModel — static helper on ECSReloadScene that guards the scoped-eviction branch. Uses SceneEntityDefinition.AssetBundleManifestVersionOrFailed.ComposeCacheKey(hash) to determine whether the hash addresses a raw GLTF or an asset-bundle-wrapped model. This is tightly coupled to the reload decision logic; ECSReloadScene is the right home. ✅

No new long-lived units are introduced. The new methods are extensions of existing owners (CacheCleaner, the cache interfaces). No duplicate lifecycle reconciliation, no per-frame scanning, no persistent state outside ECS.

Teardown trace: The eviction calls (Remove) dispose assets synchronously at the point of call. No subscriptions, callbacks, or connections are opened. GltfContainerAssetsCache.Remove disposes every pooled GltfContainerAsset under the key, clears the list, removes from the unload queue, and clears irrecoverable failures. RefCountStreamableCacheBase.Remove checks CanBeDisposed() (respecting refcounts) before disposing. Both are complete teardown paths. ✅

Step 4 — Member audit: PASS ✅

Member Consumers Notes
IStreamableCache.Remove(key) CacheCleaner.EvictGltfModel (via gltfLoadCache) Default interface method (no-op); overridden in RefCountStreamableCacheBase. Legitimate interface extension mirroring Unload.
RefCountStreamableCacheBase.Remove(key) Via interface dispatch Correctly mirrors Unload scoped to one key, honoring refcounts.
IGltfContainerAssetsCache.Remove(key) CacheCleaner.EvictGltfModel Required interface method; implemented by GltfContainerAssetsCache and test mock.
GltfContainerAssetsCache.Remove(key) Via interface dispatch Disposes all pooled assets, updates profiling counter, clears cache/queue/failures.
ICacheCleaner.EvictGltfModel(hash, src) ECSReloadScene.DisposeAndRestartAsync Single consumer. Legitimate — it's a specialized operation on the cache cleaner triggered by a specific event.
ECSReloadScene.IsRawGltfModel(definition, hash) DisposeAndRestartAsync, ECSReloadSceneShould (tests) Internal static, well-tested. Encapsulates the asset-bundle guard logic.
ChangedGltfModel struct LocalSceneDevelopmentController, ECSReloadScene Simple data carrier between websocket parsing and reload logic.

No single-use derived predicates that should be merged. No re-derived values. ✅

Step 5 — Line-level findings

See inline comment(s) below.

Step 6 — Complexity: COMPLEX

Touches cache unloading/eviction paths (IStreamableCache, RefCountStreamableCacheBase, GltfContainerAssetsCache, CacheCleaner), scene lifecycle (ECSReloadScene), and asset loading pipeline interfaces. Modifies shared interfaces used across assemblies.

Step 7 — QA assessment: YES

Modifies runtime code that affects scene loading behavior during local scene development. User-facing impact: reload speed and asset correctness during hot reload.

Step 8 — Non-blocking warnings

⚠️ CI: semantic / title-matches-convention is failing. The PR title prefix opti: is not a recognized semantic commit type. Consider renaming to perf: (performance optimization) to match the project's branch & PR standards.

Security review

No security issues found. The websocket data originates from the local dev server (same-machine, developer-controlled). No user input reaches external systems. No secrets, credentials, or sensitive data exposure. No auth/authz changes.


REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Modifies cache eviction paths (IStreamableCache, RefCountStreamableCacheBase, GltfContainerAssetsCache, CacheCleaner) and scene lifecycle (ECSReloadScene) — core asset loading and memory management subsystems.
QA_REQUIRED: YES


Reviewed by Jarvis 🤖 · Requested by decentraland-bot via GitHub

Comment thread Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/ECSReloadScene.cs
@pravusjif pravusjif changed the title opti: evict only the changed GLTF on LSD hot reload instead of draining every cache fix: evict only the changed GLTF on LSD hot reload instead of draining every cache Aug 10, 2026

@pravusjif pravusjif left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Currently the case or changing a model in runtime is only having an effect on the 2nd hot-reload (this case).

It can be tested as follows:
1 - start locally this test scene and change the Animator.create(.. call to just Animator.create(shark) so that it automatically plays any animation on the model
2 - While the scene is running, connected to this PR Explorer, replace the shark.glb file in the scene with another model that has been renamed to have the exact same filename (e.g. 1Monster.glb1 from this other scene
3 - The hot-reload happens, but the model doesn't change (the cached one is still served), the actual model update happens on a 2nd hot-reload (change anything in the code to trigger that 2nd hot reload and confirm that)

dalkia and others added 3 commits August 10, 2026 11:29
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…on LSD scoped eviction

The UpdateModel websocket message's hash is minted from the watcher-relative
path, while every cache key derives from the content-mapping hash minted from
the absolute path — they never match, so the eviction removed a key that was
never cached and the edited model kept loading stale.

Join the changed file to the definition's content list by src to get the real
hash, and evict the parsed-import cache by hash alone (its identity includes
the verbatim src casing from scene code, which the watcher cannot reproduce).
The message's hash is no longer used at all, so the carrier struct collapses
to the src string. Repro: replacing models/shark.glb's bytes with another
model kept showing the shark; now the new content loads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@pravusjif pravusjif left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, now it works as expected in the previously mentioned case.

Screen.Recording.2026-08-10.at.5.36.32.PM.mp4

@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.

PR Review: fix: evict only the changed GLTF on LSD hot reload instead of draining every cache


STEP 2 — Root-cause check: PASS

The problem is clear: the dev server's UpdateModel websocket message already identifies the exact model that changed, but the client discarded that info and force-drained every cache on each hot reload. This PR consumes the message's src field to scope the eviction. This is a genuine fix for the root cause — not a symptom workaround.

STEP 3 — Design & integration: PASS

No new long-lived units introduced. All changes extend existing owners:

  • CacheCleaner already owns UnloadCache (full drain). Adding EvictGltfModel (scoped drain) is consistent with its single responsibility as the cache eviction coordinator.
  • ECSReloadScene already owns the reload decision logic. The new TryResolveContentHash and IsRawGltfModel internal static helpers serve that decision and live with it.
  • LocalSceneDevelopmentController already parses websocket messages and calls the reload. Extracting changedModelSrc from UpdateModel is naturally placed here.

Owner search: The caches being evicted (GltfContainerAssetsCache, GltfLoadCache) already have their lifecycle managed by CacheCleaner via the existing Register() / UnloadCache() contract. The new Remove methods are scoped versions of the existing Unload, consistent with each cache's existing disposal pattern.

Teardown/consumption trace:

  • GltfContainerAssetsCache.Remove: disposes pooled assets, clears list, removes from cache dict + unloadQueue, clears IrrecoverableFailures. Complete cleanup — no leak.
  • RefCountStreamableCacheBase.Remove: checks CanBeDisposed() (ref count ≤ 0), disposes, removes from cache dict + listedCache. The scene is fully disposed (SceneState.Disposed) before eviction runs, so references should be released. Silent no-op if ref count > 0 — consistent with Unload behavior.
  • GltfLoadCache.RemoveByHash: iterates listedCache backwards calling Remove per match. Safe: backwards iteration + per-item RemoveAt preserves indices below the removal point; unique entries per key (ensured by TryAdd in Add).

STEP 4 — Member audit

New member Consumers Assessment
IGltfContainerAssetsCache.Remove(in string key) CacheCleaner.EvictGltfModel (1) Scoped counterpart to Unload; appropriate interface addition
IStreamableCache.Remove(in TLoadingIntention key) Default no-op; overridden by RefCountStreamableCacheBase Avoids breaking all existing implementations; single override is fine
GltfLoadCache.RemoveByHash(string hash) CacheCleaner.EvictGltfModel (1) GltfLoadCache-specific: full key is (Name, Hash) but caller only has hash
ICacheCleaner.EvictGltfModel(string hash) ECSReloadScene.DisposeAndRestartAsync (1) Complements existing UnloadCache
ECSReloadScene.TryResolveContentHash DisposeAndRestartAsync + tests (2) Internal static helper; tested
ECSReloadScene.IsRawGltfModel DisposeAndRestartAsync + tests (2) Internal static helper; tested

All new members are appropriately scoped. No single-use indirection or merge candidates.

STEP 5 — Line-level findings

See inline comments below.

Security review

No security issues found. The UpdateModel.Src string from the websocket is used only for a case-insensitive string.Equals lookup against ContentDefinition.file entries — never as a file path, shell argument, or interpolated into any unsafe operation.

STEP 8 — Non-blocking warnings

None. Main.unity is not modified.


REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Touches GLTF cache eviction pipeline, scene lifecycle reload, and multiple cache hierarchies across assemblies
QA_REQUIRED: YES


Reviewed by Jarvis 🤖 · Requested by Juan Ignacio Molteni [Dalkia] (<@U03JSUQ5Z7U>) via Slack

Comment thread Explorer/Assets/DCL/ResourcesUnloading/CacheCleaner.cs Outdated
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Jarvis reviewed this PR and found no blocking issues, but assessed it as complex — human DEV review is still required before merging.

dalkia and others added 3 commits August 10, 2026 16:58
The local dev server file watcher reports the changed model with the
platform path separator, while content mappings always spell paths with
'/'. On Windows the watcher sends "assets\models\out\models\Bench.glb"
against a mapping entry of "assets/models/out/models/Bench.glb", so the
ordinal join in TryResolveContentHash never matched: the hash stayed
unresolved and every Windows reload silently fell back to the full cache
drain. Scoped eviction has therefore never been active on Windows, while
macOS matched by luck since its separator is already '/'.

Compare paths in place, treating '\' and '/' as equal and ignoring case,
keeping the reload path allocation-free.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Muna <44584806+decentraland-bot@users.noreply.github.com>
Signed-off-by: Juan Ignacio Molteni <juanignaciomolteni@gmail.com>
@dalkia

dalkia commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Given that we want it on the next release, I got @anicalbano okay to merge and do the final test there. I have validated working on both platforms

@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.

Re-review — fix: evict only the changed GLTF on LSD hot reload instead of draining every cache

Changes since last Jarvis review

Three commits landed after the previous review (2026-08-10T16:37):

Commit What changed
7d6e06bf fix: match LSD changed-model paths regardless of platform separatorContentPathEquals normalizes \/ and compares case-insensitively, keeping the reload path allocation-free. Windows LSD scoped eviction was silently broken.
80156d7b Merge dev
79cc5001 Applied previous review suggestion: as GltfLoadCacheis GltfLoadCache typedCache in CacheCleaner.EvictGltfModel

Previous review findings status

# Finding Status
1 [P2] IsRawGltfModelhash should be string? ⚠️ Still open
2 [P2] as cast → is pattern in EvictGltfModel ✅ Applied in 79cc5001
3 [P2] Remove returning bool Dismissed by author — scene is fully disposed before eviction

STEP 2 — Root-cause check: PASS

Unchanged from previous review. The PR correctly consumes the UpdateModel websocket message to scope eviction to the changed model, fixing the root cause (discarding per-model information and draining every cache).

STEP 3 — Design & integration: PASS

No new long-lived units introduced by the new commits. ContentPathEquals is a private static helper on ECSReloadScene — the natural home since it serves TryResolveContentHash. No lifecycle duplication, no per-frame reconciliation, no subscription leaks.

STEP 4 — Member audit

New member (since last review) Consumers Notes
ContentPathEquals(string?, string) TryResolveContentHash (1) Private static helper, allocation-free. Single-use but encapsulates non-trivial separator + case normalization logic — extraction is justified.

STEP 5 — Line-level findings

See inline comments below. Both are the same nullability pattern: a non-nullable parameter guarded with string.IsNullOrEmpty, violating CLAUDE.md NRT rules.

STEP 6 — Complexity: COMPLEX

Unchanged — touches GLTF cache eviction paths and scene lifecycle across assemblies.

STEP 7 — QA: YES

Unchanged — modifies runtime code affecting scene loading during local development.

STEP 8 — Non-blocking warnings

None. Main.unity not modified.

Security review

No issues found. The websocket Src string is used only for case-insensitive string comparison against content definitions, then the resolved hash (from the definition's own data) is used as a cache key. No file I/O, shell execution, or external calls with the input.


REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Touches GLTF cache eviction pipeline (IStreamableCache, RefCountStreamableCacheBase, GltfContainerAssetsCache, GltfLoadCache, CacheCleaner) and scene lifecycle (ECSReloadScene) across assemblies
QA_REQUIRED: YES


Reviewed by Jarvis 🤖 · Requested by Juan Ignacio Molteni [Dalkia] (<@U03JSUQ5Z7U>) via Slack

/// which returns the bare hash only in that case; under <c>--local-ab</c> the key differs and
/// the model lives in the asset-bundle caches instead, so scoped eviction must not be used.
/// </summary>
internal static bool IsRawGltfModel(SceneEntityDefinition? definition, string hash)

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.

[P2] Nullability annotation (repeat from previous review) — hash is declared string (non-nullable) but is null-checked via string.IsNullOrEmpty. Per CLAUDE.md anti-patterns: "Defensive null-checks against non-null declarations — if the declared type is T (not T?), don't null-check it." Since null is a legitimate defensive case (the test in ECSReloadSceneShould exercises it), the parameter should honestly declare string?. This also lets the test pass null directly instead of null!.

Suggested change
internal static bool IsRawGltfModel(SceneEntityDefinition? definition, string hash)
internal static bool IsRawGltfModel(SceneEntityDefinition? definition, string? hash)

/// derives from the content-mapping hash (minted from the absolute path) — the two never
/// match, so the file must be joined to the definition's content list by its src instead.
/// </summary>
internal static bool TryResolveContentHash(SceneEntityDefinition? definition, string src, out string hash)

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.

[P2] Nullability annotation — same pattern as IsRawGltfModel: src is declared non-nullable string but guarded with string.IsNullOrEmpty(src). The caller in DisposeAndRestartAsync passes a string? (changedModelSrc) that flows through a null-check before reaching here, so in practice src is always non-null at the call site — but the method's own guard implies it accepts null. Either remove the IsNullOrEmpty guard on src (trusting the non-nullable contract) or declare string? to match the actual behavior.

Suggested change
internal static bool TryResolveContentHash(SceneEntityDefinition? definition, string src, out string hash)
internal static bool TryResolveContentHash(SceneEntityDefinition? definition, string? src, out string hash)

@dalkia
dalkia merged commit 1cfff82 into dev Aug 11, 2026
25 of 30 checks passed
@dalkia
dalkia deleted the opti/lsd-scoped-gltf-eviction branch August 11, 2026 02:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants