mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-03 15:12:48 +00:00
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.
196 lines
6.9 KiB
TypeScript
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();
|
|
}
|