mirror of
https://github.com/grp06/openclaw-studio.git
synced 2026-08-14 00:47:51 +00:00
Fix gateway reconnect lock after unexpected close
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user