Audio: a sound in the preload could hang the game on a blank screen - #1638
Conversation
Four separate ways a preload never settled, reported by a user whose game sat
on a black loading screen whenever a sound was in the manifest.
A clip that failed to load reported nothing at all unless the failure was a
transport error. The listener required a numeric voice id, and a decode
failure, a missing codec and a no-audio-support error all carry `null` — so
none of them reached the retry/give-up path: no callback either way, and a
preload waiting on that asset simply never finished. Transport errors only
worked by accident, falling back to an HTML5 element whose error carries a real
id. The realistic trigger is ordinary hosting: an SPA rewrite serves a missing
`sfx.wav` as `index.html` with a 200, so the fetch succeeds and the decode
fails. Blank screen, empty console.
With `stopOnAudioError = false` — "ignore audio errors and carry on" — a clip
that did report called the loader's ERROR callback, which for a promise-based
preload is the reject. `Promise.all` rejected, the completion handler never
ran, and the game never reached the next scene, over one missing sound effect.
`preload: false` asks the backend not to fetch, so neither callback ever fired
and the manifest waited on a clip that was never coming.
`loader.setOptions({ withCredentials: true })` reached the audio backend under
its pre-20.3 name, which the backend does not read, so an authenticated request
went out without its cookie and failed. The flat `xhrWithCredentials` still
works on `Sound` itself, deprecated rather than removed, since games pass it
straight through; the nested `xhr: { withCredentials }` wins when both are
given, and buffered audio fetches through the same shared path as every other
asset type.
Unloading a clip part-way through its retries also left its retry budget
behind, so reloading the same name gave up on its first failure.
A failed load no longer throws, either. The listener runs from a timer
callback, so the throw landed on an empty stack as an uncaught global error and
no caller's try/catch could ever see it — the documented behaviour was
unreachable. The failure is reported to the loader, which rejects the preload,
and logged with `console.error`. Nothing could catch the old form, so nothing
can depend on it; a global error reporter hooked to `window.onerror` will see a
console error rather than an exception.
The loader's `crossOrigin` and `withCredentials` are documented as read-only,
which they always were: both JSDoc examples showed an assignment that throws a
TypeError, the same trap as the removed `onload` / `onProgress`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
There was a problem hiding this comment.
🟡 Changes recommended
audio/playback.ts can invoke onloadcb twice for preload: false assets (immediate “done” + later backend onload), which can double-fire callbacks and potentially corrupt loader progress in callback-based usage.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR fixes multiple audio-loading edge cases that could cause loader.preload() (and therefore games) to hang indefinitely on a blank loading screen when an audio asset fails or is configured not to preload. It also aligns audio credential handling with the loader’s global withCredentials option and updates docs/tests to lock the new behavior in.
Changes:
- Make audio load failures reliably reach the retry/give-up path (including failures that emit
loaderrorwith anullid) and avoid uncaught throws from timer callbacks. - Ensure
stopOnAudioError = falsetruly “continues loading” (does not reject the preload) and makepreload: falsesettle the loader instead of hanging. - Fix credential forwarding by using
xhr: { withCredentials }(while keepingxhrWithCredentialssupported as deprecated), plus add regression tests and documentation updates.
File summaries
| File | Description |
|---|---|
| packages/melonjs/tests/audio-preload-regression.spec.js | Adds regression coverage for the four reported preload-hang causes and credential propagation. |
| packages/melonjs/tests/audio-audit.spec.js | Updates an audit test to match the new “continue vs error callback” contract when errors are ignored. |
| packages/melonjs/src/loader/loader.js | Updates JSDoc examples/notes to direct users to loader.setOptions({ ... }) for read-only bindings. |
| packages/melonjs/src/audio/state.ts | Changes give-up behavior to report via callbacks (no throw) and adds a “continue” callback for ignored-error flows. |
| packages/melonjs/src/audio/playback.ts | Passes credentials via nested xhr options, routes ignored errors to the “continue” callback, and reports preload:false as done. |
| packages/melonjs/src/audio/backend/types.ts | Documents xhrWithCredentials as deprecated and types onloaderror id as `number |
| packages/melonjs/src/audio/backend/sound.ts | Supports both credential spellings (nested wins) and allows null ids through to onloaderror. |
| packages/melonjs/src/audio/audio.ts | Clears retry budget on unload to avoid reloaded clips inheriting spent counters. |
| packages/melonjs/skills/melonjs-loading-assets/SKILL.md | Documents global cross-origin/credentials settings and audio-related loader pitfalls. |
| packages/melonjs/skills/melonjs-audio/SKILL.md | Documents withCredentials behavior and the setStopOnAudioError(false) contract. |
| packages/melonjs/CHANGELOG.md | Adds changelog entries describing the fixed preload hangs and behavior changes. |
Review details
- Files reviewed: 11/11 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // `preload: false` asks the backend NOT to fetch anything now, so neither | ||
| // `onload` nor `onloaderror` will ever fire — a preload manifest waiting on | ||
| // this asset would never settle. The clip exists and is playable on demand, | ||
| // which is what the caller asked for, so report it as done. | ||
| if (sound.preload === false) { |
Reported by a user whose game sat on a black loading screen whenever a sound was in the preload manifest. Four separate causes, each of which stalls the same way.
The four
A clip that failed reported nothing at all, unless the failure happened to be a transport error. The
loaderrorlistener required a numeric voice id, and a decode failure, a missing codec and a no-audio-support error all carrynull— so none of them reached the retry/give-up path: no callback either way, and a preload waiting on that asset never finished. Transport errors only worked by accident, falling back to an HTML5 element whose error carries a real id.The realistic trigger is ordinary hosting: an SPA rewrite serves a missing
sfx.wavasindex.htmlwith a 200, so the fetch succeeds and the decode fails. Blank screen, empty console — the worst combination for debugging.stopOnAudioError = falsefailed the preload anyway. "Ignore audio errors and carry on" called the loader's error callback, which for a promise-based preload is the reject:Promise.allrejected, the completion handler never ran, and the game never reached the next scene — over one missing sound effect.preload: falsenever settled. The backend is told not to fetch, so neither callback fired and the manifest waited on a clip that was never coming.withCredentialsnever reached the request.loader.setOptions({ withCredentials: true })reached the audio backend under its pre-20.3 name, which the backend does not read, so an authenticated request went out without its cookie and failed.Plus: unloading a clip part-way through its retries left the retry budget behind, so reloading the same name gave up on its first failure.
Compatibility
xhrWithCredentialsstill works onSounditself — deprecated, not removed, since games pass it straight through.xhr: { withCredentials }is the current spelling and wins when both are given. Buffered audio fetches through the same shared path as every other asset type, so one global setting covers everything.One behaviour change
A failed load no longer throws. The listener runs from a timer callback, so the throw landed on an empty stack as an uncaught global error — no caller's
try/catchcould ever see it, making the documented behaviour unreachable. The failure is now reported to the loader (which rejects the preload — the signal a caller actually catches) and logged withconsole.error.Nothing could catch the old form, so no working code can depend on it. The one observable difference: a global error reporter hooked to
window.onerrorwill see a console error rather than an exception.The loader's
crossOriginandwithCredentialsare also documented as read-only, which they always were — both JSDoc examples showed an assignment that throws aTypeError, the same trap as the removedonload/onProgress. Useloader.setOptions({ … }).Verification
loader.preload()with a bad sound and asserts the completion callback fires; reverting the fix givesexpected 'preload rejected' to be 'completed'expected 'hung' to be 'continued'Both skills updated:
melonjs-loading-assetsgains a cross-origin/credentials section stating the one global switch that covers every asset type, andmelonjs-audiodocuments thesetStopOnAudioErrorcontract and the streaming caveat (astream: trueclip plays through an<audio>element and cannot honour fetch credentials).