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
This commit is contained in:
gsxdsm
2026-06-09 12:57:21 -07:00
parent 17312c9fae
commit 5c7b3a0e5e
2 changed files with 65 additions and 8 deletions

View File

@@ -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"));

View File

@@ -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":