From 835f12ef47ee9e545b5db9f782e1bf4f41b31d67 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 1 Aug 2026 14:42:36 +0800 Subject: [PATCH] fix(talk): release owner after startup failure --- .../chat/realtime-talk-lifecycle.test.ts | 76 +++++++++++++++++++ ui/src/pages/chat/realtime-talk.ts | 60 +++++++++++---- 2 files changed, 121 insertions(+), 15 deletions(-) diff --git a/ui/src/pages/chat/realtime-talk-lifecycle.test.ts b/ui/src/pages/chat/realtime-talk-lifecycle.test.ts index ab231487ed82..353563915458 100644 --- a/ui/src/pages/chat/realtime-talk-lifecycle.test.ts +++ b/ui/src/pages/chat/realtime-talk-lifecycle.test.ts @@ -167,6 +167,82 @@ describe("RealtimeTalkSession lifecycle", () => { secondReplacement.stop(); }); + it("restores the existing owner when same-session transport startup fails", async () => { + const transcriptEntryIds: string[] = []; + const request = vi.fn(async (method: string, params?: { entryId?: string }) => { + if (method === "talk.client.create") { + return { + provider: "openai", + transport: "webrtc", + voiceSessionId: "voice-existing", + clientSecret: "secret", + }; + } + if (method === "talk.client.transcript") { + transcriptEntryIds.push(String(params?.entryId)); + } + return { ok: true }; + }); + const client = { request } as never; + const session = new RealtimeTalkSession(client, "agent:main:main"); + + await session.start(); + const existingContext = transcriptContext(transportMock.webRtcContexts); + transportMock.start.mockRejectedValueOnce(new Error("replacement startup failed")); + + await expect(session.start()).rejects.toThrow("replacement startup failed"); + expect(request.mock.calls.some(([method]) => method === "talk.client.close")).toBe(false); + + existingContext.callbacks.onTranscript?.({ role: "user", text: "still active", final: true }); + await existingContext.flushTranscriptWrites?.(); + expect(transcriptEntryIds).toEqual(["1"]); + + const concurrent = new RealtimeTalkSession(client, "agent:main:main"); + await concurrent.start(); + concurrent.stop(); + session.stop(); + }); + + it("releases newly allocated owners after transport startup failures", async () => { + let createCount = 0; + const request = vi.fn(async (method: string) => { + if (method === "talk.client.create") { + createCount += 1; + return { + provider: "openai", + transport: "webrtc", + voiceSessionId: `voice-start-${createCount}`, + clientSecret: "secret", + }; + } + return { ok: true }; + }); + const client = { request } as never; + transportMock.start + .mockRejectedValueOnce(new Error("first startup failed")) + .mockRejectedValueOnce(new Error("second startup failed")); + + const first = new RealtimeTalkSession(client, "agent:main:main"); + await expect(first.start()).rejects.toThrow("first startup failed"); + await vi.waitFor(() => + expect(request.mock.calls.filter(([method]) => method === "talk.client.close")).toHaveLength( + 1, + ), + ); + + const second = new RealtimeTalkSession(client, "agent:main:main"); + await expect(second.start()).rejects.toThrow("second startup failed"); + await vi.waitFor(() => + expect(request.mock.calls.filter(([method]) => method === "talk.client.close")).toHaveLength( + 2, + ), + ); + + const recovered = new RealtimeTalkSession(client, "agent:main:main"); + await recovered.start(); + recovered.stop(); + }); + it("surfaces transcript failure after three attempts", async () => { vi.useFakeTimers(); const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); diff --git a/ui/src/pages/chat/realtime-talk.ts b/ui/src/pages/chat/realtime-talk.ts index 9bff063598f4..ffb96303e188 100644 --- a/ui/src/pages/chat/realtime-talk.ts +++ b/ui/src/pages/chat/realtime-talk.ts @@ -240,8 +240,20 @@ export class RealtimeTalkSession { const lifecycleGeneration = ++this.lifecycleGeneration; this.closed = false; this.callbacks.onStatus?.("connecting"); + const existingTransport = this.transport; + const existingTransportGeneration = this.transportGeneration; const existingVoiceSessionId = this.voiceSessionId; + const existingAcceptingTranscripts = this.acceptingTranscripts; + const existingServerOwnedVoiceSession = this.serverOwnedVoiceSession; const existingOwner = this.clientVoiceSessionOwner; + const restoreExistingSession = () => { + this.transport = existingTransport; + this.transportGeneration = existingTransportGeneration; + this.voiceSessionId = existingVoiceSessionId; + this.acceptingTranscripts = existingAcceptingTranscripts; + this.serverOwnedVoiceSession = existingServerOwnedVoiceSession; + this.clientVoiceSessionOwner = existingOwner; + }; const owner = reserveClientVoiceSessionOwner(this.client, this.sessionKey); let ownerTransferred = false; try { @@ -307,21 +319,39 @@ export class RealtimeTalkSession { adoptedOwner.signal, ); const transcriptQueue = this.transcriptQueue; - this.transport = createTransport(session, { - client: this.client, - sessionKey: this.sessionKey, - voiceSessionId, - flushTranscriptWrites: async () => await transcriptQueue.flush(), - callbacks, - inputDeviceId: this.localOptions.inputDeviceId, - videoDeviceId: this.localOptions.videoDeviceId, - consultThinkingLevel: session.consultThinkingLevel, - consultFastMode: session.consultFastMode, - }); - this.callbacks.onVideoCapability?.( - providerVideoCapable && typeof this.transport.setVideoEnabled === "function", - ); - await this.transport.start(); + let nextTransport: RealtimeTalkTransport | null = null; + try { + nextTransport = createTransport(session, { + client: this.client, + sessionKey: this.sessionKey, + voiceSessionId, + flushTranscriptWrites: async () => await transcriptQueue.flush(), + callbacks, + inputDeviceId: this.localOptions.inputDeviceId, + videoDeviceId: this.localOptions.videoDeviceId, + consultThinkingLevel: session.consultThinkingLevel, + consultFastMode: session.consultFastMode, + }); + this.transport = nextTransport; + this.callbacks.onVideoCapability?.( + providerVideoCapable && typeof nextTransport.setVideoEnabled === "function", + ); + await nextTransport.start(); + } catch (error) { + nextTransport?.stop(); + if (existingOwner && adoptedOwner === existingOwner) { + // A same-session replacement borrows the existing owner and queue. + // Restore the live transport instead of closing their shared allocation. + restoreExistingSession(); + } else { + const detached = this.detachVoiceSession(); + restoreExistingSession(); + if (detached) { + this.closeLogicalVoiceSession(detached); + } + } + throw error; + } } finally { if (!ownerTransferred) { owner.release();