fix(#4012): a hang never emits the events I was listening for

The init marker settled it. The diagnostic reported "THE REPORTER LOADED BUT
RECORDED NO TEST EVENTS — the events file contains only the reporter's own
reporter:init marker", which refutes the reporter-never-loaded hypothesis and
leaves exactly one explanation.

The runner spawns a child process per test file and surfaces a subtest's
test:start / test:pass / test:fail to the parent's reporter only once the child
REPORTS that test — which happens when it completes. The fixture hangs forever,
so it never completes, so it never reports. I had recorded exactly those three
event types: the precise set that a hang guarantees you will never see. The
feature could not have worked for the case it was built for.

test:enqueue and test:dequeue are emitted by the runner as it queues and begins
each file, independent of anything inside finishing. test:dequeue is what
actually means "in flight", and it is now the primary signal, with test:start
kept as a secondary one. A file is in flight when it has been dequeued and has
no terminal event.

The four branches now describe states that are all real: the events file absent
(reporter never loaded); the init marker alone (the runner dequeued nothing at
all — genuinely surprising now rather than the expected outcome); everything
dequeued and terminated (the files finished and the process hung afterwards, a
handle leak); and one or more dequeued-but-unterminated files, named, which is
the case this whole feature exists to report.

Verified against the exact shape the real hang produces, by executing
analyzeChunkEvents on a synthetic events file: init + enqueue + dequeue with no
terminal event reports hangs.test.cjs as in flight, and appending a test:pass
clears it. Four more unit tests cover the ordering and multi-file cases with no
subprocess, so this logic is now checkable without a runner round-trip — which
matters, because every defect in this feature so far was visible only remotely.

T1 is untouched and should now pass for the right reason.

Verification runs on the remote runner.

Refs #4012
This commit is contained in:
sim
2026-08-28 19:52:57 -04:00
parent 444137e63a
commit bf8905fcc9
3 changed files with 191 additions and 36 deletions

View File

@@ -11,7 +11,7 @@
// Node's documented multi-reporter pairing) and appends one JSON object per // Node's documented multi-reporter pairing) and appends one JSON object per
// line to a path supplied via the GSD_RUN_TESTS_EVENTS_FILE env var. On a // line to a path supplied via the GSD_RUN_TESTS_EVENTS_FILE env var. On a
// timeout, run-tests.cjs reads that file back to name the file(s) still // timeout, run-tests.cjs reads that file back to name the file(s) still
// in flight (a `test:start` with no matching `test:pass`/`test:fail`). // in flight (a `test:dequeue` with no matching `test:pass`/`test:fail`).
// //
// Durability, not `--test-reporter-destination` (#3889 root cause): a // Durability, not `--test-reporter-destination` (#3889 root cause): a
// reporter that YIELDS strings has them piped by Node into a // reporter that YIELDS strings has them piped by Node into a
@@ -49,12 +49,22 @@
// repo's `engines.node` requires >=24.0.0 (package.json), where both // repo's `engines.node` requires >=24.0.0 (package.json), where both
// contracts have been stable since Node 20. // contracts have been stable since Node 20.
// //
// Kept intentionally tiny: only the three event types run-tests.cjs needs to // Kept intentionally tiny: only the five event types run-tests.cjs needs are
// pair start/completion are handled; everything else (diagnostics, plans, // handled — `test:enqueue`/`test:dequeue` (emitted by the RUNNER as it queues
// coverage) is ignored so a truncated events file (the process is SIGKILLed // and begins each spawned test-file child, independent of whether anything
// mid-`appendFileSync` on timeout — an individual write is unbuffered but // inside that file ever completes) plus `test:start`/`test:pass`/`test:fail`
// not atomic, so the OS can still interleave a partial write with the kill) // (emitted per-subtest, once the child reports it). `test:dequeue` is the
// never leaves more than one dangling unparsable trailing line. // event that actually means "in flight": a subtest inside a file that hangs
// forever never reaches `test:start`/`test:pass`/`test:fail` at all, because
// node:test only surfaces those to the parent once the child COMPLETES that
// test — a hang, by definition, never completes. Recording `test:dequeue`
// closes that gap: it fires the moment the runner begins the file, so a
// killed hang still leaves a durable "this file was running" record.
// Everything else (diagnostics, plans, coverage) is ignored so a truncated
// events file (the process is SIGKILLed mid-`appendFileSync` on timeout — an
// individual write is unbuffered but not atomic, so the OS can still
// interleave a partial write with the kill) never leaves more than one
// dangling unparsable trailing line.
module.exports = async function ndjsonEventReporter(source) { module.exports = async function ndjsonEventReporter(source) {
const eventsPath = process.env.GSD_RUN_TESTS_EVENTS_FILE; const eventsPath = process.env.GSD_RUN_TESTS_EVENTS_FILE;
// #3889: an init marker, written as this reporter's FIRST action — before // #3889: an init marker, written as this reporter's FIRST action — before
@@ -77,6 +87,8 @@ module.exports = async function ndjsonEventReporter(source) {
for await (const event of source) { for await (const event of source) {
if (!eventsPath) continue; // no destination configured — nothing to record if (!eventsPath) continue; // no destination configured — nothing to record
if ( if (
event.type === 'test:enqueue' ||
event.type === 'test:dequeue' ||
event.type === 'test:start' || event.type === 'test:start' ||
event.type === 'test:pass' || event.type === 'test:pass' ||
event.type === 'test:fail' event.type === 'test:fail'

View File

@@ -685,11 +685,22 @@ function selectExplicitFiles(allFiles, filesValue, filesFrom) {
} }
// #3889: reads back the ndjson companion reporter's destination file for one // #3889: reads back the ndjson companion reporter's destination file for one
// chunk and reduces it to "which file(s) were in flight" — a `test:start` // chunk and reduces it to "which file(s) were in flight" — a `test:dequeue`
// with no matching `test:pass`/`test:fail` in the same file. Matched by // with no matching `test:pass`/`test:fail` FOR THE SAME FILE. `test:dequeue`
// (file, nesting, testNumber) when testNumber is present (assigned once at // is emitted by the RUNNER the moment it begins a spawned test-file child,
// start and echoed on completion, per node:test), falling back to // independent of whether anything inside that file ever completes — it is
// (file, name) for older/odd event shapes. // the correct "in flight" signal precisely BECAUSE a hang never completes.
// `test:start`/`test:pass`/`test:fail` are per-SUBTEST events that node:test
// only surfaces to the parent once the child reports that subtest, which
// happens on completion — a subtest that hangs forever inside `new
// Promise(() => {})` never reports `test:start` either, so those three event
// types alone can NEVER see a genuine hang (this was the root cause of the
// feature never working: the three original event types are precisely the
// ones a hang guarantees are never emitted). `test:start` is still tracked
// here as a SECONDARY in-flight signal (a file can legitimately produce
// both), never as the primary one. Tracked per FILE, not per (file, nesting,
// testNumber) — dequeue/enqueue are file-level events with no subtest
// identity, so file is the only key both event families share.
// //
// Tolerant of a destination file that is missing (reporter never flushed // Tolerant of a destination file that is missing (reporter never flushed
// anything before the kill) or whose last line is truncated mid-write (the // anything before the kill) or whose last line is truncated mid-write (the
@@ -708,12 +719,23 @@ function analyzeChunkEvents(eventsPath) {
// FIRST action, before any test can run, so its absence pins the failure // FIRST action, before any test can run, so its absence pins the failure
// to reporter load/resolution, not to the tests). Distinct from "file // to reporter load/resolution, not to the tests). Distinct from "file
// exists but only the init marker was written" (readError=false, // exists but only the init marker was written" (readError=false,
// sawInitMarker=true, sawAnyEvent=false) so the diagnostic below can say // sawInitMarker=true, anyDequeued=false) so the diagnostic below can say
// explicitly WHICH of the two happened, rather than silently collapsing // explicitly WHICH of the two happened, rather than silently collapsing
// both into "no in-flight file identified". // both into "no in-flight file identified".
return { files: [], staleMs: null, sawAnyEvent: false, sawInitMarker: false, readError: true }; return {
files: [],
staleMs: null,
sawAnyEvent: false,
sawInitMarker: false,
anyDequeued: false,
readError: true,
};
} }
const inFlight = new Map(); // Map preserves insertion order; re-`set`ting an existing key moves it to
// the END, so iterating this map's keys yields "most recently
// dequeued/started" LAST — reversed below to report most-recent-first.
const dequeuedAt = new Map(); // file -> last dequeue/start ts seen
const terminated = new Set(); // files with an observed test:pass/test:fail
let lastTs = null; let lastTs = null;
let sawInitMarker = false; let sawInitMarker = false;
let sawAnyEvent = false; let sawAnyEvent = false;
@@ -731,21 +753,25 @@ function analyzeChunkEvents(eventsPath) {
continue; // not a test event — never tracked in inFlight, never proof a test ran continue; // not a test event — never tracked in inFlight, never proof a test ran
} }
sawAnyEvent = true; sawAnyEvent = true;
const key = evt.testNumber !== undefined if (!evt.file) continue; // defensive: every recorded type carries `file`
? `${evt.file}::${evt.nesting}::${evt.testNumber}` if (evt.type === 'test:dequeue' || evt.type === 'test:start') {
: `${evt.file}::${evt.name}`; dequeuedAt.delete(evt.file); // move to most-recent position
if (evt.type === 'test:start') { dequeuedAt.set(evt.file, evt.ts);
inFlight.set(key, evt.file); terminated.delete(evt.file); // a re-dequeue (retry) puts it back in flight
} else { } else if (evt.type === 'test:pass' || evt.type === 'test:fail') {
inFlight.delete(key); terminated.add(evt.file);
} }
// test:enqueue is intentionally NOT a start signal here: "enqueued" means
// "queued to run", not "running" — test:dequeue is the runner actually
// picking the file up, which is the moment that matters for "in flight".
} }
const files = [...new Set([...inFlight.values()].filter(Boolean))]; const files = [...dequeuedAt.keys()].filter((f) => !terminated.has(f)).reverse();
return { return {
files, files,
staleMs: lastTs !== null ? Date.now() - lastTs : null, staleMs: lastTs !== null ? Date.now() - lastTs : null,
sawAnyEvent, sawAnyEvent,
sawInitMarker, sawInitMarker,
anyDequeued: dequeuedAt.size > 0,
readError: false, readError: false,
}; };
} }
@@ -1222,16 +1248,23 @@ function main() {
// this parent never saw the child's own stdout, so it cannot know // this parent never saw the child's own stdout, so it cannot know
// otherwise). Falls back to "no file identified" rather than // otherwise). Falls back to "no file identified" rather than
// throwing when the reporter file is missing/empty/truncated. // throwing when the reporter file is missing/empty/truncated.
const { files: inFlightFiles, staleMs, sawAnyEvent, sawInitMarker, readError } = analyzeChunkEvents(chunkEventsPath); const {
files: inFlightFiles,
staleMs,
sawInitMarker,
anyDequeued,
readError,
} = analyzeChunkEvents(chunkEventsPath);
const inFlightMsg = inFlightFiles.length > 0 const inFlightMsg = inFlightFiles.length > 0
? `In flight when killed (test:start with no matching pass/fail): ` + ? `In flight when killed (test:dequeue with no matching pass/fail): ` +
`${inFlightFiles.map((f) => basename(f)).join(', ')} — last reporter event was ` + `${inFlightFiles.map((f) => basename(f)).join(', ')} — last reporter event was ` +
`${staleMs !== null ? `${staleMs}ms` : 'an unknown time'} before this diagnostic ` + `${staleMs !== null ? `${staleMs}ms` : 'an unknown time'} before this diagnostic ` +
`(small = output kept flowing until the kill = slow; large = it stopped early = hang).` `(small = output kept flowing until the kill = slow; large = it stopped early = hang).`
: sawAnyEvent : anyDequeued
? `No file was in flight when killed — every started test in this chunk already ` + ? `No file was in flight when killed — every file the runner dequeued in this ` +
`completed, so the CHILD PROCESS itself hung after its last test (last reporter ` + `chunk already terminated (test:pass/test:fail seen for each), so the CHILD ` +
`event was ${staleMs !== null ? `${staleMs}ms` : 'an unknown time'} before this ` + `PROCESS itself hung after its last test finished (last reporter event was ` +
`${staleMs !== null ? `${staleMs}ms` : 'an unknown time'} before this ` +
`diagnostic); suspect a leaked handle outside any single test, or an ` + `diagnostic); suspect a leaked handle outside any single test, or an ` +
`after-tests hook.` `after-tests hook.`
: readError : readError
@@ -1243,13 +1276,16 @@ function main() {
`reporter function was ever invoked (process/spawn startup stall). This ` + `reporter function was ever invoked (process/spawn startup stall). This ` +
`diagnostic could not identify an in-flight file.` `diagnostic could not identify an in-flight file.`
: sawInitMarker : sawInitMarker
? `THE REPORTER LOADED BUT RECORDED NO TEST EVENTS — the events file contains ` + ? `THE REPORTER LOADED BUT THE RUNNER NEVER DEQUEUED A SINGLE FILE — the events ` +
`only the reporter's own \`reporter:init\` marker, so the reporter module ` + `file contains only the reporter's own \`reporter:init\` marker (and possibly ` +
`was invoked and ran, but no \`test:start\` for any file in this chunk ` + `\`test:enqueue\` events with no matching \`test:dequeue\`), so the reporter ` +
`reached it before the kill. Two possible causes, NOT distinguished by this ` + `module was invoked and ran, but node's test runner never began executing any ` +
`diagnostic: node --test itself stalled before dispatching any test file, or ` + `file in this chunk before the kill. This is a genuinely surprising state — ` +
`the first file in this chunk hung/stalled before its first test began. This ` + `\`test:dequeue\` fires the instant the runner starts a file, independent of ` +
`diagnostic could not identify an in-flight file.` `whether anything inside it ever completes. Two possible causes, NOT ` +
`distinguished by this diagnostic: node --test itself stalled before ` +
`dispatching any test file, or process/spawn startup stalled. This diagnostic ` +
`could not identify an in-flight file.`
: `No reporter events were recorded before the kill — the companion reporter's ` + : `No reporter events were recorded before the kill — the companion reporter's ` +
`events file exists but is empty/unparseable (no \`reporter:init\` marker and ` + `events file exists but is empty/unparseable (no \`reporter:init\` marker and ` +
`no test events), so even the reporter's first appendFileSync may not have ` + `no test events), so even the reporter's first appendFileSync may not have ` +

View File

@@ -951,6 +951,50 @@ test('hangs forever', () => new Promise(() => {}));
} }
}); });
// Regression (#3889 root cause): a hang inside a test body NEVER produces
// a `test:start`/`test:pass`/`test:fail` for that subtest (node:test only
// surfaces those to the parent once the child reports completion), so
// those three event types alone can never see a hang. `test:enqueue` and
// `test:dequeue` are emitted by the RUNNER as it queues/begins a file,
// independent of completion — this pins that the reporter now records
// both, verbatim, for exactly the "enqueue then dequeue, then nothing"
// shape a real hang produces.
test('the reporter records test:enqueue and test:dequeue for the hang shape (enqueue, dequeue, nothing else)', async () => {
const reporter = require('../scripts/lib/ndjson-reporter.cjs');
const eventsFile = path.join(tmpDir, 'ndjson-reporter-hang-shape.ndjson');
const savedEventsFile = process.env.GSD_RUN_TESTS_EVENTS_FILE;
process.env.GSD_RUN_TESTS_EVENTS_FILE = eventsFile;
try {
async function* hangShapeEvents() {
yield { type: 'test:enqueue', data: { file: 'hangs.test.cjs', name: 'hangs.test.cjs', nesting: 0 } };
yield { type: 'test:dequeue', data: { file: 'hangs.test.cjs', name: 'hangs.test.cjs', nesting: 0 } };
// Never yields test:start/test:pass/test:fail — this IS the hang.
}
const result = await reporter(hangShapeEvents());
assert.strictEqual(result ?? null, null);
const rawContent = fs.readFileSync(eventsFile, 'utf8');
const lines = splitLines(rawContent.trim()).filter((l) => l.length > 0);
assert.strictEqual(
lines.length,
3,
`expected exactly 3 NDJSON lines (init marker + enqueue + dequeue); got:\n${lines.join('\n')}`,
);
const [init, enqueue, dequeue] = lines.map((l) => JSON.parse(l));
assert.strictEqual(init.type, 'reporter:init');
assert.strictEqual(enqueue.type, 'test:enqueue');
assert.strictEqual(enqueue.file, 'hangs.test.cjs');
assert.strictEqual(dequeue.type, 'test:dequeue');
assert.strictEqual(dequeue.file, 'hangs.test.cjs');
} finally {
if (savedEventsFile === undefined) {
delete process.env.GSD_RUN_TESTS_EVENTS_FILE;
} else {
process.env.GSD_RUN_TESTS_EVENTS_FILE = savedEventsFile;
}
cleanup(eventsFile);
}
});
// #3889: the init marker is the reporter's FIRST action, written before // #3889: the init marker is the reporter's FIRST action, written before
// the `for await` loop even begins — so it must land even when the // the `for await` loop even begins — so it must land even when the
// source event stream yields ZERO events (e.g. the child is killed // source event stream yields ZERO events (e.g. the child is killed
@@ -2402,4 +2446,67 @@ describe('analyzeChunkEvents (#3889)', () => {
assert.ok(!result.files.includes('c.test.cjs')); assert.ok(!result.files.includes('c.test.cjs'));
assert.strictEqual(result.sawAnyEvent, true, 'the complete lines before the truncation must still count as events'); assert.strictEqual(result.sawAnyEvent, true, 'the complete lines before the truncation must still count as events');
}); });
// Regression (#3889 root cause): test:start/test:pass/test:fail are the
// exact three event types a genuine hang guarantees are never emitted —
// node:test only surfaces a subtest event to the parent once the child
// reports it, which happens on completion. test:dequeue is the RUNNER's
// own "began this file" signal and fires independent of completion; these
// four cases pin analyzeChunkEvents' dequeue-based in-flight rule directly
// against the synthetic event shapes a real hang, and a real finish, produce.
test('a dequeued file with no terminal event is reported as in flight', () => {
const eventsPath = path.join(tmpDir, 'dequeue-only.ndjson');
const lines = [
JSON.stringify({ type: 'reporter:init', ts: 900 }),
JSON.stringify({ type: 'test:enqueue', file: 'a.test.cjs', ts: 1000 }),
JSON.stringify({ type: 'test:dequeue', file: 'a.test.cjs', ts: 1010 }),
].join('\n');
fs.writeFileSync(eventsPath, lines, 'utf8');
const result = analyzeChunkEvents(eventsPath);
assert.deepStrictEqual(result.files, ['a.test.cjs']);
assert.strictEqual(result.anyDequeued, true);
assert.strictEqual(result.sawInitMarker, true);
});
test('a dequeued file that also terminates reports nothing in flight (all files finished)', () => {
const eventsPath = path.join(tmpDir, 'dequeue-then-pass.ndjson');
const lines = [
JSON.stringify({ type: 'reporter:init', ts: 900 }),
JSON.stringify({ type: 'test:enqueue', file: 'a.test.cjs', ts: 1000 }),
JSON.stringify({ type: 'test:dequeue', file: 'a.test.cjs', ts: 1010 }),
JSON.stringify({ type: 'test:pass', file: 'a.test.cjs', ts: 1020 }),
].join('\n');
fs.writeFileSync(eventsPath, lines, 'utf8');
const result = analyzeChunkEvents(eventsPath);
assert.deepStrictEqual(result.files, [], 'a terminated file must not show as in flight');
assert.strictEqual(result.anyDequeued, true, 'the file WAS dequeued — "all files finished" is a distinct state from "nothing ran"');
});
test('one terminated file followed by a second dequeued-but-unterminated file reports only the second', () => {
const eventsPath = path.join(tmpDir, 'two-files.ndjson');
const lines = [
JSON.stringify({ type: 'reporter:init', ts: 900 }),
JSON.stringify({ type: 'test:dequeue', file: 'a.test.cjs', ts: 1000 }),
JSON.stringify({ type: 'test:pass', file: 'a.test.cjs', ts: 1010 }),
JSON.stringify({ type: 'test:dequeue', file: 'b.test.cjs', ts: 1020 }),
].join('\n');
fs.writeFileSync(eventsPath, lines, 'utf8');
const result = analyzeChunkEvents(eventsPath);
assert.deepStrictEqual(result.files, ['b.test.cjs']);
assert.ok(!result.files.includes('a.test.cjs'));
});
test('an init marker with no dequeue at all is distinguished (anyDequeued=false) from "all finished"', () => {
const eventsPath = path.join(tmpDir, 'init-only.ndjson');
fs.writeFileSync(eventsPath, JSON.stringify({ type: 'reporter:init', ts: 900 }), 'utf8');
const result = analyzeChunkEvents(eventsPath);
assert.deepStrictEqual(result.files, []);
assert.strictEqual(result.anyDequeued, false);
assert.strictEqual(result.sawInitMarker, true);
assert.strictEqual(result.sawAnyEvent, false);
});
}); });