mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 17:22:47 +00:00
* test(e2e): boot the Playwright harness on Windows
The harness could not start on Windows. Metro was launched with
spawn("npx", ...) and the isolated daemon resolved its runner with
execSync("which tsx") — npx is a .cmd shim that Node's bare spawn
cannot launch, and `which` does not exist there.
Route both through process.execPath instead of the node_modules/.bin
shims. Node refuses to spawn .cmd or .bat without shell: true, and
shell: true is the wrong escape hatch: it concatenates argv unescaped
(DEP0190), splits paths containing spaces, and sends kill signals to
cmd.exe rather than the real child. Spawning Node directly has none of
those properties and is identical on POSIX, so no platform branch is
needed. The same reasoning fixes the published-server npm install,
which passed "npm.cmd" to execFileSync and had been failing with
EINVAL on Windows since Node's CVE-2024-27980 mitigation landed.
Teardown now kills the process tree. A Windows signal reaches only the
direct child, so Metro's and the supervisor's forked workers kept the
listening port and the next run failed to bind.
The daemon startup deadline goes from 20s to 90s. A cold tsx start was
measured at 25s on Windows and exceeded 60s under load; the loop still
fails immediately on a crashed child, so the ceiling only costs time
that a genuinely slow start needs.
The fake editor and gh binaries the harness puts on PATH gain .cmd
wrappers, because Windows resolves PATH entries by PATHEXT and cannot
execute an extension-less shebang file. The wrappers point at the same
payload file so the recorded command name stays "cursor", "code", or
"gh" on every platform.
The asdf-backed local Elixir relay stays POSIX-only and now says so
before spawning.
* test(ci): follow shared e2e daemon launcher
---------
Co-authored-by: ABorakati <ABorakati@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
85 lines
3.5 KiB
TypeScript
85 lines
3.5 KiB
TypeScript
import { execFile, spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
|
|
import { once } from "node:events";
|
|
|
|
/**
|
|
* Runs a repo-local TypeScript entrypoint through Node's own tsx loader instead
|
|
* of the `node_modules/.bin/tsx` shim.
|
|
*
|
|
* The shim is a platform trap, not a convenience: on Windows it is `tsx.cmd`,
|
|
* and Node refuses to spawn `.cmd`/`.bat` without `shell: true` (EINVAL, from
|
|
* the CVE-2024-27980 mitigation). Opting into `shell: true` to get around that
|
|
* trades one bug for three — argv is concatenated unescaped (DEP0190), a path
|
|
* containing spaces splits, and the kill signal lands on `cmd.exe` while the
|
|
* real child keeps the port. Spawning `process.execPath` has none of those
|
|
* properties and is byte-identical on POSIX, so there is no platform branch.
|
|
*/
|
|
export function spawnTsx(entrypoint: string, args: string[], options: SpawnOptions): ChildProcess {
|
|
if (entrypoint.startsWith("-")) {
|
|
throw new Error(`TypeScript entrypoint must be a path, received ${entrypoint}`);
|
|
}
|
|
return spawn(process.execPath, ["--import", "tsx", "--", entrypoint, ...args], options);
|
|
}
|
|
|
|
/**
|
|
* Terminates a child and everything it forked, then waits for the exit.
|
|
*
|
|
* `child.kill()` on Windows maps to `TerminateProcess` against the direct child
|
|
* only. Metro and the daemon supervisor both fork workers, so the signal leaves
|
|
* grandchildren holding the listening port and the next run fails to bind.
|
|
* `taskkill /T` walks the tree; POSIX keeps the SIGTERM-then-SIGKILL ladder.
|
|
*/
|
|
export async function killProcessTree(child: ChildProcess | null): Promise<void> {
|
|
if (!child || hasExited(child)) return;
|
|
const exited = once(child, "exit").then(() => undefined);
|
|
|
|
if (process.platform === "win32") {
|
|
const pid = child.pid;
|
|
if (pid === undefined) {
|
|
throw new Error("Cannot terminate a Windows process tree without a PID");
|
|
}
|
|
|
|
// Register the exit listener before taskkill to avoid missing a fast exit.
|
|
// Bound taskkill itself so a stuck system utility cannot hang teardown.
|
|
const completed = Promise.withResolvers<Error | null>();
|
|
execFile("taskkill", ["/pid", String(pid), "/T", "/F"], { timeout: 5_000 }, (error) =>
|
|
completed.resolve(error),
|
|
);
|
|
const taskkillError = await completed.promise;
|
|
if (taskkillError && !hasExited(child)) {
|
|
try {
|
|
child.kill("SIGKILL");
|
|
} catch {
|
|
// The child can exit between the state check and the direct kill.
|
|
}
|
|
await waitForExitOrTimeout(exited, 5_000);
|
|
throw new Error(`Failed to terminate process tree for PID ${pid}`, { cause: taskkillError });
|
|
}
|
|
if (!hasExited(child) && !(await waitForExitOrTimeout(exited, 5_000))) {
|
|
throw new Error(`Process tree for PID ${pid} did not exit after taskkill`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
child.kill("SIGTERM");
|
|
if (await waitForExitOrTimeout(exited, 5_000)) return;
|
|
|
|
child.kill("SIGKILL");
|
|
if (!(await waitForExitOrTimeout(exited, 5_000))) {
|
|
throw new Error(`Process ${String(child.pid)} did not exit after SIGKILL`);
|
|
}
|
|
}
|
|
|
|
function hasExited(child: ChildProcess): boolean {
|
|
return child.exitCode !== null || child.signalCode !== null;
|
|
}
|
|
|
|
async function waitForExitOrTimeout(exited: Promise<void>, timeoutMs: number): Promise<boolean> {
|
|
const timedOut = Promise.withResolvers<boolean>();
|
|
const timeout = setTimeout(() => timedOut.resolve(false), timeoutMs);
|
|
try {
|
|
return await Promise.race([exited.then(() => true), timedOut.promise]);
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|