Version
v26.5.0 (minimal repro below, macOS arm64, Darwin 25.5.0); also observed on v24.18.0 (our CI)
Platform
macOS arm64 (Darwin 25.5.0), also observed on v24.18.0 elsewhere
Subsystem
test_runner
What steps will reproduce the bug?
Run many fast test files under --test-force-exit with --test-concurrency > 1. The parent process silently loses a fraction of the test verdicts: reporters (both built-in tap and a custom one) receive fewer test:pass/test:fail events than tests that actually ran, the parent's own # tests summary counter drops by the same amount, and the run still exits 0. No warning or error of any kind.
Generate 40 files × 50 trivially-passing tests (2000 total):
// gen.mjs
import fs from 'node:fs';
const FILES = 40, TESTS = 50;
fs.rmSync('tests', { recursive: true, force: true });
fs.mkdirSync('tests');
for (let f = 0; f < FILES; f++) {
const lines = ["import { test } from 'node:test';"];
for (let t = 0; t < TESTS; t++) {
lines.push(`test('file ${f} test ${t} — some reasonably long test name padding', () => {});`);
}
fs.writeFileSync(`tests/f${f}.test.mjs`, lines.join('\n'));
}
Counting reporter (counts leaf verdicts the parent actually received):
// count-reporter.mjs
import fs from 'node:fs';
export default async function* countReporter(source) {
let pass = 0, fail = 0;
for await (const event of source) {
if (event.type === 'test:pass' || event.type === 'test:fail') {
if (event.data.details?.type === 'test') {
if (event.type === 'test:pass') pass++; else fail++;
}
}
}
fs.writeSync(2, `\nREPORTER-COUNT pass=${pass} fail=${fail} total=${pass + fail}\n`);
}
Run:
node gen.mjs
node --test --test-force-exit --test-concurrency=4 \
--test-reporter=tap --test-reporter-destination=stdout \
--test-reporter=./count-reporter.mjs --test-reporter-destination=stderr \
'tests/*.test.mjs'
How often does it reproduce? Is there a required condition?
Every run in our testing (21/21 runs lost verdicts), with a different loss each time. Three consecutive runs of the exact command above on v26.5.0:
| run |
exit code |
parent # tests summary |
custom reporter count |
actual tests |
| 1 |
0 |
# tests 1835 |
1835 |
2000 |
| 2 |
0 |
# tests 1794 |
1794 |
2000 |
| 3 |
0 |
# tests 1936 |
1936 |
2000 |
Both reporters always agree with each other and with the summary counters — the events never reach the parent at all. Removing --test-force-exit (same command otherwise) reports exactly 2000/2000 every time. Loss magnitude varies (we've seen 25–234 of 2000, ~1–12%) and occurs at every concurrency level we tried on a larger real suite; higher concurrency loses more.
Required conditions in our testing: --test-force-exit + --test-concurrency > 1 (default process isolation).
What is the expected behavior? Why is that the expected behavior?
Either every verdict from every child is reported before the forced exit, or the runner fails loudly when it knows it dropped report data. Silent partial results defeat the purpose of a test run: on our production suite (Node v24.18.0, 1351 tests) the parent reported as few as 1168 — and since exit code stays 0 when the lost fragments contain only passes, CI stays green while a meaningful fraction of the suite is unaccounted for. A failing test whose verdict lands in a lost fragment is never printed, so the failure detail can vanish from logs even when the child's non-zero exit still fails the run. (To be precise about what we measured: in the minimal repro the injected failure's verdict happened to survive all 15 sample runs and exit stayed 1; the silent loss of pass verdicts with exit 0 reproduces on every all-pass run.)
What do you see instead?
Fewer tests reported than ran — # tests 1835 for a 2000-test suite — with exit code 0 and no diagnostic (see table above).
Additional information
Suspected mechanism: --test-force-exit calls process.exit() while child→parent report streams are still flushing, so V8-serialized test-event frames from process-isolated children are truncated/dropped before the parent's FileTest parser consumes them. This is the same failure class that #54327 reported for reporter destination files and #55099 fixed — but that fix only awaits the reporter destinations' close() on the parent side before exiting. The flush guarantee was never extended one layer up, to the child→parent report stream itself, which is where these events are being lost (a reporter writing synchronously on every event still never sees them).
Related, reviewed, and believed distinct:
We currently work around this in CI with a committed manifest of expected tests plus a completeness gate that re-runs files whose verdicts went missing — it works, but it amounts to reimplementing "report everything you ran" outside the runner.
Version
v26.5.0 (minimal repro below, macOS arm64, Darwin 25.5.0); also observed on v24.18.0 (our CI)
Platform
macOS arm64 (Darwin 25.5.0), also observed on v24.18.0 elsewhere
Subsystem
test_runner
What steps will reproduce the bug?
Run many fast test files under
--test-force-exitwith--test-concurrency> 1. The parent process silently loses a fraction of the test verdicts: reporters (both built-intapand a custom one) receive fewertest:pass/test:failevents than tests that actually ran, the parent's own# testssummary counter drops by the same amount, and the run still exits 0. No warning or error of any kind.Generate 40 files × 50 trivially-passing tests (2000 total):
Counting reporter (counts leaf verdicts the parent actually received):
Run:
node gen.mjs node --test --test-force-exit --test-concurrency=4 \ --test-reporter=tap --test-reporter-destination=stdout \ --test-reporter=./count-reporter.mjs --test-reporter-destination=stderr \ 'tests/*.test.mjs'How often does it reproduce? Is there a required condition?
Every run in our testing (21/21 runs lost verdicts), with a different loss each time. Three consecutive runs of the exact command above on v26.5.0:
# testssummary# tests 1835# tests 1794# tests 1936Both reporters always agree with each other and with the summary counters — the events never reach the parent at all. Removing
--test-force-exit(same command otherwise) reports exactly 2000/2000 every time. Loss magnitude varies (we've seen 25–234 of 2000, ~1–12%) and occurs at every concurrency level we tried on a larger real suite; higher concurrency loses more.Required conditions in our testing:
--test-force-exit+--test-concurrency> 1 (default process isolation).What is the expected behavior? Why is that the expected behavior?
Either every verdict from every child is reported before the forced exit, or the runner fails loudly when it knows it dropped report data. Silent partial results defeat the purpose of a test run: on our production suite (Node v24.18.0, 1351 tests) the parent reported as few as 1168 — and since exit code stays 0 when the lost fragments contain only passes, CI stays green while a meaningful fraction of the suite is unaccounted for. A failing test whose verdict lands in a lost fragment is never printed, so the failure detail can vanish from logs even when the child's non-zero exit still fails the run. (To be precise about what we measured: in the minimal repro the injected failure's verdict happened to survive all 15 sample runs and exit stayed 1; the silent loss of pass verdicts with exit 0 reproduces on every all-pass run.)
What do you see instead?
Fewer tests reported than ran —
# tests 1835for a 2000-test suite — with exit code 0 and no diagnostic (see table above).Additional information
Suspected mechanism:
--test-force-exitcallsprocess.exit()while child→parent report streams are still flushing, so V8-serialized test-event frames from process-isolated children are truncated/dropped before the parent'sFileTestparser consumes them. This is the same failure class that #54327 reported for reporter destination files and #55099 fixed — but that fix only awaits the reporter destinations'close()on the parent side before exiting. The flush guarantee was never extended one layer up, to the child→parent report stream itself, which is where these events are being lost (a reporter writing synchronously on every event still never sees them).Related, reviewed, and believed distinct:
FileTestframe parser hang on malformed frames (hang, not loss).--test-force-exitfeature request.We currently work around this in CI with a committed manifest of expected tests plus a completeness gate that re-runs files whose verdicts went missing — it works, but it amounts to reimplementing "report everything you ran" outside the runner.