Files
paseo/packages/app/e2e/support/helpers/daemon-lifecycle.ts
T
Mohamed Boudra 9c926ed126 fix(terminal): preserve 10,000 rows with direct ANSI replay (#8282)
Restore configured terminal history on reconnect, retaining 10,000 rows plus the screen. Serialize the worker-owned xterm buffer directly to ANSI and forward negotiated bounded replay chunks while preserving ANSI-era clients and per-viewer live ordering.

Preserve captured geometry through replay, bound pressure recovery to 500 rows, and keep the omitted visible-history default at 200. Remove the full cell-grid copy and obsolete JSON rendering.

All 21 CI checks pass. Real browser, native Debug emulator/simulator and Linux Electron coverage and platform limits are documented in the PR. The Android Debug invalid native delegate dispatch also reproduces on upstream main; its cause and any change in frequency remain unproven.

Credit Manav Dhindsa (DrakenKor), #6139 and #6148, and Lennart Schoch, #6743, for prior ANSI replay and retention work informing this change.

Closes #6137.
Closes #6147.
2026-10-31 10:51:45 +07:00

196 lines
6.9 KiB
TypeScript

import { execFile, spawn } from "node:child_process";
import { writeFile, readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { expect, type Page } from "@playwright/test";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectDaemonClient } from "./daemon-client-loader";
import { openSettings } from "./app";
import { openHostSection, openSettingsHost, seedSavedSettingsHosts } from "./settings";
import type { IsolatedHostDaemon } from "./isolated-host-daemon";
export async function openDaemonOverview(page: Page, daemon: IsolatedHostDaemon) {
await seedSavedSettingsHosts(page, [
{ serverId: daemon.serverId, label: "Lifecycle host", endpoint: `127.0.0.1:${daemon.port}` },
]);
await page.reload();
await openSettings(page);
await openSettingsHost(page, daemon.serverId);
await openHostSection(page, daemon.serverId, "host");
}
export async function restartDaemonInSettings(
page: Page,
options: { closeTerminals?: boolean } = {},
) {
const button = page.getByRole("button", {
name: options.closeTerminals ? "Restart and close terminals" : "Restart",
exact: true,
});
await expect(button).toBeEnabled();
page.once("dialog", (dialog) => dialog.accept());
await button.click();
await expect(page.getByRole("button", { name: "Restarting...", exact: true })).toBeDisabled();
}
export async function expectDaemonRestartFailed(page: Page, text: string) {
const error = page.getByTestId("host-page-restart-error");
await expect(error).toBeVisible({ timeout: 30_000 });
await expect(error).toContainText(text);
await expect(page.getByRole("button", { name: "Restart", exact: true })).toBeEnabled();
}
export async function expectDaemonRestartComplete(page: Page) {
await expect(page.getByRole("button", { name: "Restart", exact: true })).toBeEnabled({
timeout: 30_000,
});
}
export async function readWorkerPid(daemon: IsolatedHostDaemon): Promise<number> {
const client = await connectDaemonClient<DaemonClient>({
port: daemon.port,
clientIdPrefix: "lifecycle-observer",
});
try {
return (await client.getDaemonStatus()).pid;
} finally {
await client.close();
}
}
/** Opens a terminal on the isolated daemon that runs a long-lived program. */
export async function openLongLivedTerminal(daemon: IsolatedHostDaemon): Promise<string> {
const client = await connectDaemonClient<DaemonClient>({
port: daemon.port,
clientIdPrefix: "lifecycle-terminal",
});
try {
const workspace = await client.createWorkspace({
source: { kind: "directory", path: daemon.paseoHome },
title: "lifecycle",
});
if (!workspace.workspace) throw new Error(workspace.error ?? "Workspace creation failed");
const created = await client.createTerminal(daemon.paseoHome, "lifecycle", undefined, {
workspaceId: workspace.workspace.id,
command: process.execPath,
args: ["-e", "setInterval(() => {}, 1e9)"],
});
if (!created.terminal) throw new Error(created.error ?? "Terminal creation failed");
return created.terminal.id;
} finally {
await client.close();
}
}
export async function listTerminalIds(daemon: IsolatedHostDaemon): Promise<string[]> {
const client = await connectDaemonClient<DaemonClient>({
port: daemon.port,
clientIdPrefix: "lifecycle-terminals",
});
try {
return (await client.listTerminals()).terminals.map((terminal) => terminal.id);
} finally {
await client.close();
}
}
const execFileAsync = promisify(execFile);
async function psField(pid: number, field: "stat" | "lstart"): Promise<string> {
const { stdout } = await execFileAsync("ps", ["-o", `${field}=`, "-p", String(pid)], {
env: { ...process.env, LC_ALL: "C" },
});
return stdout.trim();
}
export interface UnclosableTerminalProgram {
pid: number;
/** Ends the program's parent, which lets the system reap the program. */
release(): void;
}
/**
* Leaves in the daemon's home the record a dead terminal worker leaves, naming one terminal
* program that SIGKILL cannot remove: a zombie whose parent (an exec'd `sleep`) never waits for
* it. A kill cleanup can only report it as still running. POSIX only.
*/
export async function plantUnclosableTerminalProgram(
daemon: IsolatedHostDaemon,
): Promise<UnclosableTerminalProgram> {
const parent = spawn("/bin/sh", ["-c", "sleep 0 & echo $!; exec sleep 600"], {
stdio: ["ignore", "pipe", "ignore"],
});
parent.unref();
const release = () => {
if (parent.exitCode === null && parent.signalCode === null) parent.kill("SIGKILL");
};
try {
const pid = await new Promise<number>((resolve) => {
parent.stdout!.once("data", (chunk: Buffer) => resolve(Number(chunk.toString().trim())));
});
await expect.poll(async () => (await psField(pid, "stat")).startsWith("Z")).toBe(true);
const gone = spawn(process.execPath, ["-e", ""], { stdio: "ignore" });
await new Promise((resolve) => gone.once("exit", resolve));
const record = {
recordId: "planted-record",
pid: gone.pid,
bootId: null,
createdAt: new Date().toISOString(),
identity: { startedAt: "Thu Jan 1 00:00:00 1970", commandLine: "planted-worker" },
protocolVersion: 1,
paseoVersion: "0.0.0-test",
socketPath: path.join(daemon.paseoHome, "planted-worker.sock"),
secret: "planted-secret",
terminals: [{ id: "planted-terminal", pid, startedAt: await psField(pid, "lstart") }],
};
await writeFile(
path.join(daemon.paseoHome, "terminal-worker", `${gone.pid}.json`),
`${JSON.stringify(record, null, 2)}\n`,
{ mode: 0o600 },
);
return { pid, release };
} catch (error) {
release();
throw error;
}
}
/** Fault injection and liveness checks for the isolated daemon cleanup journey. */
export function suspendDaemonProcess(pid: number): void {
process.kill(pid, "SIGSTOP");
}
export function resumeDaemonProcess(pid: number): void {
try {
process.kill(pid, "SIGCONT");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
}
}
export async function daemonProcessCanWrite(pid: number): Promise<boolean> {
try {
const state = await psField(pid, "stat");
return state.length > 0 && !state.startsWith("Z");
} catch (error) {
if ((error as { code?: unknown }).code === 1) return false;
throw error;
}
}
export async function readTerminalProcessIds(daemon: IsolatedHostDaemon): Promise<number[]> {
const directory = path.join(daemon.paseoHome, "terminal-worker");
const records = await Promise.all(
(await readdir(directory))
.filter((name) => name.endsWith(".json"))
.map(async (name) => {
const record = JSON.parse(await readFile(path.join(directory, name), "utf8")) as {
pid: number;
terminals: Array<{ pid: number }>;
};
return [record.pid].concat(record.terminals.map((terminal) => terminal.pid));
}),
);
return records.flat();
}