Repository navigation
[Bug]: iPhone voice input says a transcription is already active after leaving a thread mid-dictation instead of cancelling it #13743
Description
Activity
Triage
Confirmed on current
main(ecd3237b18). This is a real iPhone dictation bug. Not already fixed. The session lock landed with offline voice input in #8614 and has not changed since. Installed 1.2.1 already has it.88d0c40c85is the 1.2.1 bump;76cc9b08f1is the later 1.3.0 bump, not 1.2.1. Nothing between 1.2.1 andmainchanges this lock. #12227 only keeps the screen awake, and #12534 only touches theme colors. Updating the app will not change this.No open issue covers it. #10140 describes the same error and is not the fix. That PR keeps the original recording alive across threads and still rejects a second one. This report is the opposite: the abandoned session should get out of the way.
What you hit
On iPhone, picking another thread pushes a new Thread screen. The one you left blurs, and
useFocusEffectcallsdispose(). The composer goes idle. The next microphone tap is a differentVoiceInputController, but both share two module globals inpackages/client-runtime/src/voice-input/controller.ts:let activeSession: symbol | null = null; let activeTranscriptionOperation: Promise<unknown> | null = null; function acquireSession(): symbol | null { if (activeSession) return null; // ... } async function runTranscriptionOperation<T>(operation: () => Promise<T>): Promise<T> { if (activeTranscriptionOperation) { throw new Error("voice-operation-busy"); } // ... }
cancel(),dispose(), andownerChanged()only bump the operation token, abort the signal, and setidle.releaseSession()runs later, insidereleaseResources(), from thefinallyofstart()orfinishRecording(). Thatfinallywaits until the native call returns.AppleTranscription.prepare()and.transcribe()cannot be interrupted.apps/mobile/src/native/voiceTranscription.ios.tschecks the signal only before and after those awaits. An abandoned prepare or transcribe holdsactiveSessionuntil the native call settles. The newstart()then fails here:const sessionToken = acquireSession(); if (!sessionToken) { this.setError("Another voice recording is already active.", "retry"); return; }
That is the message this navigation path shows. "Voice transcription is still finishing" comes from
voice-operation-busy, and this path does not reach it, because the session lock is still held for the whole native call. Dismissing the error only clears local state. It does not release the other controller’s lock.Leaving during recording is a short window:
discardRecording()holds the lock untilrecorder.stop()and audio cleanup finish. The long block is preparing and transcribing. The lock does release when that call finishes, which matches waiting a few seconds. A permanent stuck state is not what this code does unless the native call never returns. Backgrounding duringtranscribingdoes not cancel it.appMovedToBackground()only handlespreparingandrecording.This rejection is the current contract, not an accident.
controller.test.tsexpects the next controller to see "already active" aftercancel,dispose, andownerChangeduntil a non-abortable transcription settles, and the same for a cancelled prepare. Changing the behavior means changing those tests.iPad split view is the same lock through
ownerChanged()on one composer. Android does not use this transcriber.Direction
Queue a start that follows an abandoned session. Do not show an error for a session the user already left. Keep one Apple call at a time. Do not overlap
prepareortranscribe, and do not pretend the signal stops them.Releasing the session token immediately, while the old
finallystill runsreleaseRecording(), is unsafe. That callback is process-wide (setAudioModeAsync({ allowsRecording: false })andsetIsAudioActiveAsync(false)). A late cleanup from the abandoned controller will tear down the new recording. The nextstart()has to wait until the abandoned controller has finished native work and audio cleanup, then proceed, showing Preparing rather than an error.If the owner is still actively preparing, recording, or transcribing and has not been cancelled or disposed, keep the single-session rejection.
Constraints for a PR:
- Only mobile voice input. Web does not use this controller.
- An abandoned session must not leave the next microphone tap on "Another voice recording is already active."
- A second native Apple call must not start until the abandoned one and its audio cleanup have finished.
- A still-active, not-abandoned recording must still reject a second start.
- Replace the two
controller.test.tscases that expect "already active" after cancel, dispose, owner change, and cancelled prepare. Add the case the report describes: controller A is intranscribingorpreparingwith a never-resolving native call, A is cancelled or disposed, then Bstart()reachesrecordingonly after that call settles, with no error. - Do not take feat(mobile): keep voice recording active across apps and threads #10140’s background-audio or keep-recording behavior as part of this fix.
Workaround. Wait until the abandoned transcription finishes, then tap the microphone again. Force-quitting the app clears the lock immediately because it lives in the JS heap. Backgrounding alone does not.
- addedbugSomething is broken or behaving incorrectly.Something is broken or behaving incorrectly.acceptedfeature request acceptedfeature request acceptedvia-triageFiled through npx t3 triageFiled through npx t3 triage
on Sep 26, 2026 - added 2 commits that reference this issue
on Sep 27, 2026
Before submitting
Area
apps/mobile (shared logic in
packages/client-runtime/src/voice-input)Summary
On T3 Code for iPhone, if you leave a thread while voice input is still going and then try to dictate again, the app says a transcription or recording is already in progress. It should cancel the abandoned session and start a new one.
Steps to reproduce
Observed by the reporter during normal use. It has not yet been reproduced on a second device.
Expected behavior
Starting voice input again should cancel the abandoned session, which the user no longer cares about, and start a new recording right away. It should not block.
Actual behavior
The new attempt fails with an "already active/in progress" error. There are two candidate messages in the source:
Another voice recording is already active.:acquireSession()returnsnull.Voice transcription is still finishing. Try again shortly.:runTranscriptionOperation()throwsvoice-operation-busy.Impact
Minor bug or occasional failure. Dictation is blocked until the previous native transcription finishes on its own.
Version or commit
Installed iPhone app: 1.2.1 (build 80). The voice-input code is identical between the 1.2.1 version bump (
76cc9b08f1) andmain@ed809f7ad2.Environment
iPhone, iOS 26.6.1 (Darwin 25.6.0), on-device Apple transcription (
@react-native-ai/apple). Connected to a Windows 10 T3 Code server.Investigation: suspected cause (inferred from source, not instrumented)
controller.tskeeps two module-global locks shared by everyVoiceInputControllerinstance:When the old composer's controller is cancelled (
ownerChanged()→cancel(), or blur →dispose()) duringpreparing/transcribing, it only callsinvalidateOperation()and setsidle. The locks are released later:releaseSession()runs inreleaseResources(), which sits in thefinallyofstart()/finishRecording(). That code only runs after the awaited native call settles.voiceTranscription.ios.tschecks theAbortSignalonly between awaits.AppleTranscription.prepare()/.transcribe()cannot be interrupted, so an abandoned transcription holds both locks until the native call returns. The UI shows the new composer asidle, butstart()is still rejected.Suggested fix (for a follow-up PR)
start(), if another controller holds the session, call itscancel()/dispose()and take ownership, instead of callingsetError("Another voice recording is already active.").cancel()/dispose()/discardRecording()should callreleaseSession(this.sessionToken)immediately. File/audio cleanup can stay asynchronous.runTranscriptionOperation, wait for (and ignore the result of) an abandoned in-flight operation before starting the next one, rather than throwingvoice-operation-busy. The native module still sees one call at a time.transcribingwith a never-resolvingtranscribe, A cancelled, then controller Bstart()reachesrecording. Also cover the same case frompreparing.Workaround
Wait for the abandoned transcription to finish (a few seconds), or background and reopen the app, then retry.