Fix gsplat loading progress getting permanently stuck after a cancelled mid-flight load#8998
Merged
Merged
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
Fixes a gsplat chunk-loading edge case where mid-flight cancellations (success callbacks with no resource) could leave an Asset in a terminal loaded: true, resource: null state, permanently preventing future reloads and stalling overall loading/progress completion.
Changes:
- Retry gsplat URL loads when a prior attempt “succeeded” but produced no resource (cancellation signal), while tracking genuinely permanent failures in a new
_failedset. - Fix retry behavior in
_onAssetLoadErrorto re-initiate loading in a way that re-establishes listeners for subsequent failures. - Add unit tests covering cancellation retries, permanent-failure behavior, unload/re-request behavior, and “late settle after unload” cancellation noise.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/framework/components/gsplat/gsplat-asset-loader.js | Adds cancellation-aware retry behavior and permanent-failure tracking; adjusts error retry flow. |
| test/framework/components/gsplat/gsplat-asset-loader.test.mjs | New tests validating cancellation retry, permanent failures, unload/reload, and no logging on routine cancellation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
_onAssetLoadError's retry path called _startLoading(url), which unconditionally re-attaches both 'load' and 'error' listeners. Since the original 'load' listener from the first _startLoading() call is still pending (it only fires on success), this duplicated it on every retry, causing _onAssetLoadSuccess/_processQueue to run multiple times on eventual success. Re-attach only the 'error' listener that was actually consumed. Addresses review feedback from PR playcanvas#8998.
mvaligursky
approved these changes
Jul 1, 2026
mvaligursky
pushed a commit
that referenced
this pull request
Jul 1, 2026
…ed mid-flight load (#8998) * latest * Avoid duplicate 'load' listeners on gsplat asset retry _onAssetLoadError's retry path called _startLoading(url), which unconditionally re-attaches both 'load' and 'error' listeners. Since the original 'load' listener from the first _startLoading() call is still pending (it only fires on success), this duplicated it on every retry, causing _onAssetLoadSuccess/_processQueue to run multiple times on eventual success. Re-attach only the 'error' listener that was actually consumed. Addresses review feedback from PR #8998. * latest
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Gsplat scene loading could freeze permanently (progress bar stuck short of 100%, autoRender never turning off) when a chunk's load was cancelled mid-flight — e.g. routine LOD range changes during scene traversal, which trigger frequent unload/reload cycles for prefetched and in-flight files. When this happens,
SogParserresolves via the success callback with no resource (an intentional, silent cancellation signal, not an error), leaving the underlying asset markedloaded: truewithresource: null.GSplatAssetLoader.load()treatedloaded: trueas permanently done regardless of whether a resource was ever produced, so it silently no-op'd on every future poll for that URL, and the file never loaded, even though nothing was actually broken about it — it just needed to be retried.This was confirmed via live instrumentation on a repro scene: a prefetch entry sat indefinitely with
loaded: true, resource: null, untracked by_currentlyLoading/_loadQueue/_retryCount, forever blockingpendingLoadCountfrom reaching zero.Fix
GSplatAssetLoader.load()now detects a URL that resolved successfully but produced no resource, and retries it unconditionally whenever polled again, since this only happens while something still actively wants the file — cancellations are not failures and must not be silently permanent.Genuine load errors (real network/parse failures) are tracked separately via a new
_failedset, populated only once_onAssetLoadError's existing retry budget is exhausted, so a truly dead URL doesn't retry forever.Also fixed a related bug in
_onAssetLoadError's retry path: it calledregistry.load()directly without re-attaching an'error'listener, so a second consecutive genuine failure had no listener left to catch it, meaning_failedcould never actually be reached for a repeated real error. The retry path now re-attaches only the'error'listener that was consumed by the previous attempt — it deliberately does not call_startLoading()(which would also re-attach'load'), since the original'load'listener from the first attempt is still pending and duplicating it would cause_onAssetLoadSuccess/_processQueueto run more than once on eventual success.Test plan
test/framework/components/gsplat/gsplat-asset-loader.test.mjscovering: unbounded retry after cancellation, no retry after genuine failure exhausts retries, re-request afterunload(), no duplicate'load'listener across error retries, and no console warnings/errors on routine cancellation (mid-flightunload()before resolution).