diff --git a/extensions/openai/realtime-voice-provider.ts b/extensions/openai/realtime-voice-provider.ts index acbb5fa0e838..6e12eed10130 100644 --- a/extensions/openai/realtime-voice-provider.ts +++ b/extensions/openai/realtime-voice-provider.ts @@ -787,6 +787,9 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge { attempt.resolve(); return; } + // Auth preparation owns its own timeout. Start the socket deadline only + // after connection parameters are available. + attempt.startTimeout(); const url = resolvedConnection.url; this.connectionUrl = resolvedConnection.url; const debugProxy = resolveDebugProxySettings(); diff --git a/extensions/xai/realtime-voice-bridge.ts b/extensions/xai/realtime-voice-bridge.ts index 785162adad16..1dc505e69e76 100644 --- a/extensions/xai/realtime-voice-bridge.ts +++ b/extensions/xai/realtime-voice-bridge.ts @@ -160,6 +160,9 @@ export class XaiRealtimeVoiceBridge extends XaiRealtimeVoiceEvents implements Re attempt.resolve(); return; } + // Credential refresh has its own bounded lifecycle. Arm the socket timeout + // only once valid connection parameters are ready. + attempt.startTimeout(); const { url, headers } = resolvedConnection; this.connectionUrl = url; const proxyAgent = createDebugProxyWebSocketAgent(resolveDebugProxySettings()); diff --git a/src/talk/realtime-session-lifecycle.ts b/src/talk/realtime-session-lifecycle.ts index f5e59f50b3c0..3b3925c9d283 100644 --- a/src/talk/realtime-session-lifecycle.ts +++ b/src/talk/realtime-session-lifecycle.ts @@ -88,6 +88,7 @@ type RealtimeVoiceConnectAttempt = { reject: (error: Error) => void; rejectStartup: (error: Error) => boolean; resolve: (providerReady?: boolean) => void; + startTimeout: () => void; }; type RealtimeVoiceConnectAttemptOptions = { @@ -174,8 +175,11 @@ export class RealtimeVoiceSessionLifecycle { rejectPromise = reject; }); let removeAbortListener = () => {}; + let timeout: ReturnType | undefined; const cleanup = () => { - clearTimeout(timeout); + if (timeout) { + clearTimeout(timeout); + } removeAbortListener(); }; const resolve = (providerReady = false) => { @@ -203,17 +207,22 @@ export class RealtimeVoiceSessionLifecycle { reject(error); return true; }; - const timeout = setTimeout(() => { - if ( - this.isCurrent(options.connection) && - !ready && - this.terminalOutcome(options.connection) !== "completed" - ) { - startupFailed = true; - options.onTimeout(); - reject(options.timeoutError()); + const startTimeout = () => { + if (settled || timeout) { + return; } - }, options.timeoutMs); + timeout = setTimeout(() => { + if ( + this.isCurrent(options.connection) && + !ready && + this.terminalOutcome(options.connection) !== "completed" + ) { + startupFailed = true; + options.onTimeout(); + reject(options.timeoutError()); + } + }, options.timeoutMs); + }; const onAbort = () => { const outcome = this.terminalOutcome(options.connection); options.onAbort(outcome); @@ -243,6 +252,7 @@ export class RealtimeVoiceSessionLifecycle { reject, rejectStartup, resolve, + startTimeout, }; }