Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion lib/internal/test_runner/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ const {
} = require('internal/validators');
const {
clearTimeout,
setImmediate,
setTimeout,
} = require('timers');
const { TIMEOUT_MAX } = require('internal/timers');
Expand Down Expand Up @@ -1476,14 +1477,31 @@ class Test extends AsyncResource {
if (!destination.closed && typeof destination.close === 'function') {
destination.close(resolve);
} else {
resolve();
// The destination has no close() to await - for example a child
// process's stdout, which is a pipe to the test runner's parent
// process. Writes to a pipe are asynchronous, so the process.exit()
// below can truncate buffered output and silently drop test
// results (https://github.com/nodejs/node/issues/64833). Switch the
// underlying handle to blocking so any queued bytes are flushed to
// the OS, then end the stream and wait for it to finish before
// exiting.
if (destination._handle &&
typeof destination._handle.setBlocking === 'function') {
destination._handle.setBlocking(true);
}
destination.end(resolve);
}
});
}));
}

this.harness.teardown();
await SafePromiseAllReturnVoid(promises);
// Yield once more so the event loop can drain any report events that are
// still in flight from child processes (their stdout is piped into this
// process) before we force the exit. The blocking flush above guarantees
// the writable side; this covers the readable side on the parent.
await new Promise((resolve) => setImmediate(resolve));
process.exit();
}
}
Expand Down
71 changes: 71 additions & 0 deletions test/parallel/test-runner-force-exit-no-verdict-loss.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
'use strict';
require('../common');
const tmpdir = require('../common/tmpdir');
const assert = require('node:assert');
const { spawnSync } = require('node:child_process');
const { writeFileSync, mkdirSync, readFileSync } = require('node:fs');
const { join } = require('node:path');

// Regression test for https://github.com/nodejs/node/issues/64833.
//
// When --test-force-exit is combined with process isolation and concurrency,
// each test file runs in a child process that streams its results back to the
// parent over a pipe. Writes to a pipe are asynchronous, so a forced
// process.exit() could truncate the still-flushing report stream, silently
// dropping test verdicts while the process still exited with code 0.
//
// Generate several test files, each of which keeps itself alive with a ref'd
// handle (so --test-force-exit is required to finish), then assert that every
// single verdict survives the forced exit.

const FILES = 12;
const TESTS_PER_FILE = 1000;
const EXPECTED = FILES * TESTS_PER_FILE;

tmpdir.refresh();
const testsDir = tmpdir.resolve('tests');
mkdirSync(testsDir);

for (let f = 0; f < FILES; f++) {
let body = "'use strict';\n";
body += "const { test } = require('node:test');\n";
// A ref'd handle keeps the child alive so it never exits on its own; only
// --test-force-exit ends it, forcing a process.exit() while the child's
// stdout may still be flushing verdicts to the parent.
body += 'setInterval(() => {}, 1_000_000);\n';
for (let t = 0; t < TESTS_PER_FILE; t++) {
body += `test('f${f}_t${t}', () => {});\n`;
}
writeFileSync(join(testsDir, `f${f}.test.js`), body);
}

// Write the aggregated report to a file rather than piping it through
// spawnSync (12k TAP lines would exceed the default maxBuffer). Running from
// within testsDir lets the runner auto-discover the *.test.js files.
const destination = tmpdir.resolve('out.tap');
const args = [
'--test',
'--test-force-exit',
'--test-concurrency=16',
'--test-reporter=tap',
`--test-reporter-destination=${destination}`,
];
const child = spawnSync(process.execPath, args, { encoding: 'utf8', cwd: testsDir });

assert.strictEqual(
child.status,
0,
`unexpected exit code ${child.status} (signal ${child.signal})\n${child.stderr}`,
);

const output = readFileSync(destination, 'utf8');
const pass = output.match(/# pass (\d+)/);
const fail = output.match(/# fail (\d+)/);
assert.notStrictEqual(pass, null, `missing pass summary in output:\n${output}`);
assert.strictEqual(
Number(pass[1]),
EXPECTED,
`expected ${EXPECTED} passing verdicts but only ${pass[1]} reached the reporter ` +
`(force-exit truncated the child -> parent report stream)`,
);
assert.strictEqual(Number(fail?.[1]), 0, `unexpected failures:\n${output}`);
Loading