fix(ui): bound browser gateway WebSocket opening (#109037)

* fix(ui): bound browser gateway socket opening

* fix(ui): use browser-safe timeout export
This commit is contained in:
Alix-007
2026-07-18 09:38:37 +01:00
committed by GitHub
parent bc77ef3398
commit deccdb5e57
3 changed files with 175 additions and 7 deletions
+1
View File
@@ -5,6 +5,7 @@ export * from "./browser-device-auth.js";
export * from "./connect-auth.js";
export * from "./protocol-client.js";
export * from "./reconnect-policy.js";
export { DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS } from "./timeouts.js";
export * from "@openclaw/gateway-protocol/client-info";
export * from "@openclaw/gateway-protocol/connect-error-details";
export * from "@openclaw/gateway-protocol/startup-unavailable";
+120
View File
@@ -0,0 +1,120 @@
/** @vitest-environment node */
import { DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS } from "@openclaw/gateway-client/browser";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createBrowserGatewaySocket } from "./gateway-browser-socket.ts";
type MockSocketEvent = { code?: number; data?: unknown; reason?: string };
type MockSocketHandler = (event: MockSocketEvent) => void;
const sockets: MockWebSocket[] = [];
class MockWebSocket {
static readonly OPEN = 1;
readonly close = vi.fn();
readonly handlers = new Map<string, MockSocketHandler[]>();
readyState = 0;
constructor(readonly url: string) {
sockets.push(this);
}
addEventListener(type: string, handler: MockSocketHandler) {
const handlers = this.handlers.get(type) ?? [];
handlers.push(handler);
this.handlers.set(type, handlers);
}
send(_data: string) {}
emit(type: string, event: MockSocketEvent = {}) {
for (const handler of this.handlers.get(type) ?? []) {
handler(event);
}
}
}
function createHandlers() {
return {
open: vi.fn(),
message: vi.fn(),
close: vi.fn(),
error: vi.fn(),
};
}
describe("createBrowserGatewaySocket", () => {
beforeEach(() => {
vi.useFakeTimers();
sockets.length = 0;
vi.stubGlobal("WebSocket", MockWebSocket);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("closes a websocket that never finishes opening", async () => {
const handlers = createHandlers();
createBrowserGatewaySocket("wss://gateway.example", handlers);
const socket = sockets[0];
await vi.advanceTimersByTimeAsync(DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS);
expect(handlers.error).toHaveBeenCalledOnce();
expect(handlers.error.mock.calls[0]?.[0]).toEqual(
new Error(
`gateway websocket opening timed out after ${DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS}ms`,
),
);
expect(socket?.close).toHaveBeenCalledOnce();
socket?.emit("error");
socket?.emit("close", { code: 1006, reason: "" });
expect(handlers.error).toHaveBeenCalledOnce();
expect(handlers.close).toHaveBeenCalledWith(1006, "");
});
it("clears the opening deadline after the socket opens", async () => {
const handlers = createHandlers();
createBrowserGatewaySocket("wss://gateway.example", handlers);
const socket = sockets[0];
if (socket) {
socket.readyState = MockWebSocket.OPEN;
socket.emit("open");
}
await vi.advanceTimersByTimeAsync(DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS);
expect(handlers.open).toHaveBeenCalledOnce();
expect(handlers.error).not.toHaveBeenCalled();
expect(socket?.close).not.toHaveBeenCalled();
});
it("clears the opening deadline after a native transport failure", async () => {
const handlers = createHandlers();
createBrowserGatewaySocket("wss://gateway.example", handlers);
const socket = sockets[0];
socket?.emit("error");
socket?.emit("close", { code: 1006, reason: "" });
await vi.advanceTimersByTimeAsync(DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS);
expect(handlers.error).toHaveBeenCalledOnce();
expect(handlers.error).toHaveBeenCalledWith(new Error("websocket error"));
expect(handlers.close).toHaveBeenCalledWith(1006, "");
expect(socket?.close).not.toHaveBeenCalled();
});
it("clears the opening deadline when the client closes the socket", async () => {
const handlers = createHandlers();
const socketAdapter = createBrowserGatewaySocket("wss://gateway.example", handlers);
const socket = sockets[0];
socketAdapter.close(1000, "stopped");
await vi.advanceTimersByTimeAsync(DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS);
expect(socket?.close).toHaveBeenCalledWith(1000, "stopped");
expect(handlers.error).not.toHaveBeenCalled();
});
});
+54 -7
View File
@@ -1,6 +1,7 @@
import type {
GatewayProtocolSocket,
GatewayProtocolSocketHandlers,
import {
DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS,
type GatewayProtocolSocket,
type GatewayProtocolSocketHandlers,
} from "@openclaw/gateway-client/browser";
export function createBrowserGatewaySocket(
@@ -8,13 +9,59 @@ export function createBrowserGatewaySocket(
handlers: GatewayProtocolSocketHandlers,
): GatewayProtocolSocket {
const socket = new WebSocket(url);
socket.addEventListener("open", handlers.open);
let opening = true;
let openingTimedOut = false;
let openingTimer: ReturnType<typeof setTimeout> | undefined;
const finishOpening = () => {
opening = false;
if (openingTimer !== undefined) {
clearTimeout(openingTimer);
openingTimer = undefined;
}
};
socket.addEventListener("open", () => {
finishOpening();
handlers.open();
});
socket.addEventListener("message", (event) => handlers.message(String(event.data ?? "")));
socket.addEventListener("close", (event) => handlers.close(event.code, event.reason ?? ""));
socket.addEventListener("error", () => handlers.error(new Error("websocket error")));
socket.addEventListener("close", (event) => {
finishOpening();
handlers.close(event.code, event.reason ?? "");
});
socket.addEventListener("error", () => {
finishOpening();
if (!openingTimedOut) {
handlers.error(new Error("websocket error"));
}
});
// The protocol challenge timer starts after `open`. Bound the browser's
// opening phase to the same default preauth budget used by the Node client.
openingTimer = setTimeout(() => {
openingTimer = undefined;
if (!opening) {
return;
}
opening = false;
openingTimedOut = true;
try {
handlers.error(
new Error(
`gateway websocket opening timed out after ${DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS}ms`,
),
);
} finally {
socket.close();
}
}, DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS);
return {
isOpen: () => socket.readyState === WebSocket.OPEN,
send: (data) => socket.send(data),
close: (code, reason) => socket.close(code, reason),
close: (code, reason) => {
finishOpening();
socket.close(code, reason);
},
};
}