diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts new file mode 100644 index 00000000000..ee52c423d45 --- /dev/null +++ b/ui/src/ui/app-gateway.node.test.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { connectGateway } from "./app-gateway.ts"; + +type GatewayClientMock = { + start: ReturnType; + stop: ReturnType; + 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(), + execApprovalQueue: [], + execApprovalError: null, + } as unknown as Parameters[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"); + }); +}); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 4cfa01134e9..12b2c7b6d9e 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -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[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) {