fix(#4105): park the #3889 hang fixture on a settling timer (#4349)

* test(#4105): guard the #3889 hang fixture — must genuinely hang by itself and self-terminate

RED at this sha: against the current never-settling-promise body the guard
fails on the matrix line (Node 24: the unheld promise never self-terminates,
ceiling expires) and off it (v22-class runtimes: the child exits rc=1 after
~60ms, never reaching the still-hanging checkpoint). Same shape as the #4104
self-exit regression: spawn the exact served body, observe liveness past the
chunk bound and natural exit — no elapsed-value assertions.

* fix(#4105): park the #3889 hang fixture on a settling timer

The never-settling promise held no libuv handle, so the hang T1/T4 rely on
was a property of the runtime's test-runner shutdown behavior, not of the
fixture: v24/v26 happen to hold the loop open; v22-class runtimes exit rc=1
after ~60ms (# cancelled 1), so the chunk never reaches the timeout path and
the two timeout assertions assert nothing. Park on a settling 10s timer (the
#4104 idiom): an explicit handle makes the hang the fixture's on every Node
line, 10s >> the 2000ms chunk bound (margin asserted structurally in the
#4105 guard), ~0% CPU while parked, and guaranteed self-termination if a
kill orphans it. Behavior on the Node 24 matrix line is unchanged — the
chunk is still killed by the harness timeout (~2006ms) with the identical
diagnostic.

* test(#4105): drive the fixture guard off the child's exit event + runner timeout

Review-driven restructure (Memtrace flaky_test_fixed_sleep on the 200ms poll
interval): the guard now waits on the child's natural 'exit' event — no
polling interval, no hand-rolled watchdog setTimeout. The immortal-body bound
is the node:test per-test { timeout: 2 * HANG_PARK_MS } backstop, the
health-validation #663 house pattern and the no-elapsed-assertion-compliant
form. t.after still reaps the child on every path. Same failing-first arms:
still-hanging checkpoint, natural-exit (no signal), exit code 0.

* changeset(#4105)

* changeset(#4105): backfill PR number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-05 19:39:35 -04:00
committed by GitHub
parent 0be5bf865a
commit a65cb291e8
2 changed files with 103 additions and 5 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 4349
---
**The #3889 chunk-timeout tests now keep testing the timeout diagnostic regardless of the Node line's test-runner shutdown behavior** — the hang fixture returned a never-settling promise that holds no event-loop handle, so whether the chunk actually hung (and got killed by the per-chunk timeout, exercising the diagnostic) was decided by the runtime: on Node 24/26 the runner happens to hold the loop open, but on other lines the child exits on its own in ~60ms and the two timeout assertions silently assert nothing, failing later as a confusing 72ms chunk failure. The fixture now parks on a settling 10s timer (the #4104 idiom): the hang is a property of the fixture on every runtime, it stays ~0% CPU while parked, and it self-terminates if orphaned; a new regression guard pins that property (still hanging past the chunk bound, natural exit). Behavior on Node 24 (the CI/bench matrix line) is unchanged. (#4105)

View File

@@ -20,6 +20,7 @@ const { describe, test, before, after, beforeEach, afterEach } = require('node:t
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { spawn } = require('node:child_process');
const { runNode } = require('./helpers/process-seam.cjs');
const { toLegacyResult } = require('./helpers/git-fixture.cjs');
@@ -895,11 +896,30 @@ test('noop', () => {});
// once (via `before`) and every assertion group below reads from those
// captured results instead of spawning its own. This is the whole
// savings — no assertion is weakened or removed.
const HANGS_FOREVER_BODY = `'use strict';
// The fixture served to the timeout-path run below (#4105). Parks on a
// SETTLING timer — the #4104 idiom — so the hang is a property of the
// FIXTURE on every Node line, not of the runtime: the retired
// `new Promise(() => {})` shape held NO libuv handle, so whether the
// spawned child hung was decided by the Node line's test-runner shutdown
// behavior (measured: v24/v26 happen to hold the loop open; v22.22.0
// exits on its own in ~60ms with `# cancelled 1`, so the chunk never
// reached the timeout path and T1/T4 below asserted nothing). The park
// must outlast the chunk bound below with wide margin (asserted
// structurally in the #4105 regression test), stays ~0% CPU while parked,
// and the settle guarantees the fixture SELF-TERMINATES if a kill orphans
// it — unlike `setInterval`-forever (never exits) or `while (true) {}`
// (100% CPU forever), the two shapes #4104's pairing note rules out.
const HANG_PARK_MS = 10_000;
const HANGS_BODY = `'use strict';
const { test } = require('node:test');
test('hangs forever', () => new Promise(() => {}));
test('hangs forever', () => new Promise((resolve) => { setTimeout(resolve, ${HANG_PARK_MS}); }));
`;
// The per-chunk timeout the timeout-path run below arms. Hoisted (#4105)
// so the #4105 fixture guard below asserts against the SAME bound the
// harness run uses, not a re-typed literal that can drift from it.
const CHUNK_TIMEOUT_MS_FOR_HANG_RUN = 2000;
let successDir;
let successRun;
let eventsDirBefore;
@@ -923,10 +943,10 @@ test('hangs forever', () => new Promise(() => {}));
// box that has to boot node --test, register the hang, and observe
// the kill inside the window.
timeoutDir = createTempDir('gsd-3889-timeout-');
fs.writeFileSync(path.join(timeoutDir, 'hangs.test.cjs'), HANGS_FOREVER_BODY, 'utf8');
fs.writeFileSync(path.join(timeoutDir, 'hangs.test.cjs'), HANGS_BODY, 'utf8');
timeoutRun = runHarness(timeoutDir, [], {
RUN_TESTS_NO_FORCE_EXIT: '1',
RUN_TESTS_CHUNK_TIMEOUT_MS: '2000',
RUN_TESTS_CHUNK_TIMEOUT_MS: String(CHUNK_TIMEOUT_MS_FOR_HANG_RUN),
});
});
@@ -989,7 +1009,8 @@ test('hangs forever', () => new Promise(() => {}));
// T1: on a chunk timeout, the diagnostic must NAME the file that was
// still executing — not merely list every file the chunk contained (the
// pre-instrumentation behavior). A test that hangs INSIDE its own body
// (never resolving) keeps its test:start event unmatched by any
// (parks on a settling timer that outlasts the chunk bound, so it never
// resolves inside the window) keeps its test:start event unmatched by any
// test:pass/test:fail in the ndjson companion reporter's output, which
// is exactly the signal the diagnostic reads back on timeout.
test('a chunk timeout names the file that was in flight when killed', () => {
@@ -1150,6 +1171,78 @@ test('hangs forever', () => new Promise(() => {}));
`expected the new killed/elapsed line; STDERR:\n${timeoutRun.stderr}`,
);
});
// Regression (#4105): T1/T4 above are only meaningful if the fixture
// above GENUINELY hangs, and the hang must be a property of the FIXTURE,
// not of the runtime's test-runner shutdown behavior. A never-settling
// `new Promise(() => {})` holds NO libuv handle, so whether the spawned
// child hangs is decided by the Node line: on v24/v26 the runner happens
// to hold the loop open, but on v22 the child exits on its own in ~60ms
// (`# cancelled 1`, rc=1) — the chunk then never reaches the timeout path
// and T1/T4 assert nothing about the timeout diagnostic (measured:
// v22.22.0 `node --test` exits after 61ms). RED at this test's introducing
// sha against the then-current body: on Node 24 it fails the
// self-termination arm (an unheld never-settling promise also never
// self-terminates on lines where the runner DOES hold it open), and on
// off-24 lines it fails the still-hanging arm. Deterministic guard, the
// #4104 self-exit regression's event-driven shape: spawn the EXACT served
// body directly — no harness, no runner, no polling — and observe that its
// natural 'exit' event (a) arrives no earlier than past the chunk bound
// the harness run above arms (it hangs by itself, without any runtime
// holding it up), and (b) carries a natural exit — exit code 0, no
// signal. The `{ timeout: 2 * HANG_PARK_MS }` backstop (the
// health-validation #663 pattern) fails an immortal body — one that never
// delivers 'exit' — instead of hanging the suite; a `{ timeout }` option
// is the no-elapsed-assertion-compliant bound. Liveness of a SUBPROCESS
// cannot be driven through the clock seam (mock.timers cannot reach
// inside a separately spawned node), so the guard asserts observable
// exit/signal/liveness behavior only — never a measured duration value
// (RULESET.TESTS.no-timing-assertion).
test('the #3889 hang fixture genuinely hangs by itself and self-terminates (#4105)', { timeout: 2 * HANG_PARK_MS }, (t, done) => {
// (a) Structural margin, single-sourced with the served body: the park
// must outlast the chunk bound by >= 4x, so a loaded box's scheduling
// jitter can never let the timer settle before the harness kill fires.
assert.ok(
HANG_PARK_MS >= 4 * CHUNK_TIMEOUT_MS_FOR_HANG_RUN,
`fixture park (${HANG_PARK_MS}ms) must exceed the chunk bound (${CHUNK_TIMEOUT_MS_FOR_HANG_RUN}ms) with margin`,
);
const dir = createTempDir('gsd-3889-hang-fixture-');
t.after(() => cleanup(dir));
const tf = path.join(dir, 'hangs.test.cjs');
fs.writeFileSync(tf, HANGS_BODY, 'utf8');
const child = spawn(process.execPath, [tf], { stdio: 'ignore' });
// The runner timeout above fails an immortal body; this hook guarantees
// the child is reaped on every exit path, including that one.
t.after(() => { try { child.kill('SIGKILL'); } catch { /* already gone */ } });
const spawnedAt = Date.now();
// Past the chunk bound the harness kills at: the fixture's natural exit
// must arrive no earlier than this — that is the vacuity guard. +400ms
// covers child spawn startup so the checkpoint measures the fixture,
// not boot time.
const stillHangingAt = spawnedAt + CHUNK_TIMEOUT_MS_FOR_HANG_RUN + 400;
// Fast-fail on a spawn error (execPath unspawnable): 'exit' never fires
// for a failed spawn, so without this the runner timeout would expire
// with a generic timeout message instead of the real cause (same
// hardening as #4104).
child.on('error', (err) => {
assert.fail(`could not spawn the fixture directly: ${err.message}`);
});
// `exitCode !== null` alone misses a SIGNALED exit (exitCode stays null
// when a signal ends the child); the assertions name signalCode as the
// failure when that happens.
child.on('exit', () => {
assert.ok(
Date.now() >= stillHangingAt,
`fixture must still be hanging past the ${CHUNK_TIMEOUT_MS_FOR_HANG_RUN}ms chunk bound — ` +
`it exited after only ${Date.now() - spawnedAt}ms ` +
'(#4105 regression: the hang is the runtime\'s, not the fixture\'s)',
);
assert.strictEqual(child.signalCode, null,
'fixture must SELF-terminate (natural exit) — a signal means we had to kill it (#4105/#4104 regression)');
assert.strictEqual(child.exitCode, 0, 'the parked fixture settles and its test passes cleanly');
done();
});
});
});
});