diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 47d91e4516ec92b72dc96e724913ca5424094dc1..8663fe53d3a9ee642ab92e989160b4bd68b881c4 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -276,6 +276,70 @@ const createManager = ( const withHostPlatform = (platform: NodeJS.Platform) => Layer.succeed(HostProcessPlatform, platform); +// The previous split/join algorithm is the reference for retained text. +function retainedHistory(text: string, maxLines: number): string { + const terminated = text.endsWith("\n"); + const lines = text.split("\n"); + if (terminated) lines.pop(); + const retained = lines.slice(Math.max(0, lines.length - maxLines)).join("\n"); + return terminated ? `${retained}\n` : retained; +} + +it("preserves history across arbitrary chunks, Unicode, ANSI sequences, and clear", () => { + let randomSeed = 0x20260904; + const fragments = [ + "", + "a", + "\n", + "\n\n", + "\r", + "\r\n", + "café", + "名", + "🚀", + "\u001b[31m", + "\u001b[0m", + "\u001b]8;;url\u0007", + "\ud83d", + "\ude80", + ]; + const nextFragment = () => { + randomSeed = (Math.imul(randomSeed, 1_664_525) + 1_013_904_223) >>> 0; + return fragments[randomSeed % fragments.length]!; + }; + + for (const maxLines of [0, 1, 3, 5, 5_000]) { + let expected = retainedHistory("before\ninitial\n", maxLines); + const history = new TerminalManager.BoundedTerminalHistory(maxLines, expected); + expect(history.value()).toBe(expected); + + for (let step = 0; step < 300; step += 1) { + if (step % 73 === 0) { + history.clear(); + expected = ""; + expect(history.value()).toBe(expected); + } + const chunk = nextFragment() + nextFragment(); + history.append(chunk); + expected = retainedHistory(expected + chunk, maxLines); + expect(history.value()).toBe(expected); + } + } +}); + +it("preserves retained lines as older storage is compacted", () => { + for (const maxLines of [3, 5_000]) { + let expected = ""; + const history = new TerminalManager.BoundedTerminalHistory(maxLines, expected); + for (let batch = 0; batch < 40; batch += 1) { + const chunk = Array.from({ length: 300 }, (_, line) => `${batch}:${line}\n`).join(""); + history.append(chunk); + expected = retainedHistory(expected + chunk, maxLines); + expect(history.value()).toBe(expected); + } + } +}); + it.layer( Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))), { excludeTestServices: true }, @@ -1090,6 +1154,25 @@ it.layer( }), ); + it.effect("caps incrementally appended history without losing partial or empty lines", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(3); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("line1\n"); + process.emitData("\n"); + process.emitData("line3"); + process.emitData("-continued\nline4"); + yield* manager.close({ threadId: "thread-1" }); + + const reopened = yield* manager.open(openInput()); + expect(reopened.history).toBe("\nline3-continued\nline4"); + }), + ); + it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 64c2dbb913fb457a9e9bce9e989ae809fd39c076..a89c6ae4eb783dbcda5646782faed9710f2dda5e 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -238,14 +238,14 @@ export interface TerminalStartInput extends TerminalOpenInput { rows: number; } -export interface TerminalSessionState { +interface TerminalSessionState { threadId: string; terminalId: string; cwd: string; worktreePath: string | null; status: TerminalSessionStatus; pid: number | null; - history: string; + history: BoundedTerminalHistory; pendingHistoryControlSequence: string; pendingProcessEvents: Array; pendingProcessEventIndex: number; @@ -266,7 +266,7 @@ export interface TerminalSessionState { } interface PersistHistoryRequest { - history: string; + history: BoundedTerminalHistory; immediate: boolean; } @@ -281,7 +281,7 @@ type DrainProcessEventAction = threadId: string; terminalId: string; sequence: number; - history: string | null; + history: BoundedTerminalHistory | null; data: string; } | { @@ -340,7 +340,7 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { worktreePath: session.worktreePath, status: session.status, pid: session.pid, - history: session.history, + history: session.history.value(), exitCode: session.exitCode, exitSignal: session.exitSignal, label: terminalWireLabel(session), @@ -796,6 +796,66 @@ function capHistory(history: string, maxLines: number): string { return hasTrailingNewline ? `${capped}\n` : capped; } +export class BoundedTerminalHistory { + private readonly maxLines: number; + private lines: Array; + private start = 0; + private trailingNewline: boolean; + private cachedValue: string | null = null; + + constructor(maxLines: number, initial: string) { + this.maxLines = maxLines; + const capped = capHistory(initial, maxLines); + this.trailingNewline = capped.endsWith("\n"); + this.lines = capped.length === 0 ? [] : capped.split("\n"); + if (this.trailingNewline) this.lines.pop(); + } + + append(text: string): void { + if (text.length === 0) return; + + const trailingNewline = text.endsWith("\n"); + const appendedLines = text.split("\n"); + if (trailingNewline) appendedLines.pop(); + + const activeLines = this.lines.length - this.start; + if (activeLines === 0 || this.trailingNewline) { + for (const line of appendedLines) this.lines.push(line); + } else { + this.lines[this.lines.length - 1] += appendedLines[0] ?? ""; + for (let index = 1; index < appendedLines.length; index += 1) { + this.lines.push(appendedLines[index] ?? ""); + } + } + this.trailingNewline = trailingNewline; + + const overflow = this.lines.length - this.start - this.maxLines; + if (overflow > 0) { + this.lines.fill("", this.start, this.start + overflow); + this.start += overflow; + } + if (this.start > 2_048 && this.start * 2 >= this.lines.length) { + this.lines = this.lines.slice(this.start); + this.start = 0; + } + this.cachedValue = null; + } + + clear(): void { + this.lines = []; + this.start = 0; + this.trailingNewline = false; + this.cachedValue = ""; + } + + value(): string { + if (this.cachedValue !== null) return this.cachedValue; + const joined = this.lines.slice(this.start).join("\n"); + this.cachedValue = this.trailingNewline ? `${joined}\n` : joined; + return this.cachedValue; + } +} + function isCsiFinalByte(codePoint: number): boolean { return codePoint >= 0x40 && codePoint <= 0x7e; } @@ -1372,22 +1432,24 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return; } - yield* fileSystem.writeFileString(historyPath(threadId, terminalId), request.history).pipe( - Effect.catch((error) => - Effect.logWarning("failed to persist terminal history", { - threadId, - terminalId, - error, - }), - ), - ); + yield* fileSystem + .writeFileString(historyPath(threadId, terminalId), request.history.value()) + .pipe( + Effect.catch((error) => + Effect.logWarning("failed to persist terminal history", { + threadId, + terminalId, + error, + }), + ), + ); }), }); const queuePersist = Effect.fn("terminal.queuePersist")(function* ( threadId: string, terminalId: string, - history: string, + history: BoundedTerminalHistory, ) { yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), { history, @@ -1405,7 +1467,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const persistHistory = Effect.fn("terminal.persistHistory")(function* ( threadId: string, terminalId: string, - history: string, + history: BoundedTerminalHistory, ) { yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), { history, @@ -1661,10 +1723,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ); session.pendingHistoryControlSequence = sanitized.pendingControlSequence; if (sanitized.visibleText.length > 0) { - session.history = capHistory( - `${session.history}${sanitized.visibleText}`, - historyLineLimit, - ); + session.history.append(sanitized.visibleText); } const eventStamp = advanceEventSequence(session); @@ -2160,7 +2219,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func worktreePath: input.worktreePath ?? null, status: "starting", pid: null, - history, + history: new BoundedTerminalHistory(historyLineLimit, history), pendingHistoryControlSequence: "", pendingProcessEvents: [], pendingProcessEventIndex: 0, @@ -2221,7 +2280,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func liveSession.cwd = input.cwd; liveSession.worktreePath = nextWorktreePath; liveSession.runtimeEnv = nextRuntimeEnv; - liveSession.history = ""; + liveSession.history.clear(); liveSession.pendingHistoryControlSequence = ""; liveSession.pendingProcessEvents = []; liveSession.pendingProcessEventIndex = 0; @@ -2230,7 +2289,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } else if (liveSession.status === "exited" || liveSession.status === "error") { liveSession.runtimeEnv = nextRuntimeEnv; liveSession.worktreePath = nextWorktreePath; - liveSession.history = ""; + liveSession.history.clear(); liveSession.pendingHistoryControlSequence = ""; liveSession.pendingProcessEvents = []; liveSession.pendingProcessEventIndex = 0; @@ -2535,7 +2594,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func Effect.gen(function* () { const terminalId = input.terminalId; const session = yield* requireSession(input.threadId, terminalId); - session.history = ""; + session.history.clear(); session.pendingHistoryControlSequence = ""; session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; @@ -2572,7 +2631,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func worktreePath: input.worktreePath ?? null, status: "starting", pid: null, - history: "", + history: new BoundedTerminalHistory(historyLineLimit, ""), pendingHistoryControlSequence: "", pendingProcessEvents: [], pendingProcessEventIndex: 0, @@ -2608,7 +2667,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const cols = input.cols ?? session.cols; const rows = input.rows ?? session.rows; - session.history = ""; + session.history.clear(); session.pendingHistoryControlSequence = ""; session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; diff --git a/docs/internals/terminal-runtime.md b/docs/internals/terminal-runtime.md new file mode 100644 index 0000000000000000000000000000000000000000..01bbbb1a850867bf1f7945ce42d3e725c8f4ec97 --- /dev/null +++ b/docs/internals/terminal-runtime.md @@ -0,0 +1,48 @@ +# Terminal runtime + +The environment server owns terminal processes, retained output history, and +session lifecycle. Web, desktop, and mobile clients attach to the same +server-owned session over the environment RPC connection. The desktop renderer +does not own a separate PTY. Clients can reconnect to a running PTY or share +it with another client. + +## Output path + +PTY output follows this path: + +```text +PTY callback + -> ordered process-event drain + -> bounded retained-history append + -> live terminal output event + -> coalesced history persistence +``` + +Live output events contain only the new PTY data. Full retained history is +materialized only when the server returns a snapshot or when the coalescing +persistence worker writes the latest state. + +Retained history uses an incremental bounded line buffer. Appending one PTY +chunk may scan and allocate for that new chunk, but it must not split, join, or +copy the entire retained history for every callback. Empty lines, incomplete +final lines, trailing newlines, and the configured line limit are preserved +exactly when history is materialized. + +Discard each line's string reference as soon as the line leaves retained +history. Array compaction can run later. + +The server's line limit does not limit the byte size of retained history. +Shared web and mobile client state currently retains at most 512 KiB. A server +byte limit would change persisted scrollback and needs a separate retention +decision. + +Measure sustained-output changes against a full retained history so terminal +throughput does not regress unnoticed. + +## Persistence + +History persistence is keyed by terminal session and coalesces pending writes. +The worker reads the newest bounded-history state after its debounce instead +of receiving a newly materialized full string for every PTY callback. Clear, +restart, close, and final flush operations still force the latest state to +disk before their lifecycle boundary completes.