diff --git a/AGENTS.md b/AGENTS.md index 52104ad..3558755 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/DESIGN.md b/DESIGN.md index 6f3dc4a..a9578c9 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. | diff --git a/src/client/term/renderer.ts b/src/client/term/renderer.ts index 5672681..966c64f 100644 --- a/src/client/term/renderer.ts +++ b/src/client/term/renderer.ts @@ -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) { diff --git a/src/client/term/view.ts b/src/client/term/view.ts index 769a184..01fa280 100644 --- a/src/client/term/view.ts +++ b/src/client/term/view.ts @@ -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; @@ -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(); } diff --git a/src/server/pty.ts b/src/server/pty.ts index bb6a209..e797807 100644 --- a/src/server/pty.ts +++ b/src/server/pty.ts @@ -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]; @@ -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 | null = null; private modes = new Map(); private modesStr = ""; private carry = ""; @@ -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, @@ -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. @@ -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); @@ -147,6 +161,29 @@ 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. */ @@ -154,7 +191,7 @@ class PtySession { 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); @@ -178,7 +215,10 @@ 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) { @@ -186,14 +226,33 @@ class PtySession { } 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); diff --git a/test/client.termview.test.ts b/test/client.termview.test.ts index 9d50008..0f78fdc 100644 --- a/test/client.termview.test.ts +++ b/test/client.termview.test.ts @@ -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. @@ -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 = ""; diff --git a/test/pty.coverage.test.ts b/test/pty.coverage.test.ts index 56e11f9..f78176e 100644 --- a/test/pty.coverage.test.ts +++ b/test/pty.coverage.test.ts @@ -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"); diff --git a/test/pty.test.ts b/test/pty.test.ts index 94c2884..b603c7c 100644 --- a/test/pty.test.ts +++ b/test/pty.test.ts @@ -79,6 +79,90 @@ test("a reload restores alt screen, bracketed paste and mouse modes even after h b.ws.close(); }, 30_000); +/** + * A program that reports each SIGWINCH on screen; the size proves what the PTY was resized to. + * A script, not typed commands: an interactive shell gives `sleep` its own foreground process + * group, so its own trap would never see the signal. + */ +async function winchProgram(name: string, setup: string) { + const file = `${name}.sh`; + await Bun.write( + path.join(dir, file), + `trap 'echo "SIZE=$(stty size | tr " " x)"' WINCH +${setup} +echo DONE +while :; do sleep 0.05; done +`, + ); + return `sh ./${file}\n`; +} +const ALT = `printf '\\033[?1049h'`; +const FLOOD = `i=0; while [ $i -lt 9000 ]; do echo "line $i ............................................................"; i=$((i+1)); done`; +const shows = (term: Terminal, text: string) => term.lines.some((l) => l.toString().includes(text)); +async function until(term: Terminal, text: string) { + for (let i = 0; i < 50 && !shows(term, text); i++) await Bun.sleep(100); + return shows(term, text); +} +/** Runs the program in one connection, then reattaches like a reload. */ +async function reattached(id: string, setup: string) { + const a = connect(id); + await a.opened; + a.send(await winchProgram(id, setup)); + // Not line(): dash leaves the cursor after its prompt, so on a fresh alternate screen this is indented. + expect(await until(a.term, "DONE")).toBe(true); + a.ws.close(); + await Bun.sleep(200); + return connect(id); +} +const kill = (c: ReturnType) => { + c.ws.send(JSON.stringify({ t: "kill" })); + c.ws.close(); +}; + +test("reattaching to a TUI whose first paint left the replay window makes it repaint", async () => { + const b = await reattached("repaint-trimmed-alt", `${ALT}\n${FLOOD}`); + await b.opened; + // Both size changes reach the program (a same-size resize would be no signal at all), and the + // PTY ends at the size the client asked for. + expect(await until(b.term, "SIZE=23x80")).toBe(true); + expect(await until(b.term, "SIZE=24x80")).toBe(true); + kill(b); +}, 30_000); + +test("a resize sent during the repaint nudge neither cuts it short nor is lost", async () => { + const b = await reattached("repaint-resize-race", `${ALT}\n${FLOOD}`); + await b.opened; + b.ws.send(JSON.stringify({ t: "resize", cols: 80, rows: 30 })); + expect(await until(b.term, "SIZE=23x80")).toBe(true); + expect(await until(b.term, "SIZE=30x80")).toBe(true); + kill(b); +}, 30_000); + +test("a same-size resize right after connecting (what the page sends) does not cancel the repaint", async () => { + const b = await reattached("repaint-same-size", `${ALT}\n${FLOOD}`); + await b.opened; + b.ws.send(JSON.stringify({ t: "resize", cols: 80, rows: 24 })); + expect(await until(b.term, "SIZE=23x80")).toBe(true); + expect(await until(b.term, "SIZE=24x80")).toBe(true); + kill(b); +}, 30_000); + +for (const [what, id, setup] of [ + ["a complete replay (the first paint is still in it)", "repaint-untrimmed", ALT], + ["a program on the normal screen (a prompt would be redrawn for nothing)", "repaint-normal-screen", FLOOD], +] as const) { + test(`reattaching does not disturb ${what}`, async () => { + const b = await reattached(id, setup); + await b.opened; + await Bun.sleep(800); + expect(shows(b.term, "SIZE=")).toBe(false); + // The trap works: a real resize is reported, so the silence above means something. + b.ws.send(JSON.stringify({ t: "resize", cols: 80, rows: 20 })); + expect(await until(b.term, "SIZE=20x80")).toBe(true); + kill(b); + }, 30_000); +} + test("output is coalesced into few frames", async () => { const c = connect("coalesce-test-1"); await c.opened;