Skip to content

Commit 2390434

Browse files
fix(terminal): trimmed history no longer replays half of a control sequence
Byte-budget trimming cut terminal history on code point boundaries only, so the retained tail could begin inside a CSI or OSC sequence and replay its body as text after a reload or reattach. The trim now follows the terminal parser's state across what it drops. When the cut lands inside a sequence, the rest of that sequence goes too. The server history and the snapshot tail drop it outright; the client buffer keeps the bytes for terminals already mid-sequence and skips them for a terminal that replays from the start. The state machine mirrors libghostty-vt, including where a non-ASCII character ends a DCS, SOS, PM or APC string but stays inside an OSC, so output that was visible live is still retained. Fixes #36014 Co-Authored-By: Claude Fable 5.2 <[email protected]>
1 parent c229f2e commit 2390434

5 files changed

Lines changed: 229 additions & 32 deletions

File tree

‎apps/server/src/terminal/Manager.test.ts‎

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
ProviderDriverKind,
1414
ProviderInstanceId,
1515
retainedTerminalHistory,
16+
terminalSequenceEnd,
17+
terminalSequenceStateAfter,
1618
TerminalProviderInstanceNotFoundError,
1719
} from "@t3tools/contracts";
1820
import * as HostProcess from "@t3tools/shared/HostProcess";
@@ -337,7 +339,6 @@ function retainedHistory(text: string, maxLines: number, maxBytes = Infinity): s
337339
if (terminated) lines.pop();
338340
const retained = lines.slice(Math.max(0, lines.length - maxLines)).join("\n");
339341
const capped = terminated ? `${retained}\n` : retained;
340-
if (Buffer.byteLength(capped) <= maxBytes) return capped;
341342
const points = Array.from(capped);
342343
let start = points.length;
343344
let bytes = 0;
@@ -347,7 +348,11 @@ function retainedHistory(text: string, maxLines: number, maxBytes = Infinity): s
347348
bytes += next;
348349
start -= 1;
349350
}
350-
return points.slice(start).join("");
351+
const kept = points.slice(start).join("");
352+
if (maxLines <= 0 || maxBytes <= 0) return kept;
353+
// A cut inside a control sequence drops the rest of that sequence too.
354+
const state = terminalSequenceStateAfter(0, text, 0, text.length - kept.length);
355+
return kept.slice(terminalSequenceEnd(state, kept));
351356
}
352357

