Files
paseo/packages/app/e2e/support/helpers/isolated-host-daemon.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

258 lines
7.6 KiB
TypeScript

import { promisify } from "node:util";
import {
spawn,
execFile,
execFileSync,
type ChildProcess,
type SpawnOptions,
} from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { withDisabledE2ESpeechEnv } from "./speech-env";
import { killProcessTree, spawnTsx } from "./spawn-node";
const execFileAsync = promisify(execFile);
export interface IsolatedHostDaemon {
serverId: string;
port: number;
paseoHome: string;
getPid(): number | undefined;
restart(): Promise<void>;
close(): Promise<void>;
}
export interface IsolatedHostDaemonOptions {
environment?: NodeJS.ProcessEnv;
mutableRelay?: {
enabled: boolean;
endpoint?: string;
};
paseoHome?: string;
preserveHome?: boolean;
publishedVersion?: string;
}
async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to acquire an isolated daemon port")));
return;
}
server.close(() => resolve(address.port));
});
});
}
export async function getAvailableHostDaemonPort(): Promise<number> {
const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0);
let port = await getAvailablePort();
while (port === 6767 || port === 6768 || port === primaryPort) port = await getAvailablePort();
return port;
}
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
const deadline = Date.now() + 90_000;
let lastError: unknown = null;
while (Date.now() < deadline) {
if (child.exitCode !== null || child.signalCode !== null) {
throw new Error(
`Isolated host daemon exited before listening (code ${String(child.exitCode)}, signal ${String(child.signalCode)})`,
);
}
try {
await new Promise<void>((resolve, reject) => {
const socket = net.connect(port, "127.0.0.1", () => {
socket.end();
resolve();
});
socket.setTimeout(1_000, () => {
socket.destroy();
reject(new Error(`Connection timed out to isolated daemon port ${port}`));
});
socket.on("error", reject);
});
return;
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
throw new Error(
`Isolated host daemon did not listen on ${port}: ${
lastError instanceof Error ? lastError.message : String(lastError)
}`,
);
}
export async function startIsolatedHostDaemon(
serverId: string,
options: IsolatedHostDaemonOptions = {},
): Promise<IsolatedHostDaemon> {
const port = await getAvailableHostDaemonPort();
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) throw new Error("E2E_METRO_PORT is required to start an isolated host daemon");
const paseoHome =
options.paseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-secondary-host-")));
let publishedPackageRoot: string | null = null;
if (options.publishedVersion) {
publishedPackageRoot = await mkdtemp(path.join(tmpdir(), "paseo-e2e-published-server-"));
await writeFile(
path.join(publishedPackageRoot, "package.json"),
`${JSON.stringify({ private: true })}\n`,
);
try {
const npmCli = process.env.npm_execpath;
if (!npmCli || path.basename(npmCli).toLowerCase() !== "npm-cli.js") {
throw new Error(
"Published-version E2E requires npm_execpath from npm. Start it through `npm run test:e2e`.",
);
}
execFileSync(
process.execPath,
[
npmCli,
"install",
"--no-audit",
"--no-fund",
"--no-package-lock",
`@getpaseo/server@${options.publishedVersion}`,
],
{ cwd: publishedPackageRoot, stdio: "ignore" },
);
} catch (error) {
if (!options.preserveHome) {
await rm(paseoHome, { recursive: true, force: true });
}
await rm(publishedPackageRoot, { recursive: true, force: true });
throw error;
}
}
if (options.mutableRelay) {
const endpoint =
options.mutableRelay.endpoint ??
(process.env.E2E_RELAY_PORT ? `127.0.0.1:${process.env.E2E_RELAY_PORT}` : "127.0.0.1:9");
await writeFile(
path.join(paseoHome, "config.json"),
`${JSON.stringify({
version: 1,
daemon: {
relay: {
enabled: options.mutableRelay.enabled,
endpoint,
publicEndpoint: endpoint,
useTls: false,
publicUseTls: false,
},
},
})}\n`,
);
}
const serverDir = publishedPackageRoot
? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server")
: path.resolve(__dirname, "../../../../server");
let child: ChildProcess | null = null;
const stopDaemon = async (): Promise<void> => {
// The CLI owns graceful shutdown, forced cleanup and detached terminal
// reaping. Keep its ESM graph out of Playwright's transforming loader.
await execFileAsync(
process.execPath,
[
path.resolve(__dirname, "../../../../cli/dist/index.js"),
"daemon",
"stop",
"--home",
paseoHome,
"--force",
],
{ timeout: 30_000 },
);
await killProcessTree(child);
};
const spawnDaemon = async (): Promise<ChildProcess> => {
const spawnOptions: SpawnOptions = {
cwd: serverDir,
env: withDisabledE2ESpeechEnv({
...process.env,
...options.environment,
PASEO_HOME: paseoHome,
PASEO_SERVER_ID: serverId,
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
PASEO_RELAY_ENABLED: options.mutableRelay ? undefined : "0",
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
}),
stdio: ["ignore", "ignore", "pipe"],
detached: false,
};
const spawned = publishedPackageRoot
? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions)
: spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], spawnOptions);
child = spawned;
let stderr = "";
spawned.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
stderr = stderr.split("\n").slice(-40).join("\n");
});
try {
await waitForServer(port, spawned);
return spawned;
} catch (error) {
throw new Error(
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
{ cause: error },
);
}
};
try {
await spawnDaemon();
} catch (error) {
await stopDaemon();
if (!options.preserveHome) {
await rm(paseoHome, { recursive: true, force: true });
}
if (publishedPackageRoot) {
await rm(publishedPackageRoot, { recursive: true, force: true });
}
throw error;
}
let closed = false;
return {
serverId,
port,
paseoHome,
getPid: () => child?.pid,
restart: async () => {
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
await stopDaemon();
await spawnDaemon();
},
close: async () => {
if (closed) return;
await stopDaemon();
if (!options.preserveHome) {
await rm(paseoHome, { recursive: true, force: true });
}
if (publishedPackageRoot) {
await rm(publishedPackageRoot, { recursive: true, force: true });
}
closed = true;
},
};
}