From 63cb1e9d2e94e98402acfdc2bb01ccc009cab7ef Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 1 Aug 2026 17:29:15 +0800 Subject: [PATCH] fix(talk): handle reentrant Google activation cancellation --- .../realtime-talk-google-live-lifecycle.ts | 2 +- .../realtime-talk-google-live-timeout.test.ts | 26 +++++++++++++++ .../pages/chat/realtime-talk-google-live.ts | 32 +++++++++++-------- 3 files changed, 45 insertions(+), 15 deletions(-) diff --git a/ui/src/pages/chat/realtime-talk-google-live-lifecycle.ts b/ui/src/pages/chat/realtime-talk-google-live-lifecycle.ts index 1d12604af401..00679e274405 100644 --- a/ui/src/pages/chat/realtime-talk-google-live-lifecycle.ts +++ b/ui/src/pages/chat/realtime-talk-google-live-lifecycle.ts @@ -32,7 +32,7 @@ export function buildGoogleLiveUrl(session: RealtimeTalkJsonPcmWebSocketSessionR return url.toString(); } -export type GoogleLiveConnectionState = +type GoogleLiveConnectionState = | "idle" | "connecting" | "ready" diff --git a/ui/src/pages/chat/realtime-talk-google-live-timeout.test.ts b/ui/src/pages/chat/realtime-talk-google-live-timeout.test.ts index e3d1d62d4b5a..dfe0c20d66ae 100644 --- a/ui/src/pages/chat/realtime-talk-google-live-timeout.test.ts +++ b/ui/src/pages/chat/realtime-talk-google-live-timeout.test.ts @@ -67,6 +67,15 @@ class FakeAudioContext { createGain() { return { connect() {}, disconnect() {}, gain: { value: 1 } }; } + + createAnalyser() { + return { + fftSize: 0, + smoothingTimeConstant: 0, + disconnect() {}, + getFloatTimeDomainData: (samples: Float32Array) => samples.fill(0.25), + }; + } } function createSession(): RealtimeTalkJsonPcmWebSocketSessionResult { @@ -247,6 +256,23 @@ describe("Google Live setup timeout", () => { expect(audioContexts.every((context) => context.close.mock.calls.length === 1)).toBe(true); }); + it("reclaims the meter when an input-level callback cancels activation", async () => { + let stopDuringActivation = () => undefined; + const onInputLevel = vi.fn(() => stopDuringActivation()); + const transport = createTransport({ onInputLevel }); + stopDuringActivation = () => transport.stop({ emitClosed: false }); + const { start, socket } = await beginTransport(transport); + socket.emitOpen(); + socket.emitMessage({ setupComplete: {} }); + await expect(start).resolves.toBe("ready"); + + expect(() => transport.activate()).toThrow("Google Live transport activation cancelled"); + expect(vi.getTimerCount()).toBe(0); + expect(stopInputTrack).toHaveBeenCalledOnce(); + expect(socket.readyState).toBe(3); + expect(audioContexts.every((context) => context.close.mock.calls.length === 1)).toBe(true); + }); + it("clears the deadline after Google setup completes", async () => { const onStatus = vi.fn(); const transport = createTransport({ onStatus }); diff --git a/ui/src/pages/chat/realtime-talk-google-live.ts b/ui/src/pages/chat/realtime-talk-google-live.ts index 38160a287524..9f9f73b31849 100644 --- a/ui/src/pages/chat/realtime-talk-google-live.ts +++ b/ui/src/pages/chat/realtime-talk-google-live.ts @@ -209,28 +209,26 @@ export class GoogleLiveRealtimeTalkTransport implements RealtimeTalkTransport { } try { this.ctx.callbacks.onStatus?.("listening"); - if (this.closed) { - return; - } + this.assertActivationCurrent(); this.emitTalkEvent({ type: "session.ready" }); - if (this.closed) { - return; - } + this.assertActivationCurrent(); if (this.ctx.callbacks.onInputLevel && this.media && this.inputContext) { - this.inputMeter = new RealtimeTalkMediaStreamMeter(this.ctx.callbacks.onInputLevel); - this.inputMeter.start(this.media, this.inputContext); - } - if (this.closed) { - return; + const inputMeter = new RealtimeTalkMediaStreamMeter(this.ctx.callbacks.onInputLevel); + this.inputMeter = inputMeter; + inputMeter.start(this.media, this.inputContext); + if (this.closed || !this.lifecycle.isActive || this.inputMeter !== inputMeter) { + // start() publishes synchronously before installing its interval. A + // reentrant stop must reclaim the interval that start() installs next. + inputMeter.stop(false); + } + this.assertActivationCurrent(); } this.startMicrophonePump(); if (this.camera.stream && !this.cameraPublished) { this.cameraPublished = true; this.ctx.callbacks.onVideoStream?.(this.camera.stream); } - if (this.closed) { - return; - } + this.assertActivationCurrent(); this.startVideoFrames(); } catch (error) { try { @@ -242,6 +240,12 @@ export class GoogleLiveRealtimeTalkTransport implements RealtimeTalkTransport { } } + private assertActivationCurrent(): void { + if (this.closed || !this.lifecycle.isActive) { + throw new Error("Google Live transport activation cancelled"); + } + } + async setVideoEnabled(enabled: boolean): Promise { await this.camera.setEnabled(enabled); }