Skip to content

[bug] A stalled transcription hangs on "Transcribing…" forever and locks the composer — no timeout, no retry, no dismiss, reload is the only escape #421

Description

@serge-ivo

What the user saw

"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:

const res = await fetch(
  `${API}/v1/keys/proxy/api.openai.com/v1/audio/transcriptions`,
  { method: "POST", headers: { Authorization: `Bearer ${getToken() ?? ""}` }, body: form },
);

No signal. grep -c AbortController packages/sdk/src/voice/stt.ts0, 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 eitherworkers/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:47readOnly: !!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-1166Dismiss 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:

  • Retryable now (timeout, 5xx, network) → "Transcription timed out. Retry · Dismiss".
  • 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

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.
  • Narrowing readOnly to dictating risks 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.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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions