authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 08:31:17-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 08:31:17-07:00
log3bbbc1d9fd8b3d649c60ba0137c7dae93a6aab3f
tree235922a2a70bf9cd5cfa4bdaded8eb37f6256191
parent010d6bb1b5281cb1e6eacf1ee0f2500973242836
signature Signed by PGP key B5690EEEBB952194

perf(server): stop rebuilding terminal history per chunk (#9703)

Append terminal history incrementally and materialize text for snapshots and coalesced disk writes. Clear evicted line references without changing retained output. Continues [#9357](https://github.com/pingdotgg/t3code/pull/9357). The original contribution and author credit are preserved. The current line limit and wire format stay unchanged. A strict byte limit remains separate work. Created with GPT-6 Astra (preview) in Codex. Co-authored-by: will <will@moondiner.com>

3 files changed, 216 insertions(+), 26 deletions(-)

apps/server/src/terminal/Manager.test.ts+83
...@@ -276,6 +276,70 @@ const createManager = (...@@ -276,6 +276,70 @@ const createManager = (
276const withHostPlatform = (platform: NodeJS.Platform) =>276const withHostPlatform = (platform: NodeJS.Platform) =>
277 Layer.succeed(HostProcessPlatform, platform);277 Layer.succeed(HostProcessPlatform, platform);
278278
279// The previous split/join algorithm is the reference for retained text.
280function retainedHistory(text: string, maxLines: number): string {
281 const terminated = text.endsWith("\n");
282 const lines = text.split("\n");
283 if (terminated) lines.pop();
284 const retained = lines.slice(Math.max(0, lines.length - maxLines)).join("\n");
285 return terminated ? `${retained}\n` : retained;
286}
287
288it("preserves history across arbitrary chunks, Unicode, ANSI sequences, and clear", () => {
289 let randomSeed = 0x20260904;
290 const fragments = [
291 "",
292 "a",
293 "\n",
294 "\n\n",
295 "\r",
296 "\r\n",
297 "café",
298 "名",
299 "🚀",
300 "\u001b[31m",
301 "\u001b[0m",
302 "\u001b]8;;url\u0007",
303 "\ud83d",
304 "\ude80",
305 ];
306 const nextFragment = () => {
307 randomSeed = (Math.imul(randomSeed, 1_664_525) + 1_013_904_223) >>> 0;
308 return fragments[randomSeed % fragments.length]!;
309 };
310
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);
315
316 for (let step = 0; step < 300; step += 1) {
317 if (step % 73 === 0) {
318 history.clear();
319 expected = "";
320 expect(history.value()).toBe(expected);
321 }
322 const chunk = nextFragment() + nextFragment();
323 history.append(chunk);
324 expected = retainedHistory(expected + chunk, maxLines);
325 expect(history.value()).toBe(expected);
326 }
327 }
328});
329
330it("preserves retained lines as older storage is compacted", () => {
331 for (const maxLines of [3, 5_000]) {
332 let expected = "";
333 const history = new TerminalManager.BoundedTerminalHistory(maxLines, expected);
334 for (let batch = 0; batch < 40; batch += 1) {
335 const chunk = Array.from({ length: 300 }, (_, line) => `${batch}:${line}\n`).join("");
336 history.append(chunk);
337 expected = retainedHistory(expected + chunk, maxLines);
338 expect(history.value()).toBe(expected);
339 }
340 }
341});
342
279it.layer(343it.layer(
280 Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))),344 Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))),
281 { excludeTestServices: true },345 { excludeTestServices: true },
...@@ -1090,6 +1154,25 @@ it.layer(...@@ -1090,6 +1154,25 @@ it.layer(
1090 }),1154 }),
1091 );1155 );
10921156
1157 it.effect("caps incrementally appended history without losing partial or empty lines", () =>
1158 Effect.gen(function* () {
1159 const { manager, ptyAdapter } = yield* createManager(3);
1160 yield* manager.open(openInput());
1161 const process = ptyAdapter.processes[0];
1162 expect(process).toBeDefined();
1163 if (!process) return;
1164
1165 process.emitData("line1\n");
1166 process.emitData("\n");
1167 process.emitData("line3");
1168 process.emitData("-continued\nline4");
1169 yield* manager.close({ threadId: "thread-1" });
1170
1171 const reopened = yield* manager.open(openInput());
1172 expect(reopened.history).toBe("\nline3-continued\nline4");
1173 }),
1174 );
1175
1093 it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () =>1176 it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () =>
1094 Effect.gen(function* () {1177 Effect.gen(function* () {
1095 const { manager, ptyAdapter } = yield* createManager();1178 const { manager, ptyAdapter } = yield* createManager();
apps/server/src/terminal/Manager.ts+85-26
...@@ -238,14 +238,14 @@ export interface TerminalStartInput extends TerminalOpenInput {...@@ -238,14 +238,14 @@ export interface TerminalStartInput extends TerminalOpenInput {
238 rows: number;238 rows: number;
239}239}
240240
241export interface TerminalSessionState {241interface TerminalSessionState {
242 threadId: string;242 threadId: string;
243 terminalId: string;243 terminalId: string;
244 cwd: string;244 cwd: string;
245 worktreePath: string | null;245 worktreePath: string | null;
246 status: TerminalSessionStatus;246 status: TerminalSessionStatus;
247 pid: number | null;247 pid: number | null;
248 history: string;248 history: BoundedTerminalHistory;
249 pendingHistoryControlSequence: string;249 pendingHistoryControlSequence: string;
250 pendingProcessEvents: Array<PendingProcessEvent>;250 pendingProcessEvents: Array<PendingProcessEvent>;
251 pendingProcessEventIndex: number;251 pendingProcessEventIndex: number;
...@@ -266,7 +266,7 @@ export interface TerminalSessionState {...@@ -266,7 +266,7 @@ export interface TerminalSessionState {
266}266}
267267
268interface PersistHistoryRequest {268interface PersistHistoryRequest {
269 history: string;269 history: BoundedTerminalHistory;
270 immediate: boolean;270 immediate: boolean;
271}271}
272272
...@@ -281,7 +281,7 @@ type DrainProcessEventAction =...@@ -281,7 +281,7 @@ type DrainProcessEventAction =
281 threadId: string;281 threadId: string;
282 terminalId: string;282 terminalId: string;
283 sequence: number;283 sequence: number;
284 history: string | null;284 history: BoundedTerminalHistory | null;
285 data: string;285 data: string;
286 }286 }
287 | {287 | {
...@@ -340,7 +340,7 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot {...@@ -340,7 +340,7 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot {
340 worktreePath: session.worktreePath,340 worktreePath: session.worktreePath,
341 status: session.status,341 status: session.status,
342 pid: session.pid,342 pid: session.pid,
343 history: session.history,343 history: session.history.value(),
344 exitCode: session.exitCode,344 exitCode: session.exitCode,
345 exitSignal: session.exitSignal,345 exitSignal: session.exitSignal,
346 label: terminalWireLabel(session),346 label: terminalWireLabel(session),
...@@ -796,6 +796,66 @@ function capHistory(history: string, maxLines: number): string {...@@ -796,6 +796,66 @@ function capHistory(history: string, maxLines: number): string {
796 return hasTrailingNewline ? `${capped}\n` : capped;796 return hasTrailingNewline ? `${capped}\n` : capped;
797}797}
798798
799export class BoundedTerminalHistory {
800 private readonly maxLines: number;
801 private lines: Array<string>;
802 private start = 0;
803 private trailingNewline: boolean;
804 private cachedValue: string | null = null;
805
806 constructor(maxLines: number, initial: string) {
807 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();
812 }
813
814 append(text: string): void {
815 if (text.length === 0) return;
816
817 const trailingNewline = text.endsWith("\n");
818 const appendedLines = text.split("\n");
819 if (trailingNewline) appendedLines.pop();
820
821 const activeLines = this.lines.length - this.start;
822 if (activeLines === 0 || this.trailingNewline) {
823 for (const line of appendedLines) this.lines.push(line);
824 } 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] ?? "");
828 }
829 }
830 this.trailingNewline = trailingNewline;
831
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;
836 }
837 if (this.start > 2_048 && this.start * 2 >= this.lines.length) {
838 this.lines = this.lines.slice(this.start);
839 this.start = 0;
840 }
841 this.cachedValue = null;
842 }
843
844 clear(): void {
845 this.lines = [];
846 this.start = 0;
847 this.trailingNewline = false;
848 this.cachedValue = "";
849 }
850
851 value(): string {
852 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;
855 return this.cachedValue;
856 }
857}
858
799function isCsiFinalByte(codePoint: number): boolean {859function isCsiFinalByte(codePoint: number): boolean {
800 return codePoint >= 0x40 && codePoint <= 0x7e;860 return codePoint >= 0x40 && codePoint <= 0x7e;
801}861}
...@@ -1372,22 +1432,24 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -1372,22 +1432,24 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
1372 return;1432 return;
1373 }1433 }
13741434
1375 yield* fileSystem.writeFileString(historyPath(threadId, terminalId), request.history).pipe(1435 yield* fileSystem
1376 Effect.catch((error) =>1436 .writeFileString(historyPath(threadId, terminalId), request.history.value())
1377 Effect.logWarning("failed to persist terminal history", {1437 .pipe(
1378 threadId,1438 Effect.catch((error) =>
1379 terminalId,1439 Effect.logWarning("failed to persist terminal history", {
1380 error,1440 threadId,
1381 }),1441 terminalId,
1382 ),1442 error,
1383 );1443 }),
1444 ),
1445 );
1384 }),1446 }),
1385 });1447 });
13861448
1387 const queuePersist = Effect.fn("terminal.queuePersist")(function* (1449 const queuePersist = Effect.fn("terminal.queuePersist")(function* (
1388 threadId: string,1450 threadId: string,
1389 terminalId: string,1451 terminalId: string,
1390 history: string,1452 history: BoundedTerminalHistory,
1391 ) {1453 ) {
1392 yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), {1454 yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), {
1393 history,1455 history,
...@@ -1405,7 +1467,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -1405,7 +1467,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
1405 const persistHistory = Effect.fn("terminal.persistHistory")(function* (1467 const persistHistory = Effect.fn("terminal.persistHistory")(function* (
1406 threadId: string,1468 threadId: string,
1407 terminalId: string,1469 terminalId: string,
1408 history: string,1470 history: BoundedTerminalHistory,
1409 ) {1471 ) {
1410 yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), {1472 yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), {
1411 history,1473 history,
...@@ -1661,10 +1723,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -1661,10 +1723,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
1661 );1723 );
1662 session.pendingHistoryControlSequence = sanitized.pendingControlSequence;1724 session.pendingHistoryControlSequence = sanitized.pendingControlSequence;
1663 if (sanitized.visibleText.length > 0) {1725 if (sanitized.visibleText.length > 0) {
1664 session.history = capHistory(1726 session.history.append(sanitized.visibleText);
1665 `${session.history}${sanitized.visibleText}`,
1666 historyLineLimit,
1667 );
1668 }1727 }
1669 const eventStamp = advanceEventSequence(session);1728 const eventStamp = advanceEventSequence(session);
16701729
...@@ -2160,7 +2219,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -2160,7 +2219,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
2160 worktreePath: input.worktreePath ?? null,2219 worktreePath: input.worktreePath ?? null,
2161 status: "starting",2220 status: "starting",
2162 pid: null,2221 pid: null,
2163 history,2222 history: new BoundedTerminalHistory(historyLineLimit, history),
2164 pendingHistoryControlSequence: "",2223 pendingHistoryControlSequence: "",
2165 pendingProcessEvents: [],2224 pendingProcessEvents: [],
2166 pendingProcessEventIndex: 0,2225 pendingProcessEventIndex: 0,
...@@ -2221,7 +2280,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -2221,7 +2280,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
2221 liveSession.cwd = input.cwd;2280 liveSession.cwd = input.cwd;
2222 liveSession.worktreePath = nextWorktreePath;2281 liveSession.worktreePath = nextWorktreePath;
2223 liveSession.runtimeEnv = nextRuntimeEnv;2282 liveSession.runtimeEnv = nextRuntimeEnv;
2224 liveSession.history = "";2283 liveSession.history.clear();
2225 liveSession.pendingHistoryControlSequence = "";2284 liveSession.pendingHistoryControlSequence = "";
2226 liveSession.pendingProcessEvents = [];2285 liveSession.pendingProcessEvents = [];
2227 liveSession.pendingProcessEventIndex = 0;2286 liveSession.pendingProcessEventIndex = 0;
...@@ -2230,7 +2289,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -2230,7 +2289,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
2230 } else if (liveSession.status === "exited" || liveSession.status === "error") {2289 } else if (liveSession.status === "exited" || liveSession.status === "error") {
2231 liveSession.runtimeEnv = nextRuntimeEnv;2290 liveSession.runtimeEnv = nextRuntimeEnv;
2232 liveSession.worktreePath = nextWorktreePath;2291 liveSession.worktreePath = nextWorktreePath;
2233 liveSession.history = "";2292 liveSession.history.clear();
2234 liveSession.pendingHistoryControlSequence = "";2293 liveSession.pendingHistoryControlSequence = "";
2235 liveSession.pendingProcessEvents = [];2294 liveSession.pendingProcessEvents = [];
2236 liveSession.pendingProcessEventIndex = 0;2295 liveSession.pendingProcessEventIndex = 0;
...@@ -2535,7 +2594,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -2535,7 +2594,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
2535 Effect.gen(function* () {2594 Effect.gen(function* () {
2536 const terminalId = input.terminalId;2595 const terminalId = input.terminalId;
2537 const session = yield* requireSession(input.threadId, terminalId);2596 const session = yield* requireSession(input.threadId, terminalId);
2538 session.history = "";2597 session.history.clear();
2539 session.pendingHistoryControlSequence = "";2598 session.pendingHistoryControlSequence = "";
2540 session.pendingProcessEvents = [];2599 session.pendingProcessEvents = [];
2541 session.pendingProcessEventIndex = 0;2600 session.pendingProcessEventIndex = 0;
...@@ -2572,7 +2631,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -2572,7 +2631,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
2572 worktreePath: input.worktreePath ?? null,2631 worktreePath: input.worktreePath ?? null,
2573 status: "starting",2632 status: "starting",
2574 pid: null,2633 pid: null,
2575 history: "",2634 history: new BoundedTerminalHistory(historyLineLimit, ""),
2576 pendingHistoryControlSequence: "",2635 pendingHistoryControlSequence: "",
2577 pendingProcessEvents: [],2636 pendingProcessEvents: [],
2578 pendingProcessEventIndex: 0,2637 pendingProcessEventIndex: 0,
...@@ -2608,7 +2667,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -2608,7 +2667,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
2608 const cols = input.cols ?? session.cols;2667 const cols = input.cols ?? session.cols;
2609 const rows = input.rows ?? session.rows;2668 const rows = input.rows ?? session.rows;
26102669
2611 session.history = "";2670 session.history.clear();
2612 session.pendingHistoryControlSequence = "";2671 session.pendingHistoryControlSequence = "";
2613 session.pendingProcessEvents = [];2672 session.pendingProcessEvents = [];
2614 session.pendingProcessEventIndex = 0;2673 session.pendingProcessEventIndex = 0;
docs/internals/terminal-runtime.md created+48
...@@ -0,0 +1,48 @@
1# Terminal runtime
2
3The environment server owns terminal processes, retained output history, and
4session lifecycle. Web, desktop, and mobile clients attach to the same
5server-owned session over the environment RPC connection. The desktop renderer
6does not own a separate PTY. Clients can reconnect to a running PTY or share
7it with another client.
8
9## Output path
10
11PTY output follows this path:
12
13```text
14PTY callback
15 -> ordered process-event drain
16 -> bounded retained-history append
17 -> live terminal output event
18 -> coalesced history persistence
19```
20
21Live output events contain only the new PTY data. Full retained history is
22materialized only when the server returns a snapshot or when the coalescing
23persistence worker writes the latest state.
24
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.
30
31Discard each line's string reference as soon as the line leaves retained
32history. Array compaction can run later.
33
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.
38
39Measure sustained-output changes against a full retained history so terminal
40throughput does not regress unnoticed.
41
42## Persistence
43
44History persistence is keyed by terminal session and coalesces pending writes.
45The worker reads the newest bounded-history state after its debounce instead
46of receiving a newly materialized full string for every PTY callback. Clear,
47restart, close, and final flush operations still force the latest state to
48disk before their lifecycle boundary completes.