// Find (and reap) e2e fixture-binary processes that outlived the run that // spawned them. // // Why this needs to be strict: these helpers kill processes, so a loose match is // a footgun aimed at the developer's own machine. `pgrep -f fixtures/bin` is NOT // safe — it matches any shell whose command line merely *mentions* the path, // including the very command running the sweep. Verified on this box: // // a real fixture: argv[0]="node" argv[1]="<...>/e2e/fixtures/bin/fake-ytdlp.mjs" // a shell that (path appears in argv[2], the -c script) // mentions it: argv[0]="/usr/bin/zsh" // // So we match on argv[1] specifically — the script path node was handed via the // fixtures' `#!/usr/bin/env node` shebang — and require it to sit under this // checkout's absolute fixtures/bin directory. A shell can never satisfy that. // // Linux-only (reads /proc). That matches where this suite runs; on any other // platform the scan returns nothing rather than guessing with `ps`, so a sweep // degrades to a no-op instead of killing something it misidentified. import { readFile, readdir } from "node:fs/promises"; import { dirname, resolve, basename } from "node:path"; import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); // Absolute, normalised, with a trailing separator so a startsWith() test cannot // match a sibling directory whose name merely shares the prefix. export const fixtureBinDir = resolve(here, "fixtures", "bin") + "/"; export type FixtureProc = { pid: number; script: string; argv: string[] }; async function readCmdline(pid: string): Promise { try { const raw = await readFile(`/proc/${pid}/cmdline`, "utf8"); // cmdline is NUL-separated with a trailing NUL; drop the empty tail. const argv = raw.split("\0").filter((s) => s.length > 0); return argv.length > 0 ? argv : null; } catch { // The process exited between readdir and readFile, or it isn't ours to // inspect. Either way it is not a stray we can act on. return null; } } // Every live process whose argv[1] is a script inside this checkout's // e2e/fixtures/bin. Never includes the calling process. export async function listFixtureProcesses(): Promise { if (process.platform !== "linux") return []; let entries: string[]; try { entries = await readdir("/proc"); } catch { return []; } const found: FixtureProc[] = []; for (const entry of entries) { if (!/^\d+$/.test(entry)) continue; const pid = Number(entry); if (pid === process.pid) continue; const argv = await readCmdline(entry); if (!argv) continue; // argv[0] is the interpreter (the shebang resolves to node), argv[1] the // fixture script. Requiring both is what makes this safe to kill on. if (!basename(argv[0]).startsWith("node")) continue; const script = argv[1]; if (!script || !resolve(script).startsWith(fixtureBinDir)) continue; found.push({ pid, script: resolve(script), argv }); } return found; } // SIGTERM every stray, then SIGKILL whatever is still alive after `graceMs` // (a fixture may deliberately ignore SIGTERM — fake-parakeet-stitch does, in // `hangterm` dirs). Returns what it killed so callers can log it: a silent // reaper would hide a regression in the layers that are supposed to prevent // strays in the first place. export async function killFixtureProcesses( graceMs = 2_000, ): Promise { const strays = await listFixtureProcesses(); if (strays.length === 0) return []; for (const p of strays) { try { process.kill(p.pid, "SIGTERM"); } catch { /* already gone */ } } await new Promise((r) => setTimeout(r, graceMs)); for (const p of strays) { try { process.kill(p.pid, "SIGKILL"); } catch { /* exited on SIGTERM, as it should have */ } } return strays; } // The sweep both the globalSetup and globalTeardown hooks run. // // It LOGS whatever it reaps, deliberately and loudly. A silent reaper would hide // the thing we actually care about: strays are supposed to be impossible now // (jobs are cancelled before the registry is dropped, on graceful shutdown, and // each fixture self-terminates when orphaned). Anything this finds is a // regression in one of those three layers, not a routine cleanup. export async function sweepFixtureProcesses(phase: "setup" | "teardown") { const killed = await killFixtureProcesses(); if (killed.length === 0) return; const names = killed .map((p) => `${basename(p.script)}(${p.pid})`) .join(", "); const cause = phase === "setup" ? "left behind by an EARLIER run (a killed run cannot run its own teardown)" : "survived this run — the anti-orphan layers did not hold"; console.warn( `[fixture-sweep:${phase}] reaped ${killed.length} stray fixture process(es) ${cause}: ${names}`, ); }