Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ bun run build:local # binaries + the app for this machine (needs X
compares both). The renderer skips rows whose cells and overlay key are unchanged: anything new
that changes a row's pixels goes into `rowKey()` or calls `invalidate()`. Hidden terminals do
not paint (`TerminalView.setVisible`, driven by `TerminalPanel.activate`/`setShown`).
- **Terminal replay.** Replay holds only the last 512 KB of output, which for a full-screen TUI may be
diffs with no full paint. `PtySession.requestRepaint` makes the program redraw with a real size
change; a same-size resize, or two in a row, does nothing. WebKit has no canvas
`contextlost`/`contextrestored` events: repaint on `TerminalView.onReveal` instead of relying on them.
- **Terminal input is the user's.** On macOS ⌘-shortcuts belong to the app; on Linux Ctrl+key
combinations stay with the shell unless the README lists them.

Expand Down
14 changes: 14 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -903,3 +903,17 @@ more work than the output needed. The terminal now follows these rules:

Measured on JavaScriptCore: parsing an 8,000-line reprint went from 41 to 21 ms, a spinner tick
repaints one row instead of the whole status region, and plain text parses at about 24 MB/s.

## 21. Round 12: a blank terminal in long agent sessions

A long Claude Code session could come back blank or with a few rows of content. Replaying real
Claude Code 2.1.316 through the server reproduced one cause.

| Finding | Fix |
| --- | --- |
| Full-screen TUIs send cell diffs against what they drew earlier. Once their last full paint has left the 512 KB replay ring, a reattach (page reload, the Mac app reloading after its web process dies, the backpressure resync) rebuilds a mostly blank screen: 0 of 30 rows for real Claude Code with only diffs in the ring. The same-size resize sent on attach is no signal, so nothing repaired it. | `PtySession.requestRepaint`: after a replay, if the ring was trimmed and the session is in the alternate screen, the PTY is one row shorter for 150 ms, and the program repaints: 30 of 30 rows, identical. The delay matters: two resizes in a row reach Claude Code as no change. Resizes arriving meanwhile are held and applied at the end. The normal screen is left alone (it replays as text, and a resize only reprints a prompt). |
| The row skipping of round 11 assumes the canvas keeps its pixels, and repaints after `contextrestored`. WebKit, so the Mac app, has neither `contextlost` nor `contextrestored` (checked in `WKWebView`): pixels discarded in the background would stay blank except for rows that change. Whether WebKit discards them here was not reproduced; the repaint is cheap either way. | `TerminalView.onReveal` calls `Renderer.recover()` (context settings, then every row) on window `focus` and when the page becomes visible. |
| (Review) `resize` and `write` on a killed session throw `Terminal is closed` while the shell is still dying, and a throw in a WebSocket handler or timer exits the server: every terminal dies. Reproduced; `resize` already had this. | `PtySession.resizePty`, and `write` catches it too. |
| (Review) `sendReplay` guessed "trimmed" from the byte count, cutting the first line of replays that were complete. | It uses `ringTrimmed`. |
| (Review) A reattach at a new size was nudged on top of its real resize. | Nudge only when the size did not change. |
| (Review) Not changed. The 150 ms gap is fixed, so a program stalled for longer sees no change and stays as it was. `ringTrimmed` is never reset, so a TUI started after a trim gets a needless repaint on reattach. Rate-limiting nudges was rejected: each resync resets the screen, so a skipped nudge leaves it blank. | Open: inline TUIs on the normal screen (Claude Code's classic renderer) and programs that ignore SIGWINCH are still not repaired. Keeping screen state on the server, with `Terminal`, would cover them without resizing. |
11 changes: 7 additions & 4 deletions src/client/term/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,13 @@ export class Renderer {
this.ctx = this.canvas.getContext("2d", { alpha: false })!;
// Unchanged rows are never repainted, so a canvas whose backing store was discarded (GPU
// reset, memory pressure) must be repainted in full when the browser restores it.
this.canvas.addEventListener?.("contextrestored", () => {
this.ctxDefaults();
this.invalidate();
});
this.canvas.addEventListener?.("contextrestored", () => this.recover());
}

/** Repaints after a possible loss of the canvas: its context settings go with its pixels. */
recover() {
this.ctxDefaults();
this.invalidate();
}

setTheme(t: Theme) {
Expand Down
11 changes: 11 additions & 0 deletions src/client/term/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,17 @@ export class TerminalView {
}
};
window.addEventListener("resize", this.onWindowResize);
// Unchanged rows are never repainted, so pixels the browser discarded while the window was in
// the background would stay blank. WebKit (the Mac app) has no contextlost/contextrestored
// events to report that, so repaint whenever the terminal may have been out of sight.
window.addEventListener("focus", this.onReveal);
document.addEventListener("visibilitychange", this.onReveal);
}

