From ad9e24290af3f38c59d3d1cfbb6aee15f57be510 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 3 Nov 2026 20:41:05 +0800 Subject: [PATCH] fix(browser): recheck eligibility before reconnecting --- CHANGELOG.md | 2 ++ src/browser-hub.ts | 5 ++++- test/browser.test.ts | 52 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96c8df1..0f0b54e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to `@openclaw/libterminal` will be documented in this file. ### Fixed +- Recheck whether the application still needs a terminal hub connection when a delayed reconnect fires. + - Split large Worker output acknowledgements into decodable chunks so flow-control accounting stays valid above one MiB. - Complete fanout delivery and teardown before event callbacks, preserving output order during reentrant publication and closing all subscribers when callbacks throw. diff --git a/src/browser-hub.ts b/src/browser-hub.ts index 7bbcd24..ce7311d 100644 --- a/src/browser-hub.ts +++ b/src/browser-hub.ts @@ -181,7 +181,10 @@ export class TerminalHubClient { } this.reconnectTimer = setTimeout(() => { this.reconnectTimer = undefined; - this.connect(); + // The application's need for a connection can change during the delay. + if (this.shouldReconnect() && !this.closedByCaller) { + this.connect(); + } }, this.options.reconnectDelayMs ?? 1500); } diff --git a/test/browser.test.ts b/test/browser.test.ts index 749b28a..435c408 100644 --- a/test/browser.test.ts +++ b/test/browser.test.ts @@ -504,6 +504,58 @@ describe("TerminalHubClient", () => { } }); + it.each(["unused", "throws", "closes"])( + "rechecks reconnect eligibility when the delayed predicate %s", + async (mode) => { + vi.useFakeTimers(); + const sockets: TestTerminalHubSocket[] = []; + const error = new Error("reconnect predicate failed"); + const onError = vi.fn(); + let delayed = false; + const client = new TerminalHubClient({ + url: "wss://terminal.example", + reconnectDelayMs: 10, + shouldReconnect: () => { + if (!delayed) { + return true; + } + if (mode === "throws") { + throw error; + } + if (mode === "closes") { + client.close(); + return true; + } + return false; + }, + onError, + socketFactory: () => { + const socket = new TestTerminalHubSocket(); + sockets.push(socket); + return socket; + }, + }); + try { + client.connect(); + sockets[0].open(); + sockets[0].emitClose(); + expect(vi.getTimerCount()).toBe(1); + delayed = true; + + await vi.advanceTimersByTimeAsync(10); + + expect(sockets).toHaveLength(1); + expect(vi.getTimerCount()).toBe(0); + if (mode === "throws") { + expect(onError).toHaveBeenCalledWith(error); + } + } finally { + client.close(); + vi.useRealTimers(); + } + }, + ); + it("does not let a stalled prior socket block frames after reconnecting", async () => { const sockets: TestTerminalHubSocket[] = []; const frames: string[] = [];