diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js index 67c78e03c831..7af0f432181e 100644 --- a/lib/internal/test_runner/runner.js +++ b/lib/internal/test_runner/runner.js @@ -22,6 +22,7 @@ const { SafePromiseAll, SafePromiseAllReturnVoid, SafePromiseAllSettledReturnVoid, + SafePromiseRace, SafeSet, String, StringFromCharCode, @@ -293,10 +294,15 @@ class FileTest extends Test { #rawBufferSize = 0; #reportedChildren = 0; #pendingPartialV8Header = false; + #reportFinished; + #resolveReportFinished; failedSubtests = false; constructor(options) { super(options); + const { promise, resolve } = PromiseWithResolvers(); + this.#reportFinished = promise; + this.#resolveReportFinished = resolve; this.loc ??= { __proto__: null, line: 1, @@ -306,6 +312,10 @@ class FileTest extends Test { this.timeout = null; } + get reportFinished() { + return this.#reportFinished; + } + willBeFilteredByTags() { // File wrappers have no tags of their own. Tag filtering applies to the // tests inside the file, which run in a child process (or in-process @@ -518,6 +528,9 @@ class FileTest extends Test { deserializer.readHeader(); const item = deserializer.readValue(); + if (item.type === 'test:summary') { + this.#resolveReportFinished(); + } this.addToReport(item); } } @@ -592,9 +605,17 @@ function runTestFile(path, filesWatcher, opts) { }); }); + const stdoutFinished = finished( + child.stdout, + { __proto__: null, signal: t.signal }, + ); + const reportFinished = opts.forceExit ? + SafePromiseRace([stdoutFinished, subtest.reportFinished]) : + stdoutFinished; + const { 0: { 0: code, 1: signal } } = await SafePromiseAll([ once(child, 'exit', { __proto__: null, signal: t.signal }), - finished(child.stdout, { __proto__: null, signal: t.signal }), + reportFinished, ]); // Close readline interface to prevent memory leak diff --git a/test/fixtures/test-runner/force-exit-inherited-stdio.js b/test/fixtures/test-runner/force-exit-inherited-stdio.js new file mode 100644 index 000000000000..098cb30ed625 --- /dev/null +++ b/test/fixtures/test-runner/force-exit-inherited-stdio.js @@ -0,0 +1,15 @@ +'use strict'; + +const { spawn } = require('node:child_process'); +const { test } = require('node:test'); + +test('leaks a child with inherited stdio', () => { + spawn( + process.execPath, + [ + '-e', + `setTimeout(() => {}, ${process.env.TEST_RUNNER_STALL_MS})`, + ], + { stdio: 'inherit' }, + ); +}); diff --git a/test/parallel/test-runner-force-exit-inherited-stdio.js b/test/parallel/test-runner-force-exit-inherited-stdio.js new file mode 100644 index 000000000000..cb85424c6ac0 --- /dev/null +++ b/test/parallel/test-runner-force-exit-inherited-stdio.js @@ -0,0 +1,41 @@ +'use strict'; + +const common = require('../common'); + +const assert = require('node:assert'); +const { spawnSync } = require('node:child_process'); +const fixtures = require('../common/fixtures'); + +const fixture = + fixtures.path('test-runner/force-exit-inherited-stdio.js'); + +const maxDuration = common.platformTimeout(2000); +const stallDuration = common.platformTimeout(4000); +const start = Date.now(); + +const result = spawnSync( + process.execPath, + [ + '--test', + '--test-force-exit', + fixture, + ], + { + encoding: 'utf8', + env: { + ...process.env, + TEST_RUNNER_STALL_MS: String(stallDuration), + }, + }, +); + +const duration = Date.now() - start; + +assert.strictEqual(result.status, 0); +assert.strictEqual(result.signal, null); +assert.strictEqual(result.stderr, ''); + +assert.ok( + duration < maxDuration, + `test runner took ${duration}ms to exit`, +);