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:
@@ -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
|
||||
// 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
|
||||
@@ -2402,4 +2446,67 @@ describe('analyzeChunkEvents (#3889)', () => {
|
||||
assert.ok(!result.files.includes('c.test.cjs'));
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user