fix(openai): stop realtime voice reconnects promptly on close (#108209)

* fix(openai): cancel realtime reconnect backoff on close

* test(openai): cover reconnect after bridge close

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Wynne668
2026-07-16 23:27:36 +08:00
committed by GitHub
parent ae547607b0
commit 38448750eb
2 changed files with 66 additions and 4 deletions

View File

@@ -1101,6 +1101,55 @@ describe("buildOpenAIRealtimeVoiceProvider", () => {
bridge.close();
});
it("cancels a pending reconnect and allows a later explicit connect", async () => {
vi.useFakeTimers();
const provider = buildOpenAIRealtimeVoiceProvider();
const onError = vi.fn();
const bridge = provider.createBridge({
providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret
onAudio: vi.fn(),
onClearAudio: vi.fn(),
onError,
});
const connecting = bridge.connect();
const socket = FakeWebSocket.instances[0];
if (!socket) {
throw new Error("expected bridge to create a websocket");
}
socket.readyState = FakeWebSocket.OPEN;
socket.emit("open");
socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" })));
await connecting;
socket.readyState = FakeWebSocket.CLOSED;
socket.emit("close", 1006, Buffer.from("transient drop"));
await vi.advanceTimersByTimeAsync(0);
expect(vi.getTimerCount()).toBe(1);
bridge.close();
await vi.advanceTimersByTimeAsync(0);
expect(vi.getTimerCount()).toBe(0);
expect(FakeWebSocket.instances).toHaveLength(1);
expect(onError).not.toHaveBeenCalled();
const reconnecting = bridge.connect();
const reconnectedSocket = FakeWebSocket.instances[1];
if (!reconnectedSocket) {
throw new Error("expected bridge to reconnect after close");
}
reconnectedSocket.readyState = FakeWebSocket.OPEN;
reconnectedSocket.emit("open");
reconnectedSocket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" })));
await reconnecting;
expect(bridge.isConnected()).toBe(true);
expect(FakeWebSocket.instances).toHaveLength(2);
expect(onError).not.toHaveBeenCalled();
bridge.close();
});
it("keeps Azure deployment bridges on deployment-compatible session payloads", async () => {
const provider = buildOpenAIRealtimeVoiceProvider();
const bridge = provider.createBridge({

View File

@@ -27,7 +27,7 @@ import {
REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ,
REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ,
} from "openclaw/plugin-sdk/realtime-voice";
import { warn } from "openclaw/plugin-sdk/runtime-env";
import { sleepWithAbort, warn } from "openclaw/plugin-sdk/runtime-env";
import {
normalizeResolvedSecretInputString,
normalizeSecretInputString,
@@ -506,6 +506,7 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge {
private sessionReadyFired = false;
private reconnectReason: string | undefined;
private activeConnectionReason: string | undefined;
private reconnectAbortController = new AbortController();
private readonly audioFormat: RealtimeVoiceAudioFormat;
constructor(private readonly config: OpenAIRealtimeVoiceBridgeConfig) {
@@ -514,6 +515,9 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge {
async connect(): Promise<void> {
this.intentionallyClosed = false;
if (this.reconnectAbortController.signal.aborted) {
this.reconnectAbortController = new AbortController();
}
this.reconnectAttempts = 0;
await this.doConnect();
}
@@ -587,6 +591,9 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge {
close(): void {
this.intentionallyClosed = true;
// The bridge owns both its active socket and reconnect delay; canceling
// both keeps terminal close from retaining callbacks for the full backoff.
this.reconnectAbortController.abort();
this.connected = false;
this.sessionConfigured = false;
if (this.ws) {
@@ -881,9 +888,15 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge {
type: "session.reconnect.scheduled",
detail: `reason=${reason} attempt=${attempt} delayMs=${delay}`,
});
await new Promise((resolve) => {
setTimeout(resolve, delay);
});
const reconnectSignal = this.reconnectAbortController.signal;
try {
await sleepWithAbort(delay, reconnectSignal);
} catch (error) {
if (!reconnectSignal.aborted) {
throw error;
}
return;
}
if (this.intentionallyClosed) {
return;
}