Skip to content

Commit 335d0bb

Browse files
dorlugasigalCopilot
andcommitted
fix(websocket): prevent duplicate lines on reconnect via replay snapshot
Server now sends scrollback (and any alt-screen re-entry) as a single `replay` message instead of an `output`. Client treats `replay` as an authoritative snapshot — drops pending writes and resets the terminal before applying it — so preserved xterm.js content no longer gets appended-onto on reconnect, fixing duplicate-line artefacts in plain shells. Also adds a stale-socket guard to onmessage handlers and updates the API docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f959345 commit 335d0bb

6 files changed

Lines changed: 134 additions & 53 deletions

File tree

packages/site/src/content/docs/api.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1033,11 +1033,12 @@ The preview proxies **one port at a time** via HTTP only. It does not proxy WebS
10331033

10341034
<!-- prettier-ignore -->
10351035
:::tip[Limitations when accessed through a tunnel]
1036+
10361037
- **Server-rendered apps** (Next.js SSR, Rails, Django) work best — the browser receives complete HTML with no extra fetches.
10371038
- **Client-side SPAs** may break if they make API calls to a different port or use hardcoded `localhost` URLs. Apps that use a single point with an internal reverse proxy (e.g., nginx proxying `/api` to a backend) work fine.
10381039
- **Multi-port architectures** (e.g., frontend on port 3000 making API calls to port 4000) won't work unless the app routes all requests through TermBeam's preview proxy (e.g., `/preview/4000/api` instead of `localhost:4000/api`).
10391040
- The upstream service must be listening on `127.0.0.1` (localhost) on the machine running TermBeam.
1040-
:::
1041+
:::
10411042

10421043
**Response:** The upstream response is streamed back with its original status code and headers.
10431044

@@ -1187,7 +1188,7 @@ The connection is closed after sending this message. Sending any non-auth messag
11871188
{ "type": "attach", "sessionId": "a1b2c3d4" }
11881189
```
11891190

1190-
After a successful `attached` response, the server immediately sends an `output` message containing the session's scrollback buffer (up to ~1,000,000 characters). When the buffer grows beyond this size, it is trimmed back to ~500,000 characters to keep memory usage bounded, allowing the client to display recent terminal output.
1191+
After a successful attach, the server sends a single `replay` message containing the session's sanitized scrollback buffer (up to ~1,000,000 characters; trimmed back to ~500,000 when it grows beyond that), followed by the `attached` confirmation. Clients should treat `replay` as an authoritative state snapshot and reset their terminal before applying it — this prevents duplicated content on reconnect when the terminal UI is preserved across socket lifecycles.
11911192

11921193
#### Send Input
11931194

@@ -1211,6 +1212,14 @@ The server validates resize dimensions: `cols` must be between 1–500 and `rows
12111212
{ "type": "output", "data": "..." }
12121213
```
12131214

1215+
#### Replay Snapshot
1216+
1217+
Sent on attach. Contains sanitized scrollback (and an alt-screen re-entry sequence when the session is currently in alt-screen). Clients should drop pending writes, reset their terminal, and write this payload — do not append it on top of preserved terminal content.
1218+
1219+
```json
1220+
{ "type": "replay", "data": "..." }
1221+
```
1222+
12141223
#### Attached Confirmation
12151224

12161225
```json

src/frontend/src/hooks/useAgentSocket.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,9 @@ export function useAgentSocket(options: UseAgentSocketOptions): UseAgentSocketRe
232232
};
233233

234234
ws.onmessage = (event) => {
235+
// Stale-socket guard: force-reconnect paths null `onclose` but may
236+
// leave `onmessage` live; ignore messages from a superseded socket.
237+
if (wsRef.current !== ws) return;
235238
if (!mountedRef.current) return;
236239

237240
let msg: WSServerMessage;
@@ -249,12 +252,16 @@ export function useAgentSocket(options: UseAgentSocketOptions): UseAgentSocketRe
249252
}
250253
setConnected(true);
251254
setReconnecting(false);
252-
253-
// Replay scrollback through parser to reconstruct message history
254-
if (msg.scrollback) {
255-
const events = parser.feed(msg.scrollback);
256-
processEvents(events, rawBufferRef, onRawOutputRef.current);
257-
}
255+
break;
256+
}
257+
case 'replay': {
258+
// Server-authored snapshot of agent terminal output. Preserve the
259+
// prior `attached.scrollback` behavior: feed through the parser so
260+
// message history can be reconstructed on first attach. (Note: on
261+
// reconnects within the same mount this can re-add events to the
262+
// store; agent reconnect dedup is tracked separately.)
263+
const events = parser.feed(msg.data);
264+
processEvents(events, rawBufferRef, onRawOutputRef.current);
258265
break;
259266
}
260267
case 'output': {

src/frontend/src/hooks/useTerminalSocket.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,10 @@ export function useTerminalSocket(options: UseTerminalSocketOptions): UseTermina
187187
};
188188