353358
it("preserves line and byte limits across arbitrary chunks, Unicode, ANSI sequences, and clear", () => {
@@ -365,6 +370,10 @@ it("preserves line and byte limits across arbitrary chunks, Unicode, ANSI sequen
365370
"\u001b[31m",
366371
"\u001b[0m",
367372
"\u001b]8;;url\u0007",
373+
"\u001b]0;",
374+
"\u001bP",
375+
"\u0007",
376+
"\u001b\\",
368377
"\ud83d",
369378
"\ude80",
370379
];
@@ -375,7 +384,8 @@ it("preserves line and byte limits across arbitrary chunks, Unicode, ANSI sequen
375384

376385
for (const maxBytes of [0, 3, 8, 64, Infinity]) {
377386
for (const maxLines of [0, 1, 3, 5, 5_000]) {
378-
let expected = retainedHistory("before\ninitial\n", maxLines, maxBytes);
387+
let full = "before\ninitial\n";
388+
let expected = retainedHistory(full, maxLines, maxBytes);
379389
const history = new TerminalManager.BoundedTerminalHistory(
380390
maxLines,
381391
"before\ninitial\n",
@@ -386,24 +396,30 @@ it("preserves line and byte limits across arbitrary chunks, Unicode, ANSI sequen
386396
for (let step = 0; step < 300; step += 1) {
387397
if (step % 73 === 0) {
388398
history.clear();
389-
expected = "";
390-
expect(history.value()).toBe(expected);
399+
full = "";
400+
expect(history.value()).toBe("");
391401
}
392402
const chunk = nextFragment() + nextFragment();
393403
history.append(chunk);
394-
expected = retainedHistory(expected + chunk, maxLines, maxBytes);
404+
full += chunk;
405+
expected = retainedHistory(full, maxLines, maxBytes);
395406
expect(history.value()).toBe(expected);
396407
}
397408
}
398409
}
399410
});
400411

401412
it("does not retain the tail of a control sequence whose introducer was trimmed", () => {
402-
for (const [text, maxBytes] of [
403-
["prefix\u001b[31mOK", 6],
404-
["prefix\u001b]0;HIDDEN\u0007OK", 10],
413+
for (const [text, maxBytes, retained] of [
414+
["prefix\u001b[31mOK", 6, "OK"],
415+
["prefix\u001b]0;HIDDEN\u0007OK", 10, "OK"],
416+
["prefix\u001b]0;🙂HIDDEN\u0007OK", 9, "OK"],
417+
// libghostty leaves every string but OSC at the emoji's second byte, so HELLO was visible.
418+
["prefix\u001bP🙂HELLO\u001b\\OK", 9, "HELLO\u001b\\OK"],
405419
] as const) {
406-
expect(new TerminalManager.BoundedTerminalHistory(5_000, text, maxBytes).value()).toBe("OK");
420+
expect(new TerminalManager.BoundedTerminalHistory(5_000, text, maxBytes).value()).toBe(
421+
retained,
422+
);
407423
}
408424
});
409425

‎apps/server/src/terminal/Manager.ts‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import {
66
DEFAULT_TERMINAL_ID,
77
DEFAULT_MAX_TERMINAL_BUFFER_BYTES,
88
retainedTerminalHistory,
9+
TERMINAL_SEQUENCE_GROUND,
10+
terminalSequenceEnd,
11+
terminalSequenceStateAfter,
912
TerminalCwdError,
1013
TerminalCwdNotDirectoryError,
1114
TerminalCwdNotFoundError,
@@ -888,6 +891,14 @@ interface TerminalHistoryPersistenceCursor {
888891
readonly fileBytes: number;
889892
}
890893

894+
function countLineBreaks(data: string): number {
895+
let lineBreaks = 0;
896+
for (let index = data.indexOf("\n"); index !== -1; index = data.indexOf("\n", index + 1)) {
897+
lineBreaks += 1;
898+
}
899+
return lineBreaks;
900+
}
901+
891902
export class BoundedTerminalHistory {
892903
private readonly maxLines: number;
893904
private readonly maxBytes: number;
@@ -897,6 +908,8 @@ export class BoundedTerminalHistory {
897908
private lineBreaks = 0;
898909
// Reading the old string's tail on each append can force chunk concatenation.
899910
private lastCodeUnit: number | undefined;
911+
// The terminal parser's state at the first retained code unit, which is where a replay starts.
912+
private sequenceState = TERMINAL_SEQUENCE_GROUND;
900913
private cachedValue: string | null = "";
901914
private generation = 0;
902915
private appendedLength = 0;
@@ -961,10 +974,7 @@ export class BoundedTerminalHistory {
961974

962975
private appendChunk(data: string): void {
963976
const byteLength = Buffer.byteLength(data);
964-
let lineBreaks = 0;
965-
for (let index = data.indexOf("\n"); index !== -1; index = data.indexOf("\n", index + 1)) {
966-
lineBreaks += 1;
967-
}
977+
const lineBreaks = countLineBreaks(data);
968978
const previous = this.chunks.at(-1);
969979
if (previous && previous.data.length + data.length <= MAX_HISTORY_CHUNK_LENGTH) {
970980
previous.data += data;
@@ -981,6 +991,7 @@ export class BoundedTerminalHistory {
981991

982992
private discardChunk(): void {
983993
const first = this.chunks[this.start]!;
994+
this.sequenceState = terminalSequenceStateAfter(this.sequenceState, first.data);
984995
this.byteLength -= first.byteLength;
985996
this.lineBreaks -= first.lineBreaks;
986997
this.chunks[this.start++] = undefined;
@@ -992,6 +1003,7 @@ export class BoundedTerminalHistory {
9921003
this.discardChunk();
9931004
return;
9941005
}
1006+
this.sequenceState = terminalSequenceStateAfter(this.sequenceState, first.data, 0, offset);
9951007
first.data = first.data.slice(offset);
9961008
first.byteLength -= byteLength;
9971009
first.lineBreaks -= lineBreaks;
@@ -1039,6 +1051,12 @@ export class BoundedTerminalHistory {
10391051
}
10401052
this.trimChunk(offset, bytes, lineBreaks);
10411053
}
1054+
// A cut inside a control sequence would replay the rest of it as text.
1055+
while (this.sequenceState !== TERMINAL_SEQUENCE_GROUND && this.start < this.chunks.length) {
1056+
const first = this.chunks[this.start]!;
1057+
const dropped = first.data.slice(0, terminalSequenceEnd(this.sequenceState, first.data));
1058+
this.trimChunk(dropped.length, Buffer.byteLength(dropped), countLineBreaks(dropped));
1059+
}
10421060
if (
10431061
this.start === this.chunks.length ||
10441062
(this.start > 2_048 && this.start * 2 >= this.chunks.length)
@@ -1057,6 +1075,7 @@ export class BoundedTerminalHistory {
10571075
this.byteLength = 0;
10581076
this.lineBreaks = 0;
10591077
this.lastCodeUnit = undefined;
1078+
this.sequenceState = TERMINAL_SEQUENCE_GROUND;
10601079
this.cachedValue = "";
10611080
}
10621081

‎packages/client-runtime/src/state/terminalOutput.ts‎

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { retainedTerminalHistory } from "@t3tools/contracts";
1+
import {
2+
retainedTerminalHistory,
3+
TERMINAL_SEQUENCE_GROUND,
4+
terminalSequenceEnd,
5+
terminalSequenceStateAfter,
6+
} from "@t3tools/contracts";
27

38
export interface TerminalOutputChunk {
49
readonly startOffset: number;
@@ -12,18 +17,24 @@ export interface TerminalOutputState {
1217
readonly retainedBytes: number;
1318
readonly resetVersion: number;
1419
readonly nextOffset: number;
20+
// The terminal parser's state at the first retained code unit. Trimming can cut a control
21+
// sequence, and a replay has to skip the rest of it instead of printing it.
22+
readonly sequenceState: number;
1523
}
1624

1725
export interface TerminalOutputCursor {
1826
readonly generation: number;
1927
readonly resetVersion: number;
2028
readonly offset: number;
29+
// The cut control sequence still open at `offset`, which the reader's terminal never entered.
30+
readonly sequenceState: number;
2131
}
2232

2333
export const INITIAL_TERMINAL_OUTPUT_CURSOR = Object.freeze<TerminalOutputCursor>({
2434
generation: -1,
2535
resetVersion: -1,
2636
offset: 0,
37+
sequenceState: TERMINAL_SEQUENCE_GROUND,
2738
});
2839

2940
export type TerminalOutputUpdate =
@@ -54,6 +65,7 @@ export const EMPTY_TERMINAL_OUTPUT_STATE = Object.freeze<TerminalOutputState>({
5465
retainedBytes: 0,
5566
resetVersion: 0,
5667
nextOffset: 0,
68+
sequenceState: TERMINAL_SEQUENCE_GROUND,
5769
});
5870

5971
interface Utf8Chunk {
@@ -175,6 +187,7 @@ function appendOutput(
175187
retainedBytes: 0,
176188
resetVersion: current.resetVersion + 1,
177189
nextOffset: current.nextOffset + data.length,
190+
sequenceState: TERMINAL_SEQUENCE_GROUND,
178191
};
179192
}
180193
const appended = splitOutputChunks(
@@ -185,12 +198,19 @@ function appendOutput(
185198

186199
const chunks = [...current.chunks, ...appended.chunks];
187200
let retainedBytes = current.retainedBytes + appended.byteLength;
201+
let sequenceState = current.sequenceState;
188202
let firstRetainedIndex = 0;
189203
while (retainedBytes > maxBufferBytes && firstRetainedIndex < chunks.length) {
190204
const first = chunks[firstRetainedIndex]!;
191205
const bytesToDrop = retainedBytes - maxBufferBytes;
192206
if (bytesToDrop < first.byteLength) {
193207
const trimmed = trimOutputChunkStart(first, bytesToDrop);
208+
sequenceState = terminalSequenceStateAfter(
209+
sequenceState,
210+
first.data,
211+
0,
212+
first.data.length - trimmed.data.length,
213+
);
194214
retainedBytes -= first.byteLength - trimmed.byteLength;
195215
if (trimmed.byteLength > 0) {
196216
chunks[firstRetainedIndex] = trimmed;
@@ -199,6 +219,7 @@ function appendOutput(
199219
}
200220
break;
201221
}
222+
sequenceState = terminalSequenceStateAfter(sequenceState, first.data);
202223
retainedBytes -= first.byteLength;
203224
firstRetainedIndex += 1;
204225
}
@@ -209,6 +230,7 @@ function appendOutput(
209230
const excessChunks = retainedChunks.length - MAX_TERMINAL_OUTPUT_CHUNKS;
210231
if (excessChunks > 0) {
211232
for (const chunk of retainedChunks.slice(0, excessChunks)) {
233+
sequenceState = terminalSequenceStateAfter(sequenceState, chunk.data);
212234
retainedBytes -= chunk.byteLength;
213235
}
214236
retainedChunks = retainedChunks.slice(excessChunks);
@@ -221,6 +243,7 @@ function appendOutput(
221243
retainedBytes,
222244
resetVersion: current.resetVersion,
223245
nextOffset: appended.nextOffset,
246+
sequenceState,
224247
};
225248
}
226249

@@ -241,18 +264,21 @@ function resetOutput(
241264
retainedBytes: reset.byteLength,
242265
resetVersion: current.resetVersion + 1,
243266
nextOffset: reset.nextOffset,
267+
sequenceState: TERMINAL_SEQUENCE_GROUND,
244268
};
245269
}
246270

271+
/** The retained output a fresh terminal can replay: it starts outside any control sequence. */
247272
export function terminalOutputText(output: TerminalOutputState): string {
248-
return output.chunks.map((chunk) => chunk.data).join("");
273+
const text = output.chunks.map((chunk) => chunk.data).join("");
274+
return text.slice(terminalSequenceEnd(output.sequenceState, text));
249275
}
250276

251277
export function readTerminalOutputUpdate(
252278
output: TerminalOutputState,
253279
cursor: TerminalOutputCursor,
254280
): TerminalOutputUpdate {
255-
const nextCursor = {
281+
const position = {
256282
generation: output.generation,
257283
resetVersion: output.resetVersion,
258284
offset: output.nextOffset,
@@ -263,7 +289,16 @@ export function readTerminalOutputUpdate(
263289
cursor.resetVersion !== output.resetVersion ||
264290
cursor.offset < (firstChunk?.startOffset ?? output.nextOffset)
265291
) {
266-
return { type: "reset", data: terminalOutputText(output), cursor: nextCursor };
292+
const text = output.chunks.map((chunk) => chunk.data).join("");
293+
const start = terminalSequenceEnd(output.sequenceState, text);
294+
return {
295+
type: "reset",
296+
data: text.slice(start),
297+
cursor: {
298+
...position,
299+
sequenceState: terminalSequenceStateAfter(output.sequenceState, text, 0, start),
300+
},
301+
};
267302
}
268303

269304
// Appends and compaction preserve offset order, so unread chunks form a suffix.
@@ -273,17 +308,19 @@ export function readTerminalOutputUpdate(
273308
if (!(chunk.startOffset + chunk.data.length > cursor.offset)) break;
274309
firstUnread -= 1;
275310
}
276-
const appended = output.chunks.slice(firstUnread);
277-
if (appended.length === 0) {
311+
const text = output.chunks
312+
.slice(firstUnread)
313+
.map((chunk) => chunk.data.slice(Math.max(0, cursor.offset - chunk.startOffset)))
314+
.join("");
315+
const start = terminalSequenceEnd(cursor.sequenceState, text);
316+
const nextCursor = {
317+
...position,
318+
sequenceState: terminalSequenceStateAfter(cursor.sequenceState, text, 0, start),
319+
};
320+
if (start === text.length) {
278321
return { type: "none", cursor: nextCursor };
279322
}
280-
return {
281-
type: "append",
282-
data: appended
283-
.map((chunk) => chunk.data.slice(Math.max(0, cursor.offset - chunk.startOffset)))
284-
.join(""),
285-
cursor: nextCursor,
286-
};
323+
return { type: "append", data: text.slice(start), cursor: nextCursor };
287324
}
288325

289326
export { appendOutput, resetOutput };

‎packages/client-runtime/src/state/terminalSession.test.ts‎

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,16 +160,43 @@ describe("terminal session reducers", () => {
160160
});
161161

162162
it("does not retain the tail of a control sequence whose introducer was trimmed", () => {
163-
for (const [history, maxBytes] of [
164-
["prefix\u001b[31mOK", 6],
165-
["prefix\u001b]0;HIDDEN\u0007OK", 10],
163+
for (const [history, maxBytes, replayable] of [
164+
["prefix\u001b[31mOK", 6, /^(?:[a-z]*\u001b\[31mOK|O?K?)$/],
165+
["prefix\u001b]0;HIDDEN\u0007OK", 10, /^(?:[a-z]*\u001b\]0;HIDDEN\u0007OK|O?K?)$/],
166+
["prefix\u001b]0;HIDDEN\u0007OK", 4, /^(?:[a-z]*\u001b\]0;HIDDEN\u0007OK|O?K?)$/],
166167
] as const) {
167168
const state = applyTerminalAttachStreamEvent(
168169
EMPTY_TERMINAL_BUFFER_STATE,
169170
{ type: "snapshot", snapshot: { ...BASE_SNAPSHOT, history } },
170171
maxBytes,
171172
);
172173
expect(terminalOutputText(state.output)).toBe("OK");
174+
175+
// The same text as live output: a mounted terminal still receives every byte, while one
176+
// that mounts later, even inside the sequence, never prints the rest of it.
177+
let live = applyTerminalAttachStreamEvent(
178+
EMPTY_TERMINAL_BUFFER_STATE,
179+
{ type: "snapshot", snapshot: { ...BASE_SNAPSHOT, history: "" } },
180+
maxBytes,
181+
);
182+
const readers = [{ cursor: INITIAL_TERMINAL_OUTPUT_CURSOR, written: "" }];
183+
for (const data of history) {
184+
live = applyTerminalAttachStreamEvent(
185+
live,
186+
{ type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data },
187+
maxBytes,
188+
);
189+
readers.push({ cursor: INITIAL_TERMINAL_OUTPUT_CURSOR, written: "" });
190+
for (const reader of readers) {
191+
const update = readTerminalOutputUpdate(live.output, reader.cursor);
192+
if (update.type === "reset") reader.written = update.data;
193+
if (update.type === "append") reader.written += update.data;
194+
reader.cursor = update.cursor;
195+
}
196+
}
197+
expect(readers[0]!.written).toBe(history);
198+
for (const reader of readers) expect(reader.written).toMatch(replayable);
199+
expect(terminalOutputText(live.output)).toBe("OK");
173200
}
174201
});
175202

0 commit comments

Comments
 (0)