From bfb63e2f0b302591c8287d393dc5b7ab6e85737d Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 21 Sep 2026 13:56:23 -0400 Subject: [PATCH] refactor(mobile): drop the dictation finish id, which ordered nothing `finishingIdRef` tracked the dictation a stop was finishing, and every state it could name was already named: `cancel`, a disable, an unmount and a newer start each bump the generation or clear the active id, so the finish guard answered the same either way. Its one distinguishing arm released pending audio bytes for a dictation whose budget `closeDictationAudio` had just reset, and could subtract those bytes from a newer dictation's reserve. `acceptingChunksRef` stays: it is what stops a late microphone event being sent after the capture handed over its tail and before the finish goes out. `pendingChunksRef` stays: `stop` awaits it so the finish cannot overtake the last chunk send. The finish guard is pinned by a case that cancels while the finish is in flight; neutered, it reds. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../hooks/mobile-dictation-session-state.ts | 10 +--- .../hooks/mobile-dictation-stop-tail.test.tsx | 55 ++++++++++++++++++- mobile/src/hooks/use-mobile-dictation.ts | 13 +---- 3 files changed, 57 insertions(+), 21 deletions(-) diff --git a/mobile/src/hooks/mobile-dictation-session-state.ts b/mobile/src/hooks/mobile-dictation-session-state.ts index bfc2dbd8d29..578f1721342 100644 --- a/mobile/src/hooks/mobile-dictation-session-state.ts +++ b/mobile/src/hooks/mobile-dictation-session-state.ts @@ -48,18 +48,14 @@ export function isCurrentMobileDictationStart( return currentGeneration === generation && enabled && activeId === dictationId } +/** A finish is still this dictation's while nothing has superseded it: `cancel`, a disable, an + * unmount and a newer start each bump the generation or clear the active id, and most do both. */ export function isCurrentMobileDictationFinish( currentGeneration: number, generation: number, enabled: boolean, activeId: string | null, - finishingId: string | null, dictationId: string ): boolean { - return ( - currentGeneration === generation && - enabled && - activeId === dictationId && - finishingId === dictationId - ) + return currentGeneration === generation && enabled && activeId === dictationId } diff --git a/mobile/src/hooks/mobile-dictation-stop-tail.test.tsx b/mobile/src/hooks/mobile-dictation-stop-tail.test.tsx index c92323a9688..c46a4b412c3 100644 --- a/mobile/src/hooks/mobile-dictation-stop-tail.test.tsx +++ b/mobile/src/hooks/mobile-dictation-stop-tail.test.tsx @@ -104,14 +104,26 @@ function audioOf(request: SentRequest): unknown { : null } +/** Answers everything the hook has sent except one method, so a case can hold that reply open. */ +function settleExcept(rpc: FakeRpcClient, sent: SentRequest[], method: string): void { + for (const request of rpc.requests.splice(0)) { + sent.push(request) + if (request.method === method) { + rpc.requests.push(request) + continue + } + request.resolve({ id: 'desktop', ok: true, result: {} }) + } +} + const held: { dictation: UseMobileDictationResult | null } = { dictation: null } -function mount(client: FakeRpcClient): void { +function mount(client: FakeRpcClient, onTranscript: (text: string) => void = () => {}): void { function Probe(): null { held.dictation = useMobileDictation({ client, enabled: true, - onTranscript: () => {}, + onTranscript, onError: () => {} }) return null @@ -191,3 +203,42 @@ describe('the audio a capture hands over as it ends', () => { ) }) }) + +describe('a finish whose dictation stopped being the current one', () => { + it('delivers no transcript when a cancel lands while the finish is in flight', async () => { + // What `finishingIdRef` was thought to guard, pinned against the two things that actually do: + // `cancel` bumps the generation and clears the active id, and the finish is read against both + // before its text reaches the composer. A transcript that arrived here would be typed into a + // field the user has already dismissed the microphone from. + const rpc = createFakeRpcClient() + const sent: SentRequest[] = [] + const transcripts: string[] = [] + mount(rpc, (text) => transcripts.push(text)) + await act(async () => { + const started = dictation().start() + await pump(rpc, sent) + await started + }) + let stopped: Promise = Promise.resolve() + await act(async () => { + stopped = dictation().stop() + // Everything but the finish, which stays in flight while the user cancels. + for (let round = 0; round < 4; round += 1) { + settleExcept(rpc, sent, 'speech.dictation.finish') + await Promise.resolve() + await Promise.resolve() + } + }) + expect(sent.map((request) => request.method)).toContain('speech.dictation.finish') + await act(async () => { + // Started rather than awaited: the cancel's own request has to be answered by the pump below + // before it settles, and awaiting it first would deadlock the case rather than the product. + const cancelled = dictation().cancel() + await pump(rpc, sent) + await cancelled + await stopped + }) + expect(transcripts).toEqual([]) + expect(dictation().status).toBe('idle') + }) +}) diff --git a/mobile/src/hooks/use-mobile-dictation.ts b/mobile/src/hooks/use-mobile-dictation.ts index 733d259e5ba..5e3e52e6297 100644 --- a/mobile/src/hooks/use-mobile-dictation.ts +++ b/mobile/src/hooks/use-mobile-dictation.ts @@ -46,7 +46,6 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil const pendingAudioBudgetRef = useRef(new MobileDictationPendingAudioBudget()) const acceptingChunksRef = useRef(false) const generationRef = useRef(0) - const finishingIdRef = useRef(null) useLayoutEffect(() => { // Native audio events can arrive before passive Effects flush, but refs @@ -103,8 +102,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil const audioChunkQueue = { pendingChunks: pendingChunksRef.current, pendingAudioBudget: pendingAudioBudgetRef.current, - shouldReleaseBudget: (id: string) => - activeIdRef.current === id || finishingIdRef.current === id, + shouldReleaseBudget: (id: string) => activeIdRef.current === id, failActiveDictation } const sub = capture.onChunk((chunk) => { @@ -206,7 +204,6 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil const generation = generationRef.current + 1 generationRef.current = generation - finishingIdRef.current = dictationId setStatus('processing') try { // Inside the try so a throwing native shutdown still runs the finally @@ -225,7 +222,6 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil generation, enabledRef.current, activeIdRef.current, - finishingIdRef.current, dictationId ) ) { @@ -244,7 +240,6 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil generation, enabledRef.current, activeIdRef.current, - finishingIdRef.current, dictationId ) ) { @@ -253,7 +248,6 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil const transcript = rpcPayloadMember(finished, 'text') const text = typeof transcript === 'string' ? transcript.trim() : '' activeIdRef.current = null - finishingIdRef.current = null pendingChunksRef.current.clear() pendingAudioBudgetRef.current.reset() setStatus('idle') @@ -268,9 +262,6 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil // Hold the wake tag through chunk drain and the finish RPC: a screen // lock mid-processing suspends the app and loses the transcript. void keepAwakeOwner.release(dictationId).catch(() => undefined) - if (finishingIdRef.current === dictationId) { - finishingIdRef.current = null - } } }, [capture, failActiveDictation, keepAwakeOwner]) @@ -279,7 +270,6 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil const dictationId = activeIdRef.current generationRef.current += 1 activeIdRef.current = null - finishingIdRef.current = null closeDictationAudio(dictationId) if (client && dictationId) { await dictationSessionCancel.request(client, { dictationId }).catch(() => undefined) @@ -308,7 +298,6 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil const dictationId = activeIdRef.current generationRef.current += 1 activeIdRef.current = null - finishingIdRef.current = null closeDictationAudio(dictationId) capture.release() if (clientRef.current && dictationId) {