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
This commit is contained in:
Jinwoo-H
2026-09-21 13:56:23 -04:00
parent c5f8db40b1
commit bfb63e2f0b
3 changed files with 57 additions and 21 deletions
@@ -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
}
@@ -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<void> = 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')
})
})
+1 -12
View File
@@ -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<string | null>(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) {