Repository navigation
Description
Version
v24.20.0
Platform
Linux x64 (docker node:24.20.0)
Subsystem
test_runner
What happens
With --test-force-exit and process isolation, a test child calls process.exit() while its serialized events are still queued in process.stdout. The tail of the file's results never reaches the parent. The run exits 0 and its summary counts only the results that arrived, so a failure in the lost tail passes.
How to reproduce
Make 16 files t1.test.mjs … t16.test.mjs, each:
import { describe, it } from "node:test"
describe("suite N", () => {
for (let n = 0; n < 200; n++) it("case " + n + " " + "x".repeat(2000), () => {})
})Run node --test --test-concurrency=8 [--test-force-exit] --test-reporter junit --test-reporter-destination r.xml t*.test.mjs and count <testcase in r.xml. The expected count is 3200.
| flags | testcases | exit |
|---|---|---|
--test-force-exit (3 runs) |
3135, 3135, 3137 | 0 |
--test-force-exit, plus a ref'ed setInterval in each file (6 runs) |
3121–3161 | 0 |
no --test-force-exit (3 runs) |
3200, 3200, 3200 | 0 |
A preload that wraps process.exit in each child (NODE_TEST_CONTEXT=child-v8) logged process.stdout.writableLength at exit. Five children exited with 0 bytes queued. The other nine exited with 12,727 / 30,151 / 36,662 / 36,693 / 41,037 / 43,217 / 56,287 / 56,327 / 64,999 bytes still queued.
With one file at --test-concurrency=1, nothing is lost: all 200 arrive. The loss needs the parent to read slowly enough that a child's stdout pipe backs up.
What is the expected behavior?
Every result a child emits reaches the parent before the child exits, with or without --test-force-exit.
Cause
In lib/internal/test_runner/test.js, the root's forceExit branch waits for each reporter destination's unpipe event, calls destination.close only when it exists, and then calls process.exit(). For a child, the destination is process.stdout, an async pipe. unpipe means the reporter has handed off its chunks, not that they have been written, so anything still buffered is discarded.
Setting the child's stdout to blocking (process.stdout._handle.setBlocking(true) via --import) removes the loss: 3200 testcases in 8 of 8 runs. Waiting for process.stdout to drain before process.exit() (writableLength === 0, or a write('', cb)) would presumably fix it in core.
Secondary symptom
The built-in junit reporter (and @reporters/junit 2.1.3) then renders the suite whose test:start never got a test:pass/test:fail as an <undefined name="..."> element. Every later file nests inside it, which makes the JUnit invalid for consumers. Minimal reporter-only repro:
import { junit } from "node:test/reporters"
async function* events() {
yield { type: "test:start", data: { name: "suite", nesting: 0 } }
yield { type: "test:start", data: { name: "first", nesting: 1 } }
yield { type: "test:pass", data: { name: "first", nesting: 1, details: { duration_ms: 1 } } }
yield { type: "test:start", data: { name: "next file", nesting: 0 } }
yield { type: "test:pass", data: { name: "next file", nesting: 0, details: { duration_ms: 1 } } }
}
for await (const chunk of junit(events())) process.stdout.write(chunk)This outputs <undefined name="suite"> containing both testcases. The same shape was reported in #51707, closed March 2024.
Activity