diff --git a/ui/src/pages/chat/realtime-talk-transcript-owner.ts b/ui/src/pages/chat/realtime-talk-transcript-owner.ts index 963ef4eb9e49..6357b7c3cabb 100644 --- a/ui/src/pages/chat/realtime-talk-transcript-owner.ts +++ b/ui/src/pages/chat/realtime-talk-transcript-owner.ts @@ -5,6 +5,7 @@ import type { RealtimeTalkTransport } from "./realtime-talk-shared.ts"; export type ClientVoiceSessionOwner = { signal: AbortSignal; + closeSignal: AbortSignal; beginDrain: () => void; release: () => void; }; @@ -19,10 +20,12 @@ export type DetachedVoiceSession = { const MAX_CLIENT_VOICE_SESSION_OWNERS_PER_SESSION = 2; const MAX_CLIENT_VOICE_SESSION_OWNERS_PER_CLIENT = 16; -const CLIENT_VOICE_SESSION_DRAIN_TIMEOUT_MS = DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS; +const CLIENT_VOICE_TRANSCRIPT_DRAIN_TIMEOUT_MS = DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS; +const CLIENT_VOICE_SESSION_CLOSE_TIMEOUT_MS = + CLIENT_VOICE_TRANSCRIPT_DRAIN_TIMEOUT_MS + DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS; // One client may own multiple split-pane calls, but route churn must not create -// unbounded detached drains. Every detached owner also has a hard release deadline. +// unbounded detached drains. Transcript and close each get one request deadline. const clientVoiceSessionOwnerCounts = new WeakMap>(); export function reserveClientVoiceSessionOwner( @@ -44,9 +47,11 @@ export function reserveClientVoiceSessionOwner( } counts.set(sessionKey, sessionCount + 1); const ownerCounts = counts; - const controller = new AbortController(); + const transcriptController = new AbortController(); + const closeController = new AbortController(); let released = false; let drainTimer: ReturnType | undefined; + let closeTimer: ReturnType | undefined; const release = () => { if (released) { return; @@ -56,6 +61,10 @@ export function reserveClientVoiceSessionOwner( clearTimeout(drainTimer); drainTimer = undefined; } + if (closeTimer !== undefined) { + clearTimeout(closeTimer); + closeTimer = undefined; + } const nextSessionCount = (ownerCounts.get(sessionKey) ?? 1) - 1; if (nextSessionCount > 0) { ownerCounts.set(sessionKey, nextSessionCount); @@ -64,15 +73,20 @@ export function reserveClientVoiceSessionOwner( } }; return { - signal: controller.signal, + signal: transcriptController.signal, + closeSignal: closeController.signal, beginDrain: () => { - if (released || drainTimer !== undefined) { + if (released || drainTimer !== undefined || closeTimer !== undefined) { return; } drainTimer = setTimeout(() => { - controller.abort(); + transcriptController.abort(); + }, CLIENT_VOICE_TRANSCRIPT_DRAIN_TIMEOUT_MS); + closeTimer = setTimeout(() => { + transcriptController.abort(); + closeController.abort(); release(); - }, CLIENT_VOICE_SESSION_DRAIN_TIMEOUT_MS); + }, CLIENT_VOICE_SESSION_CLOSE_TIMEOUT_MS); }, release, }; diff --git a/ui/src/pages/chat/realtime-talk.ts b/ui/src/pages/chat/realtime-talk.ts index a91a56ddce4b..1e2bcc66bb62 100644 --- a/ui/src/pages/chat/realtime-talk.ts +++ b/ui/src/pages/chat/realtime-talk.ts @@ -653,8 +653,8 @@ export class RealtimeTalkSession { let lastError: unknown; for (const delayMs of [0, 500, 2_000]) { if (delayMs > 0) { - await waitForTranscriptRetry(delayMs, owner.signal); - } else if (owner.signal.aborted) { + await waitForTranscriptRetry(delayMs, owner.closeSignal); + } else if (owner.closeSignal.aborted) { throw transcriptPersistenceAbortError(); } try { @@ -665,13 +665,13 @@ export class RealtimeTalkSession { voiceSessionId: detached.voiceSessionId, }, { - signal: owner.signal, + signal: owner.closeSignal, timeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS, }, ); return; } catch (error) { - if (owner.signal.aborted) { + if (owner.closeSignal.aborted) { throw transcriptPersistenceAbortError(); } lastError = error; @@ -680,7 +680,7 @@ export class RealtimeTalkSession { throw transcriptWriteError(lastError, "Realtime Talk voice session close failed"); }) .catch((error: unknown) => { - if (owner.signal.aborted) { + if (owner.closeSignal.aborted) { return; } console.warn("Realtime Talk voice session close failed", error);