fix(talk): handle reentrant Google activation cancellation

This commit is contained in:
Vincent Koc
2026-08-01 17:29:15 +08:00
parent dbd2f7266e
commit 63cb1e9d2e
3 changed files with 45 additions and 15 deletions

View File

@@ -32,7 +32,7 @@ export function buildGoogleLiveUrl(session: RealtimeTalkJsonPcmWebSocketSessionR
return url.toString();
}
export type GoogleLiveConnectionState =
type GoogleLiveConnectionState =
| "idle"
| "connecting"
| "ready"

View File

@@ -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 });

View File

@@ -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<void> {
await this.camera.setEnabled(enabled);
}