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 {...@@ -213,6 +213,7 @@ interface CreateManagerOptions {
213 subprocessPollIntervalMs?: number;213 subprocessPollIntervalMs?: number;
214 processKillGraceMs?: number;214 processKillGraceMs?: number;
215 maxRetainedInactiveSessions?: number;215 maxRetainedInactiveSessions?: number;
216 historyByteLimit?: number;
216 ptyAdapter?: FakePtyAdapter;217 ptyAdapter?: FakePtyAdapter;
217}218}
218219
...@@ -243,6 +244,9 @@ const createManager = (...@@ -243,6 +244,9 @@ const createManager = (
243 logsDir,244 logsDir,
244 historyLineLimit,245 historyLineLimit,
245 ptyAdapter,246 ptyAdapter,
247 ...(options.historyByteLimit !== undefined
248 ? { historyByteLimit: options.historyByteLimit }
249 : {}),
246 ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}),250 ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}),
247 ...(options.env !== undefined ? { env: options.env } : {}),251 ...(options.env !== undefined ? { env: options.env } : {}),
248 ...(options.subprocessInspector !== undefined252 ...(options.subprocessInspector !== undefined
...@@ -276,16 +280,27 @@ const createManager = (...@@ -276,16 +280,27 @@ const createManager = (
276const withHostPlatform = (platform: NodeJS.Platform) =>280const withHostPlatform = (platform: NodeJS.Platform) =>
277 Layer.succeed(HostProcessPlatform, platform);281 Layer.succeed(HostProcessPlatform, platform);
278282
279// The previous split/join algorithm is the reference for retained text.283// Apply the existing line policy, then find the longest code-point-aligned byte tail.
280function retainedHistory(text: string, maxLines: number): string {284function retainedHistory(text: string, maxLines: number, maxBytes = Infinity): string {
281 const terminated = text.endsWith("\n");285 const terminated = text.endsWith("\n");
282 const lines = text.split("\n");286 const lines = text.split("\n");
283 if (terminated) lines.pop();287 if (terminated) lines.pop();
284 const retained = lines.slice(Math.max(0, lines.length - maxLines)).join("\n");288 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("");
286}301}
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", () => {
289 let randomSeed = 0x20260904;304 let randomSeed = 0x20260904;
290 const fragments = [305 const fragments = [
291 "",306 "",
...@@ -308,25 +323,49 @@ it("preserves history across arbitrary chunks, Unicode, ANSI sequences, and clea...@@ -308,25 +323,49 @@ it("preserves history across arbitrary chunks, Unicode, ANSI sequences, and clea
308 return fragments[randomSeed % fragments.length]!;323 return fragments[randomSeed % fragments.length]!;
309 };324 };
310325
311 for (const maxLines of [0, 1, 3, 5, 5_000]) {326 for (const maxBytes of [0, 3, 8, 64, Infinity]) {
312 let expected = retainedHistory("before\ninitial\n", maxLines);327 for (const maxLines of [0, 1, 3, 5, 5_000]) {
313 const history = new TerminalManager.BoundedTerminalHistory(maxLines, expected);328 let expected = retainedHistory("before\ninitial\n", maxLines, maxBytes);
314 expect(history.value()).toBe(expected);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) {336 for (let step = 0; step < 300; step += 1) {
317 if (step % 73 === 0) {337 if (step % 73 === 0) {
318 history.clear();338 history.clear();
319 expected = "";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);
320 expect(history.value()).toBe(expected);345 expect(history.value()).toBe(expected);
321 }346 }
322 const chunk = nextFragment() + nextFragment();
323 history.append(chunk);
324 expected = retainedHistory(expected + chunk, maxLines);
325 expect(history.value()).toBe(expected);
326 }347 }
327 }348 }
328});349});
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
330it("preserves retained lines as older storage is compacted", () => {369it("preserves retained lines as older storage is compacted", () => {
331 for (const maxLines of [3, 5_000]) {370 for (const maxLines of [3, 5_000]) {
332 let expected = "";371 let expected = "";
...@@ -1173,6 +1212,86 @@ it.layer(...@@ -1173,6 +1212,86 @@ it.layer(
1173 }),1212 }),
1174 );1213 );
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
1176 it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () =>1295 it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () =>
1177 Effect.gen(function* () {1296 Effect.gen(function* () {
1178 const { manager, ptyAdapter } = yield* createManager();1297 const { manager, ptyAdapter } = yield* createManager();
apps/server/src/terminal/Manager.ts+202-63
...@@ -75,6 +75,8 @@ export {...@@ -75,6 +75,8 @@ export {
75};75};
7676
77const DEFAULT_HISTORY_LINE_LIMIT = 5_000;77const DEFAULT_HISTORY_LINE_LIMIT = 5_000;
78const DEFAULT_HISTORY_BYTE_LIMIT = 8 * 1024 * 1024;
79const MAX_HISTORY_CHUNK_LENGTH = 16 * 1024;
78const DEFAULT_PERSIST_DEBOUNCE_MS = 40;80const DEFAULT_PERSIST_DEBOUNCE_MS = 40;
79const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000;81const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000;
80const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000;82const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000;
...@@ -784,74 +786,186 @@ const windowsProcessTableSnapshot = Effect.fn("terminal.windowsProcessTableSnaps...@@ -784,74 +786,186 @@ const windowsProcessTableSnapshot = Effect.fn("terminal.windowsProcessTableSnaps
784 },786 },
785);787);
786788
787function capHistory(history: string, maxLines: number): string {789interface TerminalHistoryChunk {
788 if (history.length === 0) return history;790 data: string;
789 const hasTrailingNewline = history.endsWith("\n");791 byteLength: number;
790 const lines = history.split("\n");792 lineBreaks: number;
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;
797}793}
798794
799export class BoundedTerminalHistory {795export class BoundedTerminalHistory {
800 private readonly maxLines: number;796 private readonly maxLines: number;
801 private lines: Array<string>;797 private readonly maxBytes: number;
798 private chunks: Array<TerminalHistoryChunk | undefined> = [];
802 private start = 0;799 private start = 0;
803 private trailingNewline: boolean;800 private byteLength = 0;
804 private cachedValue: string | null = null;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) {
807 this.maxLines = maxLines;807 this.maxLines = maxLines;
808 const capped = capHistory(initial, maxLines);808 this.maxBytes = maxBytes;
809 this.trailingNewline = capped.endsWith("\n");809 this.append(initial);
810 this.lines = capped.length === 0 ? [] : capped.split("\n");
811 if (this.trailingNewline) this.lines.pop();
812 }810 }
813811
814 append(text: string): void {812 append(text: string): void {
815 if (text.length === 0) return;813 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");843 while (offset < text.length) {
818 const appendedLines = text.split("\n");844 let end = Math.min(offset + MAX_HISTORY_CHUNK_LENGTH, text.length);
819 if (trailingNewline) appendedLines.pop();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;862 private appendChunk(data: string): void {
822 if (activeLines === 0 || this.trailingNewline) {863 const byteLength = Buffer.byteLength(data);
823 for (const line of appendedLines) this.lines.push(line);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;
824 } else {873 } else {
825 this.lines[this.lines.length - 1] += appendedLines[0] ?? "";874 this.chunks.push({ data, byteLength, lineBreaks });
826 for (let index = 1; index < appendedLines.length; index += 1) {875 }
827 this.lines.push(appendedLines[index] ?? "");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;
828 }915 }
916 this.trimChunk(offset, Buffer.byteLength(first.data.slice(0, offset)), linesToDrop);
917 linesToDrop = 0;
829 }918 }
830 this.trailingNewline = trailingNewline;
831919
832 const overflow = this.lines.length - this.start - this.maxLines;920 while (this.byteLength > this.maxBytes) {
833 if (overflow > 0) {921 const first = this.chunks[this.start]!;
834 this.lines.fill("", this.start, this.start + overflow);922 const bytesToDrop = this.byteLength - this.maxBytes;
835 this.start += overflow;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);
836 }943 }
837 if (this.start > 2_048 && this.start * 2 >= this.lines.length) {944 if (
838 this.lines = this.lines.slice(this.start);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);
839 this.start = 0;949 this.start = 0;
950 if (this.chunks.length === 0) this.lastCodeUnit = undefined;
840 }951 }
841 this.cachedValue = null;
842 }952 }
843953
844 clear(): void {954 clear(): void {
845 this.lines = [];955 this.chunks = [];
846 this.start = 0;956 this.start = 0;
847 this.trailingNewline = false;957 this.byteLength = 0;
958 this.lineBreaks = 0;
959 this.lastCodeUnit = undefined;
848 this.cachedValue = "";960 this.cachedValue = "";
849 }961 }
850962
851 value(): string {963 value(): string {
852 if (this.cachedValue !== null) return this.cachedValue;964 if (this.cachedValue !== null) return this.cachedValue;
853 const joined = this.lines.slice(this.start).join("\n");965 this.cachedValue = this.chunks
854 this.cachedValue = this.trailingNewline ? `${joined}\n` : joined;966 .slice(this.start)
967 .map((chunk) => chunk!.data)
968 .join("");
855 return this.cachedValue;969 return this.cachedValue;
856 }970 }
857}971}
...@@ -1171,6 +1285,7 @@ function normalizedRuntimeEnv(...@@ -1171,6 +1285,7 @@ function normalizedRuntimeEnv(
1171interface TerminalManagerOptions {1285interface TerminalManagerOptions {
1172 logsDir: string;1286 logsDir: string;
1173 historyLineLimit?: number;1287 historyLineLimit?: number;
1288 historyByteLimit?: number;
1174 ptyAdapter: PtyAdapter.PtyAdapter["Service"];1289 ptyAdapter: PtyAdapter.PtyAdapter["Service"];
1175 shellResolver?: () => string;1290 shellResolver?: () => string;
1176 env?: NodeJS.ProcessEnv;1291 env?: NodeJS.ProcessEnv;
...@@ -1211,6 +1326,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -1211,6 +1326,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
12111326
1212 const logsDir = options.logsDir;1327 const logsDir = options.logsDir;
1213 const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT;1328 const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT;
1329 const historyByteLimit = options.historyByteLimit ?? DEFAULT_HISTORY_BYTE_LIMIT;
1214 const platform = yield* HostProcessPlatform;1330 const platform = yield* HostProcessPlatform;
1215 // Terminals must inherit the user's full environment (minus the blocklist1331 // Terminals must inherit the user's full environment (minus the blocklist
1216 // applied in createTerminalSpawnEnv) — an allowlist here silently strips1332 // applied in createTerminalSpawnEnv) — an allowlist here silently strips
...@@ -1476,6 +1592,30 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -1476,6 +1592,30 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
1476 yield* flushPersist(threadId, terminalId);1592 yield* flushPersist(threadId, terminalId);
1477 });1593 });
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
1479 const readHistory = Effect.fn("terminal.readHistory")(function* (1619 const readHistory = Effect.fn("terminal.readHistory")(function* (
1480 threadId: string,1620 threadId: string,
1481 terminalId: string,1621 terminalId: string,
...@@ -1490,15 +1630,15 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -1490,15 +1630,15 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
1490 ),1630 ),
1491 )1631 )
1492 ) {1632 ) {
1493 const raw = yield* fileSystem1633 const { history: raw, truncated } = yield* readHistoryTail(nextPath).pipe(
1494 .readFileString(nextPath)1634 Effect.scoped,
1495 .pipe(1635 Effect.mapError(
1496 Effect.mapError(1636 (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }),
1497 (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }),1637 ),
1498 ),1638 );
1499 );1639 const history = new BoundedTerminalHistory(historyLineLimit, raw, historyByteLimit);
1500 const capped = capHistory(raw, historyLineLimit);1640 const capped = history.value();
1501 if (capped !== raw) {1641 if (truncated || capped !== raw) {
1502 yield* fileSystem1642 yield* fileSystem
1503 .writeFileString(nextPath, capped)1643 .writeFileString(nextPath, capped)
1504 .pipe(1644 .pipe(
...@@ -1508,11 +1648,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -1508,11 +1648,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
1508 ),1648 ),
1509 );1649 );
1510 }1650 }
1511 return capped;1651 return history;
1512 }1652 }
15131653
1514 if (terminalId !== DEFAULT_TERMINAL_ID) {1654 if (terminalId !== DEFAULT_TERMINAL_ID) {
1515 return "";1655 return new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit);
1516 }1656 }
15171657
1518 const legacyPath = legacyHistoryPath(threadId);1658 const legacyPath = legacyHistoryPath(threadId);
...@@ -1526,18 +1666,17 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -1526,18 +1666,17 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
1526 ),1666 ),
1527 ))1667 ))
1528 ) {1668 ) {
1529 return "";1669 return new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit);
1530 }1670 }
15311671
1532 const raw = yield* fileSystem1672 const { history: raw } = yield* readHistoryTail(legacyPath).pipe(
1533 .readFileString(legacyPath)1673 Effect.scoped,
1534 .pipe(1674 Effect.mapError(
1535 Effect.mapError(1675 (cause) => new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }),
1536 (cause) =>1676 ),
1537 new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }),1677 );
1538 ),1678 const history = new BoundedTerminalHistory(historyLineLimit, raw, historyByteLimit);
1539 );1679 const capped = history.value();
1540 const capped = capHistory(raw, historyLineLimit);
1541 yield* fileSystem1680 yield* fileSystem
1542 .writeFileString(nextPath, capped)1681 .writeFileString(nextPath, capped)
1543 .pipe(1682 .pipe(
...@@ -1554,7 +1693,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -1554,7 +1693,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
1554 }),1693 }),
1555 ),1694 ),
1556 );1695 );
1557 return capped;1696 return history;
1558 });1697 });
15591698
1560 const deleteHistory = Effect.fn("terminal.deleteHistory")(function* (1699 const deleteHistory = Effect.fn("terminal.deleteHistory")(function* (
...@@ -2219,7 +2358,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -2219,7 +2358,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
2219 worktreePath: input.worktreePath ?? null,2358 worktreePath: input.worktreePath ?? null,
2220 status: "starting",2359 status: "starting",
2221 pid: null,2360 pid: null,
2222 history: new BoundedTerminalHistory(historyLineLimit, history),2361 history,
2223 pendingHistoryControlSequence: "",2362 pendingHistoryControlSequence: "",
2224 pendingProcessEvents: [],2363 pendingProcessEvents: [],
2225 pendingProcessEventIndex: 0,2364 pendingProcessEventIndex: 0,
...@@ -2631,7 +2770,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -2631,7 +2770,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
2631 worktreePath: input.worktreePath ?? null,2770 worktreePath: input.worktreePath ?? null,
2632 status: "starting",2771 status: "starting",
2633 pid: null,2772 pid: null,
2634 history: new BoundedTerminalHistory(historyLineLimit, ""),2773 history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit),
2635 pendingHistoryControlSequence: "",2774 pendingHistoryControlSequence: "",
2636 pendingProcessEvents: [],2775 pendingProcessEvents: [],
2637 pendingProcessEventIndex: 0,2776 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...@@ -22,19 +22,19 @@ Live output events contain only the new PTY data. Full retained history is
22materialized only when the server returns a snapshot or when the coalescing22materialized only when the server returns a snapshot or when the coalescing
23persistence worker writes the latest state.23persistence worker writes the latest state.
2424
25Retained history uses an incremental bounded line buffer. Appending one PTY25Retained history is limited to 5,000 lines and 8 MiB of UTF-8 text per terminal.
26chunk may scan and allocate for that new chunk, but it must not split, join, or26The server discards the oldest output when either limit is reached. A byte
27copy the entire retained history for every callback. Empty lines, incomplete27cutoff can shorten the oldest retained line, but does not split a Unicode code
28final lines, trailing newlines, and the configured line limit are preserved28point. Live output is not truncated.
29exactly when history is materialized.
3029
31Discard each line's string reference as soon as the line leaves retained30History uses small chunks with byte and newline counts. Appending output scans
32history. Array compaction can run later.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.35Discard each chunk's string reference as soon as it leaves retained history.
35Shared web and mobile client state currently retains at most 512 KiB. A server36Array compaction can run later. Shared web and mobile client state retains at
36byte limit would change persisted scrollback and needs a separate retention37most 512 KiB, so each client can display less scrollback than the server keeps.
37decision.
3838
39Measure sustained-output changes against a full retained history so terminal39Measure sustained-output changes against a full retained history so terminal
40throughput does not regress unnoticed.40throughput does not regress unnoticed.
...@@ -46,3 +46,7 @@ The worker reads the newest bounded-history state after its debounce instead...@@ -46,3 +46,7 @@ The worker reads the newest bounded-history state after its debounce instead
46of receiving a newly materialized full string for every PTY callback. Clear,46of receiving a newly materialized full string for every PTY callback. Clear,
47restart, close, and final flush operations still force the latest state to47restart, close, and final flush operations still force the latest state to
48disk before their lifecycle boundary completes.48disk 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.