Commit e898f5ce595 for nodejs
commit e898f5ce5953e3539b3e0f6765a5735e2ccfe409
Author: Vasiliy Serpokryl <vasiliy.serpokryl@mail.ru>
Date: Tue Sep 22 18:28:01 2026 +0500
test_runner: do not reuse a worker ID held by a running file
Worker IDs were handed out round-robin and never released, so a file
that started after another finished could get an ID still held by a
live process. Track the IDs in use, hand out the lowest free one, and
release it in a finally block once the child process exits.
Refs: https://github.com/nodejs/node/pull/61394
Signed-off-by: Vasiliy Serpokryl <vasiliy.serpokryl@mail.ru>
PR-URL: https://github.com/nodejs/node/pull/65739
Reviewed-By: Moshe Atlow <moshe@atlow.co.il>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Pietro Marchini <pietro.marchini94@gmail.com>
diff --git a/doc/api/test.md b/doc/api/test.md
index e5ce08a7630..8cdfa884589 100644
--- a/doc/api/test.md
+++ b/doc/api/test.md
@@ -4575,7 +4575,9 @@ The unique identifier of the worker running the current test file. This value is
derived from the `NODE_TEST_WORKER_ID` environment variable. When running tests
with `--test-isolation=process` (the default), each test file runs in a separate
child process and is assigned a worker ID from 1 to N, where N is the number of
-concurrent workers. When running with `--test-isolation=none`, all tests run in
+concurrent workers. A worker ID is never shared by two test files running at the
+same time. Once a test file finishes, its worker ID is reused by the next test
+file that starts. When running with `--test-isolation=none`, all tests run in
the same process and the worker ID is always 1. This value is `undefined` when
not running in a test context.
diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js
index 9434d232980..67c78e03c83 100644
--- a/lib/internal/test_runner/runner.js
+++ b/lib/internal/test_runner/runner.js
@@ -14,7 +14,6 @@ const {
ArrayPrototypeSlice,
ArrayPrototypeSome,
ArrayPrototypeSort,
- MathMax,
ObjectAssign,
PromisePrototypeThen,
PromiseWithResolvers,
@@ -37,7 +36,6 @@ const {
const { spawn } = require('child_process');
const { statSync } = require('fs');
const { finished } = require('internal/streams/end-of-stream');
-const { availableParallelism } = require('os');
const { resolve, sep, isAbsolute } = require('path');
const { DefaultDeserializer, DefaultSerializer } = require('v8');
const { getOptionValue, getOptionsAsFlagsFromBinding } = require('internal/options');
@@ -138,17 +136,22 @@ let kResistStopPropagation;
// Worker ID pool management for concurrent test execution
class WorkerIdPool {
- #nextId = 0;
- #maxConcurrency;
-
- constructor(maxConcurrency) {
- this.#maxConcurrency = maxConcurrency;
- }
+ #acquiredIds = new SafeSet();
acquire() {
- const id = (this.#nextId++ % this.#maxConcurrency) + 1;
+ let id = 1;
+
+ while (this.#acquiredIds.has(id)) {
+ id++;
+ }
+
+ this.#acquiredIds.add(id);
return id;
}
+
+ release(id) {
+ this.#acquiredIds.delete(id);
+ }
}
function createTestFileList(patterns, cwd) {
@@ -537,94 +540,102 @@ function runTestFile(path, filesWatcher, opts) {
debug('Assigned worker ID %d to test file: %s', workerId, path);
}
- if (watchMode) {
- stdio.push('ipc');
- env.WATCH_REPORT_DEPENDENCIES = '1';
- }
- if (opts.root.harness.shouldColorizeTestFiles) {
- env.FORCE_COLOR = '1';
- }
-
- const child = spawn(
- process.execPath, args,
- {
- __proto__: null,
- signal: t.signal,
- encoding: 'utf8',
- env,
- stdio,
- cwd: opts.cwd,
- },
- );
- if (watchMode) {
- filesWatcher.runningProcesses.set(path, child);
- filesWatcher.watcher.watchChildProcessModules(child, path);
- }
-
- let err;
+ try {
+ if (watchMode) {
+ stdio.push('ipc');
+ env.WATCH_REPORT_DEPENDENCIES = '1';
+ }
+ if (opts.root.harness.shouldColorizeTestFiles) {
+ env.FORCE_COLOR = '1';
+ }
- child.on('error', (error) => {
- err = error;
- });
+ const child = spawn(
+ process.execPath, args,
+ {
+ __proto__: null,
+ signal: t.signal,
+ encoding: 'utf8',
+ env,
+ stdio,
+ cwd: opts.cwd,
+ },
+ );
+ if (watchMode) {
+ filesWatcher.runningProcesses.set(path, child);
+ filesWatcher.watcher.watchChildProcessModules(child, path);
+ }
- child.stdout.on('data', (data) => {
- subtest.parseMessage(data);
- });
+ let err;
- const rl = new Interface({ __proto__: null, input: child.stderr });
- rl.on('line', (line) => {
- if (isInspectorMessage(line)) {
- process.stderr.write(line + '\n');
- return;
- }
+ child.on('error', (error) => {
+ err = error;
+ });
- // stderr cannot be treated as TAP, per the spec. However, we want to
- // surface stderr lines to improve the DX. Inject each line into the
- // test output as an unknown token as if it came from the TAP parser.
- subtest.addToReport({
- __proto__: null,
- type: 'test:stderr',
- data: { __proto__: null, file: path, message: line + '\n' },
+ child.stdout.on('data', (data) => {
+ subtest.parseMessage(data);
});
- });
- const { 0: { 0: code, 1: signal } } = await SafePromiseAll([
- once(child, 'exit', { __proto__: null, signal: t.signal }),
- finished(child.stdout, { __proto__: null, signal: t.signal }),
- ]);
-
- // Close readline interface to prevent memory leak
- rl.close();
-
- if (watchMode) {
- filesWatcher.runningProcesses.delete(path);
- filesWatcher.runningSubtests.delete(path);
- (async () => {
- try {
- await subTestEnded;
- } finally {
- if (filesWatcher.runningSubtests.size === 0) {
- opts.root.reporter[kEmitMessage]('test:watch:drained');
- opts.root.postRun();
- }
+ const rl = new Interface({ __proto__: null, input: child.stderr });
+ rl.on('line', (line) => {
+ if (isInspectorMessage(line)) {
+ process.stderr.write(line + '\n');
+ return;
}
- })();
- }
- if (code !== 0 || signal !== null) {
- if (!err) {
- const failureType = subtest.failedSubtests ? kSubtestsFailed : kTestCodeFailure;
- err = ObjectAssign(new ERR_TEST_FAILURE('test failed', failureType), {
+ // stderr cannot be treated as TAP, per the spec. However, we want to
+ // surface stderr lines to improve the DX. Inject each line into the
+ // test output as an unknown token as if it came from the TAP parser.
+ subtest.addToReport({
__proto__: null,
- exitCode: code,
- signal: signal,
- // The stack will not be useful since the failures came from tests
- // in a child process.
- stack: undefined,
+ type: 'test:stderr',
+ data: { __proto__: null, file: path, message: line + '\n' },
});
+ });
+
+ const { 0: { 0: code, 1: signal } } = await SafePromiseAll([
+ once(child, 'exit', { __proto__: null, signal: t.signal }),
+ finished(child.stdout, { __proto__: null, signal: t.signal }),
+ ]);
+
+ // Close readline interface to prevent memory leak
+ rl.close();
+
+ if (watchMode) {
+ filesWatcher.runningProcesses.delete(path);
+ filesWatcher.runningSubtests.delete(path);
+ (async () => {
+ try {
+ await subTestEnded;
+ } finally {
+ if (filesWatcher.runningSubtests.size === 0) {
+ opts.root.reporter[kEmitMessage]('test:watch:drained');
+ opts.root.postRun();
+ }
+ }
+ })();
}
- throw err;
+ if (code !== 0 || signal !== null) {
+ if (!err) {
+ const failureType = subtest.failedSubtests ? kSubtestsFailed : kTestCodeFailure;
+ err = ObjectAssign(new ERR_TEST_FAILURE('test failed', failureType), {
+ __proto__: null,
+ exitCode: code,
+ signal: signal,
+ // The stack will not be useful since the failures came from tests
+ // in a child process.
+ stack: undefined,
+ });
+ }
+
+ throw err;
+ }
+ } finally {
+ // Every exit path must return the ID, including abort and spawn failure.
+ if (opts.workerIdPool && workerId !== undefined) {
+ opts.workerIdPool.release(workerId);
+ debug('Released worker ID %d from test file: %s', workerId, path);
+ }
}
});
const subTestEnded = subtest.start();
@@ -1010,23 +1021,10 @@ function run(options = kEmptyObject) {
let filesWatcher;
let runFiles;
- // Create worker ID pool for concurrent test execution.
- // Use concurrency from globalOptions which has been processed by parseCommandLine().
- const effectiveConcurrency = globalOptions.concurrency ?? concurrency;
- let maxConcurrency = 1;
- if (effectiveConcurrency === true) {
- maxConcurrency = MathMax(availableParallelism() - 1, 1);
- } else if (typeof effectiveConcurrency === 'number') {
- maxConcurrency = effectiveConcurrency;
- }
- const workerIdPool = new WorkerIdPool(maxConcurrency);
- debug(
- 'Created worker ID pool with max concurrency: %d, ' +
- 'effectiveConcurrency: %s, testFiles: %d',
- maxConcurrency,
- effectiveConcurrency,
- testFiles.length,
- );
+ // The pool tracks the IDs actually in use, so they stay exclusive and never
+ // exceed the number of files running concurrently.
+ const workerIdPool = new WorkerIdPool();
+ debug('Created worker ID pool, testFiles: %d', testFiles.length);
const opts = {
__proto__: null,
diff --git a/test/parallel/test-runner-worker-id.js b/test/parallel/test-runner-worker-id.js
index 9366cfb4fd8..32fd43cb9b5 100644
--- a/test/parallel/test-runner-worker-id.js
+++ b/test/parallel/test-runner-worker-id.js
@@ -1,8 +1,10 @@
'use strict';
-require('../common');
+const common = require('../common');
+const tmpdir = require('../common/tmpdir');
const fixtures = require('../common/fixtures');
const assert = require('node:assert');
const { spawnSync } = require('node:child_process');
+const { readFileSync, writeFileSync } = require('node:fs');
const { test } = require('node:test');
test('NODE_TEST_WORKER_ID is set for concurrent test files', async () => {
@@ -87,8 +89,6 @@ test('context.workerId matches NODE_TEST_WORKER_ID', async () => {
});
test('worker IDs are reused when more tests than concurrency', async () => {
- const tmpdir = require('../common/tmpdir');
- const { writeFileSync } = require('node:fs');
tmpdir.refresh();
// Create 9 separate test files dynamically
@@ -119,7 +119,6 @@ test('track worker ${i}', () => {
assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
// Read and analyze worker IDs used
- const { readFileSync } = require('node:fs');
const workerIds = readFileSync(usageFile, 'utf8').trim().split('\n');
// Count occurrences of each worker ID
@@ -129,13 +128,115 @@ test('track worker ${i}', () => {
});
const uniqueWorkers = Object.keys(workerCounts);
- assert.strictEqual(
- uniqueWorkers.length,
- 3,
- `Should have exactly 3 unique worker IDs, got ${uniqueWorkers.length}: ${uniqueWorkers.join(', ')}`
- );
+ assert.strictEqual(workerIds.length, 9,
+ `Expected 9 worker IDs, got ${workerIds.length}: ${workerIds.join(', ')}`);
+ assert.ok(uniqueWorkers.length <= 3,
+ `Should use at most 3 worker IDs, got ${uniqueWorkers.length}: ${uniqueWorkers.join(', ')}`);
+
+ uniqueWorkers.forEach((id) => {
+ assert.ok(Number(id) >= 1 && Number(id) <= 3, `Worker ID outside 1..3: ${uniqueWorkers.join(', ')}`);
+ });
+});
+
+// Generates a test file that appends `<kind> <name> <workerId>` lines to a
+// shared log, which is replayed below to find IDs held by two live files at
+// once.
+function testFile(name, { block = false, release = false } = {}) {
+ let body = '';
+
+ if (release) {
+ body += "writeFileSync(process.env.MARKER_FILE, '');\n";
+ }
+ if (block) {
+ const deadline = common.platformTimeout(10_000);
+ body += `const buf = new Int32Array(new SharedArrayBuffer(4));
+ const deadline = Date.now() + ${deadline};
+ while (!existsSync(process.env.MARKER_FILE) && Date.now() < deadline) {
+ Atomics.wait(buf, 0, 0, 20);
+ }\n`;
+ }
+
+ return `
+import { test } from 'node:test';
+import { appendFileSync, writeFileSync, existsSync } from 'node:fs';
+
+const log = (kind) => appendFileSync(process.env.WORKER_LOG,
+ kind + ' ${name} ' + process.env.NODE_TEST_WORKER_ID + '\\n');
+
+test('${name}', () => {
+ log('start');
+ ${body}
+ log('end');
+});
+`;
+}
+
+// Replays the log and reports every ID that was held by two files at once.
+function findConflicts(events) {
+ const live = new Map();
+ const conflicts = [];
+
+ for (const event of events) {
+ const [kind, name, id] = event.split(' ');
+ if (kind === 'start') {
+ if (live.has(id)) {
+ conflicts.push(`worker ID ${id} held by both '${live.get(id)}' and '${name}'`);
+ }
+ live.set(id, name);
+ } else {
+ live.delete(id);
+ }
+ }
- Object.entries(workerCounts).forEach(([id, count]) => {
- assert.strictEqual(count, 3, `Worker ID ${id} should be used 3 times, got ${count}`);
+ return conflicts;
+}
+
+test('worker IDs are exclusive to concurrently running test files', () => {
+ tmpdir.refresh();
+
+ // `slow` pins one ID for the whole run while the remaining files churn
+ // through the other slots, so IDs are released and reacquired several times
+ // with one of them permanently taken.
+ const sources = [['slow', { block: true }]];
+ for (let i = 1; i <= 4; i++) {
+ sources.push([`file-${i}`]);
+ }
+ sources.push(['last', { release: true }]);
+
+ const concurrency = 3;
+ const logFile = tmpdir.resolve('worker-id-log.txt');
+ const markerFile = tmpdir.resolve('worker-id-release.marker');
+ writeFileSync(logFile, '');
+
+ const files = sources.map(([name, options], index) => {
+ const file = tmpdir.resolve(`worker-id-${index}-${name}.mjs`);
+ writeFileSync(file, testFile(name, options));
+ return file;
});
+
+ const result = spawnSync(
+ process.execPath,
+ ['--test', `--test-concurrency=${concurrency}`, ...files],
+ { env: { ...process.env, WORKER_LOG: logFile, MARKER_FILE: markerFile } },
+ );
+ assert.strictEqual(result.status, 0, `Runner failed: ${result.stderr.toString()}`);
+
+ const events = readFileSync(logFile, 'utf8').trim().split('\n');
+ assert.strictEqual(events.length, sources.length * 2,
+ `Unexpected event log:\n${events.join('\n')}`);
+
+ // The blocking file must still be running when the last file starts
+ const eventsAt = (line) => events.findIndex((event) => event.startsWith(line));
+ assert.ok(eventsAt('start last') < eventsAt('end slow'),
+ `Files did not overlap:\n${events.join('\n')}`);
+
+ const conflicts = findConflicts(events);
+ assert.deepStrictEqual(
+ conflicts, [], `Worker IDs were not exclusive:\n${events.join('\n')}\n\n${conflicts.join('\n')}`);
+
+ for (const event of events) {
+ const id = Number(event.split(' ')[2]);
+ assert.ok(Number.isInteger(id) && id >= 1 && id <= concurrency,
+ `Worker ID outside 1..${concurrency}:\n${events.join('\n')}`);
+ }
});