You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[bug] A stalled transcription hangs on "Transcribing…" forever and locks the composer — no timeout, no retry, no dismiss, reload is the only escape #421
"when I was testing it, when it was transcribing it was taking forever"
A turn goes to Transcribing… and stays there. No error, no timeout, no way to give up. And while it is stuck the composer is readOnly — so the user cannot even type the message instead. The only escape is a page reload.
This is not the same defect as #420 (which is about mute deleting the words). Here nothing is deleted; the turn never resolves, and the UI is locked behind it.
Verified mechanism — four facts, each from the code
1. The transcription request has no timeout and no abort.packages/sdk/src/voice/stt.ts:344-351:
No signal. grep -c AbortController packages/sdk/src/voice/stt.ts → 0, and there is no AbortController or AbortSignal.timeout anywhere in packages/sdk/src/voice/ or packages/sdk/src/client.ts. The streaming branch (_readTranscriptionStream) reads res.body with no deadline either. The server side has none either — workers/api/src/routes/keys.ts (proxy at :162) has no AbortSignal/timeout.
So if the request stalls, the promise never settles.
2. Nothing else can resolve the turn. The whole failure apparatus hangs off onError (use-voice.ts:1225-1280) — and it is good: it classifies soft/mic-unavailable/real, writes dictate({type:"failed", note}), shows a notice, logs durably. A hang never reaches any of it, because onError is only ever called from a catch or a !res.ok. A request that never settles produces neither.
3. There is no watchdog.Dictation carries startedAt (machine.ts:254, set at :296 on endOfTurn). Grepping every consumer: the only reads are the reducer writing it. Nothing anywhere compares startedAt to now. The field that would make a timeout trivial exists and is unused.
4. The stuck bubble locks the composer and offers no way out.
composer.ts:47 — readOnly: !!s.dictation && s.dictation.status !== "failed". transcribing is not failed, so the textarea is held. The doc comment above it says readOnly means "voice owns this turn" — a hung turn owns it forever.
InstanceDetail.tsx:1164-1166 — Dismiss is rendered only when status === "failed". A transcribing bubble has no Dismiss, no Retry, no cancel.
:1169 — it shows … under a spinning Loader2, indefinitely.
So: locked out of typing, locked out of dismissing, spinner forever. That is the "taking forever" you saw, and it is a hard lock rather than slowness.
On the deployment theory
Partly checkable, and I will not overstate it. Verified: there were 9 Deploy API Worker runs today between 02:50 and 04:51 UTC, several overlapping your testing window. Not verified: that one of them coincided with the stuck transcription, or that a Workers deploy can stall an in-flight proxied request at all (Cloudflare deploys are atomic; I have no evidence either way and did not test it).
The more useful point is that it does not matter. Deploy, cold start, flaky mobile radio, OpenAI stalling — every cause produces the identical unresolvable hang, because there is no deadline on the client. The fix is the same for all of them, and a deploy-specific error message would be guessing at one cause out of many. If a deploy-awareness banner is still wanted, that is a separate ticket and needs a real signal (e.g. a version/health probe), not an inference from a spinner.
What to do — cheapest first
1. A deadline on the request (small, fixes the hang). Wrap both the plain and streaming paths in AbortSignal.timeout(...) and route the abort into the existing onError, which already does everything right from there — the bubble becomes failed, keeps the words the live gate heard, keeps the recording, shows the note, and offers Dismiss. Suggested: ~20s to first byte. A distinct message is needed so it does not read as a user error: "Transcription timed out", not "Whisper failed: …".
2. A watchdog on startedAt (belt-and-braces). A request can also stall after headers, in the SSE read. A timer that flips a dictation still transcribing after N seconds into failed costs a few lines and uses the field that already exists. This is the one that guarantees the UI can never be permanently stuck regardless of what the network layer does.
3. Retry, and say which kind of retry. This is what the user actually asked for: "users need to see the message and know what to do — retry now or later." The clip is still in hand (lastAudioBlobRef / the blob passed to onAudio), so Retry is cheap and should be offered on the failed bubble next to Dismiss — re-POST the same blob. Distinguish the two cases in the note, because the right action differs:
Not retryable (401/400 bad key, audio too short) → say the reason and do not offer Retry; the existing parseUpstreamErrorDetail already surfaces OpenAI's own text.
4. Never hold the composer on a stalled turn. Even before (1)-(3) land, readOnly should not outlive a turn that is no longer making progress. Options: drop readOnly once transcribing exceeds a few seconds, or narrow it to dictating only. I would narrow it to dictating — the box needs holding while words are landing live into the bubble, but once the mic is closed and we are only waiting on a network call there is no reason a user cannot type. That is one boolean and it removes the lockout entirely, independent of every other fix here.
Alternatives considered and rejected
Show a deploy banner when CI is running. Rejected as the primary fix: it addresses one hypothesised cause out of several, is unverified (see above), and leaves the hang in place for all the others.
Auto-retry silently on timeout. Rejected: transcription costs the user's own OpenAI credit, and a silent retry of a 20s stall doubles the wait before they learn anything. Tell them, offer the button.
Timeout only on the server proxy. Rejected as sufficient on its own: it fixes the upstream stall but not a client-side network stall, and the client still has no deadline. Worth adding as well, not instead.
Acceptance criteria
A transcription that never returns resolves to failed within a bounded time, with a note naming the timeout.
The failed bubble offers Retry for retryable failures (timeout/5xx/network), re-sending the same audio; no Retry for 400/401-class failures, which state their reason instead.
The composer is never held by a turn that is only waiting on the network — the user can always type instead.
A stuck turn is recoverable without reloading the page.
The timeout path writes a durable log row (reportClientError), excluded from the isConnectivityError skip so real stalls stay countable.
Regression risk
A timeout set too tight would fail slow-but-working transcriptions on poor mobile networks, and the retry costs credit. Start generous (~20s) and check the durable log for timeout rows before tightening.
composer.test.ts pins the composer binding rule and scans consumer trees; it will need updating and is the test that catches a wrong change here.
Related: #420 (mute deletes a transcribing turn — same pending-utterance state, different failure), #281 (the pending bubble and the failed status this relies on), #377 (words must survive a rejected turn), #387 (a giving-up path that produces no evidence of itself — same family).
What the user saw
A turn goes to Transcribing… and stays there. No error, no timeout, no way to give up. And while it is stuck the composer is
readOnly— so the user cannot even type the message instead. The only escape is a page reload.This is not the same defect as #420 (which is about mute deleting the words). Here nothing is deleted; the turn never resolves, and the UI is locked behind it.
Verified mechanism — four facts, each from the code
1. The transcription request has no timeout and no abort.
packages/sdk/src/voice/stt.ts:344-351:No
signal.grep -c AbortController packages/sdk/src/voice/stt.ts→ 0, and there is noAbortControllerorAbortSignal.timeoutanywhere inpackages/sdk/src/voice/orpackages/sdk/src/client.ts. The streaming branch (_readTranscriptionStream) readsres.bodywith no deadline either. The server side has none either —workers/api/src/routes/keys.ts(proxy at:162) has noAbortSignal/timeout.So if the request stalls, the promise never settles.
2. Nothing else can resolve the turn. The whole failure apparatus hangs off
onError(use-voice.ts:1225-1280) — and it is good: it classifies soft/mic-unavailable/real, writesdictate({type:"failed", note}), shows a notice, logs durably. A hang never reaches any of it, becauseonErroris only ever called from acatchor a!res.ok. A request that never settles produces neither.3. There is no watchdog.
DictationcarriesstartedAt(machine.ts:254, set at:296onendOfTurn). Grepping every consumer: the only reads are the reducer writing it. Nothing anywhere comparesstartedAtto now. The field that would make a timeout trivial exists and is unused.4. The stuck bubble locks the composer and offers no way out.
composer.ts:47—readOnly: !!s.dictation && s.dictation.status !== "failed".transcribingis notfailed, so the textarea is held. The doc comment above it saysreadOnlymeans "voice owns this turn" — a hung turn owns it forever.InstanceDetail.tsx:1164-1166— Dismiss is rendered only whenstatus === "failed". Atranscribingbubble has no Dismiss, no Retry, no cancel.:1169— it shows…under a spinningLoader2, indefinitely.So: locked out of typing, locked out of dismissing, spinner forever. That is the "taking forever" you saw, and it is a hard lock rather than slowness.
On the deployment theory
Partly checkable, and I will not overstate it. Verified: there were 9
Deploy API Workerruns today between 02:50 and 04:51 UTC, several overlapping your testing window. Not verified: that one of them coincided with the stuck transcription, or that a Workers deploy can stall an in-flight proxied request at all (Cloudflare deploys are atomic; I have no evidence either way and did not test it).The more useful point is that it does not matter. Deploy, cold start, flaky mobile radio, OpenAI stalling — every cause produces the identical unresolvable hang, because there is no deadline on the client. The fix is the same for all of them, and a deploy-specific error message would be guessing at one cause out of many. If a deploy-awareness banner is still wanted, that is a separate ticket and needs a real signal (e.g. a version/health probe), not an inference from a spinner.
What to do — cheapest first
1. A deadline on the request (small, fixes the hang). Wrap both the plain and streaming paths in
AbortSignal.timeout(...)and route the abort into the existingonError, which already does everything right from there — the bubble becomesfailed, keeps the words the live gate heard, keeps the recording, shows the note, and offers Dismiss. Suggested: ~20s to first byte. A distinct message is needed so it does not read as a user error:"Transcription timed out", not"Whisper failed: …".2. A watchdog on
startedAt(belt-and-braces). A request can also stall after headers, in the SSE read. A timer that flips a dictation stilltranscribingafter N seconds intofailedcosts a few lines and uses the field that already exists. This is the one that guarantees the UI can never be permanently stuck regardless of what the network layer does.3. Retry, and say which kind of retry. This is what the user actually asked for: "users need to see the message and know what to do — retry now or later." The clip is still in hand (
lastAudioBlobRef/ the blob passed toonAudio), so Retry is cheap and should be offered on the failed bubble next to Dismiss — re-POST the same blob. Distinguish the two cases in the note, because the right action differs:parseUpstreamErrorDetailalready surfaces OpenAI's own text.4. Never hold the composer on a stalled turn. Even before (1)-(3) land,
readOnlyshould not outlive a turn that is no longer making progress. Options: dropreadOnlyoncetranscribingexceeds a few seconds, or narrow it todictatingonly. I would narrow it todictating— the box needs holding while words are landing live into the bubble, but once the mic is closed and we are only waiting on a network call there is no reason a user cannot type. That is one boolean and it removes the lockout entirely, independent of every other fix here.Alternatives considered and rejected
failedexists precisely so they survive.Acceptance criteria
failedwithin a bounded time, with a note naming the timeout.reportClientError), excluded from theisConnectivityErrorskip so real stalls stay countable.Regression risk
readOnlytodictatingrisks a user typing and sending while a transcript is still in flight, producing two messages. That is visible and intentional on their part, unlike today's silent lock — but the arriving transcript should then go to the composer rather than auto-send. Note this interacts with [bug] Muting while a turn is transcribing deletes the words — the bubble is cleared and the in-flight transcript is then dropped or sent by an unrelated 800ms echo timer #420 fix (3); the two should be decided together.composer.test.tspins the composer binding rule and scans consumer trees; it will need updating and is the test that catches a wrong change here.Related: #420 (mute deletes a transcribing turn — same pending-utterance state, different failure), #281 (the pending bubble and the
failedstatus this relies on), #377 (words must survive a rejected turn), #387 (a giving-up path that produces no evidence of itself — same family).