Skip to content

Commit e898f5c

Browse files
authored
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: #61394 Signed-off-by: Vasiliy Serpokryl <vasiliy.serpokryl@mail.ru> PR-URL: #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>
1 parent ed9ad59 commit e898f5c

3 files changed

Lines changed: 215 additions & 114 deletions

File tree

‎doc/api/test.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4575,7 +4575,9 @@ The unique identifier of the worker running the current test file. This value is
45754575
derived from the `NODE_TEST_WORKER_ID` environment variable. When running tests
45764576
with `--test-isolation=process` (the default), each test file runs in a separate
45774577
child process and is assigned a worker ID from 1 to N, where N is the number of
4578-
concurrent workers. When running with `--test-isolation=none`, all tests run in
4578+
concurrent workers. A worker ID is never shared by two test files running at the
4579+
same time. Once a test file finishes, its worker ID is reused by the next test
4580+
file that starts. When running with `--test-isolation=none`, all tests run in
45794581
the same process and the worker ID is always 1. This value is `undefined` when
45804582
not running in a test context.
45814583

‎lib/internal/test_runner/runner.js‎

Lines changed: 100 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ const {
1414
ArrayPrototypeSlice,
1515
ArrayPrototypeSome,
1616
ArrayPrototypeSort,
17-
MathMax,
1817
ObjectAssign,
1918
PromisePrototypeThen,
2019
PromiseWithResolvers,
@@ -37,7 +36,6 @@ const {
3736
const { spawn } = require('child_process');
3837
const { statSync } = require('fs');
3938
const { finished } = require('internal/streams/end-of-stream');
40-
const { availableParallelism } = require('os');
4139
const { resolve, sep, isAbsolute } = require('path');
4240
const { DefaultDeserializer, DefaultSerializer } = require('v8');
4341
const { getOptionValue, getOptionsAsFlagsFromBinding } = require('internal/options');
@@ -138,17 +136,22 @@ let kResistStopPropagation;
138136

139137
// Worker ID pool management for concurrent test execution
140138
class WorkerIdPool {
141-
#nextId = 0;
142-
#maxConcurrency;
143-
144-
constructor(maxConcurrency) {
145-
this.#maxConcurrency = maxConcurrency;
146-
}
139+
#acquiredIds = new SafeSet();
147140

148141
acquire() {
149-
const id = (this.#nextId++ % this.#maxConcurrency) + 1;
142+
let id = 1;
143+
144+
while (this.#acquiredIds.has(id)) {
145+
id++;
146+
}
147+
148+
this.#acquiredIds.add(id);
150149
return id;
151150
}
151+
152+
release(id) {
153+
this.#acquiredIds.delete(id);
154+
}
152155
}
153156

154157
function createTestFileList(patterns, cwd) {
@@ -537,94 +540,102 @@ function runTestFile(path, filesWatcher, opts) {
537540
debug('Assigned worker ID %d to test file: %s', workerId, path);
538541
}
539542

540-
if (watchMode) {
541-
stdio.push('ipc');
542-
env.WATCH_REPORT_DEPENDENCIES = '1';
543-
}
544-
if (opts.root.harness.shouldColorizeTestFiles) {
545-
env.FORCE_COLOR = '1';
546-
}
547-
548-
const child = spawn(
549-
process.execPath, args,
550-
{
551-
__proto__: null,
552-
signal: t.signal,
553-
encoding: 'utf8',
554-
env,
555-
stdio,
556-
cwd: opts.cwd,
557-
},
558-
);
559-
if (watchMode) {
560-
filesWatcher.runningProcesses.set(path, child);
561-
filesWatcher.watcher.watchChildProcessModules(child, path);
562-
}
563-
564-
let err;
543+
try {
544+
if (watchMode) {
545+
stdio.push('ipc');
546+
env.WATCH_REPORT_DEPENDENCIES = '1';
547+
}
548+
if (opts.root.harness.shouldColorizeTestFiles) {
549+
env.FORCE_COLOR = '1';
550+
}
565551

566-
child.on('error', (error) => {
567-
err = error;
568-
});
552+
const child = spawn(
553+
process.execPath, args,
554+
{
555+
__proto__: null,
556+
signal: t.signal,
557+
encoding: 'utf8',
558+
env,
559+
stdio,
560+
cwd: opts.cwd,
561+
},
562+
);
563+
if (watchMode) {
564+
filesWatcher.runningProcesses.set(path, child);
565+
filesWatcher.watcher.watchChildProcessModules(child, path);
566+
}
569567

570-
child.stdout.on('data', (data) => {
571-
subtest.parseMessage(data);
572-
});
568+
let err;
573569

574-
const rl = new Interface({ __proto__: null, input: child.stderr });
575-
rl.on('line', (line) => {
576-
if (isInspectorMessage(line)) {
577-
process.stderr.write(line + '\n');
578-
return;
579-
}
570+
child.on('error', (error) => {
571+
err = error;
572+
});
580573

581-
// stderr cannot be treated as TAP, per the spec. However, we want to
582-
// surface stderr lines to improve the DX. Inject each line into the
583-
// test output as an unknown token as if it came from the TAP parser.
584-
subtest.addToReport({
585-
__proto__: null,
586-
type: 'test:stderr',
587-
data: { __proto__: null, file: path, message: line + '\n' },
574+
child.stdout.on('data', (data) => {
575+
subtest.parseMessage(data);
588576
});
589-
});
590577

591-
const { 0: { 0: code, 1: signal } } = await SafePromiseAll([
592-
once(child, 'exit', { __proto__: null, signal: t.signal }),
593-
finished(child.stdout, { __proto__: null, signal: t.signal }),
594-
]);
595-
596-
// Close readline interface to prevent memory leak
597-
rl.close();
598-
599-
if (watchMode) {
600-
filesWatcher.runningProcesses.delete(path);
601-
filesWatcher.runningSubtests.delete(path);
602-
(async () => {
603-
try {
604-
await subTestEnded;
605-
} finally {
606-
if (filesWatcher.runningSubtests.size === 0) {
607-
opts.root.reporter[kEmitMessage]('test:watch:drained');
608-
opts.root.postRun();
609-
}
578+
const rl = new Interface({ __proto__: null, input: child.stderr });
579+
rl.on('line', (line) => {
580+
if (isInspectorMessage(line)) {
581+
process.stderr.write(line + '\n');
582+
return;
610583
}
611-
})();
612-
}
613584

614-
if (code !== 0 || signal !== null) {
615-
if (!err) {
616-
const failureType = subtest.failedSubtests ? kSubtestsFailed : kTestCodeFailure;
617-
err = ObjectAssign(new ERR_TEST_FAILURE('test failed', failureType), {
585+
// stderr cannot be treated as TAP, per the spec. However, we want to
586+
// surface stderr lines to improve the DX. Inject each line into the
587+
// test output as an unknown token as if it came from the TAP parser.
588+
subtest.addToReport({
618589
__proto__: null,
619-
exitCode: code,
620-
signal: signal,
621-
// The stack will not be useful since the failures came from tests
622-
// in a child process.
623-
stack: undefined,
590+
type: 'test:stderr',
591+
data: { __proto__: null, file: path, message: line + '\n' },
624592
});
593+
});
594+
595+
const { 0: { 0: code, 1: signal } } = await SafePromiseAll([
596+
once(child, 'exit', { __proto__: null, signal: t.signal }),
597+
finished(child.stdout, { __proto__: null, signal: t.signal }),
598+
]);
599+
600+
// Close readline interface to prevent memory leak
601+
rl.close();
602+
603+
if (watchMode) {
604+
filesWatcher.runningProcesses.delete(path);
605+
filesWatcher.runningSubtests.delete(path);
606+
(async () => {
607+
try {
608+
await subTestEnded;
609+
} finally {
610+
if (filesWatcher.runningSubtests.size === 0) {
611+
opts.root.reporter[kEmitMessage]('test:watch:drained');
612+
opts.root.postRun();
613+
}
614+
}
615+
})();
625616
}
626617

627-
throw err;
618+
if (code !== 0 || signal !== null) {
619+
if (!err) {
620+
const failureType = subtest.failedSubtests ? kSubtestsFailed : kTestCodeFailure;
621+
err = ObjectAssign(new ERR_TEST_FAILURE('test failed', failureType), {
622+
__proto__: null,
623+
exitCode: code,
624+
signal: signal,
625+
// The stack will not be useful since the failures came from tests
626+
// in a child process.
627+
stack: undefined,
628+
});
629+
}
630+
631+
throw err;
632+
}
633+
} finally {
634+
// Every exit path must return the ID, including abort and spawn failure.
635+
if (opts.workerIdPool && workerId !== undefined) {
636+
opts.workerIdPool.release(workerId);
637+
debug('Released worker ID %d from test file: %s', workerId, path);
638+
}
628639
}
629640
});
630641
const subTestEnded = subtest.start();
@@ -1010,23 +1021,10 @@ function run(options = kEmptyObject) {
10101021
let filesWatcher;
10111022
let runFiles;
10121023

1013-
// Create worker ID pool for concurrent test execution.
1014-
// Use concurrency from globalOptions which has been processed by parseCommandLine().
1015-
const effectiveConcurrency = globalOptions.concurrency ?? concurrency;
1016-
let maxConcurrency = 1;
1017-
if (effectiveConcurrency === true) {
1018-
maxConcurrency = MathMax(availableParallelism() - 1, 1);
1019-
} else if (typeof effectiveConcurrency === 'number') {
1020-
maxConcurrency = effectiveConcurrency;
1021-
}
1022-
const workerIdPool = new WorkerIdPool(maxConcurrency);
1023-
debug(
1024-
'Created worker ID pool with max concurrency: %d, ' +
1025-
'effectiveConcurrency: %s, testFiles: %d',
1026-
maxConcurrency,
1027-
effectiveConcurrency,
1028-
testFiles.length,
1029-
);
1024+
// The pool tracks the IDs actually in use, so they stay exclusive and never
1025+
// exceed the number of files running concurrently.
1026+
const workerIdPool = new WorkerIdPool();
1027+
debug('Created worker ID pool, testFiles: %d', testFiles.length);
10301028

10311029
const opts = {
10321030
__proto__: null,

0 commit comments

Comments
 (0)