189189
ws.onmessage = (event) => {
190+
// Stale-socket guard: a force-reconnect path may null `onclose` but
191+
// leave `onmessage` live. Without this guard, late messages from the
192+
// old socket can interleave with replay/output from the new one.
193+
if (wsRef.current !== ws) return;
190194
if (!mountedRef.current || !terminal) return;
191195

192196
let msg: WSServerMessage;
@@ -212,9 +216,6 @@ export function useTerminalSocket(options: UseTerminalSocketOptions): UseTermina
212216
setConnected(true);
213217
setReconnecting(false);
214218
onConnected?.();
215-
if (msg.scrollback) {
216-
terminal.write(stripOscSequences(msg.scrollback));
217-
}
218219
// Send current dimensions so the PTY adjusts to this client's viewport
219220
if (terminal.cols && terminal.rows) {
220221
ws.send(JSON.stringify({ type: 'resize', cols: terminal.cols, rows: terminal.rows }));
@@ -228,6 +229,19 @@ export function useTerminalSocket(options: UseTerminalSocketOptions): UseTermina
228229
);
229230
break;
230231
}
232+
case 'replay': {
233+
// Authoritative state snapshot from the server. The xterm.js instance
234+
// is preserved across React re-renders / socket reconnects, so
235+
// appending the replay would duplicate visible content for plain
236+
// shells. Drop any pending writes and reset the terminal first.
237+
writeBuffer = '';
238+
rafPending = false;
239+
terminal.reset();
240+
if (msg.data) {
241+
terminal.write(stripOscSequences(msg.data));
242+
}
243+
break;
244+
}
231245
case 'output': {
232246
scheduleWrite(msg.data);
233247

src/frontend/src/types/websocket.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,18 @@ export interface WSOutputMessage {
2323
data: string;
2424
}
2525

26+
// Server-authored snapshot of the session's display state (sanitized scrollback
27+
// plus any alt-screen re-entry). Sent on attach. Clients should treat this as
28+
// authoritative: drop any pending writes, reset their terminal, then write the
29+
// payload. Prevents duplicate content on reconnect when xterm.js is preserved.
30+
export interface WSReplayMessage {
31+
type: 'replay';
32+
data: string;
33+
}
34+
2635
export interface WSAttachedMessage {
2736
type: 'attached';
2837
sessionId: string;
29-
scrollback?: string;
3038
}
3139

3240
export interface WSExitMessage {
@@ -67,6 +75,7 @@ export interface WSTunnelStatusMessage {
6775

6876
export type WSServerMessage =
6977
| WSOutputMessage
78+
| WSReplayMessage
7079
| WSAttachedMessage
7180
| WSExitMessage
7281
| WSErrorMessage

src/server/websocket.js

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,23 @@ function sanitizeForReplay(buf) {
2828
return buf;
2929
}
3030

31+
// Build an atomic replay payload for an attaching client: sanitized scrollback
32+
// followed by an alt-screen-enter sequence when the session is currently in
33+
// alt-screen. Sent as a single `replay` message so the client can reset its
34+
// terminal state once and write this snapshot, instead of appending it on top
35+
// of preserved xterm.js content (which caused duplicated lines on reconnect).
36+
function buildReplayPayload(session) {
37+
let payload = '';
38+
if (session.scrollbackBuf && session.scrollbackBuf.length > 0) {
39+
payload += sanitizeForReplay(session.scrollbackBuf);
40+
}
41+
if (session.inAltScreen) {
42+
const mode = session.altScreenMode || '1049';
43+
payload += `\x1b[?${mode}h`;
44+
}
45+
return payload;
46+
}
47+
3148
function recalcPtySize(session) {
3249
const now = Date.now();
3350
let activeCols = Infinity;
@@ -163,17 +180,15 @@ function setupWebSocket(wss, { auth, sessions, copilotService }) {
163180
ws._pendingResize = true;
164181
} else {
165182
session.clients.add(ws);
166-
if (session.scrollbackBuf.length > 0) {
167-
ws.send(
168-
JSON.stringify({ type: 'output', data: sanitizeForReplay(session.scrollbackBuf) }),
169-
);
183+
// Send sanitized scrollback (and any alt-screen re-entry) as a single
184+
// `replay` snapshot. Client treats `replay` as an authoritative full
185+
// state and resets its terminal before writing — preventing duplicate
186+
// lines on reconnect when the xterm.js instance is preserved.
187+
const payload = buildReplayPayload(session);
188+
if (payload.length > 0) {
189+
ws.send(JSON.stringify({ type: 'replay', data: payload }));
170190
}
171-
// After replaying scrollback (which has alt-screen sequences
172-
// stripped by sanitizeForReplay), re-enter alt-screen so xterm.js
173-
// uses the correct buffer before SIGWINCH triggers the app to repaint.
174191
if (session.inAltScreen) {
175-
const mode = session.altScreenMode || '1049';
176-
ws.send(JSON.stringify({ type: 'output', data: `\x1b[?${mode}h` }));
177192
ws._needsRedraw = true;
178193
}
179194
}
@@ -381,20 +396,13 @@ function setupWebSocket(wss, { auth, sessions, copilotService }) {
381396
recalcPtySize(attached);
382397
} else {
383398
attached.clients.add(ws);
384-
if (attached.scrollbackBuf.length > 0) {
385-
ws.send(
386-
JSON.stringify({
387-
type: 'output',
388-
data: sanitizeForReplay(attached.scrollbackBuf),
389-
}),
390-
);
399+
// Single atomic `replay` snapshot (scrollback + optional alt-screen
400+
// re-entry). Client resets its terminal before applying it.
401+
const payload = buildReplayPayload(attached);
402+
if (payload.length > 0) {
403+
ws.send(JSON.stringify({ type: 'replay', data: payload }));
391404
}
392-
// After replaying scrollback (which has alt-screen sequences
393-
// stripped), re-enter alt-screen so xterm.js uses the correct
394-
// buffer before SIGWINCH triggers the TUI to repaint.
395405
if (attached.inAltScreen) {
396-
const mode = attached.altScreenMode || '1049';
397-
ws.send(JSON.stringify({ type: 'output', data: `\x1b[?${mode}h` }));
398406
// Force SIGWINCH so the TUI repaints into the alt buffer
399407
recalcPtySize(attached);
400408
const targetCols = attached._lastCols || cols;

test/server/websocket.test.js

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ describe('WebSocket', () => {
301301
assert.strictEqual(err.message, 'Session not found');
302302
});
303303

304-
it('should send scrollback on attach for returning clients', () => {
304+
it('should send replay snapshot on attach for returning clients', () => {
305305
const session = createMockSession('s1', {
306306
scrollback: ['hello ', 'world'],
307307
hasHadClient: true,
@@ -312,9 +312,12 @@ describe('WebSocket', () => {
312312
wss._simulateConnection(ws);
313313
ws._simulateMessage({ type: 'attach', sessionId: 's1' });
314314

315-
const output = ws._sent.find((m) => m.type === 'output');
316-
assert.ok(output);
317-
assert.strictEqual(output.data, 'hello world');
315+
const replay = ws._sent.find((m) => m.type === 'replay');
316+
assert.ok(replay, 'should send a replay message');
317+
assert.strictEqual(replay.data, 'hello world');
318+
// No `output` for scrollback — preserved xterm.js content would duplicate
319+
const stray = ws._sent.find((m) => m.type === 'output');
320+
assert.strictEqual(stray, undefined, 'scrollback must not be sent as output');
318321
});
319322

320323
it('should defer first-ever client until first resize (size mismatch)', () => {
@@ -328,7 +331,9 @@ describe('WebSocket', () => {
328331

329332
// Client deferred — not yet in session.clients, scrollback NOT pre-cleared
330333
const output = ws._sent.find((m) => m.type === 'output');
334+
const replay = ws._sent.find((m) => m.type === 'replay');
331335
assert.strictEqual(output, undefined);
336+
assert.strictEqual(replay, undefined);
332337
assert.strictEqual(session.hasHadClient, true);
333338
assert.ok(!session.clients.has(ws));
334339
assert.strictEqual(ws._pendingResize, true);
@@ -367,9 +372,9 @@ describe('WebSocket', () => {
367372
assert.strictEqual(session.scrollbackBuf, 'correct-prompt'); // NOT cleared
368373
assert.strictEqual(session._resizes.length, 0); // no SIGWINCH
369374

370-
const output = ws._sent.find((m) => m.type === 'output');
371-
assert.ok(output);
372-
assert.strictEqual(output.data, 'correct-prompt');
375+
const replay = ws._sent.find((m) => m.type === 'replay');
376+
assert.ok(replay);
377+
assert.strictEqual(replay.data, 'correct-prompt');
373378
});
374379

375380
it('should add returning client to session on attach', () => {
@@ -396,7 +401,7 @@ describe('WebSocket', () => {
396401
assert.ok(!session.clients.has(ws));
397402
});
398403

399-
it('should send alt screen enter after replay when session is in alt screen', () => {
404+
it('should fold alt-screen enter into the replay payload when in alt screen', () => {
400405
const session = createMockSession('s1', { hasHadClient: true, scrollbackBuf: 'some output' });
401406
session.inAltScreen = true;
402407
session.altScreenMode = '1049';
@@ -406,10 +411,17 @@ describe('WebSocket', () => {
406411
wss._simulateConnection(ws);
407412
ws._simulateMessage({ type: 'attach', sessionId: 's1' });
408413

409-
// Alt-screen enter should come AFTER scrollback replay
410-
const replayIdx = ws._sent.findIndex((m) => m.type === 'output' && m.data !== '\x1b[?1049h');
411-
const altIdx = ws._sent.findIndex((m) => m.type === 'output' && m.data === '\x1b[?1049h');
412-
assert.ok(altIdx > replayIdx, 'alt-screen enter should come after replay');
414+
const replay = ws._sent.find((m) => m.type === 'replay');
415+
assert.ok(replay, 'should send a replay message');
416+
// Scrollback first, alt-screen enter appended at the end
417+
assert.ok(
418+
replay.data.endsWith('\x1b[?1049h'),
419+
'replay payload should end with alt-screen enter',
420+
);
421+
assert.ok(
422+
replay.data.startsWith('some output'),
423+
'replay payload should start with sanitized scrollback',
424+
);
413425
assert.strictEqual(ws._needsRedraw, true, 'should flag client for redraw');
414426
});
415427

@@ -423,11 +435,12 @@ describe('WebSocket', () => {
423435
wss._simulateConnection(ws);
424436
ws._simulateMessage({ type: 'attach', sessionId: 's1' });
425437

426-
const altMsg = ws._sent.find((m) => m.type === 'output' && m.data === '\x1b[?1047h');
427-
assert.ok(altMsg, 'should use mode 1047 for alt-screen enter');
438+
const replay = ws._sent.find((m) => m.type === 'replay');
439+
assert.ok(replay, 'should send a replay message');
440+
assert.ok(replay.data.endsWith('\x1b[?1047h'), 'should use mode 1047 for alt-screen enter');
428441
});
429442

430-
it('should not send alt screen enter when session is not in alt screen', () => {
443+
it('should not include alt screen enter when session is not in alt screen', () => {
431444
const session = createMockSession('s1', { hasHadClient: true, scrollbackBuf: 'hello' });
432445
session.inAltScreen = false;
433446
sessions._add(session);
@@ -436,11 +449,32 @@ describe('WebSocket', () => {
436449
wss._simulateConnection(ws);
437450
ws._simulateMessage({ type: 'attach', sessionId: 's1' });
438451

439-
const altScreenMsg = ws._sent.find((m) => m.type === 'output' && m.data === '\x1b[?1049h');
440-
assert.strictEqual(altScreenMsg, undefined, 'should not send alt screen enter');
452+
const replay = ws._sent.find((m) => m.type === 'replay');
453+
assert.ok(replay);
454+
assert.strictEqual(replay.data, 'hello', 'replay payload should be just scrollback');
455+
assert.ok(
456+
!replay.data.includes('\x1b[?1049h'),
457+
'replay payload must not include alt-screen enter',
458+
);
441459
assert.strictEqual(ws._needsRedraw, undefined, 'should not flag for redraw');
442460
});
443461

462+
it('should send replay with only alt-screen enter when scrollback is empty', () => {
463+
const session = createMockSession('s1', { hasHadClient: true });
464+
session.scrollbackBuf = '';
465+
session.inAltScreen = true;
466+
session.altScreenMode = '1049';
467+
sessions._add(session);
468+
469+
const ws = createMockWs();
470+
wss._simulateConnection(ws);
471+
ws._simulateMessage({ type: 'attach', sessionId: 's1' });
472+
473+
const replay = ws._sent.find((m) => m.type === 'replay');
474+
assert.ok(replay, 'should still send replay so client can re-enter alt-screen');
475+
assert.strictEqual(replay.data, '\x1b[?1049h');
476+
});
477+
444478
it('should force SIGWINCH via temporary resize on first resize after alt-screen reattach', () => {
445479
const session = createMockSession('s1', { hasHadClient: true, _lastCols: 80, _lastRows: 24 });
446480
session.inAltScreen = true;
@@ -1164,9 +1198,9 @@ describe('WebSocket', () => {
11641198
// Should enter the inAltScreen branch and create bounce timer
11651199
assert.ok(session._resizeBounceTimer, 'bounce timer should be set for alt-screen same-size');
11661200

1167-
// Should have sent alt-screen enter sequence
1168-
const altEnter = ws._sent.find((m) => m.type === 'output' && m.data.includes('\x1b[?1049h'));
1169-
assert.ok(altEnter, 'should send alt-screen enter');
1201+
// Should have sent alt-screen enter sequence (folded into replay payload)
1202+
const replay = ws._sent.find((m) => m.type === 'replay' && m.data.includes('\x1b[?1049h'));
1203+
assert.ok(replay, 'should send replay containing alt-screen enter');
11701204

11711205
// Wait for bounce timer to complete
11721206
await new Promise((r) => setTimeout(r, 80));

0 commit comments

Comments
 (0)