fix(talk): cancel pending media acquisition

This commit is contained in:
Vincent Koc
2026-08-01 15:25:47 +08:00
parent 91e86a329c
commit 5394bb1069
2 changed files with 71 additions and 8 deletions

View File

@@ -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<MediaStream>((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 () => {

View File

@@ -167,6 +167,44 @@ function realtimeTalkAbortReason(signal: AbortSignal): Error {
: new DOMException("Realtime Talk input cancelled", "AbortError");
}
async function awaitRealtimeTalkMediaRequest(
request: Promise<MediaStream>,
signal: AbortSignal | undefined,
): Promise<MediaStream> {
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<never>((_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);