Skip to content

Bound match boot's wait on the voice-over manifest - #154

Merged
Knorcedger merged 2 commits into
mainfrom
claude/match-boot-voiceover-timeout-prkvf7
Aug 23, 2026
Merged

Bound match boot's wait on the voice-over manifest#154
Knorcedger merged 2 commits into
mainfrom
claude/match-boot-voiceover-timeout-prkvf7

Conversation

@Knorcedger

Copy link
Copy Markdown
Owner

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 bootGame hanging 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:

  • loadVoiceManifest races 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).
  • Match boot goes through a new 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.
  • bootGame kicks 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?

  • The bound lives inside loadVoiceManifest (not just in the caller's signal) because fetch only 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 + setTimeout instead of AbortSignal.timeout, which iOS 15 WKWebView and stale Android System WebViews (< Chrome 103) lack — the platforms most likely to sit behind a captive portal.
  • Retry-next-match applies to every unanswered attempt, not only timeouts: an airplane-mode boot must not silence recordings until app restart, while a 404 is the build's real "no recordings" and stays cached.
  • Considered reusing boundedAssetLoad/scopedAbortSignal from assets.ts: declined because boundedAssetLoad awaits its operation without racing (it would hang on a fetch that ignores its signal, hiding the exact bug), scopedAbortSignal is 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.
  • Out of scope, per the issue: in-boot retries, progress UI, and the recorded/synthesised seam (createRecordedSpeech is untouched).

Verification

  • npm run typecheck
  • npm test (17/17 in recordedSpeech.test.ts, full suite green)
  • npm run build (via npm run check, incl. bundle budget)
  • I added or updated tests where behavior changed — stall bounded with a never-settling fetch (headers and body variants), happy path parses, answered results cached, timed-out/rejected/synchronously-throwing attempts resolve empty and retry next match
  • I included screenshots or a recording for visible changes — n/a, no visible changes (boot no longer hangs; nothing looks different)

Provenance and AI assistance

Implemented and reviewed with Claude Code (analysis, code, tests, and this PR); no third-party assets involved.

Contributor checklist

  • I searched issues and active pull requests before implementation and coordinated ownership in the linked issue
  • This pull request closes or references exactly one primary coordination issue
  • I read CONTRIBUTING.md and followed the deterministic simulation rules
  • I reviewed and understand every submitted change
  • I have the right to submit this code and any included assets
  • My commits are signed off under the Developer Certificate of Origin (git commit -s)
  • This PR contains no credentials, private player data, or unlicensed third-party material

Generated by Claude Code

claude added 2 commits August 23, 2026 08:50
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>
@vercel

vercel Bot commented Aug 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
bannerfall Ready Ready Preview Aug 23, 2026 9:25am

Request Review

@Knorcedger
Knorcedger merged commit a53deaf into main Aug 23, 2026
3 checks passed
@Knorcedger
Knorcedger deleted the claude/match-boot-voiceover-timeout-prkvf7 branch August 23, 2026 09:25
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.

Match boot waits on the voice-over manifest with no timeout

2 participants