fix(coding-agent): reconnect after abnormal Radius closure

This commit is contained in:
Armin Ronacher 2026-08-28 14:57:52 +02:00
parent 35741a456d
commit 35c49350fd
2 changed files with 41 additions and 5 deletions

View file

@ -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);
}

View file

@ -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({