diff --git a/src/lib/gateway/GatewayClient.ts b/src/lib/gateway/GatewayClient.ts index a7fd056..38d0f42 100644 --- a/src/lib/gateway/GatewayClient.ts +++ b/src/lib/gateway/GatewayClient.ts @@ -175,51 +175,65 @@ export class GatewayClient { this.rejectConnect = reject; }); - this.client = new GatewayBrowserClient({ + const nextClient = new GatewayBrowserClient({ url: options.gatewayUrl, token: options.token, authScopeKey: options.authScopeKey, clientName: options.clientName, disableDeviceAuth: options.disableDeviceAuth, onHello: (hello) => { + if (this.client !== nextClient) return; this.lastHello = hello; this.updateStatus("connected"); this.resolveConnect?.(); this.clearConnectPromise(); }, - onEvent: (event) => { - this.eventHandlers.forEach((handler) => handler(event)); - }, - onClose: ({ code, reason }) => { - const connectFailed = - code === CONNECT_FAILED_CLOSE_CODE ? parseConnectFailedCloseReason(reason) : null; - const err = connectFailed - ? new GatewayResponseError({ - code: connectFailed.code, - message: connectFailed.message, - }) - : new Error(`Gateway closed (${code}): ${reason}`); - if (this.rejectConnect) { - this.rejectConnect(err); - this.clearConnectPromise(); - } - this.updateStatus(this.manualDisconnect ? "disconnected" : "connecting"); + onEvent: (event) => { + if (this.client !== nextClient) return; + this.eventHandlers.forEach((handler) => handler(event)); + }, + onClose: ({ code, reason }) => { + if (this.client !== nextClient) return; + const connectFailed = + code === CONNECT_FAILED_CLOSE_CODE ? parseConnectFailedCloseReason(reason) : null; + const err = connectFailed + ? new GatewayResponseError({ + code: connectFailed.code, + message: connectFailed.message, + }) + : new Error(`Gateway closed (${code}): ${reason}`); + if (this.rejectConnect) { + this.rejectConnect(err); + this.clearConnectPromise(); + } + if (!this.manualDisconnect) { + nextClient.stop(); + } + if (this.client === nextClient) { + this.client = null; + } + this.updateStatus("disconnected"); if (this.manualDisconnect) { console.info("Gateway disconnected."); } }, onGap: ({ expected, received }) => { + if (this.client !== nextClient) return; this.gapHandlers.forEach((handler) => handler({ expected, received })); }, }); - this.client.start(); + this.client = nextClient; + nextClient.start(); try { await this.pendingConnect; } catch (err) { - this.client.stop(); - this.client = null; + const activeClient = this.client; + activeClient?.stop(); + if (this.client === activeClient) { + this.client = null; + } this.updateStatus("disconnected"); throw err; } diff --git a/tests/unit/gatewayClient.reconnect-recovery.test.ts b/tests/unit/gatewayClient.reconnect-recovery.test.ts new file mode 100644 index 0000000..1dc08f6 --- /dev/null +++ b/tests/unit/gatewayClient.reconnect-recovery.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { GatewayClient } from "@/lib/gateway/GatewayClient"; + +type MockClientOptions = { + token?: unknown; + onHello?: (hello: unknown) => void; + onClose?: (info: { code: number; reason: string }) => void; +}; + +type MockInstance = { + opts: MockClientOptions; + stopped: boolean; +}; + +let instances: MockInstance[] = []; + +vi.mock("@/lib/gateway/openclaw/GatewayBrowserClient", () => { + class GatewayBrowserClient { + connected = false; + private index: number; + + constructor(opts: MockClientOptions) { + this.index = instances.length; + instances.push({ opts, stopped: false }); + } + + start() { + this.connected = true; + } + + stop() { + this.connected = false; + instances[this.index]!.stopped = true; + } + + request() { + return Promise.resolve({}); + } + } + + return { GatewayBrowserClient }; +}); + +afterEach(() => { + instances = []; +}); + +describe("GatewayClient reconnect recovery", () => { + it("allows a fresh connect after unexpected close", async () => { + const client = new GatewayClient(); + const statuses: string[] = []; + client.onStatus((status) => statuses.push(status)); + + const firstConnect = client.connect({ + gatewayUrl: "ws://example.invalid", + token: "old-token", + }); + const first = instances[0]; + if (!first) throw new Error("Expected first GatewayBrowserClient instance"); + + const onHelloFirst = first.opts.onHello; + const onCloseFirst = first.opts.onClose; + if (!onHelloFirst || !onCloseFirst) { + throw new Error("Expected first instance callbacks"); + } + + onHelloFirst({}); + await expect(firstConnect).resolves.toBeUndefined(); + + onCloseFirst({ code: 1012, reason: "upstream closed" }); + + expect(first.stopped).toBe(true); + expect(statuses.at(-1)).toBe("disconnected"); + + const secondConnect = client.connect({ + gatewayUrl: "ws://example.invalid", + token: "new-token", + }); + const second = instances[1]; + if (!second) throw new Error("Expected second GatewayBrowserClient instance"); + + expect(second.opts.token).toBe("new-token"); + + const onHelloSecond = second.opts.onHello; + if (!onHelloSecond) { + throw new Error("Expected second instance onHello callback"); + } + + onHelloSecond({}); + await expect(secondConnect).resolves.toBeUndefined(); + + expect(statuses.at(-1)).toBe("connected"); + }); + + it("ignores stale onClose callbacks from old instances", async () => { + const client = new GatewayClient(); + const statuses: string[] = []; + client.onStatus((status) => statuses.push(status)); + + const firstConnect = client.connect({ gatewayUrl: "ws://example.invalid" }); + const first = instances[0]; + if (!first) throw new Error("Expected first GatewayBrowserClient instance"); + + const onHelloFirst = first.opts.onHello; + const onCloseFirst = first.opts.onClose; + if (!onHelloFirst || !onCloseFirst) { + throw new Error("Expected first instance callbacks"); + } + + onHelloFirst({}); + await firstConnect; + + onCloseFirst({ code: 1012, reason: "upstream closed" }); + + const secondConnect = client.connect({ gatewayUrl: "ws://example.invalid" }); + const second = instances[1]; + if (!second) throw new Error("Expected second GatewayBrowserClient instance"); + + const onHelloSecond = second.opts.onHello; + if (!onHelloSecond) { + throw new Error("Expected second instance onHello callback"); + } + + onHelloSecond({}); + await secondConnect; + + const statusCountBeforeStaleClose = statuses.length; + onCloseFirst({ code: 1012, reason: "late stale close" }); + + expect(statuses.length).toBe(statusCountBeforeStaleClose); + expect(statuses.at(-1)).toBe("connected"); + }); +});