From ea0297f283df74ae307f2b06b9d25c8db92e6c10 Mon Sep 17 00:00:00 2001 From: Langston-2479 Date: Sun, 18 Oct 2026 00:09:44 -0400 Subject: [PATCH] test: the force-exit test reads its runner's report from a file, so that runner's exit can't drop what it has written force-exit-blocking-stdout's runner test counts the fixture's bytes in the report of the runner it spawns. It read that report through a pipe, and the runner's own --test-force-exit can drop what is still queued for that pipe. On Node 20.20.2 the root calls process.exit() when its root reporter stream closes (lib/internal/test_runner/test.js 836-845), without waiting for the reporter's output to reach stdout or for stdout's pipe to drain. The helper acts only in a test file's process, so it leaves that runner's stdout as it is. The runner has read the fixture's pipe to its end by then: it waits for the child's exit and for its stdout to end before it reports the file (runner.js 413-416). The test failed on macOS Node 20 in #250's CI at ed40f2d5 (9,953,278 of 10,000,000 bytes; its rerun passed) and in #480's at 256baf6 (9,945,086). In the 15 CI runs between #464's merge and 03:53Z, every job's latest attempt passed it. The runner now writes its report to a temporary file. Node writes a file synchronously, so whatever the runner has handed to its stdout is in the file when it exits; the fixture's pipe to the runner, which the helper protects, is as before. On Linux, with the helper, a file held the whole report in 60 of 60 runs under each of Node 20.20.2 and 24.21.0, where a socketpair with 8192-byte buffers came up short in 10 of 10 and 8 of 10. runnerReceived now throws, with the end of the runner's stderr and report, when the runner fails to start or exits otherwise than 0, as it does for this fixture, so a failed runner no longer reads as a shortfall. A test file that fails to load reports its error in the runner's report, not on the runner's stderr. Co-Authored-By: Claude Opus 5.5 --- test/force-exit-blocking-stdout.test.ts | 45 ++++++++++++++++++++----- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/test/force-exit-blocking-stdout.test.ts b/test/force-exit-blocking-stdout.test.ts index 552449dcf..65e4a725c 100644 --- a/test/force-exit-blocking-stdout.test.ts +++ b/test/force-exit-blocking-stdout.test.ts @@ -2,16 +2,23 @@ * `npm test` loads test/helpers/force-exit-blocking-stdout into the runner, * which passes it on to each test file's process, so --test-force-exit * can't drop the results still queued on a file's pipe to the runner - * (agent-framework #294). These tests run a fixture under the runner as - * `npm test` does and count, in the runner's report, the bytes its test - * wrote: a run of '~', which nothing else in the report spells, and which - * the runner may pass on in pieces. Where a run without the helper loses + * (agent-framework #294). The first test runs a fixture under the runner + * with npm test's options and counts, in the runner's report, the bytes the + * fixture's test wrote: a run of '~', which nothing else in the report + * spells, and which the runner may pass on in pieces. The runner writes that + * report to a file, not to a pipe as in npm test. Node writes a file + * synchronously, so whatever the runner has handed to its stdout is in the + * file when it exits. Through a pipe, the runner's own forced exit can drop + * what is still queued for it, which the helper, acting only in a test + * file's process, doesn't prevent. Where a run without the helper loses * nothing, as on a Node that carries nodejs/node#64875, there's nothing for - * the helper to prevent, and they skip. + * the helper to prevent, and the first two tests skip. */ import { spawnSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; +import { closeSync, mkdtempSync, openSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -34,10 +41,32 @@ function childEnv(): NodeJS.ProcessEnv { return env; } -/** How many of the fixture's bytes the runner's report holds, run as npm test runs a file, with the helper loaded or not. */ +/** + * How many of the fixture's bytes the runner's report holds, run with npm + * test's options, with the helper loaded or not. With this fixture the runner + * exits 0, so one that fails to start or exits otherwise throws, with the + * end of its stderr and its report, rather than reading as a shortfall. + */ function runnerReceived(withHelper: boolean): number { const args = [...(withHelper ? ['--import', helper] : []), '--test', '--test-force-exit', '--test-reporter=tap', fixture]; - return tildes(spawnSync(process.execPath, args, { env: childEnv(), maxBuffer: 4 * BYTES }).stdout.toString()); + const dir = mkdtempSync(join(tmpdir(), 'af-force-exit-report-')); + try { + const report = join(dir, 'report.tap'); + const fd = openSync(report, 'w'); + const run = (() => { + try { + return spawnSync(process.execPath, args, { env: childEnv(), stdio: ['ignore', fd, 'pipe'] }); + } finally { + closeSync(fd); + } + })(); + if (run.error) throw run.error; + const text = readFileSync(report, 'utf8'); + if (run.status !== 0) throw new Error(`the runner exited with ${run.status ?? run.signal}: ${(String(run.stderr) + text).slice(-2000)}`); + return tildes(text); + } finally { + rmSync(dir, { recursive: true, force: true }); + } } /** Bytes that reach a pipe from a plain process that writes BYTES to stdout and exits at once. */