fix(realtime): terminate failed sockets

This commit is contained in:
Federico Jaramillo Martinez
2026-07-18 08:33:51 +02:00
parent c569a03f54
commit 65350fd1b5
2 changed files with 14 additions and 2 deletions
+8 -2
View File
@@ -6,6 +6,7 @@ class FakeSocket extends EventEmitter implements RealtimeSocket {
readonly OPEN = 1; readonly OPEN = 1;
readyState = this.OPEN; readyState = this.OPEN;
send = vi.fn(); send = vi.fn();
terminate = vi.fn();
} }
describe("SessionEventHub", () => { describe("SessionEventHub", () => {
@@ -54,7 +55,7 @@ describe("SessionEventHub", () => {
expect(removed.send).not.toHaveBeenCalled(); expect(removed.send).not.toHaveBeenCalled();
}); });
it("continues publishing session events when one socket send fails", () => { it("terminates a failed session socket without disrupting healthy delivery or sequence watermarks", () => {
const hub = new SessionEventHub(); const hub = new SessionEventHub();
const failed = new FakeSocket(); const failed = new FakeSocket();
const healthy = new FakeSocket(); const healthy = new FakeSocket();
@@ -65,6 +66,7 @@ describe("SessionEventHub", () => {
hub.publish("s1", { type: "assistant.delta", text: "hello" }); hub.publish("s1", { type: "assistant.delta", text: "hello" });
expect(failed.send).toHaveBeenCalledOnce(); expect(failed.send).toHaveBeenCalledOnce();
expect(failed.terminate).toHaveBeenCalledOnce();
expect(healthy.send).toHaveBeenCalledWith(JSON.stringify({ type: "assistant.delta", text: "hello", seq: 1 })); expect(healthy.send).toHaveBeenCalledWith(JSON.stringify({ type: "assistant.delta", text: "hello", seq: 1 }));
expect(hub.currentSeq("s1")).toBe(1); expect(hub.currentSeq("s1")).toBe(1);
@@ -72,6 +74,7 @@ describe("SessionEventHub", () => {
hub.publish("s1", { type: "assistant.delta", text: "again" }); hub.publish("s1", { type: "assistant.delta", text: "again" });
expect(failed.send).not.toHaveBeenCalled(); expect(failed.send).not.toHaveBeenCalled();
expect(failed.terminate).toHaveBeenCalledOnce();
expect(healthy.send).toHaveBeenLastCalledWith(JSON.stringify({ type: "assistant.delta", text: "again", seq: 2 })); expect(healthy.send).toHaveBeenLastCalledWith(JSON.stringify({ type: "assistant.delta", text: "again", seq: 2 }));
expect(hub.currentSeq("s1")).toBe(2); expect(hub.currentSeq("s1")).toBe(2);
}); });
@@ -100,23 +103,26 @@ describe("SessionEventHub", () => {
expect(sessionSocket.send).not.toHaveBeenCalled(); expect(sessionSocket.send).not.toHaveBeenCalled();
}); });
it("continues publishing unstamped global events when one socket send fails", () => { it("contains termination failures while publishing unstamped global events", () => {
const hub = new SessionEventHub(); const hub = new SessionEventHub();
const failed = new FakeSocket(); const failed = new FakeSocket();
const healthy = new FakeSocket(); const healthy = new FakeSocket();
failed.send.mockImplementation(() => { throw new Error("socket closed"); }); failed.send.mockImplementation(() => { throw new Error("socket closed"); });
failed.terminate.mockImplementation(() => { throw new Error("termination failed"); });
hub.addGlobal(failed); hub.addGlobal(failed);
hub.addGlobal(healthy); hub.addGlobal(healthy);
hub.publishGlobal({ type: "session.name", sessionId: "s1", name: "Renamed" }); hub.publishGlobal({ type: "session.name", sessionId: "s1", name: "Renamed" });
expect(failed.send).toHaveBeenCalledOnce(); expect(failed.send).toHaveBeenCalledOnce();
expect(failed.terminate).toHaveBeenCalledOnce();
expect(healthy.send).toHaveBeenCalledWith(JSON.stringify({ type: "session.name", sessionId: "s1", name: "Renamed" })); expect(healthy.send).toHaveBeenCalledWith(JSON.stringify({ type: "session.name", sessionId: "s1", name: "Renamed" }));
failed.send.mockClear(); failed.send.mockClear();
hub.publishGlobal({ type: "session.name", sessionId: "s1", name: "Renamed again" }); hub.publishGlobal({ type: "session.name", sessionId: "s1", name: "Renamed again" });
expect(failed.send).not.toHaveBeenCalled(); expect(failed.send).not.toHaveBeenCalled();
expect(failed.terminate).toHaveBeenCalledOnce();
expect(healthy.send).toHaveBeenLastCalledWith(JSON.stringify({ type: "session.name", sessionId: "s1", name: "Renamed again" })); expect(healthy.send).toHaveBeenLastCalledWith(JSON.stringify({ type: "session.name", sessionId: "s1", name: "Renamed again" }));
}); });
+6
View File
@@ -5,6 +5,7 @@ export interface RealtimeSocket {
readonly OPEN: number; readonly OPEN: number;
readyState: number; readyState: number;
send(payload: string): void; send(payload: string): void;
terminate(): void;
on(event: "close", listener: () => void): unknown; on(event: "close", listener: () => void): unknown;
} }
@@ -64,6 +65,11 @@ export class SessionEventHub {
socket.send(payload); socket.send(payload);
} catch { } catch {
sockets.delete(socket); sockets.delete(socket);
try {
socket.terminate();
} catch {
// Removal is authoritative; cleanup failure must not block healthy sockets.
}
} }
} }
} }