fix: stop terminal history duplicating on reconnect, agree one size across viewers

Driving a shared PTY with two real WebSocket viewers against a live instance
surfaced two defects.

Duplicated history: the server replayed the entire scrollback on every
attach, and the client appended it into an xterm that still displayed that
history. Every reconnect — backgrounded tab, laptop sleep, heartbeat timeout
— therefore added a second copy, seen as the last prompt appearing twice.
TerminalService now tracks cumulative output and serves a resume: a client
reports the offset it has rendered and receives only the gap, or a full
replay flagged reset so it clears first.

Wrong size: resize was last-writer-wins. Measured — viewer A at 80x24 had
its shell report 200x50 the moment viewer B attached on a bigger screen,
while A still drew 80 columns, so wrapped lines and full-screen programs
broke for A. TerminalViewportRegistry sizes the PTY to the per-dimension
minimum across attached viewers, the rule terminal multiplexers settled on,
and gives room back when a viewer leaves. Viewers that have not yet measured
themselves do not constrain the size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-18 19:52:36 -07:00
parent 9f10767254
commit e4a53b6f47
8 changed files with 323 additions and 15 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix duplicated terminal history on reconnect and a wrong terminal size when two browsers share a session.
category: fix
dev: Two defects found by driving a shared PTY with two real WebSocket viewers. (1) The server replayed the whole scrollback on every attach and the client appended it into an xterm that still displayed that history, so any reconnect — backgrounded tab, sleep, heartbeat timeout — added a second copy (visible as a duplicated prompt). `TerminalService` now tracks cumulative output (`scrollbackSeq`) and `getScrollbackSince(sessionId, sinceSeq)` returns only the delta when the offset is inside the retained window, or the full buffer with `reset: true`; the client reports `sinceSeq` on connect and resets the terminal before writing a full replay. (2) Resize was last-writer-wins across viewers: A at 80x24 had its shell report 200x50 as soon as B attached at that size, while A still rendered 80 columns. `TerminalViewportRegistry` sizes the PTY to the per-dimension minimum across attached viewers (the tmux rule) and restores room when a viewer disconnects; viewers that have not reported a size do not constrain it.

View File

