Files
gods-eye-view/scripts/qa/process.mjs
T
German Khokhlov ed6ae54ef6 Stop the strict acceptance from waiting for itself
A scenario whose body ran past about 19 s idled until 45 s after its
start, and twice one never exited and hit the runner's 300 s timeout
after it had passed: something of Chrome outlived Browser.close and kept
its stdout and stderr open.

- scripts/qa/browser.mjs closes Chrome within 5 s, kills its process
  group and closes its pipes; the harness then finishes and exits with
  the scenario's code. QA_EXIT_DIAGNOSTICS=1 keeps the old ending and
  prints the open handles and Chrome's process tree 5 s after finish.
- Every strict page starts with its clock at the 18:30 UTC nearest to
  the real time, so Navara's sun is up over the test cities whenever the
  acceptance runs; a scenario can ask for its own instant or the real
  clock (Wildlife does, for its freshness fixtures).
- The runner records a scenario that passed but did not exit as hung,
  kills the whole process group on a timeout, runs a failed scenario
  once more and reports one that passes then as flaky, and appends each
  run to --history=FILE; scripts/report-acceptance-flake.mjs reads it.
2026-11-10 14:10:46 +04:00

120 lines
3.4 KiB
JavaScript

import { execFileSync, spawn } from 'node:child_process';
export function readProcesses() {
try {
return execFileSync('ps', ['-axo', 'pid=,ppid=,pgid=,etime=,command='], {
encoding: 'utf8',
timeout: 2000,
maxBuffer: 1 << 24,
})
.trim()
.split('\n')
.flatMap((line) => {
const match = line
.trim()
.match(/^(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/);
return match
? [
{
pid: Number(match[1]),
ppid: Number(match[2]),
pgid: Number(match[3]),
elapsed: match[4],
command: match[5],
line: line.trim(),
},
]
: [];
});
} catch {
return [];
}
}
/** Include the group even when its leader has already exited. */
export function processTree(pid, processes) {
const ids = new Set([pid]);
let changed;
do {
changed = false;
for (const row of processes) {
if (!ids.has(row.pid) && (ids.has(row.ppid) || row.pgid === pid)) {
ids.add(row.pid);
changed = true;
}
}
} while (changed);
return processes.filter((row) => ids.has(row.pid));
}
/** Sequential callers get captured output and a deadline for the whole tree. */
export function runScenario(command, args, { timeout, env }) {
return new Promise((resolve) => {
const child = spawn(command, args, {
env,
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
const groups = new Set(child.pid ? [child.pid] : []);
const stdout = [];
const stderr = [];
let bytes = 0;
let error = null;
let done = false;
const rememberGroups = () => {
const processes = readProcesses();
for (const pid of [...groups]) {
const rows = processTree(pid, processes);
if (processes.length && !rows.length && pid !== child.pid)
groups.delete(pid);
for (const row of rows) groups.add(row.pgid);
}
};
const finish = (status, signal) => {
if (done) return;
done = true;
clearTimeout(deadline);
clearInterval(watch);
resolve({
status,
signal,
error,
stdout: Buffer.concat(stdout).toString(),
stderr: Buffer.concat(stderr).toString(),
});
};
const stop = (code, message) => {
if (done) return;
error = Object.assign(new Error(message), { code });
rememberGroups();
// Chrome detaches into another group; retain it before Node can exit.
for (const pid of [...groups].reverse()) {
try {
if (pid > 0) process.kill(-pid, 'SIGKILL');
} catch {}
}
child.stdout.destroy();
child.stderr.destroy();
child.unref();
finish(null, 'SIGKILL');
};
const capture = (chunks) => (chunk) => {
bytes += chunk.length;
if (bytes > 1 << 26) stop('ENOBUFS', 'Scenario output exceeded 64 MiB');
else chunks.push(chunk);
};
child.stdout.on('data', capture(stdout));
child.stderr.on('data', capture(stderr));
child.on('error', (failure) => {
error = failure;
});
child.on('close', finish);
// Keep detached descendants identifiable if Chrome's leader exits first.
const watch = setInterval(rememberGroups, 1000);
const deadline = setTimeout(
() => stop('ETIMEDOUT', `Scenario timed out after ${timeout} ms`),
timeout,
);
});
}