Add timeout and error handling for Web Audio API - #6
Conversation
There was a problem hiding this comment.
The added diagnostics on the error paths are useful, and the scope is tight (one file, no unrelated changes). But the "timeout protection" half of this PR does not work as described, and one of the timeouts introduces a resource leak.
1. The AudioContext timeout is a no-op — AudioEngine.ts:105-109 and AudioEngine.ts:158-162
audioContext = await this.withTimeout(
Promise.resolve(new AudioContext({ sampleRate: 44100 })),
AUDIO_CONTEXT_TIMEOUT,
...
);new AudioContext(...) is a synchronous constructor. It has already fully run by the time Promise.resolve() is called, so the promise handed to withTimeout is always already-resolved and the 5s timer can never win the Promise.race. AUDIO_CONTEXT_TIMEOUT is effectively dead code, and the stated intent of "timeout protection ... for AudioContext creation" is not delivered.
If catching a synchronous constructor throw is the real goal, drop withTimeout here and keep just the try/catch plus diagnostics — that part works and is worth keeping. If the goal is guarding against a blocked/suspended context, the thing to time out is audioContext.resume(), which does return a promise.
2. The getUserMedia timeout leaks a live microphone stream — AudioEngine.ts:75-86
Promise.race does not cancel the loser. If the 10s timer fires first we reject and take the catch path, but the underlying getUserMedia promise is still pending. When the user eventually grants permission it resolves with a real MediaStream that nothing holds a reference to and nothing calls .stop() on. The mic stays open and the browser recording indicator stays lit until the tab is closed.
This is a regression: before this PR the stream was always assigned to this.stream, so stop() could clean it up. Please attach a cleanup handler to the original promise, roughly:
const micPromise = navigator.mediaDevices.getUserMedia({ ... });
micPromise.then((s) => { if (timedOut) s.getTracks().forEach((t) => t.stop()); }).catch(() => {});
stream = await this.withTimeout(micPromise, MICROPHONE_TIMEOUT, "getUserMedia");Also worth reconsidering whether a 10s cap on getUserMedia is desirable at all. That promise is pending precisely while the user is reading the permission prompt, and 10s is a plausible amount of time for a person to take, so this will spuriously fail for anyone slow to decide.
3. timeoutHandle is likely a strict-mode type error — AudioEngine.ts:39, used at AudioEngine.ts:57
let timeoutHandle: NodeJS.Timeout;Two problems. tsconfig.json sets "strict": true, and TypeScript definite-assignment analysis does not track assignments made inside a callback (the Promise executor), so clearTimeout(timeoutHandle) in the finally should raise TS2454 "Variable timeoutHandle is used before being assigned" under tsc -b. Please confirm against the CI build — I could not run the typecheck locally. Separately, NodeJS.Timeout is the wrong type for browser code: lib here is ["ES2020", "DOM", "DOM.Iterable"] and DOM setTimeout returns number, and @types/node is only present transitively, so the annotation resolves by accident. let timeoutHandle: ReturnType<typeof setTimeout> | undefined; fixes both.
Happy to re-review once these are addressed. Points 1 and 3 are small; point 2 is the one that affects users.
…op AudioContext timeout, fix strict-mode timer type
|
All three points in the review were verified against the code and fixed. 1. AudioContext timeout was a no-op — confirmed: 2. getUserMedia timeout leaked the mic stream — confirmed: 3. Added a regression test ( Nothing was pushed back on — all three points held up under verification. Checks:
|
There was a problem hiding this comment.
The three points from the previous round are genuinely fixed, and I verified each against the code: the dead withTimeout(Promise.resolve(new AudioContext(...))) wrapping is gone from both _startCapture and _loadFile (AudioEngine.ts:109-128, AudioEngine.ts:161-178) with the useful try/catch + diagnostics kept; the late-getUserMedia stream is now stopped (AudioEngine.ts:86-90) and has a regression test that exercises the exact ordering; and timeoutHandle is now ReturnType<typeof setTimeout> | undefined (AudioEngine.ts:38), which is both correct for DOM setTimeout and satisfies definite-assignment analysis. Scope is tight — one source file plus its test, no unrelated changes.
Blocking on the one thing that was raised last round and not addressed or replied to: the 10s timeout on getUserMedia itself.
AudioEngine.ts:5 / AudioEngine.ts:91 — a 10s cap on the permission prompt is a user-facing regression, and the round-2 fix makes it worse, not better
getUserMedia is pending precisely while the user is reading the browser permission prompt. That is not a hang — it is the normal first-run flow, and 10 seconds is a very plausible amount of time for a person to read a dialog, especially where the browser prompt is followed by an OS-level microphone prompt (macOS, Windows 11). So on first run this now fails for slow-to-decide users.
The leak fix compounds it: with AudioEngine.ts:88 in place, a user who clicks Allow at t=11s has their freshly granted stream immediately .stop()'d and still sees an error. Before this PR they got a working microphone. Stopping the stream is the right call given the timeout exists — the problem is the timeout.
The PR description says this "prevents indefinite hangs," but I could not identify an actual hang this guards against. If there is a concrete one (a specific browser/device where getUserMedia never settles after the user has already granted), please say which — that would change the analysis, and it would also argue for a narrower guard.
Options, roughly in order of preference:
- Drop the
getUserMediatimeout and keep the diagnostics. The error logging atAudioEngine.ts:92-104is the part of this PR with clear value and it stands on its own.withTimeoutthen has no call sites and can go too. - Only time out when permission is already granted — gate on
navigator.permissions.query({ name: 'microphone' }), so the timer never races a visible prompt. Handle thepermissionsAPI being unavailable (Firefox does not support themicrophonedescriptor) by skipping the timeout. - If the timeout stays as-is, raise it substantially (60s+) and stop mislabeling it.
AudioEngine.ts:104wraps every failure asMicrophone access denied: ..., so a timeout surfaces to the user asMicrophone access denied: getUserMedia timed out after 10000ms— it propagates throughuseVoiceAnalysis.ts:54to the UI. A timeout is not a denial and needs different copy and a retry affordance.
Two smaller notes, neither blocking on its own:
AudioEngine.ts:46—audioContextState: this.audioContext?.state ?? 'not-initialized'is always'not-initialized'at the only call site, because_startCapturedoesawait this.stop()(which nullsaudioContext) immediately before. It is a constant, not a diagnostic.AudioEngine.ts:93—timedOut = trueis set in the catch for anygetUserMediafailure, not just timeouts. Harmless today (a rejectedmicPromisehits the.catch(() => {})at line 90, never the.then), but the name no longer matches the condition.settledor an explicit timeout check would read truer.
Happy to re-review promptly once the timeout question is resolved — including if the answer is "keep it, here's the hang it fixes."
|
Auto-fix budget exhausted after 1 rounds — not attempting another pass. Two bots disagreeing past this point usually means the proposal needs a human call, or is not worth the churn. Leaving the PR as-is; |
What
Add timeout protection and comprehensive error logging for AudioContext creation and microphone access with fallback diagnostics.
Why
Prevents indefinite hangs and improves debuggability by capturing context about failures including device state and browser capabilities.