authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 11:49:15-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 11:49:15-07:00
logcf9729d5ee9660c08556e823080d3bb19648ed28
tree68117ec8193cd1221e24940b28a54383b59cc7e9
parentfec606f9ae524277ef1f6886e614f32d9e15e36e
signature Signed by PGP key B5690EEEBB952194

perf(server): bound terminal history by bytes (#9748)

Keep at most 5,000 lines and 8 MiB of retained UTF-8 terminal history. Discard the oldest text at either limit while preserving complete live output. Track bytes and newlines in small chunks. Join split surrogates before eviction. Restore only the needed file tail, handle short reads, and close the file before rewriting current or legacy history. Created with GPT-6 Astra (preview) in Codex. Co-authored-by: will <will@moondiner.com>

4 files changed, 360 insertions(+), 90 deletions(-)

apps/server/src/terminal/Manager.test.ts+135-16
......@@ -213,6 +213,7 @@ interface CreateManagerOptions {
213213 subprocessPollIntervalMs?: number;
214214 processKillGraceMs?: number;
215215 maxRetainedInactiveSessions?: number;
216 historyByteLimit?: number;
216217 ptyAdapter?: FakePtyAdapter;
217218}
218219
......@@ -243,6 +244,9 @@ const createManager = (
243244 logsDir,
244245 historyLineLimit,
245246 ptyAdapter,
247 ...(options.historyByteLimit !== undefined
248 ? { historyByteLimit: options.historyByteLimit }
249 : {}),
246250 ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}),
247251 ...(options.env !== undefined ? { env: options.env } : {}),
248252 ...(options.subprocessInspector !== undefined
......@@ -276,16 +280,27 @@ const createManager = (
276280const withHostPlatform = (platform: NodeJS.Platform) =>
277281 Layer.succeed(HostProcessPlatform, platform);
278282
279// The previous split/join algorithm is the reference for retained text.
280function retainedHistory(text: string, maxLines: number): string {
283// Apply the existing line policy, then find the longest code-point-aligned byte tail.
284function retainedHistory(text: string, maxLines: number, maxBytes = Infinity): string {
281285 const terminated = text.endsWith("\n");
282286 const lines = text.split("\n");
283287 if (terminated) lines.pop();
284288 const retained = lines.slice(Math.max(0, lines.length - maxLines)).join("\n");
285 return terminated ? `${retained}\n` : retained;
289 const capped = terminated ? `${retained}\n` : retained;
290 if (Buffer.byteLength(capped) <= maxBytes) return capped;
291 const points = Array.from(capped);
292 let start = points.length;
293 let bytes = 0;
294 while (start > 0) {
295 const next = Buffer.byteLength(points[start - 1]!);
296 if (bytes + next > maxBytes) break;
297 bytes += next;
298 start -= 1;
299 }
300 return points.slice(start).join("");
286301}
287302
288it("preserves history across arbitrary chunks, Unicode, ANSI sequences, and clear", () => {
303it("preserves line and byte limits across arbitrary chunks, Unicode, ANSI sequences, and clear", () => {
289304 let randomSeed = 0x20260904;
290305 const fragments = [
291306 "",
......@@ -308,25 +323,49 @@ it("preserves history across arbitrary chunks, Unicode, ANSI sequences, and clea
308323 return fragments[randomSeed % fragments.length]!;
309324 };
310325
311 for (const maxLines of [0, 1, 3, 5, 5_000]) {
312 let expected = retainedHistory("before\ninitial\n", maxLines);
313 const history = new TerminalManager.BoundedTerminalHistory(maxLines, expected);
314 expect(history.value()).toBe(expected);
326 for (const maxBytes of [0, 3, 8, 64, Infinity]) {
327 for (const maxLines of [0, 1, 3, 5, 5_000]) {
328 let expected = retainedHistory("before\ninitial\n", maxLines, maxBytes);
329 const history = new TerminalManager.BoundedTerminalHistory(
330 maxLines,
331 "before\ninitial\n",
332 maxBytes,
333 );
334 expect(history.value()).toBe(expected);
315335
316 for (let step = 0; step < 300; step += 1) {
317 if (step % 73 === 0) {
318 history.clear();
319 expected = "";
336 for (let step = 0; step < 300; step += 1) {
337 if (step % 73 === 0) {
338 history.clear();
339 expected = "";
340 expect(history.value()).toBe(expected);
341 }
342 const chunk = nextFragment() + nextFragment();
343 history.append(chunk);
344 expected = retainedHistory(expected + chunk, maxLines, maxBytes);
320345 expect(history.value()).toBe(expected);
321346 }
322 const chunk = nextFragment() + nextFragment();
323 history.append(chunk);
324 expected = retainedHistory(expected + chunk, maxLines);
325 expect(history.value()).toBe(expected);
326347 }
327348 }
328349});
329350
351it("bounds long partial lines and joins surrogate pairs across chunk boundaries", () => {
352 const maxBytes = 65_539;
353 let expected = "";
354 const history = new TerminalManager.BoundedTerminalHistory(5_000, "", maxBytes);
355 const writes = [
356 "a".repeat(16_383) + "😀" + "b".repeat(70_000),
357 "\r" + "c".repeat(70_000) + "\ud83d",
358 "\ude80" + "d".repeat(100),
359 "\uFEFF" + "名".repeat(30_000),
360 ];
361 for (const text of writes) {
362 history.append(text);
363 expected = retainedHistory(expected + text, 5_000, maxBytes);
364 expect(history.value()).toBe(expected);
365 expect(Buffer.byteLength(history.value())).toBeLessThanOrEqual(maxBytes);
366 }
367});
368
330369it("preserves retained lines as older storage is compacted", () => {
331370 for (const maxLines of [3, 5_000]) {
332371 let expected = "";
......@@ -1173,6 +1212,86 @@ it.layer(
11731212 }),
11741213 );
11751214
1215 it.effect("bounds persisted and attached history without truncating live output", () =>
1216 Effect.gen(function* () {
1217 const { manager, ptyAdapter, logsDir } = yield* createManager(5, { historyByteLimit: 10 });
1218 const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
1219 const unsubscribe = yield* manager.attachStream(openInput(), (event) =>
1220 Ref.update(attachEvents, (events) => [...events, event]),
1221 );
1222 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
1223 const writes = ["a".repeat(32), "😀\rEND"];
1224 const process = ptyAdapter.processes[0]!;
1225 for (const text of writes) process.emitData(text);
1226 yield* manager.close({ threadId: "thread-1" });
1227 expect(yield* readFileString(yield* historyLogPath(logsDir))).toBe("aa😀\rEND");
1228
1229 const reopened = yield* manager.open(openInput());
1230 const events = yield* Ref.get(attachEvents);
1231 expect(events.filter((event) => event.type === "output").map((event) => event.data)).toEqual(
1232 writes,
1233 );
1234 const snapshot = events.filter((event) => event.type === "snapshot").at(-1)?.snapshot;
1235 expect(snapshot?.history).toBe("aa😀\rEND");
1236 expect(snapshot?.sequence).toBe(reopened.sequence);
1237 }),
1238 );
1239
1240 for (const source of ["current", "legacy"] as const) {
1241 it.effect(`reads only a Unicode-safe tail from oversized ${source} history`, () =>
1242 Effect.gen(function* () {
1243 const fs = yield* FileSystem.FileSystem;
1244 const path = yield* Path.Path;
1245 let sourcePath: string | undefined;
1246 let closedReads = 0;
1247 const readRequests: number[] = [];
1248 const trackedFileSystem = FileSystem.FileSystem.of({
1249 ...fs,
1250 readFileString: (candidate, encoding) =>
1251 candidate === sourcePath
1252 ? Effect.die("History restoration must not read the whole file")
1253 : fs.readFileString(candidate, encoding),
1254 open: (candidate, options) =>
1255 Effect.gen(function* () {
1256 if (candidate !== sourcePath) return yield* fs.open(candidate, options);
1257 yield* Effect.addFinalizer(() =>
1258 Effect.sync(() => {
1259 closedReads += 1;
1260 }),
1261 );
1262 const file = yield* fs.open(candidate, options);
1263 return new Proxy(file, {
1264 get(target, key) {
1265 if (key === "read") {
1266 return (buffer: Uint8Array) => {
1267 readRequests.push(buffer.byteLength);
1268 return target.read(buffer.subarray(0, 5));
1269 };
1270 }
1271 return Reflect.get(target, key, target);
1272 },
1273 });
1274 }),
1275 });
1276 const { manager, logsDir } = yield* createManager(5, { historyByteLimit: 15 }).pipe(
1277 Effect.provideService(FileSystem.FileSystem, trackedFileSystem),
1278 );
1279 const nextPath = yield* historyLogPath(logsDir);
1280 sourcePath = source === "current" ? nextPath : path.join(logsDir, "thread-1.log");
1281 yield* fs.writeFileString(sourcePath, "old".repeat(32_768) + "😀\uFEFFnewest\ré");
1282
1283 const snapshot = yield* manager.open(openInput());
1284 expect(snapshot.history).toBe("\uFEFFnewest\ré");
1285 expect(readRequests).toEqual([15, 10, 5]);
1286 expect(closedReads).toBe(1);
1287 expect(Buffer.from(yield* fs.readFile(nextPath)).toString()).toBe("\uFEFFnewest\ré");
1288 if (source === "legacy") expect(yield* fs.exists(sourcePath)).toBe(false);
1289 yield* manager.close({ threadId: "thread-1" });
1290 expect((yield* manager.open(openInput())).history).toBe("\uFEFFnewest\ré");
1291 }),
1292 );
1293 }
1294
11761295 it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () =>
11771296 Effect.gen(function* () {
11781297 const { manager, ptyAdapter } = yield* createManager();
apps/server/src/terminal/Manager.ts+202-63
......@@ -75,6 +75,8 @@ export {
7575};
7676
7777const DEFAULT_HISTORY_LINE_LIMIT = 5_000;
78const DEFAULT_HISTORY_BYTE_LIMIT = 8 * 1024 * 1024;
79const MAX_HISTORY_CHUNK_LENGTH = 16 * 1024;
7880const DEFAULT_PERSIST_DEBOUNCE_MS = 40;
7981const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000;
8082const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000;
......@@ -784,74 +786,186 @@ const windowsProcessTableSnapshot = Effect.fn("terminal.windowsProcessTableSnaps
784786 },
785787);
786788
787function capHistory(history: string, maxLines: number): string {
788 if (history.length === 0) return history;
789 const hasTrailingNewline = history.endsWith("\n");
790 const lines = history.split("\n");
791 if (hasTrailingNewline) {
792 lines.pop();
793 }
794 if (lines.length <= maxLines) return history;
795 const capped = lines.slice(lines.length - maxLines).join("\n");
796 return hasTrailingNewline ? `${capped}\n` : capped;
789interface TerminalHistoryChunk {
790 data: string;
791 byteLength: number;
792 lineBreaks: number;
797793}
798794
799795export class BoundedTerminalHistory {
800796 private readonly maxLines: number;
801 private lines: Array<string>;
797 private readonly maxBytes: number;
798 private chunks: Array<TerminalHistoryChunk | undefined> = [];
802799 private start = 0;
803 private trailingNewline: boolean;
804 private cachedValue: string | null = null;
800 private byteLength = 0;
801 private lineBreaks = 0;
802 // Reading the old string's tail on each append can force chunk concatenation.
803 private lastCodeUnit: number | undefined;
804 private cachedValue: string | null = "";
805805
806 constructor(maxLines: number, initial: string) {
806 constructor(maxLines: number, initial: string, maxBytes = DEFAULT_HISTORY_BYTE_LIMIT) {
807807 this.maxLines = maxLines;
808 const capped = capHistory(initial, maxLines);
809 this.trailingNewline = capped.endsWith("\n");
810 this.lines = capped.length === 0 ? [] : capped.split("\n");
811 if (this.trailingNewline) this.lines.pop();
808 this.maxBytes = maxBytes;
809 this.append(initial);
812810 }
813811
814812 append(text: string): void {
815813 if (text.length === 0) return;
814 this.cachedValue = null;
815 if (this.maxBytes <= 0 || this.maxLines <= 0) {
816 this.clear();
817 // Preserve the existing zero-line limit's trailing newline behavior.
818 if (this.maxBytes > 0 && text.endsWith("\n")) this.appendChunk("\n");
819 return;
820 }
821
822 let offset = 0;
823 const previous = this.chunks.at(-1);
824 const lastCode = this.lastCodeUnit;
825 const firstCode = text.charCodeAt(0);
826 if (
827 previous &&
828 lastCode !== undefined &&
829 lastCode >= 0xd800 &&
830 lastCode <= 0xdbff &&
831 firstCode >= 0xdc00 &&
832 firstCode <= 0xdfff
833 ) {
834 // Joining a split surrogate changes its UTF-8 size from 3 to 4 bytes.
835 previous.data += text[0];
836 previous.byteLength += 1;
837 this.byteLength += 1;
838 this.lastCodeUnit = firstCode;
839 offset = 1;
840 this.trim();
841 }
816842
817 const trailingNewline = text.endsWith("\n");
818 const appendedLines = text.split("\n");
819 if (trailingNewline) appendedLines.pop();
843 while (offset < text.length) {
844 let end = Math.min(offset + MAX_HISTORY_CHUNK_LENGTH, text.length);
845 const before = text.charCodeAt(end - 1);
846 const after = text.charCodeAt(end);
847 if (before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff) {
848 end -= 1;
849 }
850 const data = text.slice(offset, end);
851 // Detach small chunks from large input strings so evicted prefixes can be collected.
852 this.appendChunk(
853 text.length > MAX_HISTORY_CHUNK_LENGTH
854 ? Buffer.from(data, "utf16le").toString("utf16le")
855 : data,
856 );
857 this.trim();
858 offset = end;
859 }
860 }
820861
821 const activeLines = this.lines.length - this.start;
822 if (activeLines === 0 || this.trailingNewline) {
823 for (const line of appendedLines) this.lines.push(line);
862 private appendChunk(data: string): void {
863 const byteLength = Buffer.byteLength(data);
864 let lineBreaks = 0;
865 for (let index = data.indexOf("\n"); index !== -1; index = data.indexOf("\n", index + 1)) {
866 lineBreaks += 1;
867 }
868 const previous = this.chunks.at(-1);
869 if (previous && previous.data.length + data.length <= MAX_HISTORY_CHUNK_LENGTH) {
870 previous.data += data;
871 previous.byteLength += byteLength;
872 previous.lineBreaks += lineBreaks;
824873 } else {
825 this.lines[this.lines.length - 1] += appendedLines[0] ?? "";
826 for (let index = 1; index < appendedLines.length; index += 1) {
827 this.lines.push(appendedLines[index] ?? "");
874 this.chunks.push({ data, byteLength, lineBreaks });
875 }
876 this.byteLength += byteLength;
877 this.lineBreaks += lineBreaks;
878 this.lastCodeUnit = data.charCodeAt(data.length - 1);
879 this.cachedValue = null;
880 }
881
882 private discardChunk(): void {
883 const first = this.chunks[this.start]!;
884 this.byteLength -= first.byteLength;
885 this.lineBreaks -= first.lineBreaks;
886 this.chunks[this.start++] = undefined;
887 }
888
889 private trimChunk(offset: number, byteLength: number, lineBreaks: number): void {
890 const first = this.chunks[this.start]!;
891 if (offset === first.data.length) {
892 this.discardChunk();
893 return;
894 }
895 first.data = first.data.slice(offset);
896 first.byteLength -= byteLength;
897 first.lineBreaks -= lineBreaks;
898 this.byteLength -= byteLength;
899 this.lineBreaks -= lineBreaks;
900 }
901
902 private trim(): void {
903 const trailingNewline = this.lastCodeUnit === 10;
904 let linesToDrop = this.lineBreaks + (trailingNewline ? 0 : 1) - this.maxLines;
905 while (linesToDrop > 0) {
906 const first = this.chunks[this.start]!;
907 if (first.lineBreaks < linesToDrop) {
908 linesToDrop -= first.lineBreaks;
909 this.discardChunk();
910 continue;
911 }
912 let offset = 0;
913 for (let line = 0; line < linesToDrop; line += 1) {
914 offset = first.data.indexOf("\n", offset) + 1;
828915 }
916 this.trimChunk(offset, Buffer.byteLength(first.data.slice(0, offset)), linesToDrop);
917 linesToDrop = 0;
829918 }
830 this.trailingNewline = trailingNewline;
831919
832 const overflow = this.lines.length - this.start - this.maxLines;
833 if (overflow > 0) {
834 this.lines.fill("", this.start, this.start + overflow);
835 this.start += overflow;
920 while (this.byteLength > this.maxBytes) {
921 const first = this.chunks[this.start]!;
922 const bytesToDrop = this.byteLength - this.maxBytes;
923 if (first.byteLength <= bytesToDrop) {
924 this.discardChunk();
925 continue;
926 }
927 if (first.byteLength === first.data.length && first.lineBreaks === 0) {
928 // ASCII without newlines needs no scan to find the byte cutoff.
929 this.trimChunk(bytesToDrop, bytesToDrop, 0);
930 continue;
931 }
932 let offset = 0;
933 let bytes = 0;
934 let lineBreaks = 0;
935 // Scan only the discarded prefix of one small chunk, never all history.
936 while (bytes < bytesToDrop) {
937 const codePoint = first.data.codePointAt(offset)!;
938 bytes += codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4;
939 offset += codePoint <= 0xffff ? 1 : 2;
940 if (codePoint === 10) lineBreaks += 1;
941 }
942 this.trimChunk(offset, bytes, lineBreaks);
836943 }
837 if (this.start > 2_048 && this.start * 2 >= this.lines.length) {
838 this.lines = this.lines.slice(this.start);
944 if (
945 this.start === this.chunks.length ||
946 (this.start > 2_048 && this.start * 2 >= this.chunks.length)
947 ) {
948 this.chunks = this.chunks.slice(this.start);
839949 this.start = 0;
950 if (this.chunks.length === 0) this.lastCodeUnit = undefined;
840951 }
841 this.cachedValue = null;
842952 }
843953
844954 clear(): void {
845 this.lines = [];
955 this.chunks = [];
846956 this.start = 0;
847 this.trailingNewline = false;
957 this.byteLength = 0;
958 this.lineBreaks = 0;
959 this.lastCodeUnit = undefined;
848960 this.cachedValue = "";
849961 }
850962
851963 value(): string {
852964 if (this.cachedValue !== null) return this.cachedValue;
853 const joined = this.lines.slice(this.start).join("\n");
854 this.cachedValue = this.trailingNewline ? `${joined}\n` : joined;
965 this.cachedValue = this.chunks
966 .slice(this.start)
967 .map((chunk) => chunk!.data)
968 .join("");
855969 return this.cachedValue;
856970 }
857971}
......@@ -1171,6 +1285,7 @@ function normalizedRuntimeEnv(
11711285interface TerminalManagerOptions {
11721286 logsDir: string;
11731287 historyLineLimit?: number;
1288 historyByteLimit?: number;
11741289 ptyAdapter: PtyAdapter.PtyAdapter["Service"];
11751290 shellResolver?: () => string;
11761291 env?: NodeJS.ProcessEnv;
......@@ -1211,6 +1326,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
12111326
12121327 const logsDir = options.logsDir;
12131328 const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT;
1329 const historyByteLimit = options.historyByteLimit ?? DEFAULT_HISTORY_BYTE_LIMIT;
12141330 const platform = yield* HostProcessPlatform;
12151331 // Terminals must inherit the user's full environment (minus the blocklist
12161332 // applied in createTerminalSpawnEnv) — an allowlist here silently strips
......@@ -1476,6 +1592,30 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
14761592 yield* flushPersist(threadId, terminalId);
14771593 });
14781594
1595 const readHistoryTail = Effect.fn("terminal.readHistoryTail")(function* (filePath: string) {
1596 const file = yield* fileSystem.open(filePath, { flag: "r" });
1597 const info = yield* file.stat;
1598 const limit = BigInt(historyByteLimit);
1599 const offset = info.size > limit ? info.size - limit : 0n;
1600 yield* file.seek(offset, "start");
1601 const bytes = new Uint8Array(Number(info.size - offset));
1602 let length = 0;
1603 while (length < bytes.length) {
1604 const read = Number(yield* file.read(bytes.subarray(length)));
1605 if (read === 0) break;
1606 length += read;
1607 }
1608 let start = 0;
1609 if (offset > 0n) {
1610 // A tail read can start inside a UTF-8 code point. Skip its remaining bytes.
1611 while (start < length && ((bytes[start] ?? 0) & 0xc0) === 0x80) start += 1;
1612 }
1613 return {
1614 history: new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes.subarray(start, length)),
1615 truncated: offset > 0n,
1616 };
1617 });
1618
14791619 const readHistory = Effect.fn("terminal.readHistory")(function* (
14801620 threadId: string,
14811621 terminalId: string,
......@@ -1490,15 +1630,15 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
14901630 ),
14911631 )
14921632 ) {
1493 const raw = yield* fileSystem
1494 .readFileString(nextPath)
1495 .pipe(
1496 Effect.mapError(
1497 (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }),
1498 ),
1499 );
1500 const capped = capHistory(raw, historyLineLimit);
1501 if (capped !== raw) {
1633 const { history: raw, truncated } = yield* readHistoryTail(nextPath).pipe(
1634 Effect.scoped,
1635 Effect.mapError(
1636 (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }),
1637 ),
1638 );
1639 const history = new BoundedTerminalHistory(historyLineLimit, raw, historyByteLimit);
1640 const capped = history.value();
1641 if (truncated || capped !== raw) {
15021642 yield* fileSystem
15031643 .writeFileString(nextPath, capped)
15041644 .pipe(
......@@ -1508,11 +1648,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
15081648 ),
15091649 );
15101650 }
1511 return capped;
1651 return history;
15121652 }
15131653
15141654 if (terminalId !== DEFAULT_TERMINAL_ID) {
1515 return "";
1655 return new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit);
15161656 }
15171657
15181658 const legacyPath = legacyHistoryPath(threadId);
......@@ -1526,18 +1666,17 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
15261666 ),
15271667 ))
15281668 ) {
1529 return "";
1669 return new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit);
15301670 }
15311671
1532 const raw = yield* fileSystem
1533 .readFileString(legacyPath)
1534 .pipe(
1535 Effect.mapError(
1536 (cause) =>
1537 new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }),
1538 ),
1539 );
1540 const capped = capHistory(raw, historyLineLimit);
1672 const { history: raw } = yield* readHistoryTail(legacyPath).pipe(
1673 Effect.scoped,
1674 Effect.mapError(
1675 (cause) => new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }),
1676 ),
1677 );
1678 const history = new BoundedTerminalHistory(historyLineLimit, raw, historyByteLimit);
1679 const capped = history.value();
15411680 yield* fileSystem
15421681 .writeFileString(nextPath, capped)
15431682 .pipe(
......@@ -1554,7 +1693,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
15541693 }),
15551694 ),
15561695 );
1557 return capped;
1696 return history;
15581697 });
15591698
15601699 const deleteHistory = Effect.fn("terminal.deleteHistory")(function* (
......@@ -2219,7 +2358,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
22192358 worktreePath: input.worktreePath ?? null,
22202359 status: "starting",
22212360 pid: null,
2222 history: new BoundedTerminalHistory(historyLineLimit, history),
2361 history,
22232362 pendingHistoryControlSequence: "",
22242363 pendingProcessEvents: [],
22252364 pendingProcessEventIndex: 0,
......@@ -2631,7 +2770,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
26312770 worktreePath: input.worktreePath ?? null,
26322771 status: "starting",
26332772 pid: null,
2634 history: new BoundedTerminalHistory(historyLineLimit, ""),
2773 history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit),
26352774 pendingHistoryControlSequence: "",
26362775 pendingProcessEvents: [],
26372776 pendingProcessEventIndex: 0,
docs/internals/terminal-runtime.md+15-11
......@@ -22,19 +22,19 @@ Live output events contain only the new PTY data. Full retained history is
2222materialized only when the server returns a snapshot or when the coalescing
2323persistence worker writes the latest state.
2424
25Retained history uses an incremental bounded line buffer. Appending one PTY
26chunk may scan and allocate for that new chunk, but it must not split, join, or
27copy the entire retained history for every callback. Empty lines, incomplete
28final lines, trailing newlines, and the configured line limit are preserved
29exactly when history is materialized.
25Retained history is limited to 5,000 lines and 8 MiB of UTF-8 text per terminal.
26The server discards the oldest output when either limit is reached. A byte
27cutoff can shorten the oldest retained line, but does not split a Unicode code
28point. Live output is not truncated.
3029
31Discard each line's string reference as soon as the line leaves retained
32history. Array compaction can run later.
30History uses small chunks with byte and newline counts. Appending output scans
31the new text and any removed chunk prefix, not the full retained history.
32Empty lines, incomplete final lines, and trailing newlines remain unchanged
33within the limits. Split surrogate pairs are joined before byte eviction.
3334
34The server's line limit does not limit the byte size of retained history.
35Shared web and mobile client state currently retains at most 512 KiB. A server
36byte limit would change persisted scrollback and needs a separate retention
37decision.
35Discard each chunk's string reference as soon as it leaves retained history.
36Array compaction can run later. Shared web and mobile client state retains at
37most 512 KiB, so each client can display less scrollback than the server keeps.
3838
3939Measure sustained-output changes against a full retained history so terminal
4040throughput does not regress unnoticed.
......@@ -46,3 +46,7 @@ The worker reads the newest bounded-history state after its debounce instead
4646of receiving a newly materialized full string for every PTY callback. Clear,
4747restart, close, and final flush operations still force the latest state to
4848disk before their lifecycle boundary completes.
49
50Restoration reads at most the last 8 MiB from current and legacy history files.
51It skips an incomplete UTF-8 code point at the start and applies the line limit
52before rewriting oversized files. File handles close before that rewrite.
docs/user/terminal.md created+8
......@@ -0,0 +1,8 @@
1# Terminal history
2
3Each terminal keeps up to 5,000 lines and 8 MiB of scrollback on its environment
4server. T3 Code removes the oldest output when either limit is reached. A long
5line can be shortened at the start. New terminal output is not truncated.
6
7These limits apply when you reconnect and when T3 Code restores saved terminal
8history. A client can show less scrollback than the server keeps.