fix(#4104): park the prohibition-enforcement hang fixture on a settling timer (#4331)

* test(#4104): add self-exit regression for the hang fixture (RED at this sha)

* fix(#4104): park the hang fixture on a settling timer instead of a busy loop

The prohibition-enforcement hang fixture busy-looped `while (true) {}`, so a
worker orphaned by a killed runner burned a core forever. It now parks on a
10s settling setTimeout — still hung for any enforcement bound, ~0% CPU if
leaked, and guaranteed to self-terminate. A regression test spawns the exact
served body and asserts it self-exits with no signal.

* test(#4104): harden the self-exit regression (signal/spawn-error paths)

* changeset(#4104)

* changeset(#4104): backfill PR number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-05 18:10:04 -04:00
committed by GitHub
parent 17e163f15c
commit 7533cb4454
2 changed files with 70 additions and 3 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 4331
---
**Test machines no longer accumulate immortal 100%-CPU orphan processes when a test runner is killed mid-hang** — the prohibition-enforcement hang fixture busy-looped `while (true) {}`, so a worker orphaned by a chunk timeout, CI cancellation, or Ctrl+C burned a core indefinitely (users found orphans days old); the fixture now parks on a settling 10s timer — still hung for any enforcement bound, ~0% CPU if leaked, and guaranteed to self-terminate. (#4104)

View File

@@ -499,6 +499,17 @@ describe('prohibition-enforcement real-runner helpers (#1259)', () => {
// SF-01 slip past the injected-double tests. Typed-field assertions only.
describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => {
const fs = require('node:fs');
const { spawn } = require('node:child_process');
// The hang fixture served to the bounded-timeout test below, hoisted so the #4104 self-exit
// regression cannot drift from the body it guards. Parks on a SETTLING 10s timer: still "hung"
// for any enforcement bound (the test below uses 1500ms), ~0% CPU while parked, and guaranteed
// to self-terminate (#4104) — unlike the retired `while (true) {}` busy loop, which orphaned at
// 100% CPU forever when the runner was killed, and unlike a never-settling `new Promise(() => {})`
// (#4105), whose non-exit relies on unstated runtime behavior.
const HANGS_BODY =
"const { test } = require('node:test');\n" +
"test('hangs forever', () => new Promise((resolve) => { setTimeout(resolve, 10_000); }));\n";
test('a genuine non-vacuous passing node-test proven fail-first greens via the real runner + real prover', (t) => {
const enforce = require(ENFORCEMENT_LIB);
@@ -687,9 +698,14 @@ describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => {
const dir = createTempDir('prohib-hang-');
t.after(() => cleanup(dir));
const tf = path.join(dir, 'hang.test.cjs');
// A test that never returns; the bounded timeout must kill it and dispose non-green.
fs.writeFileSync(tf,
"const { test } = require('node:test');\ntest('hangs forever', () => { while (true) {} });\n");
// A test that never returns within the enforcement bound; the bounded timeout must kill it and
// dispose non-green. The body PARKS on a settling setTimeout (#4104) rather than busy-looping
// `while (true) {}`: still "hung" for any enforcement timeout (10s >> the 1500ms bound below),
// but an orphaned worker costs ~0% CPU while parked and self-exits when the timer settles —
// never an immortal 100%-CPU process when the runner is killed. Deliberately NOT the
// never-settling `new Promise(() => {})` shape (the #4105 Node-24 concern): the settle is
// stated platform behavior, so the self-exit is guaranteed rather than incidental.
fs.writeFileSync(tf, HANGS_BODY);
const result = enforce.runProhibitionEnforcement(
TEST_TIER,
{ kind: 'node-test', target: tf, failFirst: true },
@@ -699,6 +715,52 @@ describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => {
assert.equal(result.located, true);
});
test('the #4104 hang fixture parks (~0% CPU) and self-terminates — no immortal 100%-CPU orphan', (t, done) => {
// Regression (#4104): the enforcement hang test above relies on `t.after` cleanup and the
// bounded timeout, but if the RUNNER itself is killed (chunk timeout, CI cancel, Ctrl+C) nothing
// owns the fixture's worker. Old body: `while (true) {}` — orphaned at ~100% CPU forever
// (reproduced: worker reparented to PID 1 at 100.0% CPU surviving `kill -9` of the runner).
// Deterministic guard, no orphan hunt: spawn the EXACT served body directly, observe that it
// (a) is still running shortly into the enforcement-timeout window (it genuinely hangs), and
// (b) exits on its own — natural exit, no signal — inside a generous ceiling.
const dir = createTempDir('prohib-hang-selfexit-');
t.after(() => cleanup(dir));
const tf = path.join(dir, 'hang.test.cjs');
fs.writeFileSync(tf, HANGS_BODY);
const child = spawn(process.execPath, [tf], { stdio: 'ignore' });
t.after(() => { try { child.kill('SIGKILL'); } catch { /* already gone */ } });
// Fast-fail on a spawn error (execPath unspawnable): the poll below keys on exit/signal, which
// a failed spawn never sets, so without this the 20s ceiling would expire with a misleading
// "did not self-terminate" message instead of the real cause.
child.on('error', (err) => {
assert.fail(`could not spawn the fixture directly: ${err.message}`);
});
const CEILING_MS = 20_000;
const stillHangingAt = Date.now() + 750; // > half the 1500ms enforcement bound: it must not finish early
const deadline = Date.now() + CEILING_MS;
const poll = () => {
// `exitCode !== null` alone misses a SIGNALED exit (exitCode stays null when a signal ends
// the child); signalCode covers that, and the assertions below then name it as the failure.
if (child.exitCode !== null || child.signalCode !== null) {
// Exited. If it exited BEFORE the still-hanging checkpoint the fixture is no longer a hang
// fixture at all (breaks the enforcement test it serves — the negative space in 10-diagnosis).
assert.ok(Date.now() >= stillHangingAt,
`fixture must still be hanging at 750ms (exited after only ${Date.now() - (stillHangingAt - 750)}ms)`);
assert.equal(child.signalCode, null,
'fixture must SELF-terminate (natural exit) — a signal means we had to kill it (#4104 regression)');
assert.equal(child.exitCode, 0, 'the parked timer settles and the test passes cleanly');
done();
return;
}
if (Date.now() >= deadline) {
child.kill('SIGKILL');
assert.fail(`fixture did not self-terminate within ${CEILING_MS}ms — immortal process (#4104 regression)`);
}
setTimeout(poll, 250);
};
setImmediate(poll);
});
test('an EMPTY node-test file (exit 0, zero tests) does NOT green via the real runner (BL-01)', (t) => {
const enforce = require(ENFORCEMENT_LIB);
const dir = createTempDir('prohib-real-empty-');