Skip to content

Commit 3342161

Browse files
vserpokryladuh95
authored andcommitted
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 a8b9b14 commit 3342161

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
@@ -4541,7 +4541,9 @@ The unique identifier of the worker running the current test file. This value is
45414541
derived from the `NODE_TEST_WORKER_ID` environment variable. When running tests
45424542
with `--test-isolation=process` (the default), each test file runs in a separate
45434543
child process and is assigned a worker ID from 1 to N, where N is the number of
4544-
concurrent workers. When running with `--test-isolation=none`, all tests run in
4544+
concurrent workers. A worker ID is never shared by two test files running at the
4545+
same time. Once a test file finishes, its worker ID is reused by the next test
4546+
file that starts. When running with `--test-isolation=none`, all tests run in
45454547
the same process and the worker ID is always 1. This value is `undefined` when
45464548
not running in a test context.
45474549

‎lib/internal/test_runner/runner.js‎

Lines changed: 100 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ const {
1515
ArrayPrototypeSlice,
1616
ArrayPrototypeSome,
1717
ArrayPrototypeSort,
18-
MathMax,
1918
ObjectAssign,
2019
PromisePrototypeThen,
2120
PromiseWithResolvers,
@@ -36,7 +35,6 @@ const {
3635

3736
const { spawn } = require('child_process');
3837
const { finished } = require('internal/streams/end-of-stream');
39-
const { availableParallelism } = require('os');
4038
const { resolve, sep, isAbsolute } = require('path');
4139
const { DefaultDeserializer, DefaultSerializer } = require('v8');
4240
const { getOptionValue, getOptionsAsFlagsFromBinding } = require('internal/options');
@@ -137,17 +135,22 @@ let kResistStopPropagation;
137135

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

147140
acquire() {
148-
const id = (this.#nextId++ % this.#maxConcurrency) + 1;
141+
let id = 1;
142+
143+
while (this.#acquiredIds.has(id)) {
144+
id++;
145+
}
146+
147+
this.#acquiredIds.add(id);
149148
return id;
150149
}
150+
151+
release(id) {
152+
this.#acquiredIds.delete(id);
153+
}
151154
}
152155

153156
function createTestFileList(patterns, cwd) {
@@ -519,94 +522,102 @@ function runTestFile(path, filesWatcher, opts) {
519522
debug('Assigned worker ID %d to test file: %s', workerId, path);
520523
}
521524

522-
if (watchMode) {
523-
stdio.push('ipc');
524-
env.WATCH_REPORT_DEPENDENCIES = '1';
525-
}
526-
if (opts.root.harness.shouldColorizeTestFiles) {
527-
env.FORCE_COLOR = '1';
528-
}
529-
530-
const child = spawn(
531-
process.execPath, args,
532-
{
533-
__proto__: null,
534-
signal: t.signal,
535-
encoding: 'utf8',
536-
env,
537-
stdio,
538-
cwd: opts.cwd,
539-
},
540-
);
541-
if (watchMode) {
542-
filesWatcher.runningProcesses.set(path, child);
543-
filesWatcher.watcher.watchChildProcessModules(child, path);
544-
}
545-
546-
let err;
525+
try {
526+
if (watchMode) {
527+
stdio.push('ipc');
528+
env.WATCH_REPORT_DEPENDENCIES = '1';
529+
}
530+
if (opts.root.harness.shouldColorizeTestFiles) {
531+
env.FORCE_COLOR = '1';
532+
}
547533

548-
child.on('error', (error) => {
549-
err = error;
550-
});
534+
const child = spawn(
535+
process.execPath, args,
536+
{
537+
__proto__: null,
538+
signal: t.signal,
539+
encoding: 'utf8',
540+
env,
541+
stdio,
542+
cwd: opts.cwd,
543+
},
544+
);
545+
if (watchMode) {
546+
filesWatcher.runningProcesses.set(path, child);
547+
filesWatcher.watcher.watchChildProcessModules(child, path);
548+
}
551549

552-
child.stdout.on('data', (data) => {
553-
subtest.parseMessage(data);
554-
});
550+
let err;
555551

556-
const rl = new Interface({ __proto__: null, input: child.stderr });
557-
rl.on('line', (line) => {
558-
if (isInspectorMessage(line)) {
559-
process.stderr.write(line + '\n');
560-
return;
561-
}
552+
child.on('error', (error) => {
553+
err = error;
554+
});
562555

563-
// stderr cannot be treated as TAP, per the spec. However, we want to
564-
// surface stderr lines to improve the DX. Inject each line into the
565-
// test output as an unknown token as if it came from the TAP parser.
566-
subtest.addToReport({
567-
__proto__: null,
568-
type: 'test:stderr',
569-
data: { __proto__: null, file: path, message: line + '\n' },
556+
child.stdout.on('data', (data) => {
557+
subtest.parseMessage(data);
570558
});
571-
});
572559

573-
const { 0: { 0: code, 1: signal } } = await SafePromiseAll([
574-
once(child, 'exit', { __proto__: null, signal: t.signal }),
575-
finished(child.stdout, { __proto__: null, signal: t.signal }),
576-
]);
577-
578-
// Close readline interface to prevent memory leak
579-
rl.close();
580-
581-
if (watchMode) {
582-
filesWatcher.runningProcesses.delete(path);
583-
filesWatcher.runningSubtests.delete(path);
584-
(async () => {
585-
try {
586-
await subTestEnded;
587-
} finally {
588-
if (filesWatcher.runningSubtests.size === 0) {
589-
opts.root.reporter[kEmitMessage]('test:watch:drained');
590-
opts.root.postRun();
591-
}
560+
const rl = new Interface({ __proto__: null, input: child.stderr });
561+
rl.on('line', (line) => {
562+
if (isInspectorMessage(line)) {
563+
process.stderr.write(line + '\n');
564+
return;
592565
}
593-
})();
594-
}
595566

596-
if (code !== 0 || signal !== null) {
597-
if (!err) {
598-
const failureType = subtest.failedSubtests ? kSubtestsFailed : kTestCodeFailure;
599-
err = ObjectAssign(new ERR_TEST_FAILURE('test failed', failureType), {
567+
// stderr cannot be treated as TAP, per the spec. However, we want to
568+
// surface stderr lines to improve the DX. Inject each line into the
569+
// test output as an unknown token as if it came from the TAP parser.
570+
subtest.addToReport({
600571
__proto__: null,
601-
exitCode: code,
602-
signal: signal,
603-
// The stack will not be useful since the failures came from tests
604-
// in a child process.
605-
stack: undefined,
572+
type: 'test:stderr',
573+
data: { __proto__: null, file: path, message: line + '\n' },
606574
});
575+
});
576+
577+
const { 0: { 0: code, 1: signal } } = await SafePromiseAll([
578+
once(child, 'exit', { __proto__: null, signal: t.signal }),
579+
finished(child.stdout, { __proto__: null, signal: t.signal }),
580+
]);
581+
582+
// Close readline interface to prevent memory leak
583+
rl.close();
584+
585+
if (watchMode) {
586+
filesWatcher.runningProcesses.delete(path);
587+
filesWatcher.runningSubtests.delete(path);
588+
(async () => {
589+
try {
590+
await subTestEnded;
591+
} finally {
592+
if (filesWatcher.runningSubtests.size === 0) {
593+
opts.root.reporter[kEmitMessage]('test:watch:drained');
594+
opts.root.postRun();
595+
}
596+
}
597+
})();
607598
}
608599

609-
throw err;
600+
if (code !== 0 || signal !== null) {
601+
if (!err) {
602+
const failureType = subtest.failedSubtests ? kSubtestsFailed : kTestCodeFailure;
603+
err = ObjectAssign(new ERR_TEST_FAILURE('test failed', failureType), {
604+
__proto__: null,
605+
exitCode: code,
606+
signal: signal,
607+
// The stack will not be useful since the failures came from tests
608+
// in a child process.
609+
stack: undefined,
610+
});
611+
}
612+
613+
throw err;
614+
}
615+
} finally {
616+
// Every exit path must return the ID, including abort and spawn failure.
617+
if (opts.workerIdPool && workerId !== undefined) {
618+
opts.workerIdPool.release(workerId);
619+
debug('Released worker ID %d from test file: %s', workerId, path);
620+
}
610621
}
611622
});
612623
const subTestEnded = subtest.start();
@@ -992,23 +1003,10 @@ function run(options = kEmptyObject) {
9921003
let filesWatcher;
9931004
let runFiles;
9941005

995-
// Create worker ID pool for concurrent test execution.
996-
// Use concurrency from globalOptions which has been processed by parseCommandLine().
997-
const effectiveConcurrency = globalOptions.concurrency ?? concurrency;
998-
let maxConcurrency = 1;
999-
if (effectiveConcurrency === true) {
1000-
maxConcurrency = MathMax(availableParallelism() - 1, 1);
1001-
} else if (typeof effectiveConcurrency === 'number') {
1002-
maxConcurrency = effectiveConcurrency;
1003-
}
1004-
const workerIdPool = new WorkerIdPool(maxConcurrency);
1005-
debug(
1006-
'Created worker ID pool with max concurrency: %d, ' +
1007-
'effectiveConcurrency: %s, testFiles: %d',
1008-
maxConcurrency,
1009-
effectiveConcurrency,
1010-
testFiles.length,
1011-
);
1006+
// The pool tracks the IDs actually in use, so they stay exclusive and never
1007+
// exceed the number of files running concurrently.
1008+
const workerIdPool = new WorkerIdPool();
1009+
debug('Created worker ID pool, testFiles: %d', testFiles.length);
10121010

10131011
const opts = {
10141012
__proto__: null,

0 commit comments

Comments
 (0)