fix(scribe): bound microphone setup so a stalled resume cannot hang it - #964
fix(scribe): bound microphone setup so a stalled resume cannot hang it#964chinmayv095 wants to merge 2 commits into
Conversation
AudioContext.resume() can fail to settle at all in WebKit when called several awaits away from the user gesture, and nothing in the microphone setup chain had a timeout. The session then looked healthy while capturing no audio. It also stranded the microphone: streamFromMicrophone only assigns _audioCleanup once setup resolves, so a setup that never settles leaves close() with nothing to call and the tracks live for the lifetime of the page. Bound the work that follows getUserMedia, leaving the permission prompt itself unbounded, and release the pipeline on timeout through the existing catch. Configurable via setupTimeoutMs, default 10000, with 0 preserving the previous unbounded behaviour. Fixes elevenlabs#887
PR SummaryMedium Risk Overview
New Reviewed by Cursor Bugbot for commit a9f594d. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Reviewed by Cursor Bugbot for commit d54aeca. Configure here.
MicrophoneOptions["microphone"] is a structural copy of ScribeMicrophoneConfig rather than a reference to it, so adding the option to the config alone left it unsettable through Scribe.connect and useScribe even though the changeset advertised it. Add the field to the public shape and pin the two together with a type-level assertion, so a future drift fails the build.

Fixes #887
What happens
webScribeMicrophoneSetupfinishes with an unbounded await:As @codewinkel reports,
AudioContext.resume()in WebKit can fail to settle at all when it is called several awaits away from the originating user gesture. It neither resolves nor rejects. Nothing in the chain has a timeout, so setup hangs.The socket stays open and can still receive
session_started, souseScribe()reportsconnectedandonSessionStartedfires while zero audio is ever captured.The part that is worse than the issue says
The hang does not only make the session silently dead. It strands the microphone.
streamFromMicrophoneonly ever gets the cleanup handle from the resolved setup:close()is written to cope with a setup that is still in flight, and its own comment says so:That handshake assumes the setup promise eventually settles. When it never does,
_audioCleanupis never assigned and theif (connection._closed) result.cleanup()branch is never reached, so neitherclose()nor the server-initiated close path inconnection.tshas anything to call. Thecleanupclosure that owns theMediaStreamis reachable only from inside the hung setup. The tracks stay live and the browser's recording indicator stays on for the lifetime of the page, after the user has ended the session.That is what makes this worth a timeout rather than documentation.
Why the timeout does not cover the whole setup
The issue asks to "wrap the microphone setup (or at minimum the
audioContext.resume()call)". Wrapping the whole thing would includegetUserMedia, and that is where the browser is showing the permission prompt and waiting for a human to click. That wait is unbounded by design, and a 10 second timeout across it would abort ordinary first-run sessions on the fairly common case of someone reading the dialog before clicking.So the bound starts after
getUserMediaresolves and covers the rest, which runs at machine speed with no user in the loop: worklet load, node construction, wiring, and theresume()that actually hangs.does not time out while the permission prompt is openpins this: a 60 secondgetUserMediastill succeeds under a 1000ms setup timeout.The rest
setupTimeoutMsonScribeMicrophoneConfig, defaulting to 10000. A value of 0, or any non-finite value, waits indefinitely and gives back exactly today's behaviour for anyone who wants it.On timeout the rejection goes through the existing
catch, which already callscleanup(), and from there into the handlerstreamFromMicrophonealready has:_emitErrorthenconnection.close(). So the failure surfaces asRealtimeEvents.ERRORfollowed byCLOSE, the same as a setup that rejects today. No new error path, and @codewinkel's application-level watchdog can come out.One residual, stated plainly: the abandoned continuation cannot be cancelled, so it may still assign to the outer
audioContext/source/scribeNodereferences aftercleanup()has run. By then the tracks are stopped and the context is closed, so no hardware is held and no audio is delivered. Cancelling properly would mean threading anAbortSignalthrough theScribeMicrophoneSetupcontract, which is a bigger change to a platform-injectable interface and did not belong in a bug fix. Happy to do it separately if you want it.Verification
Four tests added to the existing
scribeMicrophone.test.ts, alongside theresumerejection test already there. Kept out ofscribe/scribe.tsand its test file, which open PR #680 is editing.rejects and releases the microphone when resume never settlestrack.stop/source.disconnect/scribeNode.disconnect/audioContext.closeall ranhonors a custom setupTimeoutMsdoes not time out while the permission prompt is opengetUserMediasurvives a 1000ms setup timeoutwaits indefinitely when setupTimeoutMs is 0Fail-first, stashing only
scribeMicrophone.ts: 2 failed, 7 passed. The two failures are the two timeout tests, and they fail by hitting vitest's own 5000ms limit rather than by assertion, which is the bug reproducing exactly: with no bound, the setup promise never settles. The other two are controls and pass both ways by design.packages/clientsuite: 238 on main, 242 on the branch, delta exactly the four added, zero failures either side.pnpm -w run check-types: 16 successful, 16 total.prettier --checkclean; the husky pre-committurbo lintpassed 29/29.minor.