fix(terminal): preserve client cursor defaults on replay

Leave cursor presentation to renderer defaults unless the program selected a style. Align replay and worker-upgrade fixtures with committed cursor state, independent live output, and the protocol 5 hard cut.
This commit is contained in:
Mohamed Boudra
2026-10-30 17:45:31 +01:00
parent 1557765860
commit 4ce30bd90a
7 changed files with 272 additions and 63 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ supervisor (paseo.pid) ──► daemon worker ──► launcher ──► term
- The worker exits on its own when it has no controller and no terminals, on `shutdown`, on SIGTERM (killing its terminals), and when its record disappears (a deleted `$PASEO_HOME`: nobody can ever reach it again).
- Its stderr goes to `$PASEO_HOME/terminal-worker.log`. No pipe to the daemon survives the daemon.
The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. This addon omits cursor visibility and DECSCUSR presentation, so the session supplements those sequences from xterm’s cursor state. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut.
The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. This addon omits cursor visibility and DECSCUSR presentation, so the session supplements visibility and explicit program-selected presentation from xterm’s cursor state. An unset program style leaves each client’s configured cursor defaults intact. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut.
Which triggers keep terminals and which close them is owned by the stop reason (`keepTerminals`) that flows from the request through the supervisor to `stop()`. The kill side always ends in the reaper (`terminal-worker-reaper.ts`), the only path that signals terminal processes outside a connected daemon.
@@ -1,6 +1,10 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { getTerminalBufferText, waitForTerminalContent } from "../support/helpers/terminal-perf";
import {
expectTerminalCursor,
getTerminalBufferText,
waitForTerminalContent,
} from "../support/helpers/terminal-perf";
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
interface AttachOverlayProbeWindow extends Window {
@@ -95,4 +99,30 @@ test.describe("retained terminal tab streams", () => {
if (count > 10000) expect(restored).not.toContain("history-0001界🙂");
});
}
for (const input of [
{ name: "client blinking bar", presentation: "", shape: "bar" as const, blinking: true },
{
name: "program blinking underline",
presentation: "\x1b[3 q",
shape: "underline" as const,
blinking: true,
},
]) {
test(`reattach preserves the ${input.name} cursor`, async ({ page }) => {
const terminal = await harness.createTerminal({
name: "cursor-presentation",
command: process.execPath,
args: [
"-e",
`process.stdout.write(${JSON.stringify(input.presentation + "CURSOR-READY\r\n")});setInterval(()=>{},1000)`,
],
});
await harness.openTerminal(page, { terminalId: terminal.id });
await waitForTerminalContent(page, (text) => text.includes("CURSOR-READY"), 10000);
await expectTerminalCursor(page, input);
await page.reload();
await waitForTerminalContent(page, (text) => text.includes("CURSOR-READY"), 10000);
await expectTerminalCursor(page, input);
});
}
});
@@ -1,4 +1,5 @@
import { expect, type Page } from "@playwright/test";
import type { Terminal } from "@xterm/xterm";
import { buildHostWorkspaceRoute } from "../../../src/utils/host-routes";
import { getServerId } from "./server-id";
@@ -206,3 +207,100 @@ export function computePercentile(samples: number[], p: number): number {
export function round2(value: number): number {
return Math.round(value * 100) / 100;
}
/** Observe actual cursor pixels and blink phases through xterm's public render event. */
export async function expectTerminalCursor(
page: Page,
expected: { shape: "bar" | "underline"; blinking: boolean },
): Promise<void> {
const observed = await page.evaluate(async () => {
const terminal = (window as Window & { __paseoTerminal?: Terminal }).__paseoTerminal;
if (!terminal) throw new Error("Terminal did not mount");
const source = Array.from(document.querySelectorAll(".xterm-screen canvas")).find(
(canvas): canvas is HTMLCanvasElement =>
canvas instanceof HTMLCanvasElement && canvas.getContext("webgl2") !== null,
);
if (!source) throw new Error("Cursor verification requires the actual WebGL renderer");
const copy = document.createElement("canvas");
const context = copy.getContext("2d");
if (!context) throw new Error("Cursor pixel capture is unavailable");
terminal.focus();
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
if (!terminal.element?.contains(document.activeElement))
throw new Error("Terminal did not focus");
const started = performance.now();
const frames: Array<{ time: number; width: number; height: number }> = [];
const rendered = terminal.onRender(() => {
copy.width = source.width;
copy.height = source.height;
// WebGL's default drawing buffer must be copied before this render returns.
context.drawImage(source, 0, 0);
const cellWidth = source.width / terminal.cols;
const cellHeight = source.height / terminal.rows;
const x = Math.round(terminal.buffer.active.cursorX * cellWidth);
const y = Math.round(terminal.buffer.active.cursorY * cellHeight);
const width = Math.ceil(cellWidth);
const height = Math.ceil(cellHeight);
const background = context.getImageData(x + width + 2, y, 1, 1).data;
const pixels = context.getImageData(x, y, width, height).data;
let minX = width;
let maxX = -1;
let minY = height;
let maxY = -1;
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
const offset = (row * width + col) * 4;
if (
![0, 1, 2, 3].some(
(channel) => Math.abs(pixels[offset + channel]! - background[channel]!) > 20,
)
)
continue;
minX = Math.min(minX, col);
maxX = Math.max(maxX, col);
minY = Math.min(minY, row);
maxY = Math.max(maxY, row);
}
}
frames.push({
time: performance.now() - started,
width: maxX < 0 ? 0 : (maxX - minX + 1) / cellWidth,
height: maxY < 0 ? 0 : (maxY - minY + 1) / cellHeight,
});
});
try {
const refresh = setInterval(() => terminal.refresh(0, terminal.rows - 1), 40);
try {
await new Promise((resolve) => setTimeout(resolve, 1800));
} finally {
clearInterval(refresh);
}
} finally {
rendered.dispose();
}
return {
frames,
focused: terminal.element?.contains(document.activeElement),
samples: frames.length,
width: Math.max(...frames.map((frame) => frame.width)),
height: Math.max(...frames.map((frame) => frame.height)),
blinking:
frames.filter(
(frame, index) => index > 0 && (frame.width === 0) !== (frames[index - 1]!.width === 0),
).length >= 2,
};
});
expect(observed.samples).toBeGreaterThan(5);
expect(observed.blinking, JSON.stringify(observed)).toBe(expected.blinking);
if (expected.shape === "bar") {
expect(observed.width).toBeGreaterThan(0);
expect(observed.width).toBeLessThan(0.4);
expect(observed.height).toBeGreaterThan(0.6);
} else {
expect(observed.width).toBeGreaterThan(0.6);
expect(observed.height).toBeGreaterThan(0);
expect(observed.height).toBeLessThan(0.4);
}
}
@@ -10,11 +10,10 @@
* `stop --keep-terminals` and moving A's node-pty, worker bundle and shell integration away,
* a daemon from B adopts the same worker and opens a new, working terminal in it with B's
* environment and CLI, while the first terminal keeps A's.
* 2. Worker protocol versions (PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE): a worker one version
* old drains (keeps its terminal, new ones open in a second worker, it exits after its last
* terminal and its copy is deleted); one two versions old is shut down; one from a newer
* Paseo is left running, reported as terminal_worker_incompatible, and closed by
* `daemon stop`.
* 2. Worker protocol versions (PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE): protocols 0–4
* are obsolete unreleased wires, so their programs/workers/copies are closed and a current
* worker serves new terminals. A truly newer protocol is left running, reported as
* terminal_worker_incompatible, and closed by `daemon stop`.
*
* Every process it starts is stopped in `finally`.
*/
@@ -51,6 +50,7 @@ const cliHome = join(root, "os-home");
const workDir = join(root, "work");
for (const dir of [paseoHome, cliHome, workDir]) mkdirSync(dir, { recursive: true });
const port = await getAvailablePort();
const currentProtocolVersion = 5;
const zsh = ["/bin/zsh", "/usr/bin/zsh"].find((candidate) => existsSync(candidate)) ?? null;
interface Installation {
@@ -387,6 +387,7 @@ try {
const t1 = await openTerminal(a, "t1");
const [firstWorker] = workerRecords();
assert(firstWorker, "one worker runs");
assert.equal(firstWorker.protocolVersion, currentProtocolVersion, "the current wire is adopted");
assert.match(firstWorker.runtime ?? "", /^0\.98\.0-[0-9a-f]{12}$/);
const copyA = join(paseoHome, "terminal-worker", firstWorker.runtime!);
assert(
@@ -432,54 +433,53 @@ try {
restoreA = null;
console.log("✓ closed and cleaned up\n");
console.log("Test 4: a worker one protocol version old drains");
const old = await leaveWorkerWithProtocol(a, 0, "t3");
await startDaemon(b);
const workers = workerRecords();
assert.equal(workers.length, 2, "the old worker drains beside a new one");
const current = workers.find((record) => record.pid !== old.workerPid)!;
assert.equal(current.protocolVersion, 1);
assert.match(current.runtime ?? "", /^0\.99\.0-/);
assert((await terminalIds(b)).includes(old.terminal.id), "the old worker's terminal is listed");
await type(b, old.terminal.id, "echo still-served");
await waitForScreen(b, old.terminal.id, "\nstill-served");
const t4 = await openTerminal(b, "t4");
assert.deepEqual(
workerRecords()
.find((record) => record.pid === current.pid)!
.terminals.map((terminal) => terminal.id),
[t4.id],
"new terminals open in the current worker",
);
await paseo(b, ["terminal", "kill", old.terminal.id]);
await waitFor(
() => !isAlive(old.workerPid),
() => `the drained worker ${old.workerPid} did not exit`,
);
await waitFor(
() => !runtimeCopies().includes(old.runtime),
() => `the drained worker's copy ${old.runtime} was not deleted: ${runtimeCopies().join(", ")}`,
);
assert.deepEqual(runtimeCopies(), [current.runtime], "the current worker's copy stays");
await type(b, t4.id, "echo after-drain");
await waitForScreen(b, t4.id, "\nafter-drain");
await stopDaemon(b);
console.log("✓ drained, exited, copy deleted\n");
// There is no supported previous wire at protocol 5. Future previous-version classification
// is covered by terminal-worker-protocol.test.ts; do not simulate an unreleased drain adapter.
for (const protocolVersion of [0, 1, 2, 3, 4]) {
console.log(`Test 4: obsolete protocol ${protocolVersion} is closed before serving terminals`);
const obsolete = await leaveWorkerWithProtocol(
a,
protocolVersion,
`obsolete-${protocolVersion}`,
);
await startDaemon(b);
await waitFor(
() => !isAlive(obsolete.workerPid) && !isAlive(obsolete.terminal.pid),
() => "the obsolete worker or its terminal survived",
);
assert(!(await terminalIds(b)).includes(obsolete.terminal.id), "its terminal is gone");
const [current] = workerRecords();
assert.equal(workerRecords().length, 1, "one current worker runs");
assert(current);
assert.equal(current.protocolVersion, currentProtocolVersion);
assert.match(current.runtime ?? "", /^0\.99\.0-/);
await waitFor(
() => !runtimeCopies().includes(obsolete.runtime),
() => `the obsolete worker's copy ${obsolete.runtime} was not deleted`,
);
assert.deepEqual(runtimeCopies(), [current.runtime], "the current copy stays");
const replacement = await openTerminal(b, `replacement-${protocolVersion}`);
assert.deepEqual(
workerRecords()
.find((record) => record.pid === current.pid)!
.terminals.map((terminal) => terminal.id),
[replacement.id],
"new terminals open in the current worker",
);
await type(b, replacement.id, `echo after-protocol-cut-${protocolVersion}`);
await waitForScreen(b, replacement.id, `\nafter-protocol-cut-${protocolVersion}`);
await stopDaemon(b);
await waitFor(
() => !isAlive(replacement.pid) && !isAlive(current.pid),
() => "the replacement worker or program outlived daemon stop",
);
assert.deepEqual(workerRecords(), [], "no worker record is left");
assert.deepEqual(runtimeCopies(), [], "no copy is left");
console.log("✓ obsolete closed, current usable, copies cleaned up\n");
}
console.log("Test 5: a worker two protocol versions old is shut down");
const obsolete = await leaveWorkerWithProtocol(a, -1, "t5");
await startDaemon(b);
await waitFor(
() => !isAlive(obsolete.workerPid) && !isAlive(obsolete.terminal.pid),
() => "the obsolete worker or its terminal survived",
);
assert(!(await terminalIds(b)).includes(obsolete.terminal.id), "its terminal is gone");
assert.equal(workerRecords().length, 1, "one current worker runs");
await stopDaemon(b);
console.log("✓ shut down\n");
console.log("Test 6: a worker from a newer Paseo is left running and reported");
const newer = await leaveWorkerWithProtocol(a, 2, "t6");
console.log("Test 5: a worker from a newer Paseo is left running and reported");
const newer = await leaveWorkerWithProtocol(a, currentProtocolVersion + 1, "newer");
await startDaemon(b);
const listed = await run(b, ["terminal", "ls", "--all", "--json"]);
assert.notEqual(listed.exitCode, 0, "terminal ls fails");
+60 -2
View File
@@ -1563,10 +1563,13 @@ it.runIf(isPlatform("linux", "darwin"))(
it.each([false, true])(
"serializes styled Unicode, cursor presentation and modes directly (alternate=%s)",
async (alternate) => {
const gate = mkdtempSync(join(tmpdir(), "terminal-cursor-cut-"));
temporaryDirs.push(gate);
const releaseCursor = join(gate, "position");
const content =
Array.from({ length: 8 }, (_, i) => "normal-" + i + "\r\n").join("") +
(alternate ? "\x1b[?1049h\x1b[H" : "") +
"\x1b[38;2;255;128;64;4m界é🙂\x1b[0m\r\nVIEW-READY\x1b[?1h\x1b[?2004h\x1b[>1u\x1b[?9001h\x1b[?7l\x1b[?1000h\x1b[5 q\x1b[?25l\x1b[2;7H";
"\x1b[38;2;255;128;64;4m界é🙂\x1b[0m\r\nVIEW-READY\x1b[?1h\x1b[?2004h\x1b[>1u\x1b[?9001h\x1b[?7l\x1b[?1000h\x1b[5 q\x1b[?25l";
const session = trackSession(
await createTerminal({
cwd: realpathSync(tmpdir()),
@@ -1574,7 +1577,10 @@ it.each([false, true])(
rows: 4,
cols: 16,
command: process.execPath,
args: ["-e", `process.stdout.write(${JSON.stringify(content)});setInterval(()=>{},1000)`],
args: [
"-e",
`const fs=require("node:fs");const watcher=fs.watch(${JSON.stringify(gate)},()=>{if(fs.existsSync(${JSON.stringify(releaseCursor)})){watcher.close();process.stdout.write("\\x1b[2;7H");}});process.stdout.write(${JSON.stringify(content)});setInterval(()=>{},1000)`,
],
}),
);
const deadline = Date.now() + 5000;
@@ -1582,6 +1588,9 @@ it.each([false, true])(
if (Date.now() > deadline) throw new Error("source content did not parse");
await delay(20);
}
// VIEW-READY precedes the last cursor update; its visible text is not a capture barrier.
writeFileSync(releaseCursor, "position");
await waitForState(session, (state) => state.cursor.row === 1 && state.cursor.col === 6);
const replay = session.serializeReplay();
const ansi = new TextDecoder().decode(replay.data);
expect(ansi).toContain("\x1b[?25l\x1b[5 q");
@@ -1633,6 +1642,55 @@ it.each([false, true])(
},
);
it.each([
{ name: "unset", presentation: "", style: undefined, visible: true },
{
name: "reset to client default",
presentation: "\x1b[5 q\x1b[0 q",
style: undefined,
visible: true,
},
{ name: "unset hidden", presentation: "\x1b[?25l", style: undefined, visible: false },
...[1, 2, 3, 4, 5, 6].map((style) => ({
name: `explicit style ${style}`,
presentation: `\x1b[${style} q\x1b[?25l`,
style,
visible: false,
})),
])("replays cursor presentation without replacing client defaults ($name)", async (input) => {
const session = trackSession(
await createTerminal({
cwd: realpathSync(tmpdir()),
workspaceId: "cursor-presentation",
command: process.execPath,
args: [
"-e",
`process.stdout.write(${JSON.stringify(input.presentation + "CURSOR-READY\r\n")});setInterval(()=>{},1000)`,
],
}),
);
await waitForLines(session, ["CURSOR-READY"]);
const { data } = session.serializeReplay();
const ansi = new TextDecoder().decode(data);
const receiver = new xterm.Terminal({
cursorStyle: "bar",
cursorBlink: true,
allowProposedApi: true,
});
const styles: number[] = [];
receiver.parser.registerCsiHandler({ intermediates: " ", final: "q" }, (params) => {
styles.push(Number(params[0]));
return false;
});
try {
await new Promise<void>((resolve) => receiver.write(data, resolve));
expect(styles).toEqual(input.style === undefined ? [] : [input.style]);
expect(ansi).toContain(`\x1b[?25${input.visible ? "h" : "l"}`);
} finally {
receiver.dispose();
}
});
it("captures input modes at the same committed cut as serialized bytes", async () => {
const session = trackSession(
await createTerminal({
+6 -2
View File
@@ -547,8 +547,12 @@ function serializeCursorPresentation(terminal: TerminalType): string {
};
}
)._core.coreService;
const style = { block: 2, underline: 4, bar: 6 }[core.decPrivateModes.cursorStyle ?? "block"];
return `\x1b[?25${core.isCursorHidden ? "l" : "h"}\x1b[${style - (core.decPrivateModes.cursorBlink ? 1 : 0)} q`;
const visibility = `\x1b[?25${core.isCursorHidden ? "l" : "h"}`;
const cursorStyle = core.decPrivateModes.cursorStyle;
// Unset presentation belongs to each renderer's configured defaults.
if (cursorStyle === undefined) return visibility;
const style = { block: 2, underline: 4, bar: 6 }[cursorStyle];
return `${visibility}\x1b[${style - (core.decPrivateModes.cursorBlink ? 1 : 0)} q`;
}
function normalizeProcessToken(token: string): string {
@@ -1708,7 +1708,7 @@ it.skipIf(isPlatform("win32"))(
},
);
it("replays bounded ANSI chunks before concurrent live output and reconstructs retained text", async () => {
it("replays bounded ANSI chunks independently of live output and reconstructs the revision cut", async () => {
const cwd = createTemporaryHome();
manager = await startRealWorkerManager();
const session = trackTerminal(
@@ -1719,7 +1719,7 @@ it("replays bounded ANSI chunks before concurrent live output and reconstructs r
cols: 120,
...nodeTerminalCommand(`
for (let i = 0; i < 800; i++) process.stdout.write("\\x1b[31m" + String(i).padStart(4, "0") + ":" + "x".repeat(100) + "\\x1b[0m\\r\\n");
process.stdin.on("data", () => process.stdout.write("LIVE-AFTER-REPLAY\\r\\n"));
require("node:readline").createInterface({ input: process.stdin }).on("line", line => process.stdout.write(line === "before" ? "PRE-CUT\\r\\n" : "LIVE-AFTER-REPLAY\\r\\n"));
setInterval(() => {}, 1000);
`),
}),
@@ -1729,25 +1729,42 @@ it("replays bounded ANSI chunks before concurrent live output and reconstructs r
(await manager!.captureTerminal(session.id)).lines.some((line) => line.startsWith("0799:")),
10000,
);
const expected = await manager.captureTerminal(session.id);
const events: string[] = [];
const replayEvents: string[] = [];
const liveOutputs: Array<{ data: string; revision?: number }> = [];
const chunks: Uint8Array[] = [];
let live = "";
const unsubscribe = session.subscribe((event) => {
if (event.type === "output") {
events.push("live");
liveOutputs.push(event);
live += event.data;
}
});
session.send({ type: "input", data: "before\r" });
await waitForCondition(() => live.includes("PRE-CUT"), 10000);
const expected = await manager.captureTerminal(session.id);
const metadata = await manager.replayTerminal(session.id, undefined, (event) => {
events.push(event.type);
replayEvents.push(event.type);
if (event.type === "begin") session.send({ type: "input", data: "go\r" });
if (event.type === "data") chunks.push(event.data);
});
await waitForCondition(() => live.includes("LIVE-AFTER-REPLAY"), 10000);
unsubscribe();
expect(events[0]).toBe("begin");
expect(events.lastIndexOf("end")).toBeGreaterThan(events.lastIndexOf("data"));
// Existing live observers are independent of replay. Pre-cut output may also be
// flushed by capture before begin; only the replay events have begin/data/end order.
expect(events[0]).toBe("live");
expect(replayEvents[0]).toBe("begin");
expect(replayEvents.at(-1)).toBe("end");
expect(replayEvents.slice(1, -1)).toEqual(chunks.map(() => "data"));
const beforeCut = liveOutputs.filter((output) => output.data.includes("PRE-CUT"));
const afterCut = liveOutputs.filter((output) => output.data.includes("LIVE-AFTER-REPLAY"));
expect(beforeCut).toHaveLength(1);
expect(afterCut).toHaveLength(1);
expect(beforeCut[0]!.revision).toBeDefined();
expect(beforeCut[0]!.revision).toBeLessThanOrEqual(metadata!.revision);
expect(afterCut[0]!.revision).toBeGreaterThan(metadata!.revision);
expect(live.match(/LIVE-AFTER-REPLAY/g)).toHaveLength(1);
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.every((chunk) => chunk.byteLength <= 64 * 1024)).toBe(true);
@@ -1765,6 +1782,8 @@ it("replays bounded ANSI chunks before concurrent live output and reconstructs r
expect(
Array.from({ length: buffer.length }, (_, i) => buffer.getLine(i)!.translateToString(true)),
).toEqual(expected.lines);
expect(expected.lines.filter((line) => line.includes("PRE-CUT"))).toHaveLength(1);
expect(expected.lines.some((line) => line.includes("LIVE-AFTER-REPLAY"))).toBe(false);
expect(buffer.getLine(0)!.getCell(0)!.getFgColor()).toBe(1);
} finally {
terminal.dispose();