Files
node/test/parallel/test-runner-worker-id.js
T
Vasiliy Serpokryl e898f5ce59 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>
2026-09-22 13:28:01 +00:00

243 lines
7.9 KiB
JavaScript

'use strict';
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 () => {
const args = [
'--test',
fixtures.path('test-runner', 'worker-id', 'test-1.mjs'),
fixtures.path('test-runner', 'worker-id', 'test-2.mjs'),
fixtures.path('test-runner', 'worker-id', 'test-3.mjs'),
];
const result = spawnSync(process.execPath, args, {
cwd: fixtures.path(),
env: { ...process.env }
});
assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
});
test('NODE_TEST_WORKER_ID is set with explicit concurrency', async () => {
const args = [
'--test',
'--test-concurrency=2',
fixtures.path('test-runner', 'worker-id', 'test-1.mjs'),
fixtures.path('test-runner', 'worker-id', 'test-2.mjs'),
];
const result = spawnSync(process.execPath, args, {
cwd: fixtures.path(),
env: { ...process.env }
});
assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
});
test('NODE_TEST_WORKER_ID is 1 with concurrency=1', async () => {
const args = ['--test', '--test-concurrency=1', fixtures.path('test-runner', 'worker-id', 'test-1.mjs')];
const result = spawnSync(process.execPath, args, {
cwd: fixtures.path(),
env: { ...process.env }
});
assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
});
test('NODE_TEST_WORKER_ID with explicit isolation=process', async () => {
const args = [
'--test',
'--test-isolation=process',
fixtures.path('test-runner', 'worker-id', 'test-1.mjs'),
fixtures.path('test-runner', 'worker-id', 'test-2.mjs'),
];
const result = spawnSync(process.execPath, args, {
cwd: fixtures.path(),
env: { ...process.env }
});
assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
});
test('NODE_TEST_WORKER_ID is 1 with isolation=none', async () => {
const args = [
'--test',
'--test-isolation=none',
fixtures.path('test-runner', 'worker-id', 'test-1.mjs'),
fixtures.path('test-runner', 'worker-id', 'test-2.mjs'),
];
const result = spawnSync(process.execPath, args, {
cwd: fixtures.path(),
env: { ...process.env }
});
assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
});
test('context.workerId matches NODE_TEST_WORKER_ID', async () => {
const args = ['--test', fixtures.path('test-runner', 'worker-id', 'test-1.mjs')];
const result = spawnSync(process.execPath, args, {
cwd: fixtures.path(),
env: { ...process.env }
});
// The fixture tests already verify that context.workerId matches the env var
assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
});
test('worker IDs are reused when more tests than concurrency', async () => {
tmpdir.refresh();
// Create 9 separate test files dynamically
const testFiles = [];
const usageFile = tmpdir.resolve('worker-usage.txt');
for (let i = 1; i <= 9; i++) {
const testFile = tmpdir.resolve(`reuse-test-${i}.mjs`);
writeFileSync(
testFile,
`import { test } from 'node:test';
import { appendFileSync } from 'node:fs';
test('track worker ${i}', () => {
const workerId = process.env.NODE_TEST_WORKER_ID;
const usageFile = process.env.WORKER_USAGE_FILE;
appendFileSync(usageFile, workerId + '\\n');
});
`,
);
testFiles.push(testFile);
}
const args = ['--test', '--test-concurrency=3', ...testFiles];
const result = spawnSync(process.execPath, args, {
env: { ...process.env, WORKER_USAGE_FILE: usageFile }
});
assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
// Read and analyze worker IDs used
const workerIds = readFileSync(usageFile, 'utf8').trim().split('\n');
// Count occurrences of each worker ID
const workerCounts = {};
workerIds.forEach((id) => {
workerCounts[id] = (workerCounts[id] || 0) + 1;
});
const uniqueWorkers = Object.keys(workerCounts);
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);
}
}
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')}`);
}
});