Repository navigation
Audio: one owner for the context, and the Safari suspend that wedged it - #1737
Merged
Merged
Conversation
Confirmed in Safari by Olivier: a page left idle for thirty seconds before its first click lost every loaded clip for the life of the page, while `tone()` and `noise()` kept playing. That asymmetry is the whole diagnosis. The generators need only a live context; `Sound#play` goes through the engine's state machine. So the context was fine and the ENGINE's bookkeeping was wrong. The thirty-second idle timer moves the engine to "suspending" and calls `ctx.suspend()`, and until that settles a resume only raised a flag for the settlement to act on. WebKit never settles a suspend asked for outside a user gesture on a context the page was never allowed to start, and an engine waiting on that promise never asks for another. The first click then started the hardware through the unlock path's own scratch resume, so a generated tone was audible while the engine sat in "suspending" forever and every clip parked under `_playLock` waiting for a "resume" that could not come. A resume requested while the suspend is in flight is now issued at once; the context orders it after the suspend, so the last request wins. Chromium and Firefox settle that promise immediately, which is why this reproduced on the reporter's machine every time and never once in automation. Playwright's WebKit build does not carry Safari's gesture restriction either, so it is unreachable from a harness here; the fix is pinned by a unit test that stubs the suspend promise to one that never settles. The rest is the structure that made the bug possible. The context had two owners: the engine held it and ran its own recovery, while the procedural generators carried a private resume they called before every beep. One context, two owners, and only one of them ever restarted it. `src/audio/context.ts` is now the only thing that takes, drops or revives a context, and both sides go through it. It lives outside `backend/` because that directory is the vendored fork and this is ours. Three rules it makes structural rather than conventional: the recovery listeners read the current context when they fire instead of capturing one, so they cannot be stranded by the replacement the unlock performs; a statechange only recovers when the engine still believes it is running, so the deliberate idle suspend is left alone, while a gesture or the tab returning always recovers; and reviving goes through `_autoResume` so the state is corrected and the "resume" event reaches parked voices, which a bare `ctx.resume()` silently skips. Named against the Web Audio API: `requestResume`, `isRunning`, `resume`, `attach`, `detach`. 172 audio tests, up from 162. Eleven mutations run against the final tree, all killed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
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.
Fixes a Safari-only failure confirmed by @obiot: a page left idle for thirty seconds before its first click lost every loaded clip for the life of the page, while
tone()andnoise()kept playing.Why that asymmetry is the whole diagnosis
The generators need only a live context.
Sound#playgoes through the engine's state machine. So "tones audible, clips silent" means the context is healthy and the engine's bookkeeping is wrong — it is never a context problem.The wedge
suspended. The thirty-second idle timer arms.state = "suspending", thenctx.suspend(). WebKit never settles that promise when the suspend is asked for outside a user gesture, on a context the page was never allowed to start. There is no "already suspended, resolve now" shortcut, and an engine waiting on that promise never issues another suspend._autoResume()'s"suspending"branch only raised a flag for the settlement to act on. But the unlock path's own scratchctx.resume()ran in the same gesture and started the hardware."suspending", forever. Everyplay()seesstate !== "running", sets_playLockand parks ononce("resume", ...). Nothing will ever emit it.A resume requested while the suspend is in flight is now issued at once; the context orders it after the suspend, so the last request wins.
_resumeAfterSuspendis gone.Chromium and Firefox settle that promise immediately, which is why this reproduced on the reporter's machine every time and never once in automation. Playwright's WebKit build does not carry Safari's gesture restriction either, so it is unreachable from a harness here. The fix is pinned by a unit test that stubs
ctx.suspendto a promise that never settles, fake-times the idle window, and asserts the clip actually plays after the click.The structure that made it possible
The context had two owners: the engine held it and ran its own recovery, while the procedural generators carried a private
_resumeIfSuspendedthey called before every beep. One context, two owners, and only one of them ever restarted it.src/audio/context.tsis now the only thing that takes, drops or revives a context, and both sides go through it. It sits outsidebackend/because that directory is the vendored howler derivative and this is our own code.Three rules it makes structural rather than conventional:
sampleRate !== 44100) cannot strand them.statechangerecovers only when the engine still believes it is running, so the deliberate idle suspend is left alone; a gesture or the tab returning always recovers, including when the engine and the context disagree._autoResume()sostateis corrected and"resume"reaches parked voices. A barectx.resume()restarts the hardware and leaves them silent underneath it.The engine keeps
_autoResume/_autoSuspend— those are playback bookkeeping, and moving them would have draggedsounds[],_playLockand the event into the owner, which is the coupling this avoids. It lost_watchContext,_recoverContextand_recoveryArmed.Named against the Web Audio API:
requestResume,isRunning,resume,attach,detach.Also fixed on the way
unload()never calleddetach(), so a failed recreate left the owner pointing at a closed context.onended; a one-sample buffer in a context that stays suspended never ends, so a refused gesture left the engine half-unlocked with"unlock"never sent. A resolvedresume()completes it too.Verification
"suspending"fix, the gesture net, the visibility net, the identity check, and a delegate doing a barectx.resume()tsc --noEmitclean;pnpm lint0 errorsBoth changelogs updated: the root one for the user-facing fix, and the backend's own for the fork's changes, including a
### Changednote that the engine no longer owns the context's lifetime, since that is a divergence anyone applying an upstream PR needs to know about.🤖 Generated with Claude Code
https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t