From 5394bb1069a7c6cfec8f70e0a54fd84dbb2c0ae1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 1 Aug 2026 15:25:47 +0800 Subject: [PATCH] fix(talk): cancel pending media acquisition --- ui/src/pages/chat/realtime-talk-input.test.ts | 23 +++++++- ui/src/pages/chat/realtime-talk-input.ts | 56 +++++++++++++++++-- 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/ui/src/pages/chat/realtime-talk-input.test.ts b/ui/src/pages/chat/realtime-talk-input.test.ts index e93bff94cc01..c824106548f4 100644 --- a/ui/src/pages/chat/realtime-talk-input.test.ts +++ b/ui/src/pages/chat/realtime-talk-input.test.ts @@ -123,6 +123,25 @@ describe("realtime Talk microphone inputs", () => { }); }); + it("settles microphone cancellation before browser permission resolves", async () => { + const stop = vi.fn(); + let resolveMedia: (stream: MediaStream) => void = () => undefined; + const pending = new Promise((resolve) => { + resolveMedia = resolve; + }); + vi.stubGlobal("navigator", { + mediaDevices: { getUserMedia: vi.fn(() => pending) }, + }); + const controller = new AbortController(); + + const opening = openRealtimeTalkInput(undefined, { signal: controller.signal }); + controller.abort(); + + await expect(opening).rejects.toMatchObject({ name: "AbortError" }); + resolveMedia({ getTracks: () => [{ stop }] } as unknown as MediaStream); + await vi.waitFor(() => expect(stop).toHaveBeenCalledOnce()); + }); + it("acquires camera separately so camera errors cannot stop microphone input", async () => { const audio = { getTracks: () => [] } as unknown as MediaStream; const camera = { getTracks: () => [] } as unknown as MediaStream; @@ -172,10 +191,10 @@ describe("realtime Talk microphone inputs", () => { const opening = openRealtimeTalkCamera(undefined, { signal: controller.signal }); await vi.waitFor(() => expect(getUserMedia).toHaveBeenCalledOnce()); controller.abort(); - resolveCamera(camera); await expect(opening).rejects.toMatchObject({ name: "AbortError" }); - expect(videoStop).toHaveBeenCalledOnce(); + resolveCamera(camera); + await vi.waitFor(() => expect(videoStop).toHaveBeenCalledOnce()); }); it("enables voice processing with the system default microphone", async () => { diff --git a/ui/src/pages/chat/realtime-talk-input.ts b/ui/src/pages/chat/realtime-talk-input.ts index ec0af89c51f5..5b1b0eda63f0 100644 --- a/ui/src/pages/chat/realtime-talk-input.ts +++ b/ui/src/pages/chat/realtime-talk-input.ts @@ -167,6 +167,44 @@ function realtimeTalkAbortReason(signal: AbortSignal): Error { : new DOMException("Realtime Talk input cancelled", "AbortError"); } +async function awaitRealtimeTalkMediaRequest( + request: Promise, + signal: AbortSignal | undefined, +): Promise { + if (!signal) { + return await request; + } + if (signal.aborted) { + void request.then( + (stream) => stream.getTracks().forEach((track) => track.stop()), + () => undefined, + ); + throw realtimeTalkAbortReason(signal); + } + let removeAbortListener = () => undefined; + const aborted = new Promise((_resolve, reject) => { + const onAbort = () => reject(realtimeTalkAbortReason(signal)); + signal.addEventListener("abort", onAbort, { once: true }); + removeAbortListener = () => signal.removeEventListener("abort", onAbort); + }); + try { + return await Promise.race([request, aborted]); + } catch (error) { + if (signal.aborted) { + // Browser permission prompts are not cancellable. Release any stream that + // arrives after the lifecycle owner has already moved on. + void request.then( + (stream) => stream.getTracks().forEach((track) => track.stop()), + () => undefined, + ); + throw realtimeTalkAbortReason(signal); + } + throw error; + } finally { + removeAbortListener(); + } +} + export async function openRealtimeTalkInput( inputDeviceId: string | undefined, options: { signal?: AbortSignal } = {}, @@ -177,9 +215,12 @@ export async function openRealtimeTalkInput( } let audio: MediaStream; try { - audio = await devices.getUserMedia({ - audio: realtimeTalkAudioConstraints(inputDeviceId), - }); + audio = await awaitRealtimeTalkMediaRequest( + devices.getUserMedia({ + audio: realtimeTalkAudioConstraints(inputDeviceId), + }), + options.signal, + ); } catch (error) { if ( inputDeviceId?.trim() && @@ -208,9 +249,12 @@ export async function openRealtimeTalkCamera( const deviceId = videoDeviceId?.trim(); let camera: MediaStream; try { - camera = await devices.getUserMedia({ - video: deviceId ? { deviceId: { exact: deviceId } } : true, - }); + camera = await awaitRealtimeTalkMediaRequest( + devices.getUserMedia({ + video: deviceId ? { deviceId: { exact: deviceId } } : true, + }), + options.signal, + ); if (options.signal?.aborted) { camera.getTracks().forEach((track) => track.stop()); throw realtimeTalkAbortReason(options.signal);