diff --git a/packages/coding-agent/src/experimental/radius-relay.ts b/packages/coding-agent/src/experimental/radius-relay.ts index 8daf59974..660428a81 100644 --- a/packages/coding-agent/src/experimental/radius-relay.ts +++ b/packages/coding-agent/src/experimental/radius-relay.ts @@ -18,6 +18,11 @@ const HOST_RETRY_MAX_MS = 30_000; const MISSING_AUTH_RETRY_MS = 30_000; const CLIENT_RETRY_INITIAL_MS = 1_000; const CLIENT_RETRY_MAX_MS = 30_000; +// Undici implements the browser WebSocket API, which permits callers to send +// only code 1000 or application codes from 3000 through 4999. RFC protocol +// codes such as 1002 and 1011 may be received but cannot be passed to close(). +const LOCAL_PROTOCOL_ERROR_CLOSE_CODE = 4000; +const LOCAL_TRANSPORT_ERROR_CLOSE_CODE = 4001; interface RadiusRelayMessageEvent extends Event { readonly data: unknown; @@ -193,7 +198,7 @@ export class RadiusRelayHost { try { this.#handleHostMessage(event.data); } catch (error) { - socket.close(1002, "Radius relay protocol error"); + closeWebSocket(socket, LOCAL_PROTOCOL_ERROR_CLOSE_CODE, "Radius relay protocol error"); finish(toError(error)); } }; @@ -216,9 +221,11 @@ export class RadiusRelayHost { const control = parseHostControlMessage(value); switch (control.type) { case "ping": - void this.#sendControl({ version: 1, type: "pong" }).catch((error: unknown) => - this.#socket?.close(1011, errorMessage(error)), - ); + void this.#sendControl({ version: 1, type: "pong" }).catch(() => { + if (this.#socket !== undefined) { + closeWebSocket(this.#socket, LOCAL_TRANSPORT_ERROR_CLOSE_CODE, "Radius relay send failed"); + } + }); return; case "pong": return; @@ -458,7 +465,7 @@ class RadiusClientByteTransport implements ByteTransport { #fail(error: Error): void { if (!this.#markClosed()) return; - this.#socket.close(1011, "Radius relay transport error"); + closeWebSocket(this.#socket, LOCAL_TRANSPORT_ERROR_CLOSE_CODE, "Radius relay transport error"); this.#handlers.onError(error); } diff --git a/packages/coding-agent/test/experimental-radius-relay.test.ts b/packages/coding-agent/test/experimental-radius-relay.test.ts index f47cbe8c6..7cf7bf7cd 100644 --- a/packages/coding-agent/test/experimental-radius-relay.test.ts +++ b/packages/coding-agent/test/experimental-radius-relay.test.ts @@ -45,6 +45,9 @@ class FakeWebSocket { } close(code = 1000, reason = ""): void { + if (code !== 1000 && (code < 3000 || code > 4999)) { + throw new DOMException("Invalid close code", "InvalidAccessError"); + } if (this.readyState === 3) return; this.readyState = 3; this.emit("close", { code, reason }); @@ -69,6 +72,12 @@ class FakeWebSocket { this.emit("error", { error, message: error.message }); } + abnormalClose(error: Error): void { + this.readyState = 3; + this.emit("error", { error, message: error.message }); + this.emit("close", { code: 1006, reason: "" }); + } + private emit(type: string, event: unknown): void { for (const listener of [...(this.listeners.get(type) ?? [])]) listener(event); } @@ -260,6 +269,26 @@ describe("experimental Radius relay", () => { expect(onError).not.toHaveBeenCalled(); }); + test("reports established abnormal closures so the client can reconnect", async () => { + const webSockets = socketFactory(); + const onClose = vi.fn(); + const onError = vi.fn(); + const transportPromise = createRadiusClientTransportFactory({ + serverId, + auth: new RadiusRelayAuthResolver({ type: "token", token: "secret" }), + webSocketFactory: webSockets.factory, + })({ onData: vi.fn(), onClose, onError }); + await vi.waitFor(() => expect(webSockets.sockets).toHaveLength(1)); + const { socket, options } = webSockets.sockets[0]!; + socket.open(options.protocol); + await transportPromise; + + expect(() => socket.abnormalClose(new Error("network lost"))).not.toThrow(); + expect(onError).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: "network lost" })); + expect(onClose).not.toHaveBeenCalled(); + }); + test("reports a useful error when Undici omits WebSocket failure details", async () => { const webSockets = socketFactory(); const opening = createRadiusClientTransportFactory({