@@ -1852,7 +1852,17 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
writeToExpectedSession(data);
});
const unsubScrollback = onScrollback((data) => {
/*
FNXC:TerminalSharing 2026-08-19-02:45:
A scrollback frame is either a RESUME (only the bytes this terminal missed — append them) or a
full replay (reset first). Appending a full replay to a terminal that still shows that history is
what duplicated the screen — visibly, the last prompt twice — every time a backgrounded tab
reconnected.
*/
const unsubScrollback = onScrollback((data, reset) => {
if (reset && xtermInitializedRef.current === expectedSessionId) {
xtermRef.current?.reset();
}
writeToExpectedSession(data);
});

View File

@@ -17,7 +17,7 @@ export interface UseTerminalReturn {
/** Register a callback for connection events */
onConnect: (callback: (info: { shell: string; cwd: string }) => void) => () => void;
/** Register a callback for scrollback data */
onScrollback: (callback: (data: string) => void) => () => void;
onScrollback: (callback: (data: string, reset: boolean) => void) => () => void;
/** Manually reconnect */
reconnect: () => void;
/**
@@ -37,11 +37,15 @@ interface WebSocketMessage {
cwd?: string;
cols?: number;
rows?: number;
/** Cumulative output offset this scrollback frame brings the client up to. */
seq?: number;
/** True when the client must clear its terminal before writing this scrollback. */
reset?: boolean;
}
/** Buffered initial message types that must survive late subscriber registration */
interface BufferedMessages {
scrollback: string | null;
scrollback: { data: string; reset: boolean } | null;
connected: { shell: string; cwd: string } | null;
/** Accumulated data messages received before any subscriber registered */
data: string[];
@@ -122,7 +126,16 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
const onDataCallbacksRef = useRef<Set<(data: string) => void>>(new Set());
const onExitCallbacksRef = useRef<Set<(exitCode: number) => void>>(new Set());
const onConnectCallbacksRef = useRef<Set<(info: { shell: string; cwd: string }) => void>>(new Set());
const onScrollbackCallbacksRef = useRef<Set<(data: string) => void>>(new Set());
const onScrollbackCallbacksRef = useRef<Set<(data: string, reset: boolean) => void>>(new Set());
/*
FNXC:TerminalSharing 2026-08-19-02:45:
How much of this session's output the terminal has already rendered. Sent as `sinceSeq` on
reconnect so the server replays only the gap: without it every reattach (backgrounded tab, sleep,
heartbeat timeout) appended a second copy of the scrollback to a terminal that still displayed it,
which is the duplicated-prompt-on-return symptom. Reset when the session changes, because the
offset is meaningless against a different PTY.
*/
const renderedSeqRef = useRef<Map<string, number>>(new Map());
const onSessionInvalidCallbacksRef = useRef<Set<() => void>>(new Set());
// Buffer for initial messages received before subscribers are registered.
@@ -161,12 +174,12 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
return () => onConnectCallbacksRef.current.delete(callback);
}, []);
const onScrollback = useCallback((callback: (data: string) => void) => {
const onScrollback = useCallback((callback: (data: string, reset: boolean) => void) => {
onScrollbackCallbacksRef.current.add(callback);
// Replay buffered scrollback
const buffer = initialBufferRef.current;
if (buffer.scrollback) {
callback(buffer.scrollback);
callback(buffer.scrollback.data, buffer.scrollback.reset);
// Clear after replay to prevent stale re-delivery to subsequent subscribers
buffer.scrollback = null;
}
@@ -301,6 +314,15 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
if (projectId) {
wsUrl += `&projectId=${encodeURIComponent(projectId)}`;
}
/*
FNXC:TerminalSharing 2026-08-19-02:45:
Tell the server what this terminal has already rendered so a reconnect replays only the gap. On a
first connect there is no offset and the server sends the retained buffer with reset:true.
*/
const renderedSeq = renderedSeqRef.current.get(sessionId);
if (typeof renderedSeq === "number") {
wsUrl += `&sinceSeq=${renderedSeq}`;
}
// Carry the bearer token on the URL — WebSocket `new WebSocket` can't set
// an Authorization header. `appendTokenQuery` adds `fn_token=<token>`
@@ -352,6 +374,8 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
switch (msg.type) {
case "data":
if (msg.data) {
// Live output counts toward the rendered offset, same as replayed scrollback.
renderedSeqRef.current.set(sessionId, (renderedSeqRef.current.get(sessionId) ?? 0) + msg.data.length);
// Buffer data when no subscribers are registered yet
if (onDataCallbacksRef.current.size === 0) {
buffer.data.push(msg.data!);
@@ -359,17 +383,22 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
onDataCallbacksRef.current.forEach((cb) => cb(msg.data!));
}
break;
case "scrollback":
if (msg.data) {
// Buffer scrollback only when no subscribers are registered yet
case "scrollback": {
// FNXC:TerminalSharing 2026-08-19-02:45: record how far this session has been rendered so
// a later reconnect can ask for the gap instead of the whole buffer.
if (typeof msg.seq === "number") renderedSeqRef.current.set(sessionId, msg.seq);
const reset = msg.reset === true;
const data = msg.data ?? "";
if (data || reset) {
if (onScrollbackCallbacksRef.current.size === 0) {
buffer.scrollback = msg.data;
buffer.scrollback = { data, reset };
} else {
onScrollbackCallbacksRef.current.forEach((cb) => cb(msg.data!));
onScrollbackCallbacksRef.current.forEach((cb) => cb(data, reset));
buffer.scrollback = null;
}
}
break;
}
case "connected":
if (msg.shell && msg.cwd) {
const connectedInfo = { shell: msg.shell!, cwd: msg.cwd! };

View File

@@ -581,6 +581,56 @@ describe("TerminalService", () => {
is invisible with a single viewer and silently deletes a slice of everyone else's live stream once
a second one connects. flushPendingOutput() is what an attach uses instead.
*/
/*
FNXC:TerminalSharing 2026-08-19-02:45:
Every attach used to replay the whole scrollback. A reconnecting client (backgrounded tab, sleep,
heartbeat timeout) still displays that history, so the replay appended a second copy — the
duplicated-prompt-on-return symptom. A client reports the offset it rendered and gets only the gap.
*/
describe("scrollback resume", () => {
it("returns only the bytes a reattaching client missed", async () => {
const createResult = await service.createSession();
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
const id = createResult.session.id;
mockPtyProcess._onDataCallback?.("first output\n");
const initial = service.getScrollbackSince(id);
expect(initial).toEqual({ data: "first output\n", seq: 13, reset: true });
mockPtyProcess._onDataCallback?.("second output\n");
const resumed = service.getScrollbackSince(id, initial!.seq);
// Only the delta, and no reset — the client keeps the screen it already has.
expect(resumed).toEqual({ data: "second output\n", seq: 27, reset: false });
});
it("asks the client to reset when it is caught up (nothing to append)", async () => {
const createResult = await service.createSession();
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
const id = createResult.session.id;
mockPtyProcess._onDataCallback?.("output\n");
const at = service.getScrollbackSince(id)!.seq;
expect(service.getScrollbackSince(id, at)).toEqual({ data: "", seq: at, reset: false });
});
it("falls back to a full replay with reset for a first attach or a bogus offset", async () => {
const createResult = await service.createSession();
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
const id = createResult.session.id;
mockPtyProcess._onDataCallback?.("output\n");
// First attach (no offset), an offset from the future, and a negative one all reset.
expect(service.getScrollbackSince(id)?.reset).toBe(true);
expect(service.getScrollbackSince(id, 9999)?.reset).toBe(true);
expect(service.getScrollbackSince(id, -5)?.reset).toBe(true);
expect(service.getScrollbackSince(id, Number.NaN)?.reset).toBe(true);
});
it("returns null for an unknown session", () => {
expect(service.getScrollbackSince("no-such-session", 0)).toBeNull();
});
});
describe("shared-viewer output handoff", () => {
it("delivers queued output to existing subscribers instead of discarding it", async () => {
const existingViewer = vi.fn();

View File

@@ -0,0 +1,62 @@
import { describe, it, expect } from "vitest";
import { TerminalViewportRegistry } from "../terminal-viewport.js";
/*
FNXC:TerminalSharing 2026-08-19-02:45:
A PTY has one size but a shared session has many viewers. Applying every viewer's resize verbatim is
last-writer-wins: measured live, viewer A at 80x24 had its shell report 200x50 the moment viewer B
attached on a bigger screen, while A still drew 80 columns — wrapped lines and a broken cursor, with
full-screen programs worst hit. The agreed size is the per-dimension minimum, so content fits inside
every viewer.
*/
describe("TerminalViewportRegistry", () => {
it("sizes to the smallest attached viewer per dimension", () => {
const registry = new TerminalViewportRegistry();
registry.set("s1", "a", { cols: 80, rows: 50 });
expect(registry.effectiveSize("s1")).toEqual({ cols: 80, rows: 50 });
registry.set("s1", "b", { cols: 200, rows: 24 });
// Narrowest columns from A, shortest rows from B.
expect(registry.effectiveSize("s1")).toEqual({ cols: 80, rows: 24 });
});
it("gives room back when a viewer leaves", () => {
const registry = new TerminalViewportRegistry();
registry.set("s1", "a", { cols: 80, rows: 24 });
registry.set("s1", "b", { cols: 200, rows: 50 });
expect(registry.effectiveSize("s1")).toEqual({ cols: 80, rows: 24 });
registry.remove("s1", "a");
expect(registry.effectiveSize("s1")).toEqual({ cols: 200, rows: 50 });
});
it("reports no opinion until a viewer has actually measured itself", () => {
const registry = new TerminalViewportRegistry();
expect(registry.effectiveSize("s1")).toBeNull();
// A freshly attached socket must not pin the session to a placeholder size.
registry.set("s1", "a", { cols: 0, rows: 0 });
registry.set("s1", "b", { cols: Number.NaN, rows: 24 });
expect(registry.effectiveSize("s1")).toBeNull();
expect(registry.viewerCount("s1")).toBe(0);
});
it("keeps sessions independent and forgets a dead one", () => {
const registry = new TerminalViewportRegistry();
registry.set("s1", "a", { cols: 80, rows: 24 });
registry.set("s2", "a", { cols: 200, rows: 50 });
expect(registry.effectiveSize("s1")).toEqual({ cols: 80, rows: 24 });
expect(registry.effectiveSize("s2")).toEqual({ cols: 200, rows: 50 });
registry.clear("s1");
expect(registry.effectiveSize("s1")).toBeNull();
expect(registry.effectiveSize("s2")).toEqual({ cols: 200, rows: 50 });
});
it("re-measures rather than accumulating when one viewer resizes repeatedly", () => {
const registry = new TerminalViewportRegistry();
registry.set("s1", "a", { cols: 80, rows: 24 });
registry.set("s1", "a", { cols: 120, rows: 40 });
expect(registry.viewerCount("s1")).toBe(1);
expect(registry.effectiveSize("s1")).toEqual({ cols: 120, rows: 40 });
});
});

View File

@@ -33,6 +33,7 @@ import {
setOnProjectFirstCreated,
} from "./project-store-resolver.js";
import { getOrCreateScopedChatStore } from "./chat-project-services.js";
import { TerminalViewportRegistry } from "./terminal-viewport.js";
import { getTerminalService, STALE_SESSION_THRESHOLD_MS } from "./terminal-service.js";
import { WebSocketServer, type WebSocket } from "ws";
import { terminalSessionManager } from "./terminal.js";
@@ -2442,6 +2443,13 @@ export function setupTerminalWebSocket(
store: TaskStore,
options?: ServerOptions,
): void {
/*
FNXC:TerminalSharing 2026-08-19-02:45:
Per-session viewer sizes for the shared-PTY min-sizing rule. Server-scoped, matching the terminal
session registry's lifetime.
*/
const terminalViewports = new TerminalViewportRegistry();
const wss = new WebSocketServer({ noServer: true });
// Default terminal service for stale eviction (uses default store's root dir)
@@ -2553,11 +2561,19 @@ export function setupTerminalWebSocket(
must not clear the pending-output queue. Flush it to whoever is already attached FIRST (this
socket has not subscribed yet, so it cannot double-receive), then send the scrollback, which now
contains those bytes for the newcomer.
FNXC:TerminalSharing 2026-08-19-02:45:
`sinceSeq` lets a RE-attaching client (tab backgrounded, laptop asleep, heartbeat timeout) ask
for only what it missed. Replaying the whole buffer into a terminal that still shows it appended
a duplicate copy of history on every reconnect; `reset` tells the client when it must clear
first because the delta could not be served from the retained window.
*/
terminalService.flushPendingOutput(sessionId);
const scrollback = terminalService.getScrollback(sessionId);
if (scrollback) {
ws.send(JSON.stringify({ type: "scrollback", data: scrollback }));
const sinceSeqRaw = url.searchParams.get("sinceSeq");
const sinceSeq = sinceSeqRaw === null ? undefined : Number(sinceSeqRaw);
const resume = terminalService.getScrollbackSince(sessionId, sinceSeq);
if (resume && (resume.data || resume.reset)) {
ws.send(JSON.stringify({ type: "scrollback", data: resume.data, seq: resume.seq, reset: resume.reset }));
}
// Send connection info
@@ -2567,6 +2583,17 @@ export function setupTerminalWebSocket(
cwd: session.cwd,
}));
/*
FNXC:TerminalSharing 2026-08-19-02:45:
One PTY, one size, many viewers. Register this viewer so resizes agree on the SMALLEST attached
window instead of last-writer-wins, which left every other viewer rendering a stale column count.
*/
const viewerId = `${sessionId}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
const applyEffectiveViewport = () => {
const effective = terminalViewports.effectiveSize(sessionId);
if (effective) terminalService.resize(sessionId, effective.cols, effective.rows);
};
// Subscribe to data events
dataUnsub = terminalService.onData((id, data) => {
if (id === sessionId && isAlive) {
@@ -2640,7 +2667,8 @@ export function setupTerminalWebSocket(
break;
case "resize":
if (typeof msg.cols === "number" && typeof msg.rows === "number") {
terminalService.resize(sessionId, msg.cols, msg.rows);
terminalViewports.set(sessionId, viewerId, { cols: msg.cols, rows: msg.rows });
applyEffectiveViewport();
}
break;
case "ping":
@@ -2661,6 +2689,10 @@ export function setupTerminalWebSocket(
clearInterval(pingInterval);
if (dataUnsub) dataUnsub();
if (exitUnsub) exitUnsub();
// FNXC:TerminalSharing 2026-08-19-02:45: a departing viewer no longer constrains the size, so
// the remaining viewers get their room back.
terminalViewports.remove(sessionId, viewerId);
applyEffectiveViewport();
// Do NOT kill the PTY session on WebSocket close — the session should
// survive transient disconnects and modal close/reopen cycles. Sessions
// are cleaned up through explicit kill paths (tab close, restart, shell

View File

@@ -102,6 +102,15 @@ export interface TerminalSession {
lastActivityAt: Date;
shell: string;
scrollbackBuffer: string;
/*
FNXC:TerminalSharing 2026-08-19-02:45:
Total characters ever emitted by this PTY. scrollbackBuffer holds only the last MAX_SCROLLBACK_SIZE
of them, so the retained window is [scrollbackSeq - scrollbackBuffer.length, scrollbackSeq). A
reattaching client reports the offset it already rendered and receives only the delta, instead of
the whole buffer being replayed into a terminal that already shows it (which duplicated history —
visibly, the last prompt twice — on every reconnect after a tab was backgrounded).
*/
scrollbackSeq: number;
/**
* Pending output chunks awaiting flush to clients. Stored as an array
* (not a single concatenated string) so heavy bursts — e.g. a `pnpm test`
@@ -645,6 +654,7 @@ export class TerminalService extends EventEmitter {
lastActivityAt: new Date(),
shell,
scrollbackBuffer: "",
scrollbackSeq: 0,
outputChunks: [],
outputBytes: 0,
flushTimeout: null,
@@ -739,6 +749,7 @@ export class TerminalService extends EventEmitter {
// Always append to scrollback buffer so no output is lost
session.scrollbackBuffer += data;
session.scrollbackSeq += data.length;
if (session.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) {
session.scrollbackBuffer = session.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE);
}
@@ -1023,6 +1034,32 @@ export class TerminalService extends EventEmitter {
return session.scrollbackBuffer || null;
}
/**
* Resolve what a (re)attaching client still needs to render.
*
* FNXC:TerminalSharing 2026-08-19-02:45:
* `sinceSeq` is the cumulative offset the client last rendered for this session. When that offset
* still falls inside the retained scrollback window the client gets ONLY the bytes it missed and
* keeps its existing screen (`reset: false`). Otherwise — a first attach, a client that fell too
* far behind, or a bogus/rewound offset — it gets the whole retained buffer and is told to reset,
* because appending a full replay on top of an already-populated terminal is exactly what
* duplicated the visible history.
*/
getScrollbackSince(sessionId: string, sinceSeq?: number): { data: string; seq: number; reset: boolean } | null {
if (!this.isValidSessionId(sessionId)) return null;
const session = this.sessions.get(sessionId);
if (!session) return null;
const seq = session.scrollbackSeq;
const buffer = session.scrollbackBuffer;
const windowStart = seq - buffer.length;
if (typeof sinceSeq === "number" && Number.isFinite(sinceSeq) && sinceSeq >= windowStart && sinceSeq <= seq) {
return { data: buffer.slice(sinceSeq - windowStart), seq, reset: false };
}
return { data: buffer, seq, reset: true };
}
/**
* Get all active sessions
*/

View File

@@ -0,0 +1,81 @@
/*
FNXC:TerminalSharing 2026-08-19-03:05:
Several browsers can attach to one PTY, but a PTY has exactly ONE size. Each viewer reports the size
of its own window, so the naive "apply every resize" behaviour is last-writer-wins: when a second
browser attaches on a bigger screen the PTY grows to match it and the first viewer — still rendering
its old column count — sees wrapped lines and a broken cursor, with full-screen programs (vim, htop,
less) worst hit. Measured against a live instance: viewer A at 80x24, viewer B attaches at 200x50,
and A's shell reports 200x50 while A still draws 80 columns.
The fix is the one terminal multiplexers settled on: size the PTY to the SMALLEST attached viewer,
per dimension. Content then fits inside every viewer's window; viewers larger than the agreed size
simply have unused space, which is recoverable, whereas content wider than a viewer is not.
Only viewers that have actually reported a size participate — a freshly attached socket must not
pin the session to a placeholder size before its client has measured itself.
*/
export interface ViewportSize {
cols: number;
rows: number;
}
function isUsable(size: ViewportSize): boolean {
return Number.isFinite(size.cols) && Number.isFinite(size.rows) && size.cols > 0 && size.rows > 0;
}
/**
* Tracks each attached viewer's requested size per session and resolves the size the PTY should use.
*
* Deliberately pure and transport-free: the WebSocket layer owns viewer identity and lifetime, this
* owns only the arithmetic, so the min-sizing rule is testable without sockets or a real PTY.
*/
export class TerminalViewportRegistry {
private readonly sessions = new Map<string, Map<string, ViewportSize>>();
/** Record (or update) one viewer's requested size. */
set(sessionId: string, viewerId: string, size: ViewportSize): void {
if (!isUsable(size)) return;
let viewers = this.sessions.get(sessionId);
if (!viewers) {
viewers = new Map();
this.sessions.set(sessionId, viewers);
}
viewers.set(viewerId, { cols: Math.floor(size.cols), rows: Math.floor(size.rows) });
}
/** Drop a viewer, e.g. on socket close. The remaining viewers may then get more room back. */
remove(sessionId: string, viewerId: string): void {
const viewers = this.sessions.get(sessionId);
if (!viewers) return;
viewers.delete(viewerId);
if (viewers.size === 0) this.sessions.delete(sessionId);
}
/** Forget a session entirely (PTY exited). */
clear(sessionId: string): void {
this.sessions.delete(sessionId);
}
/** How many viewers have reported a size for this session. */
viewerCount(sessionId: string): number {
return this.sessions.get(sessionId)?.size ?? 0;
}
/**
* The size the PTY should be: the per-dimension minimum across attached viewers, or null when no
* viewer has reported one yet (in which case the session keeps whatever size it was created with).
*/
effectiveSize(sessionId: string): ViewportSize | null {
const viewers = this.sessions.get(sessionId);
if (!viewers || viewers.size === 0) return null;
let cols = Number.POSITIVE_INFINITY;
let rows = Number.POSITIVE_INFINITY;
for (const size of viewers.values()) {
cols = Math.min(cols, size.cols);
rows = Math.min(rows, size.rows);
}
return Number.isFinite(cols) && Number.isFinite(rows) ? { cols, rows } : null;
}
}