mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-15 23:56:29 +03:00
The orphan reaper and every harness cleanup find live servers through `ps -A -E`, run with spawnSync's default 1 MiB maxBuffer. A laptop with a thousand processes produces more than that, spawnSync kills ps, reports status null, and the sweep silently finds nothing: the SIGKILL leak guard fails and, worse, real orphans survive. Seen on the maintainer's machine mid-session, reproduced with main's own engine. A 256 MiB buffer covers any realistic process table. Written with AI assistance (Claude). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
266 lines
11 KiB
JavaScript
266 lines
11 KiB
JavaScript
/**
|
|
* Finding and killing live servers a test run left behind.
|
|
*
|
|
* The harness starts live servers in two shapes and neither one dies with the
|
|
* process that started it:
|
|
*
|
|
* 1. a direct child (`node skill/scripts/live-server.mjs --port=N`, or
|
|
* `$IMPECCABLE_BIN live-server --port=N` once the engine is Rust), stopped
|
|
* by an HTTP `/stop` call in an `after()` hook;
|
|
* 2. a detached daemon (`live-server --background`, or a full `live` boot),
|
|
* which is orphaned to pid 1 by design and stopped by the `stop` verb.
|
|
*
|
|
* Both survive a runner that dies before teardown: a `SIGKILL`, a Ctrl-C, a
|
|
* `node:test` timeout that skips the `after()` hook. This module is the safety
|
|
* net. It identifies servers by an environment marker the runner exports, so a
|
|
* sweep can only ever match a server this repo's test harness started; nothing
|
|
* is matched by port, by name alone, or by "looks like impeccable".
|
|
*
|
|
* Every marker value is an opaque token: a random run or process id, or a hash
|
|
* of the checkout path. None of them is a path, and none can contain
|
|
* whitespace, which is what keeps the `ps -E` matching below exact.
|
|
*/
|
|
|
|
import { spawnSync } from 'node:child_process';
|
|
import { createHash, randomBytes } from 'node:crypto';
|
|
import { readFileSync, readdirSync, realpathSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
/**
|
|
* The three matching markers. Every value is an opaque token from the alphabet
|
|
* below, never a path and never anything a user chose. That is what lets the
|
|
* `ps -E` matcher work on a "starts an entry, ends at whitespace or line end"
|
|
* rule with no ambiguous case left, and it is a load-bearing invariant rather
|
|
* than a formatting preference: a value free to contain a space and a
|
|
* `KEY=`-shaped token could impersonate the end of its own entry, and one
|
|
* checkout's cleanup could then reach a neighbouring checkout's servers.
|
|
*/
|
|
const MARKER_VALUE_RE = /^[A-Za-z0-9_-]+$/;
|
|
|
|
/** Env var carrying the id of one suite command. Descendants inherit it. */
|
|
export const RUN_ID_ENV = 'IMPECCABLE_TEST_RUN_ID';
|
|
/**
|
|
* Env var carrying the id of one test process. `node --test` runs files
|
|
* concurrently and they all inherit the same run id, so a per-process id is
|
|
* what lets one file's reaper kill that file's servers and not its siblings'.
|
|
*/
|
|
export const PROC_ID_ENV = 'IMPECCABLE_TEST_PROC_ID';
|
|
/**
|
|
* Env var carrying a hash of the checkout, so a cleanup can be scoped to it.
|
|
* The value is `repoMarker()`, not the path: two checkouts whose paths share a
|
|
* prefix get unrelated hashes, and no filesystem path can leak into matching.
|
|
*/
|
|
export const REPO_ENV = 'IMPECCABLE_TEST_REPO';
|
|
/**
|
|
* The checkout path in readable form, for a human looking at `ps -E` output or
|
|
* a stuck process. **Never used for matching**, and nothing should start: it is
|
|
* the one marker-adjacent value that can contain whitespace.
|
|
*/
|
|
export const REPO_PATH_ENV = 'IMPECCABLE_TEST_REPO_PATH';
|
|
|
|
/**
|
|
* A command line belonging to a live server. Matches the Node script
|
|
* (`.../live-server.mjs`) and the engine verb (`.../impeccable live-server`).
|
|
*/
|
|
// `ps -E` prints every process's whole environment. On a laptop with a thousand
|
|
// processes that passes Node's default 1 MiB `maxBuffer`, at which point
|
|
// spawnSync kills `ps`, reports `status: null`, and the sweep silently finds
|
|
// nothing: the exact orphan case the reaper exists for. Size the buffer for a
|
|
// busy machine instead.
|
|
const PS_MAX_BUFFER = 256 * 1024 * 1024;
|
|
|
|
const LIVE_SERVER_RE = /(^|[\s/\\])live-server(\.mjs|\.exe)?(\s|$)/;
|
|
|
|
/**
|
|
* A stable, opaque id for one checkout: the first 16 hex characters of the
|
|
* sha256 of its real path. Symlinked and `/private`-prefixed spellings of the
|
|
* same directory resolve to the same marker; `/work/impeccable` and
|
|
* `/work/impeccable-copy` do not share a prefix.
|
|
*/
|
|
export function repoMarker(repoRoot) {
|
|
// path.resolve first so a trailing slash or a `.` segment cannot change the
|
|
// marker for a directory that is not on disk (realpathSync throws for those).
|
|
let resolved = path.resolve(repoRoot);
|
|
try { resolved = realpathSync(resolved); } catch { /* not on disk; hash as normalized */ }
|
|
return createHash('sha256').update(resolved).digest('hex').slice(0, 16);
|
|
}
|
|
|
|
/**
|
|
* An id for one suite command. Random, so two runs of the same suite in the
|
|
* same second never collide, and hex/`-` only so it can never break out of its
|
|
* own environment entry.
|
|
*/
|
|
export function makeRunId(repoRoot = process.cwd()) {
|
|
return `${repoMarker(repoRoot)}-${randomBytes(8).toString('hex')}`;
|
|
}
|
|
|
|
/** An id for one test process. Same alphabet rule as the run id. */
|
|
export function makeProcId() {
|
|
return `p${process.pid}-${randomBytes(8).toString('hex')}`;
|
|
}
|
|
|
|
/**
|
|
* Live servers still running that carry one of the given markers.
|
|
*
|
|
* @param {object} opts
|
|
* Every marker is an environment entry the harness itself exported. There is
|
|
* deliberately no fallback that matches a command line under the checkout: a
|
|
* developer running `impeccable live` in this repo has exactly that command
|
|
* line, and a cleanup must never be able to kill their session.
|
|
*
|
|
* @param {string} [opts.runId] match `IMPECCABLE_TEST_RUN_ID=<runId>` exactly.
|
|
* @param {string} [opts.procId] match `IMPECCABLE_TEST_PROC_ID=<procId>` exactly.
|
|
* @param {string} [opts.repo] a checkout path; matched as its `repoMarker()`
|
|
* hash, never as the path itself.
|
|
* @returns {{pid:number, command:string}[]}
|
|
*/
|
|
export function findLiveServers({ runId, procId, repo } = {}) {
|
|
const markers = [];
|
|
if (runId) markers.push(`${RUN_ID_ENV}=${assertMarkerValue(runId)}`);
|
|
if (procId) markers.push(`${PROC_ID_ENV}=${assertMarkerValue(procId)}`);
|
|
if (repo) markers.push(`${REPO_ENV}=${assertMarkerValue(repoMarker(repo))}`);
|
|
if (!markers.length) return [];
|
|
|
|
const commands = listCommands();
|
|
if (!commands.size) return [];
|
|
|
|
const matched = new Set(pidsWithEnvMarker(markers, commands));
|
|
|
|
const out = [];
|
|
for (const pid of matched) {
|
|
if (pid === process.pid) continue;
|
|
const command = commands.get(pid);
|
|
if (!command || !LIVE_SERVER_RE.test(command)) continue;
|
|
out.push({ pid, command });
|
|
}
|
|
return out.sort((a, b) => a.pid - b.pid);
|
|
}
|
|
|
|
/**
|
|
* SIGTERM every process, then SIGKILL whatever is still alive after `graceMs`.
|
|
* Kills the process group too when the process leads one, so a server that
|
|
* spawned helpers does not leave them behind.
|
|
*
|
|
* @returns {number} how many processes were signalled.
|
|
*/
|
|
export function killLiveServers(procs, { graceMs = 400 } = {}) {
|
|
if (!procs.length) return 0;
|
|
for (const { pid } of procs) signal(pid, 'SIGTERM');
|
|
const deadline = Date.now() + graceMs;
|
|
// Busy-wait: this runs from `process.on('exit')` handlers, where the event
|
|
// loop is already closed and nothing asynchronous can be awaited.
|
|
while (Date.now() < deadline) {
|
|
if (!procs.some(({ pid }) => alive(pid))) return procs.length;
|
|
}
|
|
for (const { pid } of procs) {
|
|
if (alive(pid)) signal(pid, 'SIGKILL');
|
|
}
|
|
return procs.length;
|
|
}
|
|
|
|
export function alive(pid) {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (err) {
|
|
return err.code === 'EPERM';
|
|
}
|
|
}
|
|
|
|
function signal(pid, sig) {
|
|
// A process group id is always the pid of its leader, so `-pid` can only ever
|
|
// reach a group this process leads. When it leads none, the call is ESRCH and
|
|
// the plain kill below is what does the work.
|
|
try { process.kill(-pid, sig); } catch { /* not a group leader */ }
|
|
try { process.kill(pid, sig); } catch { /* already gone */ }
|
|
}
|
|
|
|
/**
|
|
* Whether a `ps -E` line contains `marker` as a complete environment entry.
|
|
*
|
|
* A plain substring test is wrong here: a marker is a substring of any longer
|
|
* value that starts with it, and matching that would let one checkout's cleanup
|
|
* kill a neighbouring checkout's servers. `ps` flattens the environment into
|
|
* whitespace-separated `KEY=VALUE` pairs, so an entry runs to the next
|
|
* whitespace or to the end of the line.
|
|
*
|
|
* That simple rule is sound only because every marker value is drawn from
|
|
* `MARKER_VALUE_RE` and can therefore never contain whitespace: no value can
|
|
* hide the end of its own entry, and there is no ambiguous case. Give a marker
|
|
* a free-form value and this becomes guesswork again, which is why
|
|
* `assertMarkerValue` refuses one.
|
|
*/
|
|
export function envLineHasEntry(line, marker) {
|
|
for (let from = 0; ; from += 1) {
|
|
const at = line.indexOf(marker, from);
|
|
if (at === -1) return false;
|
|
const startsEntry = at === 0 || /\s/.test(line[at - 1]);
|
|
const end = at + marker.length;
|
|
const endsEntry = end === line.length || /\s/.test(line[end]);
|
|
if (startsEntry && endsEntry) return true;
|
|
from = at;
|
|
}
|
|
}
|
|
|
|
/** Refuse a marker value that could break out of its own environment entry. */
|
|
function assertMarkerValue(value) {
|
|
if (!MARKER_VALUE_RE.test(value)) {
|
|
throw new Error(
|
|
`refusing to match on "${value}": a marker value must be [A-Za-z0-9_-] only, ` +
|
|
'so that an environment entry ends where the whitespace after it does. ' +
|
|
'Hash or tokenize the value before passing it (see repoMarker).',
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/** pid -> full command line, for every process this user can see. */
|
|
function listCommands() {
|
|
const map = new Map();
|
|
if (process.platform === 'win32') return map; // no supported sweep yet
|
|
const res = spawnSync('ps', ['-A', '-ww', '-o', 'pid=,command='], { encoding: 'utf-8', maxBuffer: PS_MAX_BUFFER });
|
|
if (res.status !== 0 || !res.stdout) return map;
|
|
for (const line of res.stdout.split('\n')) {
|
|
const m = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
if (m) map.set(Number(m[1]), m[2]);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Pids whose environment contains one of `markers`.
|
|
*
|
|
* Linux exposes `/proc/<pid>/environ` directly, so entries are compared whole.
|
|
* BSD/macOS `ps -E` appends the environment to the command column instead, so
|
|
* the marker is matched against that combined line through `envLineHasEntry`,
|
|
* which requires the same whole-entry boundary. The command itself is then read
|
|
* back from the marker-free listing, so an env value that happened to contain
|
|
* "live-server" cannot decide the match.
|
|
*/
|
|
function pidsWithEnvMarker(markers, commands) {
|
|
const hits = [];
|
|
if (process.platform === 'linux') {
|
|
let entries = [];
|
|
try { entries = readdirSync('/proc'); } catch { return hits; }
|
|
for (const entry of entries) {
|
|
if (!/^\d+$/.test(entry)) continue;
|
|
let environ = '';
|
|
try { environ = readFileSync(`/proc/${entry}/environ`, 'utf-8'); } catch { continue; }
|
|
const vars = environ.split('\0');
|
|
if (markers.some((marker) => vars.includes(marker))) hits.push(Number(entry));
|
|
}
|
|
return hits;
|
|
}
|
|
|
|
const res = spawnSync('ps', ['-A', '-E', '-ww', '-o', 'pid=,command='], { encoding: 'utf-8', maxBuffer: PS_MAX_BUFFER });
|
|
if (res.status !== 0 || !res.stdout) return hits;
|
|
for (const line of res.stdout.split('\n')) {
|
|
const m = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
if (!m) continue;
|
|
const pid = Number(m[1]);
|
|
if (!commands.has(pid)) continue;
|
|
if (markers.some((marker) => envLineHasEntry(m[2], marker))) hits.push(pid);
|
|
}
|
|
return hits;
|
|
}
|