From 02dc0c87526f11ddec302ab13b73d40c513cfbfe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 21:09:21 +0100 Subject: [PATCH] fix(control-ui): stop websocket client on lifecycle teardown (#23422) Co-authored-by: floatinggball-design <262259579+floatinggball-design@users.noreply.github.com> --- CHANGELOG.md | 1 + ui/src/ui/app-lifecycle.node.test.ts | 44 ++++++++++++++++++++++++++++ ui/src/ui/app-lifecycle.ts | 5 ++++ 3 files changed, 50 insertions(+) create mode 100644 ui/src/ui/app-lifecycle.node.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ff1cb2aae..5419af7dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai - Security/Elevated: match `tools.elevated.allowFrom` against sender identities only (not recipient `ctx.To`), closing a recipient-token bypass for `/elevated` authorization. (#11022) Thanks @coygeek. - Webchat/Sessions: preserve external session routing metadata when internal `chat.send` turns run under `webchat`, so explicit channel-keyed sessions (for example Telegram) no longer get rewritten to `webchat` and misroute follow-up delivery. (#23258) Thanks @binary64. - Webchat/Sessions: preserve existing session `label` across `/new` and `/reset` rollovers so reset sessions remain discoverable in session history lists. (#23755) Thanks @ThunderStormer. +- Control UI/WebSocket: stop and clear the browser gateway client on UI teardown so remounts cannot leave orphan websocket clients that create duplicate active connections. (#23422) Thanks @floatinggball-design. - Config/Memory: allow `"mistral"` in `agents.defaults.memorySearch.provider` and `agents.defaults.memorySearch.fallback` schema validation. (#14934) Thanks @ThomsenDrake. - Security/Feishu: enforce ID-only allowlist matching for DM/group sender authorization, normalize Feishu ID prefixes during checks, and ignore mutable display names so display-name collisions cannot satisfy allowlist entries. This ships in the next npm release. Thanks @jiseoung for reporting. - Security/Group policy: harden `channels.*.groups.*.toolsBySender` matching by requiring explicit sender-key types (`id:`, `e164:`, `username:`, `name:`), preventing cross-identifier collisions across mutable/display-name fields while keeping legacy untyped keys on a deprecated ID-only path. This ships in the next npm release. Thanks @jiseoung for reporting. diff --git a/ui/src/ui/app-lifecycle.node.test.ts b/ui/src/ui/app-lifecycle.node.test.ts new file mode 100644 index 000000000..13fccdd86 --- /dev/null +++ b/ui/src/ui/app-lifecycle.node.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vitest"; +import { handleDisconnected } from "./app-lifecycle.ts"; + +function createHost() { + return { + basePath: "", + client: { stop: vi.fn() }, + connected: true, + tab: "chat", + assistantName: "OpenClaw", + assistantAvatar: null, + assistantAgentId: null, + chatHasAutoScrolled: false, + chatManualRefreshInFlight: false, + chatLoading: false, + chatMessages: [], + chatToolMessages: [], + chatStream: null, + logsAutoFollow: false, + logsAtBottom: true, + logsEntries: [], + popStateHandler: vi.fn(), + topbarObserver: { disconnect: vi.fn() } as unknown as ResizeObserver, + }; +} + +describe("handleDisconnected", () => { + it("stops and clears gateway client on teardown", () => { + const removeSpy = vi.spyOn(window, "removeEventListener").mockImplementation(() => undefined); + const host = createHost(); + const disconnectSpy = ( + host.topbarObserver as unknown as { disconnect: ReturnType } + ).disconnect; + + handleDisconnected(host as unknown as Parameters[0]); + + expect(removeSpy).toHaveBeenCalledWith("popstate", host.popStateHandler); + expect(host.client).toBeNull(); + expect(host.connected).toBe(false); + expect(disconnectSpy).toHaveBeenCalledTimes(1); + expect(host.topbarObserver).toBeNull(); + removeSpy.mockRestore(); + }); +}); diff --git a/ui/src/ui/app-lifecycle.ts b/ui/src/ui/app-lifecycle.ts index 414427141..36527c161 100644 --- a/ui/src/ui/app-lifecycle.ts +++ b/ui/src/ui/app-lifecycle.ts @@ -21,6 +21,8 @@ import type { Tab } from "./navigation.ts"; type LifecycleHost = { basePath: string; + client?: { stop: () => void } | null; + connected?: boolean; tab: Tab; assistantName: string; assistantAvatar: string | null; @@ -65,6 +67,9 @@ export function handleDisconnected(host: LifecycleHost) { stopNodesPolling(host as unknown as Parameters[0]); stopLogsPolling(host as unknown as Parameters[0]); stopDebugPolling(host as unknown as Parameters[0]); + host.client?.stop(); + host.client = null; + host.connected = false; detachThemeListener(host as unknown as Parameters[0]); host.topbarObserver?.disconnect(); host.topbarObserver = null;