private onReveal = () => {
if (document.visibilityState !== "hidden") this.renderer.recover();
};

private onWindowResize: () => void = () => {};
private onWindowKeyUp = (e: KeyboardEvent) => {
if (e.code === "AltLeft") this.leftAltDown = false;
Expand Down Expand Up @@ -258,6 +267,8 @@ export class TerminalView {
cancelAnimationFrame(this.logFrame);
window.removeEventListener("resize", this.onWindowResize);
window.removeEventListener("keyup", this.onWindowKeyUp);
window.removeEventListener("focus", this.onReveal);
document.removeEventListener("visibilitychange", this.onReveal);
this.el.remove();
}

Expand Down
67 changes: 63 additions & 4 deletions src/server/pty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ const FLUSH_BYTES = 64 * 1024;
const HIGH_WATER = 8 * 1024 * 1024;
/** A shell that exits while nobody is attached (page reload) is kept this long for its exit notice. */
const EXIT_GRACE_MS = 60_000;
/**
* How long the PTY stays one row shorter when asking a TUI to repaint. Two resizes in a row reach
* Claude Code 2.1.316 as no change at all; 120 ms apart they repaint the whole screen (measured).
*/
const REPAINT_NUDGE_MS = 150;

/** Private modes that change how input/output must be interpreted; restored on replay. */
const TRACKED = [1, 25, 1000, 1002, 1003, 1004, 1006, 2004, 47, 1047, 1049];
Expand Down Expand Up @@ -38,6 +43,10 @@ class PtySession {
/** Replay ring: each chunk remembers the mode-restoring prefix in effect before it. */
private chunks: { data: Uint8Array; modes: string }[] = [];
private bytes = 0;
/** The ring has dropped output, so a replay no longer holds everything since the session began. */
private ringTrimmed = false;
private size: { cols: number; rows: number };
private nudge: ReturnType<typeof setTimeout> | null = null;
private modes = new Map<number, boolean>();
private modesStr = "";
private carry = "";
Expand All @@ -56,6 +65,7 @@ class PtySession {
rows: number,
private onGone: (id: string) => void,
) {
this.size = { cols, rows };
this.proc = Bun.spawn([opts.shell, "-l"], {
cwd: opts.cwd,
env: opts.env,
Expand All @@ -68,6 +78,7 @@ class PtySession {
this.term = (this.proc as unknown as { terminal: PtySession["term"] }).terminal;
this.proc.exited.then((code) => {
this.exited = code;
this.endNudge();
if (!this.ws) {
// Nobody attached (page reload mid-exit): keep the final output and exit code for the
// next attach, unless no one comes back within the grace period.
Expand All @@ -93,7 +104,10 @@ class PtySession {
const copy = data.slice();
this.chunks.push({ data: copy, modes: this.modesStr });
this.bytes += copy.length;
while (this.bytes > REPLAY_BYTES && this.chunks.length > 1) this.bytes -= this.chunks.shift()!.data.length;
while (this.bytes > REPLAY_BYTES && this.chunks.length > 1) {
this.bytes -= this.chunks.shift()!.data.length;
this.ringTrimmed = true;
}
this.trackModes(copy);
if (!this.ws || this.lagging) return;
this.pending.push(copy);
Expand Down Expand Up @@ -147,14 +161,37 @@ class PtySession {
if (!this.lagging || !this.ws || this.ws.getBufferedAmount() > HIGH_WATER / 4) return;
this.lagging = false;
this.sendReplay(this.ws);
this.requestRepaint();
}

/**
* Full-screen TUIs send cell diffs against what they drew earlier, so once that first paint has
* left the ring a replay rebuilds a mostly blank screen (Claude Code: 1 row of 31). Only the
* program can redraw it, and it does so when its size changes. A shell prompt is left alone: the
* normal screen replays fine as plain text, and a resize would only reprint the prompt.
*/
private requestRepaint() {
const alt = this.modes.get(1049) || this.modes.get(1047) || this.modes.get(47);
if (!this.ringTrimmed || !alt || this.nudge || this.exited !== null || this.size.rows < 2) return;
this.resizePty(this.size.cols, this.size.rows - 1);
this.nudge = setTimeout(() => {
this.nudge = null;
this.resizePty(this.size.cols, this.size.rows);
}, REPAINT_NUDGE_MS);
this.nudge.unref?.();
}

private endNudge() {
if (this.nudge) clearTimeout(this.nudge);
this.nudge = null;
}

/** Mode prefix + the retained tail, starting at a line boundary when history was trimmed. */
private sendReplay(ws: ServerWebSocket<PtySocketData>) {
if (!this.chunks.length) return;
const first = this.chunks[0]!;
let head = first.data;
if (this.bytes >= REPLAY_BYTES - 64 * 1024) {
if (this.ringTrimmed) {
// History was trimmed: the first chunk may start mid-escape or mid-UTF-8.
const nl = head.indexOf(0x0a);
if (nl >= 0) head = head.subarray(nl + 1);
Expand All @@ -178,22 +215,44 @@ class PtySession {
this.pending = [];
this.pendingBytes = 0;
this.sendReplay(ws);
// A real size change already makes the program repaint: a nudge on top only flickers.
const sameSize = ws.data.cols === this.size.cols && ws.data.rows === this.size.rows;
this.resize(ws.data.cols, ws.data.rows);
if (sameSize) this.requestRepaint();
}

detach(ws: ServerWebSocket<PtySocketData>) {
if (this.ws === ws) this.ws = null;
}

write(data: string | Uint8Array) {
if (this.exited === null) this.term.write(data);
if (this.exited !== null) return;
try {
this.term.write(data);
} catch {} // the PTY closed (see resizePty)
}

resize(cols: number, rows: number) {
if (cols > 0 && rows > 0 && this.exited === null) this.term.resize(cols, rows);
if (!(cols > 0 && rows > 0) || this.exited !== null) return;
this.size = { cols, rows };
// During a repaint nudge the end of the nudge applies the latest size: resizing now would
// undo the size change before the program sees it (the page sends its size right after connecting).
if (!this.nudge) this.resizePty(cols, rows);
}

/**
* kill() closes the PTY at once, and the shell can take up to 1.5 s to die: until then `exited`
* is still null and a resize throws. A throw in a WebSocket handler or timer exits the server.
*/
private resizePty(cols: number, rows: number) {
if (this.exited !== null) return;
try {
this.term.resize(cols, rows);
} catch {}
}

kill() {
this.endNudge();
if (this.grace) {
clearTimeout(this.grace);
this.onGone(this.id);
Expand Down
51 changes: 50 additions & 1 deletion test/client.termview.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { installDom, uninstallDom, FakeElement } from "./helpers/fakedom.ts";
import { installDom, uninstallDom, FakeElement, FakeEvent } from "./helpers/fakedom.ts";

// TerminalView per-frame work: the scrollbar must not force a layout every frame, and the
// screen-reader log must not stringify output it will drop.
Expand Down Expand Up @@ -134,6 +134,55 @@ describe("terminal visibility and the screen-reader log", () => {
expect(v.renderer.rowsPainted).toBeGreaterThan(0);
});

// WebKit has no contextrestored: coming back is the only signal that pixels may be gone.
test("returning to the window or tab repaints every row, not only the ones that change", () => {
const { v } = mk();
v.write("some output\r\n");
frame();
frame();
for (const fire of [
() => window.dispatchEvent(new FakeEvent("focus")),
() => document.dispatchEvent(new FakeEvent("visibilitychange")),
]) {
v.renderer.rowsPainted = 0;
fire();
frame();
expect(v.renderer.rowsPainted).toBe(v.term.rows);
}
v.dispose();
});

// If the canvas was discarded its context state went too: unset, the cached font and spacing lie.
test("returning to the window restores the canvas settings, not only the pixels", () => {
const { v } = mk();
frame();
const ctx = (v.renderer as unknown as { ctx: { font: string } }).ctx;
ctx.font = "10px sans-serif";
window.dispatchEvent(new FakeEvent("focus"));
expect(ctx.font).not.toBe("10px sans-serif");
v.dispose();
});

test("a page going into the background, or a disposed view, does not repaint", () => {
const { v } = mk();
frame();
frame();
v.renderer.rowsPainted = 0;
const proto = Object.getPrototypeOf(document);
Object.defineProperty(proto, "visibilityState", { value: "hidden", configurable: true });
try {
document.dispatchEvent(new FakeEvent("visibilitychange"));
} finally {
delete proto.visibilityState;
}
frame();
expect(v.renderer.rowsPainted).toBe(0);
v.dispose();
window.dispatchEvent(new FakeEvent("focus"));
frame();
expect(v.renderer.rowsPainted).toBe(0);
});

test("a burst of output keeps the newest 200 lines in the log", () => {
const { v, priv } = mk();
let s = "";
Expand Down
91 changes: 91 additions & 0 deletions test/pty.coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,97 @@ test("large output is coalesced into frames and bounded by the replay ring", asy
expect(/^(\x1b\[\?\d+h)*line-\d+-/.test(text)).toBe(true);
});

test("a lagging client's resync makes a TUI repaint when the replay no longer holds its first paint", async () => {
const { m, a } = await trimmedTui("repaint-after-lag");

// The socket falls behind: the next output marks the client lagging, and nothing more is sent.
a.buffered = 9 * 1024 * 1024;
m.message(asWs(a), Buffer.from("x"));
await Bun.sleep(100);
a.bins = [];
a.texts = [];
m.message(asWs(a), Buffer.from("x"));
await Bun.sleep(100);
expect(a.bins.length).toBe(0);

a.buffered = 0;
m.drain(asWs(a));
expect(a.texts).toContain(JSON.stringify({ t: "replay" }));
await until(() => a.output().includes("SIZE=23x80") && a.output().includes("SIZE=24x80"));
}, 40_000);

const TUI = `trap 'echo "SIZE=$(stty size | tr " " x)"' WINCH
printf '\033[?1049h'
i=0; while [ $i -lt 9000 ]; do echo "line $i ............................................................"; i=$((i+1)); done
echo FLOODED
while :; do sleep 0.05; done
`;
/** A session running a TUI whose first paint has left the replay ring. */
async function trimmedTui(id: string) {
const m = await manager();
await Bun.write(path.join(cwd, "tui.sh"), TUI);
const a = fakeWs(id);
m.open(asWs(a));
m.message(asWs(a), Buffer.from("sh ./tui.sh\n"));
await until(() => a.output().includes("FLOODED"), 20000);
return { m, a };
}

// A throw in a WebSocket handler or timer exits the server, and every terminal with it. The PTY
// is closed at once by kill() while the shell can take up to 1.5 s to die.
test("a session being killed survives a drain, an attach, a resize and input", async () => {
const { m, a } = await trimmedTui("killed-session");
a.buffered = 9 * 1024 * 1024;
m.message(asWs(a), Buffer.from("x"));
await Bun.sleep(100);
m.message(asWs(a), JSON.stringify({ t: "kill" }));
a.buffered = 0;
expect(() => m.drain(asWs(a))).not.toThrow();
expect(() => m.message(asWs(a), JSON.stringify({ t: "resize", cols: 90, rows: 30 }))).not.toThrow();
expect(() => m.message(asWs(a), Buffer.from("x"))).not.toThrow();
const b = fakeWs("killed-session", 100, 40);
expect(() => m.open(asWs(b))).not.toThrow();
}, 40_000);

test("a kill during the repaint nudge cancels the size it would restore", async () => {
const { m, a } = await trimmedTui("killed-mid-nudge");
m.close(asWs(a));
const b = fakeWs("killed-mid-nudge");
m.open(asWs(b)); // starts the nudge
m.message(asWs(b), JSON.stringify({ t: "kill" }));
await Bun.sleep(300); // past the nudge: an uncaught throw in its timer fails this test
}, 40_000);

test("reattaching at a new size relies on that resize: no extra repaint nudge", async () => {
const { m, a } = await trimmedTui("attach-new-size");
m.close(asWs(a));
const b = fakeWs("attach-new-size", 80, 30);
m.open(asWs(b));
await until(() => b.output().includes("SIZE=30x80"));
await Bun.sleep(400);
expect(b.output()).not.toContain("SIZE=29x80");
expect(b.output().match(/SIZE=30x80/g)!.length).toBe(1);
}, 40_000);

test("a replay close to the ring's size is not cut when nothing was dropped", async () => {
const m = await manager();
await Bun.write(path.join(cwd, "big.sh"), `echo FIRSTLINE
i=0; while [ $i -lt 6800 ]; do echo "line $i ............................................................"; i=$((i+1)); done
echo FLOODED
`);
const a = fakeWs("almost-full-ring");
m.open(asWs(a));
m.message(asWs(a), Buffer.from("sh ./big.sh\n"));
await until(() => a.output().includes("FLOODED"), 20000);
const size = Buffer.concat(a.bins).length;
expect(size).toBeGreaterThan(450 * 1024); // inside the old "probably trimmed" guess
expect(size).toBeLessThan(510 * 1024);
m.close(asWs(a));
const b = fakeWs("almost-full-ring");
m.open(asWs(b));
expect(b.output()).toBe(a.output());
}, 40_000);

test("a lagging client stops receiving live data and is resynced once its socket drains", async () => {
const m = await manager();
const a = fakeWs("s8");
Expand Down
Loading
Loading