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 = (
276276const withHostPlatform = (platform: NodeJS.Platform) =>
277277 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
279343it.layer(
280344 Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))),
281345 { excludeTestServices: true },
......@@ -1090,6 +1154,25 @@ it.layer(
10901154 }),
10911155 );
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
10931176 it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () =>
10941177 Effect.gen(function* () {
10951178 const { manager, ptyAdapter } = yield* createManager();
apps/server/src/terminal/Manager.ts+85-26
......@@ -238,14 +238,14 @@ export interface TerminalStartInput extends TerminalOpenInput {
238238 rows: number;
239239}
240240
241export interface TerminalSessionState {
241interface TerminalSessionState {
242242 threadId: string;
243243 terminalId: string;
244244 cwd: string;
245245 worktreePath: string | null;
246246 status: TerminalSessionStatus;
247247 pid: number | null;
248 history: string;
248 history: BoundedTerminalHistory;
249249 pendingHistoryControlSequence: string;
250250 pendingProcessEvents: Array<PendingProcessEvent>;
251251 pendingProcessEventIndex: number;
......@@ -266,7 +266,7 @@ export interface TerminalSessionState {
266266}
267267
268268interface PersistHistoryRequest {
269 history: string;
269 history: BoundedTerminalHistory;
270270 immediate: boolean;
271271}
272272
......@@ -281,7 +281,7 @@ type DrainProcessEventAction =
281281 threadId: string;
282282 terminalId: string;
283283 sequence: number;
284 history: string | null;
284 history: BoundedTerminalHistory | null;
285285 data: string;
286286 }
287287 | {
......@@ -340,7 +340,7 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot {
340340 worktreePath: session.worktreePath,
341341 status: session.status,
342342 pid: session.pid,
343 history: session.history,
343 history: session.history.value(),
344344 exitCode: session.exitCode,
345345 exitSignal: session.exitSignal,
346346 label: terminalWireLabel(session),
......@@ -796,6 +796,66 @@ function capHistory(history: string, maxLines: number): string {
796796 return hasTrailingNewline ? `${capped}\n` : capped;
797797}
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
799859function isCsiFinalByte(codePoint: number): boolean {
800860 return codePoint >= 0x40 && codePoint <= 0x7e;
801861}
......@@ -1372,22 +1432,24 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
13721432 return;
13731433 }
13741434
1375 yield* fileSystem.writeFileString(historyPath(threadId, terminalId), request.history).pipe(
1376 Effect.catch((error) =>
1377 Effect.logWarning("failed to persist terminal history", {
1378 threadId,
1379 terminalId,
1380 error,
1381 }),
1382 ),
1383 );
1435 yield* fileSystem
1436 .writeFileString(historyPath(threadId, terminalId), request.history.value())
1437 .pipe(
1438 Effect.catch((error) =>
1439 Effect.logWarning("failed to persist terminal history", {
1440 threadId,
1441 terminalId,
1442 error,
1443 }),
1444 ),
1445 );
13841446 }),
13851447 });
13861448
13871449 const queuePersist = Effect.fn("terminal.queuePersist")(function* (
13881450 threadId: string,
13891451 terminalId: string,
1390 history: string,
1452 history: BoundedTerminalHistory,
13911453 ) {
13921454 yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), {
13931455 history,
......@@ -1405,7 +1467,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
14051467 const persistHistory = Effect.fn("terminal.persistHistory")(function* (
14061468 threadId: string,
14071469 terminalId: string,
1408 history: string,
1470 history: BoundedTerminalHistory,
14091471 ) {
14101472 yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), {
14111473 history,
......@@ -1661,10 +1723,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
16611723 );
16621724 session.pendingHistoryControlSequence = sanitized.pendingControlSequence;
16631725 if (sanitized.visibleText.length > 0) {
1664 session.history = capHistory(
1665 `${session.history}${sanitized.visibleText}`,
1666 historyLineLimit,
1667 );
1726 session.history.append(sanitized.visibleText);
16681727 }
16691728 const eventStamp = advanceEventSequence(session);
16701729
......@@ -2160,7 +2219,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
21602219 worktreePath: input.worktreePath ?? null,
21612220 status: "starting",
21622221 pid: null,
2163 history,
2222 history: new BoundedTerminalHistory(historyLineLimit, history),
21642223 pendingHistoryControlSequence: "",
21652224 pendingProcessEvents: [],
21662225 pendingProcessEventIndex: 0,
......@@ -2221,7 +2280,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
22212280 liveSession.cwd = input.cwd;
22222281 liveSession.worktreePath = nextWorktreePath;
22232282 liveSession.runtimeEnv = nextRuntimeEnv;
2224 liveSession.history = "";
2283 liveSession.history.clear();
22252284 liveSession.pendingHistoryControlSequence = "";
22262285 liveSession.pendingProcessEvents = [];
22272286 liveSession.pendingProcessEventIndex = 0;
......@@ -2230,7 +2289,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
22302289 } else if (liveSession.status === "exited" || liveSession.status === "error") {
22312290 liveSession.runtimeEnv = nextRuntimeEnv;
22322291 liveSession.worktreePath = nextWorktreePath;
2233 liveSession.history = "";
2292 liveSession.history.clear();
22342293 liveSession.pendingHistoryControlSequence = "";
22352294 liveSession.pendingProcessEvents = [];
22362295 liveSession.pendingProcessEventIndex = 0;
......@@ -2535,7 +2594,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
25352594 Effect.gen(function* () {
25362595 const terminalId = input.terminalId;
25372596 const session = yield* requireSession(input.threadId, terminalId);
2538 session.history = "";
2597 session.history.clear();
25392598 session.pendingHistoryControlSequence = "";
25402599 session.pendingProcessEvents = [];
25412600 session.pendingProcessEventIndex = 0;
......@@ -2572,7 +2631,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
25722631 worktreePath: input.worktreePath ?? null,
25732632 status: "starting",
25742633 pid: null,
2575 history: "",
2634 history: new BoundedTerminalHistory(historyLineLimit, ""),
25762635 pendingHistoryControlSequence: "",
25772636 pendingProcessEvents: [],
25782637 pendingProcessEventIndex: 0,
......@@ -2608,7 +2667,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
26082667 const cols = input.cols ?? session.cols;
26092668 const rows = input.rows ?? session.rows;
26102669
2611 session.history = "";
2670 session.history.clear();
26122671 session.pendingHistoryControlSequence = "";
26132672 session.pendingProcessEvents = [];
26142673 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.