diff --git a/packages/gateway-client/src/browser.ts b/packages/gateway-client/src/browser.ts index b1e199351f16..beabbb43854d 100644 --- a/packages/gateway-client/src/browser.ts +++ b/packages/gateway-client/src/browser.ts @@ -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"; diff --git a/ui/src/api/gateway-browser-socket.test.ts b/ui/src/api/gateway-browser-socket.test.ts new file mode 100644 index 000000000000..8fcae0ad77b8 --- /dev/null +++ b/ui/src/api/gateway-browser-socket.test.ts @@ -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(); + 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(); + }); +}); diff --git a/ui/src/api/gateway-browser-socket.ts b/ui/src/api/gateway-browser-socket.ts index c09e781add62..1b4c98602fa4 100644 --- a/ui/src/api/gateway-browser-socket.ts +++ b/ui/src/api/gateway-browser-socket.ts @@ -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 | 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); + }, }; }