fix: enhance connectGateway to handle stale clients on reconnect

This commit is contained in:
Nick Taylor
2026-02-13 23:38:41 -05:00
committed by Peter Steinberger
parent 51c4a0a9e9
commit 9228ef0856
2 changed files with 166 additions and 4 deletions

View File

@@ -0,0 +1,146 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { connectGateway } from "./app-gateway.ts";
type GatewayClientMock = {
start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
emitClose: (code: number, reason?: string) => void;
emitGap: (expected: number, received: number) => void;
emitEvent: (evt: { event: string; payload?: unknown; seq?: number }) => void;
};
const gatewayClientInstances: GatewayClientMock[] = [];
vi.mock("./gateway.ts", () => {
class GatewayBrowserClient {
readonly start = vi.fn();
readonly stop = vi.fn();
constructor(
private opts: {
onClose?: (info: { code: number; reason: string }) => void;
onGap?: (info: { expected: number; received: number }) => void;
onEvent?: (evt: { event: string; payload?: unknown; seq?: number }) => void;
},
) {
gatewayClientInstances.push({
start: this.start,
stop: this.stop,
emitClose: (code, reason) => {
this.opts.onClose?.({ code, reason: reason ?? "" });
},
emitGap: (expected, received) => {
this.opts.onGap?.({ expected, received });
},
emitEvent: (evt) => {
this.opts.onEvent?.(evt);
},
});
}
}
return { GatewayBrowserClient };
});
function createHost() {
return {
settings: {
gatewayUrl: "ws://127.0.0.1:18789",
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
},
password: "",
client: null,
connected: false,
hello: null,
lastError: null,
eventLogBuffer: [],
eventLog: [],
tab: "overview",
presenceEntries: [],
presenceError: null,
presenceStatus: null,
agentsLoading: false,
agentsList: null,
agentsError: null,
debugHealth: null,
assistantName: "OpenClaw",
assistantAvatar: null,
assistantAgentId: null,
sessionKey: "main",
chatRunId: null,
refreshSessionsAfterChat: new Set<string>(),
execApprovalQueue: [],
execApprovalError: null,
} as unknown as Parameters<typeof connectGateway>[0];
}
describe("connectGateway", () => {
beforeEach(() => {
gatewayClientInstances.length = 0;
});
it("ignores stale client onGap callbacks after reconnect", () => {
const host = createHost();
connectGateway(host);
const firstClient = gatewayClientInstances[0];
expect(firstClient).toBeDefined();
connectGateway(host);
const secondClient = gatewayClientInstances[1];
expect(secondClient).toBeDefined();
firstClient.emitGap(10, 13);
expect(host.lastError).toBeNull();
secondClient.emitGap(20, 24);
expect(host.lastError).toBe(
"event gap detected (expected seq 20, got 24); refresh recommended",
);
});
it("ignores stale client onEvent callbacks after reconnect", () => {
const host = createHost();
connectGateway(host);
const firstClient = gatewayClientInstances[0];
expect(firstClient).toBeDefined();
connectGateway(host);
const secondClient = gatewayClientInstances[1];
expect(secondClient).toBeDefined();
firstClient.emitEvent({ event: "presence", payload: { presence: [{ host: "stale" }] } });
expect(host.eventLogBuffer).toHaveLength(0);
secondClient.emitEvent({ event: "presence", payload: { presence: [{ host: "active" }] } });
expect(host.eventLogBuffer).toHaveLength(1);
expect(host.eventLogBuffer[0]?.event).toBe("presence");
});
it("ignores stale client onClose callbacks after reconnect", () => {
const host = createHost();
connectGateway(host);
const firstClient = gatewayClientInstances[0];
expect(firstClient).toBeDefined();
connectGateway(host);
const secondClient = gatewayClientInstances[1];
expect(secondClient).toBeDefined();
firstClient.emitClose(1005);
expect(host.lastError).toBeNull();
secondClient.emitClose(1005);
expect(host.lastError).toBe("disconnected (1005): no reason");
});
});

View File

@@ -122,14 +122,17 @@ export function connectGateway(host: GatewayHost) {
host.execApprovalQueue = [];
host.execApprovalError = null;
host.client?.stop();
host.client = new GatewayBrowserClient({
const previousClient = host.client;
const client = new GatewayBrowserClient({
url: host.settings.gatewayUrl,
token: host.settings.token.trim() ? host.settings.token : undefined,
password: host.password.trim() ? host.password : undefined,
clientName: "openclaw-control-ui",
mode: "webchat",
onHello: (hello) => {
if (host.client !== client) {
return;
}
host.connected = true;
host.lastError = null;
host.hello = hello;
@@ -147,18 +150,31 @@ export function connectGateway(host: GatewayHost) {
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
},
onClose: ({ code, reason }) => {
if (host.client !== client) {
return;
}
host.connected = false;
// Code 1012 = Service Restart (expected during config saves, don't show as error)
if (code !== 1012) {
host.lastError = `disconnected (${code}): ${reason || "no reason"}`;
}
},
onEvent: (evt) => handleGatewayEvent(host, evt),
onEvent: (evt) => {
if (host.client !== client) {
return;
}
handleGatewayEvent(host, evt);
},
onGap: ({ expected, received }) => {
if (host.client !== client) {
return;
}
host.lastError = `event gap detected (expected seq ${expected}, got ${received}); refresh recommended`;
},
});
host.client.start();
host.client = client;
previousClient?.stop();
client.start();
}
export function handleGatewayEvent(host: GatewayHost, evt: GatewayEventFrame) {