Skip to content

Audio: a sound in the preload could hang the game on a blank screen - #1638

Merged
obiot merged 1 commit into
masterfrom
fix/audio-preload-regression
Sep 3, 2026
Merged

Audio: a sound in the preload could hang the game on a blank screen#1638
obiot merged 1 commit into
masterfrom
fix/audio-preload-regression

Conversation

@obiot

@obiot obiot commented Sep 3, 2026

Copy link
Copy Markdown
Member

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 loaderror 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 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 — the worst combination for debugging.

stopOnAudioError = false failed 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.all rejected, the completion handler never ran, and the game never reached the next scene — over one missing sound effect.

preload: false never settled. The backend is told not to fetch, so neither callback fired and the manifest waited on a clip that was never coming.

withCredentials never 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

xhrWithCredentials still works on Sound itself — 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/catch could 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 with console.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.onerror will see a console error rather than an exception.

The loader's crossOrigin and withCredentials are also 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. Use loader.setOptions({ … }).

Verification

  • 15 tests in a new spec, written failing-first, every one mutation-checked
  • the headline test drives the real loader.preload() with a bad sound and asserts the completion callback fires; reverting the fix gives expected 'preload rejected' to be 'completed'
  • the decode/codec tests hang for 8s under the old code — expected 'hung' to be 'continued'
  • dropping the error callback fails two tests, one timing out at 20s: that is the contract guard
  • 6633 tests overall, lint clean, types clean
  • reviewed adversarially in an isolated worktree; findings F1 (the null-id guard), F3, F4 and F5 from that review are all fixed here

Both skills updated: melonjs-loading-assets gains a cross-origin/credentials section stating the one global switch that covers every asset type, and melonjs-audio documents the setStopOnAudioError contract and the streaming caveat (a stream: true clip plays through an <audio> element and cannot honour fetch credentials).

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
Copilot AI lite review requested due to automatic review settings September 3, 2026 08:12
@obiot
obiot merged commit ab2ee59 into master Sep 3, 2026
7 checks passed
@obiot
obiot deleted the fix/audio-preload-regression branch September 3, 2026 08:18

Copilot AI 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.

🟡 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 loaderror with a null id) and avoid uncaught throws from timer callbacks.
  • Ensure stopOnAudioError = false truly “continues loading” (does not reject the preload) and make preload: false settle the loader instead of hanging.
  • Fix credential forwarding by using xhr: { withCredentials } (while keeping xhrWithCredentials supported 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.

Comment on lines +123 to +127
// `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) {
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.

2 participants