Bound match boot's wait on the voice-over manifest - #154
Merged
Conversation
A manifest fetch that connects and then stalls — a captive portal, a wedged CDN edge — left bootGame's await pending indefinitely: a black screen after START with no error and no way past it, cached for the whole session. loadVoiceManifest now races the fetch and the body read against its AbortSignal, so an expired signal resolves to the empty manifest even where the platform's fetch never surfaces the abort. Match boot obtains the manifest through createVoiceManifestFetch, which passes AbortSignal.timeout (2 s), keeps the per-session cache, and forgets a timed-out attempt so the next match retries instead of staying silent until restart; a build's real no-recordings answer stays cached. On timeout the match starts with synthesised narration via the existing per-beat fallback. Tests drive the loader with a never-settling fetch (and a stalled body) to assert the bound, and cover the cache's keep and retry behaviour. Refs #148 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FTY45njBY7VNcKDqKxWeb Signed-off-by: Claude <noreply@anthropic.com>
Review findings on the first pass at this: - Build the timeout from an AbortController and a timer instead of AbortSignal.timeout, so WebViews that predate it (iOS 15 WKWebView, stale Android System WebView) get the bound too rather than the old unbounded wait. - Forget any attempt the server never answered, not just a timed-out one: a fetch that rejects outright (airplane mode, DNS failure) no longer silences recordings for the whole session, while a 404 stays cached as the build's real "no recordings" answer. - Never reject out of the session fetch and never cache a failure: a throwing load resolves to the empty manifest, and the clear runs strictly after the cache assignment so even a synchronous failure cannot re-cache itself. - Start the manifest fetch before artwork loading in bootGame so its time bound overlaps asset loading instead of stacking a serial wait on top of it. Refs #148 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FTY45njBY7VNcKDqKxWeb Signed-off-by: Claude <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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.
What changed?
Starting a match blocked on fetching the optional voice-over manifest with no timeout: a connection that accepts and then stalls (captive portal, wedged CDN edge) left
bootGamehanging on a black screen after START, and the stalled promise was cached for the whole session.The wait is now bounded and the cache made failure-aware:
loadVoiceManifestraces its fetch and body read against the abort signal, so the bound holds even where the platform's fetch never surfaces the abort (this is also what lets the stall test drive it with a never-settling fetch).createVoiceManifestFetch(): one bounded attempt (2 s,AbortController+ timer) cached per session. An attempt the server never answered — timed out, offline, refused — resolves empty so the match starts on time with synthesised narration, and is not kept, so the next match retries. A real answer (manifest, or the 404 of a build with no recordings) stays cached as before. The fetch can never reject out of boot, whatever the load does.bootGamekicks the fetch off before artwork loading, so the bound overlaps asset loading instead of stacking a serial wait on it; the happy path gains no delay.Related issue
Closes #148
Why this approach?
loadVoiceManifest(not just in the caller's signal) becausefetchonly rejects on abort when the platform honours it — a stalled connection on a sloppy WebView stack is exactly the case the timeout exists for.AbortController+setTimeoutinstead ofAbortSignal.timeout, which iOS 15 WKWebView and stale Android System WebViews (< Chrome 103) lack — the platforms most likely to sit behind a captive portal.boundedAssetLoad/scopedAbortSignalfromassets.ts: declined becauseboundedAssetLoadawaits its operation without racing (it would hang on a fetch that ignores its signal, hiding the exact bug),scopedAbortSignalis module-private, and importing the pixi-heavy atlas loader into the audio module inverts the dependency direction. The controller pattern is reused; the helpers are not.createRecordedSpeechis untouched).Verification
npm run typechecknpm test(17/17 inrecordedSpeech.test.ts, full suite green)npm run build(vianpm run check, incl. bundle budget)Provenance and AI assistance
Implemented and reviewed with Claude Code (analysis, code, tests, and this PR); no third-party assets involved.
Contributor checklist
CONTRIBUTING.mdand followed the deterministic simulation rulesgit commit -s)Generated by Claude Code