From 727fc79ed290e12e8c50447794062a3a367321f2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 00:33:14 +0000 Subject: [PATCH] fix: force-stop lingering gateway client sockets --- src/gateway/client.test.ts | 30 ++++++++++++++++++++++++++++++ src/gateway/client.ts | 13 ++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index d9bcc55b722..d3fdc89c86a 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -23,6 +23,8 @@ class MockWebSocket { private closeHandlers: WsEventHandlers["close"][] = []; private errorHandlers: WsEventHandlers["error"][] = []; readonly sent: string[] = []; + closeCalls = 0; + terminateCalls = 0; constructor(_url: string, _options?: unknown) { wsInstances.push(this); @@ -52,9 +54,14 @@ class MockWebSocket { } close(code?: number, reason?: string): void { + this.closeCalls += 1; this.emitClose(code ?? 1000, reason ?? ""); } + terminate(): void { + this.terminateCalls += 1; + } + send(data: string): void { this.sent.push(data); } @@ -297,6 +304,29 @@ describe("GatewayClient close handling", () => { client.stop(); }); + it("force-terminates a lingering socket after stop", async () => { + vi.useFakeTimers(); + try { + const client = new GatewayClient({ + url: "ws://127.0.0.1:18789", + }); + + client.start(); + const ws = getLatestWs(); + + client.stop(); + + expect(ws.closeCalls).toBe(1); + expect(ws.terminateCalls).toBe(0); + + await vi.advanceTimersByTimeAsync(250); + + expect(ws.terminateCalls).toBe(1); + } finally { + vi.useRealTimers(); + } + }); + it("does not clear persisted device auth when explicit shared token is provided", () => { const onClose = vi.fn(); const identity: DeviceIdentity = { diff --git a/src/gateway/client.ts b/src/gateway/client.ts index f2c7a184dd8..b559995ace4 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -117,6 +117,8 @@ export function describeGatewayCloseCode(code: number): string | undefined { return GATEWAY_CLOSE_CODE_HINTS[code]; } +const FORCE_STOP_TERMINATE_GRACE_MS = 250; + export class GatewayClient { private ws: WebSocket | null = null; private opts: GatewayClientOptions; @@ -273,8 +275,17 @@ export class GatewayClient { clearInterval(this.tickTimer); this.tickTimer = null; } - this.ws?.close(); + const ws = this.ws; this.ws = null; + if (ws) { + ws.close(); + const forceTerminateTimer = setTimeout(() => { + try { + ws.terminate(); + } catch {} + }, FORCE_STOP_TERMINATE_GRACE_MS); + forceTerminateTimer.unref?.(); + } this.flushPendingErrors(new Error("gateway client stopped")); }