From 921cff1ba7dce3f531c81a3a8fa99d849f02240a Mon Sep 17 00:00:00 2001 From: Tailong Wu <74086437+tailong-wu@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:16:52 +0800 Subject: [PATCH 1/2] fix(terminal): stop repeated history refresh replays --- src/web/public/app.js | 30 ++++-- test/opencode-resize.test.ts | 53 +++++++++- test/terminal-refresh-routing.test.ts | 141 ++++++++++++++++++++++++++ test/terminal-scroll-intent.test.ts | 4 +- 4 files changed, 217 insertions(+), 11 deletions(-) create mode 100644 test/terminal-refresh-routing.test.ts diff --git a/src/web/public/app.js b/src/web/public/app.js index 52e97b149..02d66b802 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -684,6 +684,9 @@ class CodemanApp { this._loadBufferQueue = null; // queued SSE events during buffer load this._bufferLoadSeq = 0; this._bufferLoadOwner = null; + // Coalesce repeated needsRefresh signals so full-history fetches cannot + // overlap their destructive clear-and-replay phase. + this._terminalRefreshSessionId = null; // Flicker filter state (buffers output after screen clears) this.flickerFilterBuffer = ''; @@ -1859,7 +1862,11 @@ class CodemanApp { this._onSessionTerminal(data); } _onSSENeedsRefresh(data) { - if (this._wsReady && this._wsSessionId === data?.id) return; + // Session-scoped refreshes for background tabs must not repaint the active + // terminal. Anonymous refreshes come from SSE backpressure recovery; when + // the active WS is complete, that duplicate transport has nothing to repair. + if (data?.id && data.id !== this.activeSessionId) return; + if (this._wsReady && this._wsSessionId === this.activeSessionId) return; this._onSessionNeedsRefresh(data); } _onSSEClearTerminal(data) { @@ -2354,13 +2361,16 @@ class CodemanApp { } } - async _onSessionNeedsRefresh() { + async _onSessionNeedsRefresh(data = {}) { // Server sends this after SSE backpressure clears — terminal data was dropped, // so reload the buffer to recover from any display corruption. - if (!this.activeSessionId || !this.terminal) return; - // Skip if buffer load already in progress — avoids competing clear+rewrite cycles - if (this._isLoadingBuffer) return; - const sessionId = this.activeSessionId; + const sessionId = data?.id || this.activeSessionId; + if (!sessionId || sessionId !== this.activeSessionId || !this.terminal) return; + // Skip if another buffer load or refresh is already in progress. Without a + // refresh-specific guard, repeated SSE drain signals can overlap multiple + // fetch -> clear -> replay cycles and visibly loop through old history. + if (this._isLoadingBuffer || this._terminalRefreshSessionId === sessionId) return; + this._terminalRefreshSessionId = sessionId; try { // Recovery should restore the WHOLE picture, so ask for full history // rather than a tail. Measured on a 900-line shell pane: the tail rewrite @@ -2410,6 +2420,12 @@ class CodemanApp { } } catch (err) { console.error('needsRefresh reload failed:', err); + } finally { + // A tab switch can release this slot for the new active session while the + // old fetch is still resolving. Never let that stale request clear its lock. + if (this._terminalRefreshSessionId === sessionId) { + this._terminalRefreshSessionId = null; + } } } @@ -3468,6 +3484,7 @@ class CodemanApp { this._isLoadingBuffer = false; this._loadBufferQueue = null; this._bufferLoadOwner = null; + this._terminalRefreshSessionId = null; // Abort any in-flight chunkedTerminalWrite (SSE reconnect reloads buffers) this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1; // Preserve local echo overlay text across SSE reconnect — just hide until @@ -5291,6 +5308,7 @@ class CodemanApp { this._isLoadingBuffer = false; this._loadBufferQueue = null; this._bufferLoadOwner = null; + this._terminalRefreshSessionId = null; // Abort any in-flight chunkedTerminalWrite from the previous session. // Without this, old rAF-scheduled chunks continue writing stale data // into the terminal, interleaving with the new session's buffer. diff --git a/test/opencode-resize.test.ts b/test/opencode-resize.test.ts index af330777c..f1de97f81 100644 --- a/test/opencode-resize.test.ts +++ b/test/opencode-resize.test.ts @@ -225,10 +225,11 @@ describe('OpenCode session initial resize', () => { await route.continue(); }); - // Dispatch the needsRefresh event directly on the EventSource - // (this is how the server sends SSE events — as named events) + // Exercise the SSE fallback explicitly. With an active WebSocket this same + // frame is a duplicate and must be ignored by the routing guard. await page.evaluate((sid: string) => { - const app = (window as unknown as { app: { eventSource: EventSource } }).app; + const app = (window as unknown as { app: { eventSource: EventSource; _wsReady: boolean } }).app; + app._wsReady = false; if (app.eventSource) { const event = new MessageEvent('session:needsRefresh', { data: JSON.stringify({ id: sid }), @@ -250,6 +251,52 @@ describe('OpenCode session initial resize', () => { await fetch(`/api/sessions/${sid}`, { method: 'DELETE' }); }, sessionId); }); + + it('does not replay history for duplicate or background SSE refreshes while WS is active', async () => { + ({ context, page } = await freshPage()); + await navigateAndWait(page); + + const sessionId = await page.evaluate(async () => { + const res = await fetch('/api/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workingDir: '/tmp', name: 'refresh-routing-test' }), + }); + const body = await res.json(); + const data = body.data ?? body; + return data.id ?? data.session?.id; + }); + + await page.evaluate(async (sid: string) => { + const app = (window as unknown as { app: { selectSession: (id: string) => Promise } }).app; + await app.selectSession(sid); + }, sessionId); + await page.waitForFunction(() => (window as unknown as { app: { _wsReady: boolean } }).app._wsReady, undefined, { + timeout: 5000, + }); + + const terminalRequests: string[] = []; + await page.route('**/api/sessions/*/terminal*', async (route) => { + terminalRequests.push(route.request().url()); + await route.continue(); + }); + + await page.evaluate(() => { + const app = (window as unknown as { app: { eventSource: EventSource } }).app; + app.eventSource.dispatchEvent( + new MessageEvent('session:needsRefresh', { data: JSON.stringify({ id: 'background-session' }) }) + ); + app.eventSource.dispatchEvent(new MessageEvent('session:needsRefresh', { data: '{}' })); + }); + await page.waitForTimeout(300); + + expect(terminalRequests).toEqual([]); + + await page.unroute('**/api/sessions/*/terminal*'); + await page.evaluate(async (sid: string) => { + await fetch(`/api/sessions/${sid}`, { method: 'DELETE' }); + }, sessionId); + }); }); describe('OpenCode close modal text', () => { diff --git a/test/terminal-refresh-routing.test.ts b/test/terminal-refresh-routing.test.ts new file mode 100644 index 000000000..adcf7ea14 --- /dev/null +++ b/test/terminal-refresh-routing.test.ts @@ -0,0 +1,141 @@ +/** + * @fileoverview Regression coverage for terminal refresh routing and coalescing. + * + * `session:needsRefresh` has two sources: a session-scoped signal and an + * anonymous SSE backpressure recovery signal. Neither may replay unrelated + * history, duplicate the active WebSocket's complete stream, or start a second + * clear-and-replay cycle while one is already in flight. + */ +import { readFileSync } from 'node:fs'; +import { performance } from 'node:perf_hooks'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +let fetchImpl: typeof fetch = vi.fn(); + +function loadCodemanAppClass() { + const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'); + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8'); + const context = vm.createContext({ + console, + performance, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn(), + HTMLCanvasElement: class HTMLCanvasElement {}, + WebSocket: { OPEN: 1 }, + fetch: (...args: Parameters) => fetchImpl(...args), + document: { addEventListener: vi.fn() }, + localStorage: { + length: 0, + key: vi.fn(), + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + MobileDetection: {}, + }); + vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context); + return (context as { __CodemanApp: new () => unknown }).__CodemanApp; +} + +const CodemanApp = loadCodemanAppClass(); + +type RefreshApp = { + activeSessionId: string | null; + _wsReady: boolean; + _wsSessionId: string | null; + _isLoadingBuffer: boolean; + _terminalRefreshSessionId: string | null; + _onSSENeedsRefresh: (data?: { id?: string }) => void; + _onSessionNeedsRefresh: (data?: { id?: string }) => Promise; + [key: string]: unknown; +}; + +function appFromPrototype(): RefreshApp { + return Object.create((CodemanApp as { prototype: object }).prototype) as RefreshApp; +} + +describe('terminal refresh routing', () => { + it('ignores a refresh emitted for a background session', () => { + const app = appFromPrototype(); + app.activeSessionId = 'active'; + app._wsReady = false; + app._wsSessionId = null; + app._onSessionNeedsRefresh = vi.fn(); + + app._onSSENeedsRefresh({ id: 'background' }); + + expect(app._onSessionNeedsRefresh).not.toHaveBeenCalled(); + }); + + it('ignores anonymous SSE backpressure recovery while the active WebSocket is complete', () => { + const app = appFromPrototype(); + app.activeSessionId = 'active'; + app._wsReady = true; + app._wsSessionId = 'active'; + app._onSessionNeedsRefresh = vi.fn(); + + app._onSSENeedsRefresh({}); + + expect(app._onSessionNeedsRefresh).not.toHaveBeenCalled(); + }); + + it('still refreshes the active session when SSE is the terminal transport', () => { + const app = appFromPrototype(); + app.activeSessionId = 'active'; + app._wsReady = false; + app._wsSessionId = null; + app._onSessionNeedsRefresh = vi.fn(); + + app._onSSENeedsRefresh({ id: 'active' }); + + expect(app._onSessionNeedsRefresh).toHaveBeenCalledOnce(); + expect(app._onSessionNeedsRefresh).toHaveBeenCalledWith({ id: 'active' }); + }); +}); + +describe('terminal refresh coalescing', () => { + it('allows only one clear-and-replay cycle per active session', async () => { + let releaseWrite!: () => void; + const writeBlocked = new Promise((resolveWrite) => { + releaseWrite = resolveWrite; + }); + fetchImpl = vi.fn(async () => ({ + json: async () => ({ data: { terminalBuffer: 'history', source: 'mux-full-history' } }), + })) as unknown as typeof fetch; + + const app = appFromPrototype(); + app.activeSessionId = 'active'; + app._isLoadingBuffer = false; + app._terminalRefreshSessionId = null; + app.terminal = { + buffer: { active: { baseY: 20, viewportY: 20 } }, + clear: vi.fn(), + reset: vi.fn(), + scrollToBottom: vi.fn(), + scrollToLine: vi.fn(), + }; + app._replayWouldShrinkBuffer = () => false; + app.chunkedTerminalWrite = vi.fn(async () => { + await writeBlocked; + }); + app._setHistoryTruncation = vi.fn(); + app.sendResize = vi.fn(); + app._localEchoOverlay = null; + + const first = app._onSessionNeedsRefresh({ id: 'active' }); + const duplicate = app._onSessionNeedsRefresh({ id: 'active' }); + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalled()); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + + releaseWrite(); + await Promise.all([first, duplicate]); + expect(app.chunkedTerminalWrite).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/terminal-scroll-intent.test.ts b/test/terminal-scroll-intent.test.ts index 717e34d63..c3668e056 100644 --- a/test/terminal-scroll-intent.test.ts +++ b/test/terminal-scroll-intent.test.ts @@ -191,7 +191,7 @@ describe('backpressure refresh keeps a reader in place (issue #259)', () => { it('is wired into the refresh path instead of an unconditional scrollToBottom', () => { const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8'); - const start = app.indexOf('async _onSessionNeedsRefresh()'); + const start = app.indexOf('async _onSessionNeedsRefresh('); expect(start).toBeGreaterThan(-1); const body = app.slice(start, app.indexOf('\n async _onSessionClearTerminal', start)); expect(body).toContain('computeRewriteScrollLine'); @@ -206,7 +206,7 @@ describe('backpressure refresh keeps a reader in place (issue #259)', () => { // falls back to the tail only when the full capture would shrink the buffer // (a repaint-mode pane keeps roughly one frame in tmux). const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8'); - const start = app.indexOf('async _onSessionNeedsRefresh()'); + const start = app.indexOf('async _onSessionNeedsRefresh('); const body = app.slice(start, app.indexOf('\n async _onSessionClearTerminal', start)); expect(body).toContain('terminal?full=1'); expect(body).toContain('this._replayWouldShrinkBuffer(data.terminalBuffer)'); From 2aef62c883f6f20340e1f208a41c4b43eae7c0d9 Mon Sep 17 00:00:00 2001 From: Tailong Wu <74086437+tailong-wu@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:44:16 +0800 Subject: [PATCH 2/2] fix(terminal): reject stale refresh generations --- src/web/public/app.js | 42 +++++++++++++++---- test/terminal-refresh-routing.test.ts | 60 +++++++++++++++++++++++++++ test/ws-state-lifecycle.test.ts | 22 ++++++++++ 3 files changed, 115 insertions(+), 9 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index 02d66b802..b78c65f58 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -685,8 +685,10 @@ class CodemanApp { this._bufferLoadSeq = 0; this._bufferLoadOwner = null; // Coalesce repeated needsRefresh signals so full-history fetches cannot - // overlap their destructive clear-and-replay phase. + // overlap their destructive clear-and-replay phase. The generation also + // invalidates an older activation of the same session after A -> B -> A. this._terminalRefreshSessionId = null; + this._terminalRefreshGeneration = 0; // Flicker filter state (buffers output after screen clears) this.flickerFilterBuffer = ''; @@ -2370,7 +2372,12 @@ class CodemanApp { // refresh-specific guard, repeated SSE drain signals can overlap multiple // fetch -> clear -> replay cycles and visibly loop through old history. if (this._isLoadingBuffer || this._terminalRefreshSessionId === sessionId) return; + const refreshGeneration = ++this._terminalRefreshGeneration; this._terminalRefreshSessionId = sessionId; + const isCurrentRefresh = () => + this._terminalRefreshGeneration === refreshGeneration && + this._terminalRefreshSessionId === sessionId && + this.activeSessionId === sessionId; try { // Recovery should restore the WHOLE picture, so ask for full history // rather than a tail. Measured on a 900-line shell pane: the tail rewrite @@ -2382,15 +2389,18 @@ class CodemanApp { // the same downgrade guard as the scroll-to-top re-pull and fall back to // the historical tail there, leaving that case exactly as it was. let res = await fetch(`/api/sessions/${sessionId}/terminal?full=1`); + if (!isCurrentRefresh()) return; let data = (await res.json())?.data ?? {}; + if (!isCurrentRefresh()) return; if (data.terminalBuffer && this._replayWouldShrinkBuffer(data.terminalBuffer)) { res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`); + if (!isCurrentRefresh()) return; data = (await res.json())?.data ?? {}; + if (!isCurrentRefresh()) return; } - // Bail on a tab switch mid-fetch: writing here would paint this session's - // history into the terminal the user is now looking at. The window is two - // fetches wide in the fallback case, so this guard is not optional. - if (this.activeSessionId !== sessionId) return; + // Bail on a tab switch (including A -> B -> A) mid-fetch: writing here + // would paint an earlier activation's history into the current terminal. + if (!isCurrentRefresh()) return; if (data.terminalBuffer) { // This refresh is SERVER-triggered, so a user quietly reading scrollback // did not ask for it and must not be dragged to the bottom by it (#259). @@ -2401,6 +2411,7 @@ class CodemanApp { this.terminal.clear(); this.terminal.reset(); await this.chunkedTerminalWrite(data.terminalBuffer); + if (!isCurrentRefresh()) return; // A tail fetch can be partial, and the banner would otherwise keep // describing the pre-refresh buffer (#258). this._setHistoryTruncation(sessionId, data); @@ -2421,9 +2432,12 @@ class CodemanApp { } catch (err) { console.error('needsRefresh reload failed:', err); } finally { - // A tab switch can release this slot for the new active session while the - // old fetch is still resolving. Never let that stale request clear its lock. - if (this._terminalRefreshSessionId === sessionId) { + // The generation check prevents an A -> B -> A stale request from + // releasing the newer A activation's lock. + if ( + this._terminalRefreshGeneration === refreshGeneration && + this._terminalRefreshSessionId === sessionId + ) { this._terminalRefreshSessionId = null; } } @@ -2719,6 +2733,7 @@ class CodemanApp { ws.onopen = () => { // Only mark ready if this is still the intended session if (this._ws === ws) { + const recoveredFromGap = (this._wsReconnectAttempts || 0) > 0; this._wsReady = true; this._wsState = 'connected'; this._wsReconnectAttempts = 0; @@ -2732,6 +2747,10 @@ class CodemanApp { // Flush any durably-queued input over the fresh socket (covers frames a // prior half-open socket silently dropped, and input typed while offline). this._onWsReady(sessionId); + // SSE is the output fallback while WS reconnects. Its anonymous drain + // frame may arrive after onopen, when the normal SSE wrapper ignores it; + // recover the gap explicitly once so dropped fallback bytes are restored. + if (recoveredFromGap) this._onSessionNeedsRefresh({ id: sessionId }); } }; @@ -3484,6 +3503,7 @@ class CodemanApp { this._isLoadingBuffer = false; this._loadBufferQueue = null; this._bufferLoadOwner = null; + this._terminalRefreshGeneration = (this._terminalRefreshGeneration || 0) + 1; this._terminalRefreshSessionId = null; // Abort any in-flight chunkedTerminalWrite (SSE reconnect reloads buffers) this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1; @@ -5276,8 +5296,11 @@ class CodemanApp { } } - // Close WebSocket for previous session (new one opens after buffer load) + // Close WebSocket for previous session (new one opens after buffer load). + // Reconnect attempts belong to that session; the next tab's first socket is + // a fresh connection and must not run the fallback-gap recovery path. this._disconnectWs(); + this._wsReconnectAttempts = 0; // Clear CJK input to prevent sending stale text to the wrong session. // Must go through CjkInput.clear() — a raw value wipe leaves the module's @@ -5308,6 +5331,7 @@ class CodemanApp { this._isLoadingBuffer = false; this._loadBufferQueue = null; this._bufferLoadOwner = null; + this._terminalRefreshGeneration = (this._terminalRefreshGeneration || 0) + 1; this._terminalRefreshSessionId = null; // Abort any in-flight chunkedTerminalWrite from the previous session. // Without this, old rAF-scheduled chunks continue writing stale data diff --git a/test/terminal-refresh-routing.test.ts b/test/terminal-refresh-routing.test.ts index adcf7ea14..856474d63 100644 --- a/test/terminal-refresh-routing.test.ts +++ b/test/terminal-refresh-routing.test.ts @@ -51,6 +51,7 @@ type RefreshApp = { _wsSessionId: string | null; _isLoadingBuffer: boolean; _terminalRefreshSessionId: string | null; + _terminalRefreshGeneration: number; _onSSENeedsRefresh: (data?: { id?: string }) => void; _onSessionNeedsRefresh: (data?: { id?: string }) => Promise; [key: string]: unknown; @@ -113,6 +114,7 @@ describe('terminal refresh coalescing', () => { app.activeSessionId = 'active'; app._isLoadingBuffer = false; app._terminalRefreshSessionId = null; + app._terminalRefreshGeneration = 0; app.terminal = { buffer: { active: { baseY: 20, viewportY: 20 } }, clear: vi.fn(), @@ -138,4 +140,62 @@ describe('terminal refresh coalescing', () => { await Promise.all([first, duplicate]); expect(app.chunkedTerminalWrite).toHaveBeenCalledTimes(1); }); + + it('rejects an older activation after switching A -> B -> A', async () => { + let releaseOldResponse!: (response: unknown) => void; + let releaseCurrentJson!: (payload: unknown) => void; + const oldResponse = new Promise((resolveResponse) => { + releaseOldResponse = resolveResponse; + }); + const currentJson = new Promise((resolveJson) => { + releaseCurrentJson = resolveJson; + }); + fetchImpl = vi + .fn() + .mockImplementationOnce(() => oldResponse) + .mockResolvedValueOnce({ json: () => currentJson }) as unknown as typeof fetch; + + const app = appFromPrototype(); + app.activeSessionId = 'active'; + app._isLoadingBuffer = false; + app._terminalRefreshSessionId = null; + app._terminalRefreshGeneration = 0; + app.terminal = { + buffer: { active: { baseY: 20, viewportY: 20 } }, + clear: vi.fn(), + reset: vi.fn(), + scrollToBottom: vi.fn(), + scrollToLine: vi.fn(), + }; + app._replayWouldShrinkBuffer = () => false; + app.chunkedTerminalWrite = vi.fn(async () => {}); + app._setHistoryTruncation = vi.fn(); + app.sendResize = vi.fn(); + app._localEchoOverlay = null; + + const stale = app._onSessionNeedsRefresh({ id: 'active' }); + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(1)); + + // Mirror the generation invalidation in selectSession for A -> B -> A. + app.activeSessionId = 'background'; + app._terminalRefreshGeneration++; + app._terminalRefreshSessionId = null; + app.activeSessionId = 'active'; + app._terminalRefreshGeneration++; + app._terminalRefreshSessionId = null; + + const current = app._onSessionNeedsRefresh({ id: 'active' }); + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2)); + + releaseOldResponse({ json: async () => ({ data: { terminalBuffer: 'stale history' } }) }); + await stale; + expect(app.terminal.clear).not.toHaveBeenCalled(); + expect(app._terminalRefreshSessionId).toBe('active'); + + releaseCurrentJson({ data: { terminalBuffer: 'current history', source: 'mux-full-history' } }); + await current; + expect(app.terminal.clear).toHaveBeenCalledOnce(); + expect(app.chunkedTerminalWrite).toHaveBeenCalledWith('current history'); + expect(app._terminalRefreshSessionId).toBeNull(); + }); }); diff --git a/test/ws-state-lifecycle.test.ts b/test/ws-state-lifecycle.test.ts index 39923f3ea..8f1a070b7 100644 --- a/test/ws-state-lifecycle.test.ts +++ b/test/ws-state-lifecycle.test.ts @@ -106,6 +106,7 @@ type LifecycleApp = { _wsReady: boolean; _wsReconnectAttempts: number | undefined; _ws: FakeWebSocket | null; + _onSessionNeedsRefresh: (data: { id: string }) => void; activeSessionId: string | null; }; @@ -134,6 +135,7 @@ function makeApp( app.isOnline = true; app.sendResize = vi.fn(); app._onWsReady = vi.fn(); + app._onSessionNeedsRefresh = vi.fn(); Object.assign(app, overrides); return { app: app as LifecycleApp, els }; } @@ -255,6 +257,26 @@ describe('WS reconnect backoff — attempts survive the _connectWs → _disconne expect(app._wsReconnectAttempts).toBe(0); expect(app._wsState).toBe('connected'); }); + + it('recovers terminal history once after a WS fallback gap, but not on initial open', () => { + const { CodemanApp, timers } = loadHarness(); + const { app } = makeApp(CodemanApp); + + app._connectWs('s1'); + const ws1 = FakeWebSocket.instances[0]; + ws1.readyState = 1; + ws1.onopen?.(); + expect(app._onSessionNeedsRefresh).not.toHaveBeenCalled(); + + ws1.onclose?.({ code: 1006, reason: '' }); + fireNextTimer(timers); + const ws2 = FakeWebSocket.instances[1]; + ws2.readyState = 1; + ws2.onopen?.(); + + expect(app._onSessionNeedsRefresh).toHaveBeenCalledOnce(); + expect(app._onSessionNeedsRefresh).toHaveBeenCalledWith({ id: 's1' }); + }); }); describe('WS upgrade cid — per-TAB identity (clientId:tabNonce)', () => {