From 5c7b3a0e5e83c8fceb9e20cef417ce9775e7a7b3 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 9 Jun 2026 12:57:21 -0700 Subject: [PATCH] FN-6116: clear terminal event buffers after subscriber delivery Prevent stale terminal connection metadata from replaying after it has already been delivered. - only retain scrollback and connected payloads when no listeners are registered yet - clear buffered terminal events after live subscribers receive them - add regression coverage for scrollback and connected replay behavior Files changed: .../app/hooks/__tests__/useTerminal.test.ts | 50 ++++++++++++++++++++++ packages/dashboard/app/hooks/useTerminal.ts | 23 ++++++---- 2 files changed, 65 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-6116 Fusion-Task-Lineage: 5876fe24-2e90-4ef1-9e8b-5abce40555b4 --- .../app/hooks/__tests__/useTerminal.test.ts | 50 +++++++++++++++++++ packages/dashboard/app/hooks/useTerminal.ts | 23 ++++++--- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/packages/dashboard/app/hooks/__tests__/useTerminal.test.ts b/packages/dashboard/app/hooks/__tests__/useTerminal.test.ts index d9e16e392c..5411fc1657 100644 --- a/packages/dashboard/app/hooks/__tests__/useTerminal.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTerminal.test.ts @@ -402,6 +402,56 @@ describe("useTerminal", () => { expect(onData).toHaveBeenCalledTimes(1); }); + it("clears scrollback buffer after direct delivery to subscribers", () => { + const { result } = renderHook(() => useTerminal("test-session-123")); + const firstSubscription = vi.fn(); + const secondSubscription = vi.fn(); + + let unsubscribeFirst: (() => void) | undefined; + act(() => { + unsubscribeFirst = result.current.onScrollback(firstSubscription); + }); + + act(() => { + MockWebSocket.instances[0].emitMessage({ type: "scrollback", data: "initial prompt$ " }); + }); + + expect(firstSubscription).toHaveBeenCalledTimes(1); + expect(firstSubscription).toHaveBeenCalledWith("initial prompt$ "); + + act(() => { + unsubscribeFirst?.(); + result.current.onScrollback(secondSubscription); + }); + + expect(secondSubscription).not.toHaveBeenCalled(); + }); + + it("clears connected buffer after direct delivery to subscribers", () => { + const { result } = renderHook(() => useTerminal("test-session-123")); + const firstSubscription = vi.fn(); + const secondSubscription = vi.fn(); + + let unsubscribeFirst: (() => void) | undefined; + act(() => { + unsubscribeFirst = result.current.onConnect(firstSubscription); + }); + + act(() => { + MockWebSocket.instances[0].emitMessage({ type: "connected", shell: "/bin/bash", cwd: "/project" }); + }); + + expect(firstSubscription).toHaveBeenCalledTimes(1); + expect(firstSubscription).toHaveBeenCalledWith({ shell: "/bin/bash", cwd: "/project" }); + + act(() => { + unsubscribeFirst?.(); + result.current.onConnect(secondSubscription); + }); + + expect(secondSubscription).not.toHaveBeenCalled(); + }); + it("clears buffered scrollback after first replay to prevent duplicate delivery", () => { const { result } = renderHook(() => useTerminal("test-session-123")); diff --git a/packages/dashboard/app/hooks/useTerminal.ts b/packages/dashboard/app/hooks/useTerminal.ts index 3d3e548a42..78e88a8bbb 100644 --- a/packages/dashboard/app/hooks/useTerminal.ts +++ b/packages/dashboard/app/hooks/useTerminal.ts @@ -353,18 +353,25 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe break; case "scrollback": if (msg.data) { - // Buffer scrollback for late subscribers - buffer.scrollback = msg.data; - onScrollbackCallbacksRef.current.forEach((cb) => cb(msg.data!)); + // Buffer scrollback only when no subscribers are registered yet + if (onScrollbackCallbacksRef.current.size === 0) { + buffer.scrollback = msg.data; + } else { + onScrollbackCallbacksRef.current.forEach((cb) => cb(msg.data!)); + buffer.scrollback = null; + } } break; case "connected": if (msg.shell && msg.cwd) { - // Buffer connected info for late subscribers - buffer.connected = { shell: msg.shell!, cwd: msg.cwd! }; - onConnectCallbacksRef.current.forEach((cb) => - cb({ shell: msg.shell!, cwd: msg.cwd! }) - ); + const connectedInfo = { shell: msg.shell!, cwd: msg.cwd! }; + // Buffer connected info only when no subscribers are registered yet + if (onConnectCallbacksRef.current.size === 0) { + buffer.connected = connectedInfo; + } else { + onConnectCallbacksRef.current.forEach((cb) => cb(connectedInfo)); + buffer.connected = null; + } } break; case "exit":