← 输入框|github.com|刷新
Skip to content

test_runner: --test-force-exit exits test child processes before their stdout drains, silently dropping results #67149

Open

Description

@yasyf

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions