fix(mobile): close the capture when the desktop start fails

The hook opens the microphone before it asks the desktop for a session, so a
refused session left the mic open — and, since the screen rides the mic, the
display held until the user cancelled, retried, or the screen unmounted. The
failure arm now runs the same `rollbackRecordingStart` the commit failure
does, because "undo the capture this start opened" is one thing and the hook
owns it; guarded like that arm, so a seam that throws on the way down cannot
take the desktop cancel with it.

Red-first on both hosts. Natively, a new test drives the real seam under the
engine and keep-awake mocks: the refusal used to leave `initialize` with no
`toggleRecording(false)` and a held screen. On the page, the mic control's
own test over the port pair saw `native.audio.start` with no
`native.audio.stop`. Two unit cases pin the call itself, including for a
start nobody will report.

Ruling 36's own words: mic closed means released. This closes the mic rather
than adding a release beside it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-21 17:36:07 -04:00
parent 851c637396
commit a1bdee9af9
4 changed files with 238 additions and 4 deletions
@@ -101,6 +101,49 @@ describe('startMobileDictationDesktopSession', () => {
expect(harness.commitRecordingStart).not.toHaveBeenCalled()
})
it('closes the capture the hook opened when the desktop start fails', async () => {
const harness = createStartHarness({
sendRequest: async (method) => {
if (method === 'speech.dictation.start') {
throw new Error('Desktop start failed')
}
return OK_RESPONSE
}
})
await expect(startMobileDictationDesktopSession(harness.options)).rejects.toThrow(
'Desktop start failed'
)
// The hook opened the microphone before this ran, and an open microphone holds the screen. No
// session started, so both have to go back; nothing else on this path would end the capture,
// and the hook's `start` has no catch to do it either.
expect(harness.rollbackRecordingStart).toHaveBeenCalledOnce()
expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', {
dictationId: 'dictation-a'
})
})
it('closes it even when the failure is not the current start to report', async () => {
let setNewerStart = () => undefined
const harness = createStartHarness({
sendRequest: async (method) => {
if (method === 'speech.dictation.start') {
setNewerStart()
throw new Error('Desktop start failed')
}
return OK_RESPONSE
}
})
setNewerStart = harness.setNewerStart
await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false)
// A start nobody will hear about still opened a microphone, and the screen does not care
// which generation held it.
expect(harness.rollbackRecordingStart).toHaveBeenCalledOnce()
})
it('does not surface a desktop-start failure after the start became stale', async () => {
let setNewerStart = () => undefined
const harness = createStartHarness({
@@ -56,6 +56,17 @@ export async function startMobileDictationDesktopSession(
dictationSessionStart.interpret(reply)
} catch (err) {
const wasCurrent = isCurrentStart(options)
// The hook opened the capture before this ran, and an open microphone holds the screen. No
// session started, so both go back — through the same rollback the commit failure below uses,
// because "undo the capture this start opened" is one thing and the hook owns it. Before the
// screen moved onto the mic this path leaked only an idle audio session; now it would pin the
// display until the user cancelled, retried, or the screen unmounted.
try {
options.rollbackRecordingStart()
} catch {
// Guarded for the reason the commit arm below is: a seam that throws on the way down must
// not take the desktop cancel with it, nor replace the failure the caller is about to see.
}
options.clearActiveId(dictationId)
await dictationSessionCancel.request(client, { dictationId }).catch(() => undefined)
// Awaited cleanup may overlap a newer start; stale work must not reset or
@@ -0,0 +1,130 @@
/**
* The native host, when the desktop refuses the session after the microphone is already open.
*
* The hook opens the capture first and asks the desktop for a session second, so by the time a
* refusal comes back the device is holding a microphone and, since ruling 36, the screen with it.
* Driven through the real native seam rather than a double, because what has to be given back are
* device calls: this file is the only place the engine and `expo-keep-awake` answer for it.
*/
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createFakeRpcClient, type FakeRpcClient } from '../mobile-web-shell/bridge-host-test-fakes'
const device = vi.hoisted(() => ({
/** Every engine call, in order. */
calls: new Array<string>(),
/** The screen, as `+` and `-`: taken when the microphone opens, given back when it closes. */
screen: new Array<string>()
}))
vi.mock('react-native', () => ({
AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) },
Platform: { OS: 'ios' }
}))
vi.mock('@orca/expo-two-way-audio', () => ({
addExpoTwoWayAudioEventListener: () => ({ remove: () => {} }),
initialize: () => {
device.calls.push('initialize')
return Promise.resolve(true)
},
requestMicrophonePermissionsAsync: () => Promise.resolve({ granted: true }),
tearDown: () => device.calls.push('tearDown'),
toggleRecording: (on: boolean) => {
device.calls.push(`toggleRecording(${String(on)})`)
return true
}
}))
vi.mock('expo-keep-awake', () => ({
activateKeepAwakeAsync: () => {
device.screen.push('+')
return Promise.resolve()
},
deactivateKeepAwake: () => {
device.screen.push('-')
return Promise.resolve()
}
}))
import { useMobileDictation, type UseMobileDictationResult } from './use-mobile-dictation'
const held: { dictation: UseMobileDictationResult | null } = { dictation: null }
function mount(client: FakeRpcClient): void {
function Probe(): null {
held.dictation = useMobileDictation({
client,
enabled: true,
onTranscript: () => {},
onError: () => {}
})
return null
}
act(() => {
create(createElement(Probe))
})
}
function dictation(): UseMobileDictationResult {
const current = held.dictation
if (current === null) {
throw new Error('nothing mounted')
}
return current
}
/** Answers every forwarded request, refusing the one the case names. */
async function pump(rpc: FakeRpcClient, refuse: string): Promise<void> {
for (let round = 0; round < 8; round += 1) {
for (const request of rpc.requests.splice(0)) {
request.resolve(
request.method === refuse
? { id: 'desktop', ok: false, error: { code: 'refused', message: 'no model installed' } }
: { id: 'desktop', ok: true, result: {} }
)
}
await Promise.resolve()
await Promise.resolve()
}
}
beforeEach(() => {
device.calls.length = 0
device.screen.length = 0
held.dictation = null
})
describe('a native start the desktop refuses', () => {
it('gives the microphone and the screen back', async () => {
const rpc = createFakeRpcClient()
mount(rpc)
await act(async () => {
// The composer's own handler: a refused start is a toast, never a throw into render.
const started = dictation()
.start()
.catch(() => undefined)
await pump(rpc, 'speech.dictation.start')
await started
})
// The engine came up before the desktop was asked, so it has to go back down.
expect(device.calls).toContain('initialize')
expect(device.calls).toContain('toggleRecording(false)')
// And the screen with it: nothing else on this path would release it, and the hook's `start`
// has no catch of its own.
expect(device.screen).toEqual(['+', '-'])
expect(dictation().status).toBe('idle')
})
it('holds nothing when the refusal comes before the microphone opens', async () => {
const rpc = createFakeRpcClient()
mount(rpc)
await act(async () => {
await dictation().cancel()
})
// No start, so no open: the screen was never taken. Read as an absence of holds rather than an
// empty list, because the lock is one module-level owner shared by both device captures and a
// release the previous case's cleanup issued would land in this one's list.
expect(device.screen).not.toContain('+')
expect(device.calls).not.toContain('initialize')
})
})
@@ -59,6 +59,9 @@ import type { BridgeNativeVerb } from '../mobile-web-shell/bridge/bridge-native-
/** Every message that reached the composer's own error handler, which is what it toasts. */
const reported: string[] = []
/** Every hold and release the shell's capture asked the device for, as `+` and `-`. */
const screen: string[] = []
/** The three verbs served by the real handlers over an engine that opens and produces no audio. */
function createAudioShell(): (verb: BridgeNativeVerb, params: unknown) => Promise<unknown> {
const engine: NativeAudioEngine = {
@@ -68,7 +71,10 @@ function createAudioShell(): (verb: BridgeNativeVerb, params: unknown) => Promis
end: () => {},
onMicrophoneData: () => ({ remove: () => {} }),
onInterruption: () => ({ remove: () => {} }),
screenLock: { hold: () => {}, release: () => {} }
screenLock: {
hold: () => screen.push('+'),
release: () => screen.push('-')
}
}
const capture = createNativeAudioCapture(engine)
return (verb, params) => capture.serve(verb, params)
@@ -108,9 +114,19 @@ function Composer({ pair }: { pair: BridgePortPair }): ReactElement {
)
}
/** What the desktop answers a forwarded request with; the default is a plain success. */
type DesktopAnswer = (method: string) => {
id: string
ok: boolean
result?: unknown
error?: unknown
}
const DESKTOP_OK: DesktopAnswer = () => ({ id: 'desktop', ok: true, result: {} })
type MicControl = {
readonly label: () => string
readonly tap: () => Promise<void>
readonly tap: (answer?: DesktopAnswer) => Promise<void>
}
/** The mic button, found by the label it carries in every state rather than by position. */
@@ -144,7 +160,7 @@ async function mount(pair: BridgePortPair): Promise<MicControl> {
}
return {
label: () => String(micOf(rendered.root).props.accessibilityLabel),
tap: async () => {
tap: async (answer: DesktopAnswer = DESKTOP_OK) => {
await act(async () => {
micOf(rendered.root).props.onPress()
})
@@ -153,7 +169,8 @@ async function mount(pair: BridgePortPair): Promise<MicControl> {
await act(async () => {
await pair.flush()
for (const request of pair.rpc.requests.splice(0)) {
request.resolve({ id: 'desktop', ok: true, result: {} })
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake client takes the reply shape the host would have sent, which is what a case builds here.
request.resolve(answer(request.method) as never)
}
await pair.flush()
})
@@ -164,6 +181,7 @@ async function mount(pair: BridgePortPair): Promise<MicControl> {
beforeEach(() => {
reported.length = 0
screen.length = 0
})
afterEach(() => {
@@ -200,6 +218,38 @@ describe('the mic control on a page the shell did not grant audio', () => {
})
})
describe('a page whose desktop start fails after the microphone opened', () => {
/**
* The shell has the microphone open by the time the desktop refuses the session: `open()` is
* `native.audio.start`, and the page only asks the desktop for a session afterwards. Nothing on
* that path used to end the capture, so the screen the shell took stayed held until the user
* cancelled or the screen unmounted.
*/
it('closes the shell capture and gives the screen back', async () => {
const shell = createAudioShell()
const verbs: string[] = []
const pair = createFakeBridgePortPair({
serveNativeVerb: (verb, params) => {
verbs.push(verb)
return shell(verb, params)
}
})
const mic = await mount(pair)
await mic.tap((method) =>
method === 'speech.dictation.start'
? { id: 'desktop', ok: false, error: { code: 'refused', message: 'no model installed' } }
: { id: 'desktop', ok: true, result: {} }
)
expect(verbs).toContain('native.audio.start')
// The microphone is closed and the screen is back, both because the capture ended.
expect(verbs).toContain('native.audio.stop')
expect(screen).toEqual(['+', '-'])
// And the user is told, rather than left looking at a live mic button.
expect(reported).not.toEqual([])
expect(mic.label()).toBe('Start voice dictation')
})
})
describe('the mic control on a page the shell did grant audio', () => {
it('reaches recording, with no refusal reported', async () => {
const pair = createFakeBridgePortPair({ serveNativeVerb: createAudioShell() })