Skip to content

fix: don't cache transient GLTFContainer asset-load failures as irrec… - #9774

Closed
alejandro-jimenez-dcl wants to merge 2 commits into
devfrom
bugsweep/fishing-pole-invisible
Closed

fix: don't cache transient GLTFContainer asset-load failures as irrec…#9774
alejandro-jimenez-dcl wants to merge 2 commits into
devfrom
bugsweep/fishing-pole-invisible

Conversation

@alejandro-jimenez-dcl

Copy link
Copy Markdown
Contributor

…overable

All rod meshes load from the same GLTF asset-bundle hash. A single transient failure during a load burst (an aborted request, a corrupt partial download, or any general exception) gets cached forever as an "irrecoverable" failure, so every subsequent load for that hash fails instantly with no retry — every rod goes permanently meshless until the client restarts. Separately (tracked as #9469), releasing a GltfContainer doesn't clear its cached promise result, so two release paths can dereference and re-pool the same live asset, causing a rod to disappear mid-use or get stuck loading forever when its shared mesh is destroyed by cache eviction. Memory pressure is what triggers cache eviction and thus both failure paths.

Fixes #9511
Related: #9469

Includes a regression test that fails without this fix.

Pull Request Description

What does this PR change?

Test Instructions

Steps (standard run):

metaforge explorer run XXXX  # ← replace with this PR number

Expected result:

Steps (fresh account):

metaforge account create --clear
metaforge explorer run XXXX  # ← replace with this PR number

Expected result:

Automation (if applicable):

metaforge explorer test XXXX

Prerequisites

  • List any required setup steps
  • Include environment/configuration requirements

Test Steps

  1. First step
  2. Second step
  3. Expected result after step 2
  4. ...

Additional Testing Notes

  • Note any edge cases to verify
  • Mention specific areas that need careful testing
  • List known limitations or potential issues

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

Code Review Reference

Please review our Branch & PR Standards before submitting. It explains the automated review flow, QA/DEV approval requirements, and what each label does — especially useful for first-time contributors.

…overable

All rod meshes load from the same GLTF asset-bundle hash. A single transient failure during a load burst (an aborted request, a corrupt partial download, or any general exception) gets cached forever as an "irrecoverable" failure, so every subsequent load for that hash fails instantly with no retry — every rod goes permanently meshless until the client restarts. Separately (tracked as #9469), releasing a `GltfContainer` doesn't clear its cached promise result, so two release paths can dereference and re-pool the same live asset, causing a rod to disappear mid-use or get stuck loading forever when its shared mesh is destroyed by cache eviction. Memory pressure is what triggers cache eviction and thus both failure paths.

Fixes #9511
Related: #9469

Includes a regression test that fails without this fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alejandro-jimenez-dcl
alejandro-jimenez-dcl requested review from a team as code owners August 18, 2026 14:26
@github-actions
github-actions Bot requested a review from DafGreco August 18, 2026 14:26
@github-actions

github-actions Bot commented Aug 18, 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 6ca5343
Logs https://github.com/decentraland/unity-explorer/actions/runs/32166606560
Download Windows https://github.com/decentraland/unity-explorer/suites/87200320621/artifacts/9337182652
Download Windows S3 https://explorer-artifacts.decentraland.org/@dcl/unity-explorer/branch/bugsweep/fishing-pole-invisible/pr-25279-6ca5343/Decentraland_windows64.zip
Download Mac https://github.com/decentraland/unity-explorer/suites/87200320621/artifacts/9337588108
Download Mac S3 https://explorer-artifacts.decentraland.org/@dcl/unity-explorer/branch/bugsweep/fishing-pole-invisible/pr-25279-6ca5343/Decentraland_macos.zip
Built on 2026-08-18T18:38:34Z

Lint

Warnings not reduced: 13116 => 13118 — remove at least 3 warnings to merge.

Warnings/errors in files changed by this PR (8)
Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Systems/CleanUpGltfContainerSystem.cs:69  CSharpWarnings::CS8604  Possible null reference argument for parameter 'gltfContainerAsset' in 'DCL.Interaction.Utility.EntityCollidersCacheExtensions.Remove'
Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Systems/ResetGltfContainerSystem.cs:71  CSharpWarnings::CS8604  Possible null reference argument for parameter 'gltfContainerAsset' in 'DCL.Interaction.Utility.EntityCollidersCacheExtensions.Remove'
Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/Tests/LoadSystemBaseIrrecoverableFailureShould.cs:175  EmptyGeneralCatchClause  Empty general catch clause suppresses any errors
Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Systems/CleanUpGltfContainerSystem.cs:65  InconsistentNaming  Name 'DestroyGLTFContainer' does not match rule 'members_should_be_pascal_case'. Suggested name is 'DestroyGltfContainer'.
Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Systems/CleanUpGltfContainerSystem.cs:48  InconsistentNaming  Name 'FinalizeGLTFContainer' does not match rule 'members_should_be_pascal_case'. Suggested name is 'FinalizeGltfContainer'.
Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/Tests/LoadSystemBaseIrrecoverableFailureShould.cs:211  NullnessAnnotationConflictWithJetBrainsAnnotations  Nullability of a member conflicts with annotations in hierarchy
Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/Common/Systems/LoadSystemBase.cs:210  UnusedMember.Local  Method 'SynchronizePartialData' is never used
Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/Common/Systems/LoadSystemBase.cs:140  VariableCanBeNotNullable  'cachedSource' can be declared as non-nullable

Tests

All Unity tests passed ✅

TESTS SUITE Result Passed Failed Skipped
EditMode ✅ Passed 25187 0 13
PlayMode ✅ Passed 238 0 37

@decentraland-bot
decentraland-bot self-requested a review August 18, 2026 14:26

@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

Files reviewed: 4 production files + 1 new test file (295 lines). Subsystem: StreamableLoading (asset loading pipeline) and GLTFContainer (GLTF asset lifecycle).

Surrounding context read: LoadSystemBase.cs (full), AssetsLoadingUtility.cs (full), WebRequestUtils.cs (full — IsIrrecoverableError, IsIrrecoverableResponseCode, IsAborted), AssetPromise.cs (full — NULL, ForgetLoading, TryGetResult), CleanUpGltfContainerSystem.cs (full), ResetGltfContainerSystem.cs (full), IStreamableCache.cs (full — interface contract for IrrecoverableFailures), StreamableLoadingResult.cs (full — Exception property, IsInitialized).

STEP 2 — Root-Cause Check

Problem: A single transient failure during a GLTF load burst (aborted request, corrupt download, general exception) is cached permanently in IrrecoverableFailures, blocking all future loads for that hash for the rest of the session. Separately, releasing a GltfContainer doesn't clear its cached promise result, enabling double-dereference of the same live asset.

Does the diff fix the cause? Yes — both fixes. The caching change in LoadSystemBase.RepeatLoopAsync (line 402) restricts what gets stored in IrrecoverableFailures from "any concluded failure" to "only definitive 4xx HTTP client errors." The Promise = NULL assignment prevents the double-dereference by clearing the cached result after cleanup. Neither fix is a symptom-level workaround. PASS.

STEP 3 — Design & Integration

No new long-lived units introduced. The diff adds one private static helper method (IsIrrecoverableFailure) and two single-line assignments. No new systems, managers, or state-holding types.

IsIrrecoverableFailure placement: Correct. LoadSystemBase is the sole gatekeeper for cache.IrrecoverableFailures (line 428, SetIrrecoverableFailure). The filtering logic belongs adjacent to RepeatLoopAsync where it is consumed. Placing it in WebRequestUtils would conflate the HTTP-level classification (retry policy) with the asset-loading-level decision (permanent cache poisoning).

Semantic layering with WebRequestUtils.IsIrrecoverableError: Intentional narrowing, not redundant overlap. IsIrrecoverableError() serves the retry policy — it returns true for aborted requests (IsAborted()), permanent 5xx codes (501, 505, 507, 508, 511 via IsIrrecoverableResponseCode default case), and non-DNS/non-SSL errors. The new IsIrrecoverableFailure() serves the caching policy — it adds the >= 400 and < 500 gate to ensure only definitive client errors are cached for the session. Aborted requests with no 4xx code, server errors (even permanent ones like 501), and non-HTTP exceptions are excluded from caching. This is the correct tightening: the retry policy says "don't retry this request," while the caching policy says "don't cache this as permanently failed."

Promise = NULL pattern consistency: This pattern appears in three locations in GLTF container systems, all added or already present in this PR. Other systems (e.g. AvatarLoaderSystem) call ForgetLoading without NULL assignment because they either immediately reassign the promise or the entity is about to be destroyed. The GLTF cleanup path is different: the component can linger after ForgetLoading (per-frame delete intention, scene teardown), making the NULL guard specifically necessary here.

Teardown trace: ForgetLoading (AssetPromise.cs:129-135) cancels the CTS and destroys the entity. The subsequent Promise = NULL clears the cached Result field. TryGetResult on a NULL promise returns false immediately (Entity == Entity.Null check, line 70). No subscription/event/callback is left dangling.

PASS.

STEP 4 — Member Audit

IsIrrecoverableFailure (private static, 1 consumer): Called only from RepeatLoopAsync (line 402). Single-use is appropriate — it's a predicate extracted for readability of the ternary condition. It classifies the exception chain rather than re-checking state. No merge-or-inline concern.

STEP 5 — Line-Level Review

No P0 or P1 issues found. One P2 finding below (posted as inline comment).

STEP 6 — Complexity

COMPLEX — Modifies the asset loading pipeline's caching policy (LoadSystemBase), changes resource cleanup paths in GLTF container systems, and involves async flow interaction between RepeatLoopAsync, CacheableFlowAsync, and FlowAsync.

STEP 7 — QA Assessment

QA_REQUIRED: YES — Changes affect runtime asset loading behavior. The fishing-rod regression (all rods going permanently meshless) is the primary scenario to verify. Testing should cover: asset loading under memory pressure, GLTF container cleanup during scene teardown, and repeated load/release cycles.

STEP 8 — Non-Blocking Warnings

No Main Scene modifications detected. No warnings.

Security Review

No security issues found. The test HttpListener binds to loopback only (IPAddress.Loopback). No secrets, injection vectors, or auth concerns.

REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Modifies the StreamableLoading asset-caching pipeline policy and GLTF container resource cleanup lifecycle
QA_REQUIRED: YES


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

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

@alejandro-jimenez-dcl alejandro-jimenez-dcl left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Approved

@alejandro-jimenez-dcl
alejandro-jimenez-dcl force-pushed the bugsweep/fishing-pole-invisible branch from ac96d63 to 6ca5343 Compare August 18, 2026 17:37
@decentraland-bot

Copy link
Copy Markdown
Contributor

PR #9774, run #32172184339

Builds: Windows change, Windows baseline, macOS change, macOS baseline

How to read this table
  • Each build is measured 3 times. The values are the median, and (min–max) is the lowest and highest of those runs — a wide range means the metric is noisy and small differences are not trustworthy.
  • Δ is Change minus Baseline (a negative Δ means Change is faster).
  • 🟢 faster / 🔴 slower — a real difference: larger than both 3% and the run-to-run range.
  • ⚪ within noise — the difference is smaller than how much the build varies between its own runs, so it cannot be told apart from random variation. Treat it as no change.
  • Exceptions per run — the average number of exceptions in a run's log; more than the baseline is flagged 🔴 even when frame times look fine. The Exception breakdown under each table groups them by the explorer's report category and exception type (as totals across the runs).
  • A run that logged unusually many exceptions (at least 10 and 5× the median of its build's runs — e.g. a service was down during it) is excluded from all numbers and called out under the table.

Intel Core i5

Metric Baseline Change Δ Result
Samples 2313 (×3) 2369 (×3)
CPU average 38.6 ms (33.4–38.8) 37.6 ms (37.6–37.7) -1.0 ms ⚪ within noise
CPU 1% worst 322.6 ms (57.0–343.5) 297.0 ms (277.6–303.1) -25.5 ms ⚪ within noise
CPU 0.1% worst 344.1 ms (341.1–360.3) 316.9 ms (291.8–330.7) -27.2 ms ⚪ within noise
GPU average 9.5 ms (9.2–9.6) 9.5 ms (9.4–9.5) -0.0 ms ⚪ within noise
GPU 1% worst 35.6 ms (23.5–37.7) 31.5 ms (29.4–33.3) -4.1 ms ⚪ within noise
GPU 0.1% worst 44.4 ms (39.8–45.0) 37.4 ms (36.9–37.9) -6.9 ms 🟢 16% faster
Exceptions per run 66 65 -1 🟢 fewer errors
Exception breakdown
Exception Baseline (3 runs) Change (3 runs)
[UI] DllNotFoundException 192 192
[ENGINE] ObjectDisposedException 3 3
[ENGINE] NullReferenceException 3 0

Apple M1

Metric Baseline Change Δ Result
Samples 4105 (×3) 4044 (×3)
CPU average 21.8 ms (21.8–22.9) 22.2 ms (22.1–23.0) 0.4 ms ⚪ within noise
CPU 1% worst 215.9 ms (215.7–217.7) 218.0 ms (217.4–223.0) 2.1 ms ⚪ within noise
CPU 0.1% worst 226.3 ms (222.9–228.8) 229.8 ms (222.1–232.8) 3.5 ms ⚪ within noise
GPU average 2.5 ms (2.0–3.2) 2.6 ms (2.4–3.2) 0.1 ms ⚪ within noise
GPU 1% worst 34.3 ms (34.2–36.2) 34.6 ms (34.6–36.5) 0.3 ms ⚪ within noise
GPU 0.1% worst 36.3 ms (34.8–37.5) 36.3 ms (35.7–38.4) -0.0 ms ⚪ within noise
Exceptions per run 0 0 0 ⚪ none new

@alejandro-jimenez-dcl

Copy link
Copy Markdown
Contributor Author

Superseded by #9784, which combines this and the other bugsweep fixes into a single PR (one squashed commit per original, rebased onto updated dev). Closing in favor of that one.

@alejandro-jimenez-dcl
alejandro-jimenez-dcl deleted the bugsweep/fishing-pole-invisible branch August 18, 2026 20:56
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.

Fishing rod model not visible during fishing — only line renders (GLB disappears under load)

3 participants