diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js index f90c7dcad10346..2922cd76b7a74e 100644 --- a/lib/internal/test_runner/runner.js +++ b/lib/internal/test_runner/runner.js @@ -942,8 +942,19 @@ function run(options = kEmptyObject) { debug('beginning test execution'); root.entryFile = null; finishBootstrap(); - return root.processPendingSubtests(); + const pendingSubtestsPromise = root.processPendingSubtests(); + if (!isTestRunner) { + PromisePrototypeThen(pendingSubtestsPromise, undefined, triggerUncaughtException); + return; + } + return pendingSubtestsPromise; }; + + if (!isTestRunner) { + // run() API consumers can keep handles alive (e.g., IPC). Finalize + // without waiting for beforeExit so the stream can close promptly. + teardown = () => root.harness.teardown(); + } } } diff --git a/test/fixtures/test-runner/run-isolation-none-in-cluster.js b/test/fixtures/test-runner/run-isolation-none-in-cluster.js new file mode 100644 index 00000000000000..de550d102d6698 --- /dev/null +++ b/test/fixtures/test-runner/run-isolation-none-in-cluster.js @@ -0,0 +1,45 @@ +'use strict'; + +const cluster = require('node:cluster'); +const { join } = require('node:path'); +const { run } = require('node:test'); + +if (cluster.isPrimary) { + const worker = cluster.fork(); + worker.on('exit', (code, signal) => { + if (signal !== null) { + process.stderr.write(`worker exited with signal ${signal}\n`); + process.exit(1); + } + + process.exit(code ?? 0); + }); +} else { + // Repro based on: https://github.com/nodejs/node/issues/60020 + // We pin `files` for deterministic test discovery in CI. + const stream = run({ + isolation: 'none', + files: [ + join(__dirname, 'default-behavior', 'test', 'random.cjs'), + ], + }); + + stream.on('error', (err) => { + process.stderr.write(`worker error: ${err}\n`); + process.exit(1); + }); + + stream.on('data', (data) => { + process.stdout.write(`on data ${data.type}\n`); + }); + + stream.on('end', () => { + process.stdout.write('on end\n'); + process.exit(0); + }); + + setTimeout(() => { + process.stderr.write('worker timed out waiting for end\n'); + process.exit(1); + }, 3000).unref(); +} diff --git a/test/parallel/test-runner-run.mjs b/test/parallel/test-runner-run.mjs index e9bb6c4a260160..621cd914b0a961 100644 --- a/test/parallel/test-runner-run.mjs +++ b/test/parallel/test-runner-run.mjs @@ -1,6 +1,7 @@ import * as common from '../common/index.mjs'; import * as fixtures from '../common/fixtures.mjs'; import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; import { describe, it, run } from 'node:test'; import { dot, spec, tap } from 'node:test/reporters'; import consumers from 'node:stream/consumers'; @@ -646,6 +647,19 @@ describe('require(\'node:test\').run', { concurrency: true }, () => { assert.strictEqual(diagnostics.includes(entry), true); } }); + + // Regression test for https://github.com/nodejs/node/issues/60020 + it('should not hang in cluster workers when isolation is none', () => { + const fixture = fixtures.path('test-runner', 'run-isolation-none-in-cluster.js'); + const { status, signal, stdout, stderr } = spawnSync(process.execPath, [fixture], { + encoding: 'utf8', + timeout: common.platformTimeout(15_000), + }); + + assert.strictEqual(signal, null); + assert.strictEqual(status, 0, stderr); + assert.match(stdout, /on end/); + }); }); describe('env', () => {