diff --git a/packages/server/src/client/daemon-client-transport.test.ts b/packages/server/src/client/daemon-client-transport.test.ts index c4298c8a6..3bb89903c 100644 --- a/packages/server/src/client/daemon-client-transport.test.ts +++ b/packages/server/src/client/daemon-client-transport.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from "node:events"; import { describe, expect, test, vi } from "vitest"; import { createEncryptedTransport, @@ -114,6 +115,22 @@ describe("daemon-client transport helpers", () => { expect(ws.removeEventListener).toHaveBeenCalledWith("message", expect.any(Function)); }); + test("createWebSocketTransportFactory suppresses close-before-open ws errors", () => { + class MockNodeWebSocket extends EventEmitter { + readyState = 0; + send = vi.fn(); + close = vi.fn((code?: number, reason?: string) => { + this.emit("error", new Error("WebSocket was closed before the connection was established")); + this.emit("close", { code, reason }); + }); + } + + const ws = new MockNodeWebSocket(); + const transport = createWebSocketTransportFactory(() => ws)({ url: "ws://example.test" }); + + expect(() => transport.close(1001, "Connection timed out")).not.toThrow(); + }); + test("describeTransportClose prefers reason, then message, then code", () => { expect(describeTransportClose({ reason: "peer closed" })).toBe("peer closed"); expect(describeTransportClose({ message: "closed" })).toBe("closed"); diff --git a/packages/server/src/client/daemon-client-websocket-transport.ts b/packages/server/src/client/daemon-client-websocket-transport.ts index ea0f2bfff..fdf4a9a38 100644 --- a/packages/server/src/client/daemon-client-websocket-transport.ts +++ b/packages/server/src/client/daemon-client-websocket-transport.ts @@ -32,7 +32,19 @@ export function createWebSocketTransportFactory(factory: WebSocketFactory): Daem } ws.send(data); }, - close: (code?: number, reason?: string) => ws.close(code, reason), + close: (code?: number, reason?: string) => { + // Node's `ws` may emit an `error` when a connecting socket is closed before the + // handshake completes. Keep a temporary no-op handler attached so cleanup during + // connect timeouts does not crash the CLI with an unhandled error event. + const suppressEarlyCloseError = bindTemporaryEarlyCloseErrorHandler(ws); + try { + ws.close(code, reason); + } finally { + if (typeof ws.on !== "function" && typeof ws.addEventListener !== "function") { + suppressEarlyCloseError(); + } + } + }, onOpen: (handler) => bindWsHandler(ws, "open", handler), onClose: (handler) => bindWsHandler(ws, "close", handler), onError: (handler) => bindWsHandler(ws, "error", handler), @@ -41,6 +53,52 @@ export function createWebSocketTransportFactory(factory: WebSocketFactory): Daem }; } +function bindTemporaryEarlyCloseErrorHandler(ws: WebSocketLike): () => void { + const noop = () => {}; + + if (typeof ws.addEventListener === "function") { + ws.addEventListener("error", noop); + const removeOnClose = bindWsHandler(ws, "close", () => { + removeOnClose(); + if (typeof ws.removeEventListener === "function") { + ws.removeEventListener("error", noop); + } + }); + return () => { + removeOnClose(); + if (typeof ws.removeEventListener === "function") { + ws.removeEventListener("error", noop); + } + }; + } + + if (typeof ws.on === "function") { + ws.on("error", noop); + const removeOnClose = bindWsHandler(ws, "close", () => { + removeOnClose(); + if (typeof ws.off === "function") { + ws.off("error", noop); + return; + } + if (typeof ws.removeListener === "function") { + ws.removeListener("error", noop); + } + }); + return () => { + removeOnClose(); + if (typeof ws.off === "function") { + ws.off("error", noop); + return; + } + if (typeof ws.removeListener === "function") { + ws.removeListener("error", noop); + } + }; + } + + return () => {}; +} + export function bindWsHandler( ws: WebSocketLike, event: "open" | "close" | "error" | "message",