mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user