feat(mobile): carry the capture's tail on the stop reply (OTA phase C, ruling 36)

`native.audio.stop` drains what the ring still holds into its own reply, so
the page's `end()` is one verb: stop, hand the bytes on, done. The drain,
await and read-once-more ordering goes with it, and so do `ending`,
`reading` and `released` — three variables that existed only to order a last
read against the stop and to stop a refused read re-entering `end`.

The tail fields default rather than being required: the page updates over
the air and the shell does not, so a page this new can meet a shell that
answers `stopped` alone. That dictation loses its tail where a required
field would have lost it the stop.

The heap case from PR D's bot round cannot recur: `end` issues no read, and
a stop reply carries no interruption, so the lane that re-entered is gone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-21 13:52:16 -04:00
parent 0f32a31a39
commit c5f8db40b1
6 changed files with 263 additions and 112 deletions
@@ -19,6 +19,7 @@ import {
audioReadResultSchema,
audioStartParamsSchema,
audioStopParamsSchema,
audioStopResultSchema,
wakelockSetParamsSchema
} from './bridge-audio-verbs'
import { BRIDGE_NATIVE_VERB_NAMES, BRIDGE_NATIVE_VERBS } from './bridge-native-verbs'
@@ -336,13 +337,21 @@ describe('the shell capture', () => {
const { engine } = createTestEngine()
const capture = createNativeAudioCapture(engine)
await capture.serve('native.audio.start', { sampleRate: 16_000 })
await expect(capture.serve('native.audio.stop', {})).resolves.toEqual({ stopped: true })
await expect(capture.serve('native.audio.stop', {})).resolves.toEqual({
stopped: true,
base64: '',
droppedBytes: 0
})
await expect(capture.serve('native.audio.read', { maxBytes: 1_024 })).rejects.toSatisfy(
(error: unknown) =>
error instanceof BridgeNativeVerbRefusedError && error.code === 'native_audio_not_capturing'
)
// A second stop is the state the page already has, not a fault.
await expect(capture.serve('native.audio.stop', {})).resolves.toEqual({ stopped: false })
await expect(capture.serve('native.audio.stop', {})).resolves.toEqual({
stopped: false,
base64: '',
droppedBytes: 0
})
})
it('refuses a read before any start', async () => {
@@ -483,7 +492,7 @@ describe('the shell capture', () => {
await started
// The stop runs after the start it followed, so it ends the capture that start opened rather
// than finding nothing and leaving a live microphone behind it.
await expect(stopped).resolves.toEqual({ stopped: true })
await expect(stopped).resolves.toEqual({ stopped: true, base64: '', droppedBytes: 0 })
expect(liveListeners()).toEqual({ microphone: 0, interruptions: 0 })
})
@@ -702,3 +711,87 @@ describe('the screen the shell holds awake while it is capturing', () => {
expect(screen).toEqual(['+', '-', '+', '-'])
})
})
describe('the tail the stop reply carries', () => {
it('answers with everything the ring still held', async () => {
const { engine, emit } = createTestEngine()
const capture = createNativeAudioCapture(engine)
await capture.serve('native.audio.start', { sampleRate: 16_000 })
const tail = pcm(12_288, 11)
emit(tail)
const stopped = audioStopResultSchema.parse(await capture.serve('native.audio.stop', {}))
expect(stopped.stopped).toBe(true)
expect(Array.from(decode(stopped.base64))).toEqual(Array.from(tail))
expect(stopped.droppedBytes).toBe(0)
})
it('hands a byte to the read or to the stop, never to both and never to neither', async () => {
const { engine, emit } = createTestEngine()
const capture = createNativeAudioCapture(engine)
await capture.serve('native.audio.start', { sampleRate: 16_000 })
const spoken = pcm(2_048, 3)
emit(spoken)
const read = audioReadResultSchema.parse(
await capture.serve('native.audio.read', { maxBytes: BRIDGE_AUDIO_RING_MAX_BYTES })
)
// What the microphone produced between that read and the stop, which is the audio no timer is
// ever coming for.
const after = pcm(1_024, 7)
emit(after)
const stopped = audioStopResultSchema.parse(await capture.serve('native.audio.stop', {}))
expect(Array.from(decode(read.base64))).toEqual(Array.from(spoken))
expect(Array.from(decode(stopped.base64))).toEqual(Array.from(after))
})
it('carries what the ring refused since the last read', async () => {
const { engine, emit } = createTestEngine()
const capture = createNativeAudioCapture(engine)
await capture.serve('native.audio.start', { sampleRate: 16_000 })
emit(pcm(BRIDGE_AUDIO_RING_MAX_BYTES))
emit(pcm(2_048))
const stopped = audioStopResultSchema.parse(await capture.serve('native.audio.stop', {}))
expect(stopped.droppedBytes).toBe(2_048)
})
it('answers no tail for a session that was not capturing', async () => {
const { engine } = createTestEngine()
const capture = createNativeAudioCapture(engine)
const stopped = audioStopResultSchema.parse(await capture.serve('native.audio.stop', {}))
expect(stopped).toEqual({ stopped: false, base64: '', droppedBytes: 0 })
})
it('leaves nothing behind for a second stop to answer with', async () => {
const { engine, emit } = createTestEngine()
const capture = createNativeAudioCapture(engine)
await capture.serve('native.audio.start', { sampleRate: 16_000 })
emit(pcm(512, 5))
await capture.serve('native.audio.stop', {})
const again = audioStopResultSchema.parse(await capture.serve('native.audio.stop', {}))
expect(again).toEqual({ stopped: false, base64: '', droppedBytes: 0 })
})
it('reads a reply from a shell too old to carry a tail', () => {
// The page updates over the air and the shell does not, so the page parses a stop reply from a
// build that answers `stopped` alone. It loses that dictation's tail; it must not lose the
// stop, which is what a required field would have cost.
expect(audioStopResultSchema.parse({ stopped: true })).toEqual({
stopped: true,
base64: '',
droppedBytes: 0
})
})
it('bounds the tail by the same budget a read is bounded by', () => {
expect(
audioStopResultSchema.safeParse({
stopped: true,
base64: 'A'.repeat(BRIDGE_AUDIO_READ_MAX_BASE64_CHARS + 1),
droppedBytes: 0
}).success
).toBe(false)
expect(
audioStopResultSchema.safeParse({ stopped: true, base64: 'not base64!', droppedBytes: 0 })
.success
).toBe(false)
})
})
@@ -147,9 +147,27 @@ export type BridgeAudioChunk = z.infer<typeof audioReadResultSchema>
/** No params: there is one capture per page session, so there is nothing to name. */
export const audioStopParamsSchema = z.strictObject({})
/** False for a session that was not capturing, which is not a fault: a page that stops twice, or
* stops after an interruption already ended the capture, asked for the state it already has. */
export const audioStopResultSchema = z.strictObject({ stopped: z.boolean() })
/**
* The stop, and the tail it takes with it.
*
* `stopped` is false for a session that was not capturing, which is not a fault: a page that stops
* twice, or stops after an interruption already ended the capture, asked for the state it already
* has.
*
* The bytes are whatever the ring still held — up to one drain interval of what the user was still
* saying as they lifted the button, which no timer is coming for. Carried by the stop rather than
* fetched by a last read, because a page that has to read before it stops has an ordering to get
* right and a re-entry to guard; a reply that brings the tail with it has neither.
*
* Both tail fields default rather than being required. The page updates over the air and the shell
* does not, so a page this new can be talking to a shell that answers `stopped` alone: absent, that
* dictation loses its tail, where a required field would have lost it the stop itself.
*/
export const audioStopResultSchema = z.strictObject({
stopped: z.boolean(),
base64: z.string().max(BRIDGE_AUDIO_READ_MAX_BASE64_CHARS).regex(BASE64_PATTERN).default(''),
droppedBytes: z.number().int().nonnegative().default(0)
})
export const wakelockSetParamsSchema = z.strictObject({
active: z.boolean(),
@@ -79,8 +79,10 @@ export type NativeVerbs = {
/** One drain of the shell's ring. `maxBytes` above the ring is refused by the shell's schema, so
* a caller bounds its own ask rather than discovering the bound as a rejection. */
readAudio: (maxBytes: number) => Promise<BridgeAudioChunk>
/** False for a session that was not capturing, which is not a fault. */
stopAudio: () => Promise<boolean>
/** Ends the capture and brings back what the shell's ring still held, which is the tail of the
* utterance no drain came back for. `stopped` is false for a session that was not capturing,
* which is not a fault. */
stopAudio: () => Promise<z.infer<typeof audioStopResultSchema>>
/** Whether the tag is held after the call. The shell asks the device nothing for a tag it never
* took, so releasing one twice is not a fault either. */
setWakelock: (active: boolean, tag: string) => Promise<boolean>
@@ -223,7 +225,7 @@ export function useNativeVerbs(): NativeVerbs {
startAudio: (sampleRate) =>
call('native.audio.start', { sampleRate }, audioStartResultSchema),
readAudio: (maxBytes) => call('native.audio.read', { maxBytes }, audioReadResultSchema),
stopAudio: async () => (await call('native.audio.stop', {}, audioStopResultSchema)).stopped,
stopAudio: () => call('native.audio.stop', {}, audioStopResultSchema),
setWakelock: async (active, tag) =>
(await call('native.wakelock.set', { active, tag }, wakelockSetResultSchema)).active
}
@@ -242,11 +242,12 @@ describe('draining the shell ring', () => {
expect(chunks[0]?.droppedBytes).toBe(2_048)
})
it('delivers the tail still in the ring before it stops the shell', async () => {
it('delivers the tail the stop reply carried', async () => {
// The utterance's last 400 ms sits in the shell's ring when the user lifts the button: less
// than one drain interval, so no timer will ever come for it. Natively that audio is already
// in the hook's hands by the time recording stops, so a page that dropped it would transcribe
// a sentence with its ending cut off.
// a sentence with its ending cut off. It rides the stop's own reply, so the page has no last
// read to order against the stop.
const shell = createAudioShell()
const pair = createFakeBridgePortPair({ serveNativeVerb: shell.serveNativeVerb })
const capture = await mount(pair)
@@ -271,19 +272,73 @@ describe('draining the shell ring', () => {
expect(Array.from(chunks[0]?.data ?? [])).toEqual(Array.from(tail))
})
it('stops the shell after the last read, never before it', async () => {
it('asks for nothing but the stop, which is what brings the tail', async () => {
const shell = createAudioShell()
const pair = createFakeBridgePortPair({ serveNativeVerb: shell.serveNativeVerb })
const capture = await mount(pair)
const chunks: DictationCaptureChunk[] = []
capture.onChunk((chunk) => chunks.push(chunk))
await capture.open()
capture.begin()
shell.speak(pcm(2_048, 12))
const tail = pcm(2_048, 12)
shell.speak(tail)
const before = shell.calls.length
await act(async () => {
await capture.end()
await pair.flush()
})
// A stop that landed first would have taken the capture away, and the read would be refused.
expect(shell.calls.slice(-2)).toEqual(['native.audio.read', 'native.audio.stop'])
// One verb, not a read and then a stop: there is no ordering here to get wrong.
expect(shell.calls.slice(before)).toEqual(['native.audio.stop'])
expect(Array.from(chunks.at(-1)?.data ?? [])).toEqual(Array.from(tail))
})
it('loses nothing and duplicates nothing when the stop follows a read still in flight', async () => {
const shell = createAudioShell()
const pair = createFakeBridgePortPair({ serveNativeVerb: shell.serveNativeVerb })
const capture = await mount(pair)
const chunks: DictationCaptureChunk[] = []
capture.onChunk((chunk) => chunks.push(chunk))
await capture.open()
capture.begin()
const spoken = pcm(1_024, 31)
const afterwards = pcm(512, 32)
shell.speak(spoken)
await act(async () => {
// The drain's read leaves the page, and the user lifts the button before its reply is back.
vi.advanceTimersByTime(DICTATION_CAPTURE_DRAIN_INTERVAL_MS)
shell.speak(afterwards)
await capture.end()
await pair.flush()
})
// Every byte the microphone produced, once each and in the order it said them: whether the
// read or the stop carried a given byte is the shell's business and neither can carry it twice.
expect(chunks.flatMap((chunk) => Array.from(chunk.data))).toEqual([
...Array.from(spoken),
...Array.from(afterwards)
])
})
it('delivers nothing for a second end, which the shell answers as already stopped', async () => {
const shell = createAudioShell()
const pair = createFakeBridgePortPair({ serveNativeVerb: shell.serveNativeVerb })
const capture = await mount(pair)
const chunks: DictationCaptureChunk[] = []
capture.onChunk((chunk) => chunks.push(chunk))
await capture.open()
capture.begin()
shell.speak(pcm(1_024, 41))
await act(async () => {
await capture.end()
await pair.flush()
})
expect(chunks).toHaveLength(1)
await act(async () => {
await capture.end()
await pair.flush()
})
// No latch on the page: the shell has no capture, so it answers an empty tail and the page
// hands nothing on. A second end that delivered would splice the last chunk in twice.
expect(chunks).toHaveLength(1)
})
it('reads nothing once the capture has ended', async () => {
@@ -300,9 +355,9 @@ describe('draining the shell ring', () => {
})
const afterEnd = shell.calls.length
await tick(pair, 3)
// The last read, the stop, and then nothing: a timer left running would keep asking a shell
// that no longer has a capture.
expect(shell.calls.slice(before, afterEnd)).toEqual(['native.audio.read', 'native.audio.stop'])
// The stop, and then nothing: a timer left running would keep asking a shell that no longer
// has a capture.
expect(shell.calls.slice(before, afterEnd)).toEqual(['native.audio.stop'])
expect(shell.calls.slice(afterEnd)).toEqual([])
})
})
@@ -370,11 +425,13 @@ describe('a capture the page loses', () => {
expect(interrupted).toBe(1)
})
it('does not loop when the interruption handler ends the capture, as the hook does', async () => {
// The hook's handler is `() => void cancel()`, and `cancel` reaches `capture.end()`
// synchronously through `closeDictationAudio`. So a refused read inside `end` re-enters `end`,
// whose own last read is refused too: without an idempotent `end` that recursion issues bridge
// reads until the page is torn down.
it('cannot re-enter end from an interruption raised while one is running', async () => {
// The heap case from PR D's bot round: the hook's handler is `() => void cancel()`, and
// `cancel` reaches `capture.end()` synchronously through `closeDictationAudio`. When `end`
// itself read, its refused read raised an interruption that called straight back into `end`,
// whose own read was refused for the same reason, and the recursion issued bridge reads until
// the page ran out of memory. There is no read inside `end` now, and a stop reply carries no
// interruption, so the lane that re-entered does not exist.
const shell = createAudioShell({
refuse: (verb) =>
verb === 'native.audio.read'
@@ -398,6 +455,36 @@ describe('a capture the page loses', () => {
await tick(pair, 3)
const reads = shell.calls.filter((verb) => verb === 'native.audio.read').length
expect(reads).toBeLessThanOrEqual(2)
// And the stop is not the recursion's new shape either: one per `end` the hook asked for.
expect(shell.calls.filter((verb) => verb === 'native.audio.stop').length).toBeLessThanOrEqual(2)
})
it('goes on ending when an interruption lands while the stop is in flight', async () => {
const shell = createAudioShell()
const pair = createFakeBridgePortPair({ serveNativeVerb: shell.serveNativeVerb })
const capture = await mount(pair)
let interrupted = 0
capture.onInterruption(() => {
interrupted += 1
void capture.end()
})
await capture.open()
capture.begin()
shell.speak(pcm(1_024, 51))
await act(async () => {
vi.advanceTimersByTime(DICTATION_CAPTURE_DRAIN_INTERVAL_MS)
// The OS takes the microphone away while the page's own stop is crossing the bridge.
const ending = capture.end()
shell.interrupt('began')
await ending
await pair.flush()
})
await tick(pair, 3)
expect(interrupted).toBeGreaterThanOrEqual(0)
// Bounded either way: the interruption's own `end` finds a shell with no capture and is
// answered, rather than reaching a read that raises the interruption again.
expect(shell.calls.filter((verb) => verb === 'native.audio.stop').length).toBeLessThanOrEqual(3)
expect(shell.calls.filter((verb) => verb === 'native.audio.read').length).toBeLessThanOrEqual(2)
})
it('reads again for the next dictation after an end, rather than staying ended', async () => {
+30 -88
View File
@@ -73,15 +73,6 @@ export function createPageDictationCapture(
const chunkHandlers: Handlers<(chunk: DictationCaptureChunk) => void> = new Set()
const interruptionHandlers: Handlers<() => void> = new Set()
let timer: ReturnType<typeof setInterval> | null = null
/** The end in flight; null when none is running. Cleared on settle rather than held, because
* `begin` is not guaranteed to run between two ends and a finished one must shadow neither. */
let ending: Promise<void> | null = null
/** The screen went away: no later end reads or stops again. Cleared by `begin`. */
let released = false
/** The read in flight, if there is one. At most one: two would double the slots dictation spends
* and can settle out of order, which is a splice of two moments reaching the transcriber as
* speech. Held rather than flagged so a stop can wait for it before taking its own turn. */
let reading: Promise<void> | null = null
function stopDraining(): void {
if (timer !== null) {
@@ -98,16 +89,20 @@ export function createPageDictationCapture(
}
}
function deliver(reply: BridgeAudioChunk): void {
if (reply.base64.length > 0 || reply.droppedBytes > 0) {
const chunk: DictationCaptureChunk = {
data: decodeBase64(reply.base64),
droppedBytes: reply.droppedBytes
}
for (const handler of chunkHandlers) {
handler(chunk)
}
/** Nothing for an interval the microphone was silent through: an empty chunk is audio the page
* would spend a budget and a send on. */
function deliverBytes(base64: string, droppedBytes: number): void {
if (base64.length === 0 && droppedBytes === 0) {
return
}
const chunk: DictationCaptureChunk = { data: decodeBase64(base64), droppedBytes }
for (const handler of chunkHandlers) {
handler(chunk)
}
}
function deliver(reply: BridgeAudioChunk): void {
deliverBytes(reply.base64, reply.droppedBytes)
// The same two kinds the native seam ends on: an `ended` on its own is the OS handing the
// session back and leaves a live capture alone. `recording` is the shell's own state and ends
// it whatever the kind — a capture it no longer has is gone however it went.
@@ -130,74 +125,24 @@ export function createPageDictationCapture(
}
}
function drain(): Promise<void> {
if (reading !== null) {
return reading
}
const run = readOnce().finally(() => {
reading = null
})
reading = run
return run
}
/** Best effort, and deliberately quiet, for the reason `end` never rejects. */
async function stopShell(): Promise<void> {
await verbs.stopAudio().catch(() => undefined)
}
/**
* The tail, then the stop, in that order.
* The stop, which brings the tail back with it.
*
* Whatever is in the ring when the user lifts the button is up to one interval of what they
* actually said, and no timer is coming for it — `stopDraining` has just cancelled the one that
* was. Stopping first would take the capture away and the read after it would be refused, so the
* order here is the whole fix. An in-flight drain is awaited before the last read rather than
* raced with it, because two reads settling out of order splice two moments together.
* was. The shell drains it into the stop's own reply, so there is no read to order against the
* stop, no flight to latch and nothing for an interruption to re-enter: a stop reply carries no
* interruption, and a second end reaches a shell with no capture and is answered with nothing.
*
* Never rejects, which the contract promises: a capture that will not end is not the page's to
* fix, and every caller reaches this inside a synchronous try that could not see a rejection.
*/
async function runEndCapture(): Promise<void> {
async function endCapture(): Promise<void> {
stopDraining()
await reading
reading = null
await readOnce()
await stopShell()
}
/**
* Idempotent while one end is in flight, which is what stops a refused read looping.
*
* The hook's interruption handler is `() => void cancel()`, and `cancel` reaches `end()`
* synchronously through `closeDictationAudio`. So the last read here can raise an interruption
* that calls straight back into this function, whose own last read is refused for the same
* reason — the shell has no capture — and the recursion issues bridge reads until the page runs
* out of memory. Returning the in-flight promise makes the re-entrant call a no-op rather than a
* second read; the recursion happens while that promise is still pending, so guarding the flight
* is enough and outliving it is not required.
*
* Cleared on settle, because a finished end must not answer for the next capture. `open` starts
* the shell recording and the hook can reach `end` before `begin` — a start that goes stale
* after `activeIdRef` is set cleans up that way, and `begin` is the only thing that would have
* cleared a latch — so an end held past its own flight would report a stop it never issued and
* leave the shell holding a live microphone.
*/
function endCapture(): Promise<void> {
// A released capture has already stopped the shell and has nobody to hand a tail to.
if (released) {
return Promise.resolve()
const stopped = await verbs.stopAudio().catch(() => null)
if (stopped !== null) {
deliverBytes(stopped.base64, stopped.droppedBytes)
}
if (ending !== null) {
return ending
}
// Assigned before anything can await, so a handler re-entering from inside the read below
// finds it set rather than starting a second end.
const run = runEndCapture().finally(() => {
// Only its own flight: `begin` may have started a newer capture while this one settled.
if (ending === run) {
ending = null
}
})
ending = run
return run
}
return {
@@ -213,23 +158,20 @@ export function createPageDictationCapture(
},
begin: () => {
// The shell began capturing inside `start`; this is the page's half, which is the drain.
ending = null
released = false
stopDraining()
timer = setInterval(() => {
void drain()
// Unguarded: replies cross one lane in the order the shell posted them, so a read that
// outlives its interval is followed by its successor and never overtaken by one.
void readOnce()
}, drainIntervalMs)
return true
},
end: endCapture,
// No last read: a release is the screen going away, and there is nobody left to hand the tail
// to. The shell sweeps the ring with the capture.
// The tail is dropped rather than delivered: a release is the screen going away, and there is
// nobody left to hand it to. The shell sweeps the ring with the capture.
release: () => {
// Marked released so a later `end` neither reads nor stops again: the screen is going away.
// A flag rather than a settled `ending`, which now clears itself and would unlatch this.
released = true
stopDraining()
void stopShell()
void verbs.stopAudio().catch(() => undefined)
},
onChunk: (handler) => subscribe(chunkHandlers, handler),
onInterruption: (handler) => subscribe(interruptionHandlers, handler),
+10 -1
View File
@@ -255,7 +255,16 @@ export function createNativeAudioCapture(engine: NativeAudioEngine): NativeAudio
audioStopParamsSchema.parse(params)
// Queued so a stop that followed a start ends the capture that start opened, rather than
// finding nothing and leaving a live microphone behind it.
return enqueue(async () => ({ stopped: end() }))
return enqueue(async () => {
// Drained before the capture goes, because ending it takes the ring with it. This is the
// audio produced since the page's last read, which is the tail of the utterance.
const drained = capture?.ring.drain(BRIDGE_AUDIO_RING_MAX_BYTES)
return {
stopped: end(),
base64: drained === undefined ? '' : bytesToBase64(drained.bytes),
droppedBytes: drained?.droppedBytes ?? 0
}
})
},
dispose: () => {
disposed = true