Fix gateway reconnect lock after unexpected close

This commit is contained in:
George Pickett
2026-02-26 17:53:34 -08:00
parent d2f96c2177
commit 7786f92d3b
2 changed files with 169 additions and 21 deletions
+35 -21
View File
@@ -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;
}
@@ -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");
});
});