Skip to content

Commit 4cd9442

Browse files
committed
fix(server): release evicted terminal history lines
Clear each evicted line's string reference without changing retained output. Compare the buffer with the previous history algorithm across chunk boundaries, Unicode, ANSI text, clear, and compaction. Created with GPT-6 Astra (preview) in Codex.
1 parent 0fbea94 commit 4cd9442

3 files changed

Lines changed: 86 additions & 17 deletions

File tree

‎apps/server/src/terminal/Manager.test.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,70 @@ const createManager = (
276276
const withHostPlatform = (platform: NodeJS.Platform) =>
277277
Layer.succeed(HostProcessPlatform, platform);
278278

279+
// The previous split/join algorithm is the reference for retained text.
280+
function 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+
288+
it("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+
330+
it("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+
279343
it.layer(
280344
Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))),
281345
{ excludeTestServices: true },

‎apps/server/src/terminal/Manager.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -796,7 +796,7 @@ function capHistory(history: string, maxLines: number): string {
796796
return hasTrailingNewline ? `${capped}\n` : capped;
797797
}
798798

799-
class BoundedTerminalHistory {
799+
export class BoundedTerminalHistory {
800800
private readonly maxLines: number;
801801
private lines: Array<string>;
802802
private start = 0;
@@ -830,7 +830,10 @@ class BoundedTerminalHistory {
830830
this.trailingNewline = trailingNewline;
831831

832832
const overflow = this.lines.length - this.start - this.maxLines;
833-
if (overflow > 0) this.start += overflow;
833+
if (overflow > 0) {
834+
this.lines.fill("", this.start, this.start + overflow);
835+
this.start += overflow;
836+
}
834837
if (this.start > 2_048 && this.start * 2 >= this.lines.length) {
835838
this.lines = this.lines.slice(this.start);
836839
this.start = 0;

‎docs/internals/terminal-runtime.md‎

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# Terminal runtime
22

33
The environment server owns terminal processes, retained output history, and
4-
session lifecycle. Web and desktop clients attach to the same server-owned
5-
session over the environment RPC connection. Desktop must not replace this
6-
with a renderer-owned PTY: reconnect, remote access, and multi-client attach
7-
all depend on the environment remaining authoritative.
4+
session lifecycle. Web, desktop, and mobile clients attach to the same
5+
server-owned session over the environment RPC connection. The desktop renderer
6+
does not own a separate PTY. Clients can reconnect to a running PTY or share
7+
it with another client.
88

99
## Output path
1010

@@ -18,8 +18,8 @@ PTY callback
1818
-> coalesced history persistence
1919
```
2020

21-
Live output events contain only the new PTY data. A full retained-history
22-
snapshot is materialized only when a client attaches or when the coalescing
21+
Live output events contain only the new PTY data. Full retained history is
22+
materialized only when the server returns a snapshot or when the coalescing
2323
persistence worker writes the latest state.
2424

2525
Retained history uses an incremental bounded line buffer. Appending one PTY
@@ -28,19 +28,21 @@ copy the entire retained history for every callback. Empty lines, incomplete
2828
final lines, trailing newlines, and the configured line limit are preserved
2929
exactly when history is materialized.
3030

31-
This is a performance invariant, not an implementation detail to discard
32-
during refactors. Measure sustained-output changes against a full retained
33-
history so terminal throughput does not regress unnoticed.
31+
Discard each line's string reference as soon as the line leaves retained
32+
history. Array compaction can run later.
3433

35-
## Persistence and transport changes
34+
The server's line limit does not limit the byte size of retained history.
35+
Shared web and mobile client state currently retains at most 512 KiB. A server
36+
byte limit would change persisted scrollback and needs a separate retention
37+
decision.
38+
39+
Measure sustained-output changes against a full retained history so terminal
40+
throughput does not regress unnoticed.
41+
42+
## Persistence
3643

3744
History persistence is keyed by terminal session and coalesces pending writes.
3845
The worker reads the newest bounded-history state after its debounce instead
3946
of receiving a newly materialized full string for every PTY callback. Clear,
4047
restart, close, and final flush operations still force the latest state to
4148
disk before their lifecycle boundary completes.
42-
43-
If measurements later justify a binary terminal data channel, keep snapshots,
44-
lifecycle commands, and PTY ownership on the environment server. Do not share
45-
the terminal data channel with port-forward payloads: a bulk TCP transfer must
46-
not head-of-line block interactive terminal input or output.

0 commit comments

Comments
 (0)