ava/lib/fork.js
Matthias Oßwald b99b703fed
Keep test worker alive until explicitly freed
This change ensures that all messages from the worker are received by
the main process before the worker exits.

It solves issues on Windows that are occurring with Node.js v20 and later
when `workerThreads: false` is set in the AVA configuration.

Fixes: https://github.com/avajs/ava/issues/3390

* Update comment and rename message

Whether the worker exits depends on whether user code is keeping the event loop busy. 'Freed' is a better term.

---------

Co-authored-by: Mark Wubben <mark@novemberborn.net>
2025-07-12 14:35:51 +02:00

178 lines
3.8 KiB
JavaScript

import childProcess from 'node:child_process';
import process from 'node:process';
import {fileURLToPath} from 'node:url';
import {Worker} from 'node:worker_threads';
import Emittery from 'emittery';
import {controlFlow} from './ipc-flow-control.cjs';
import serializeError, {tagWorkerError} from './serialize-error.js';
let workerPath = new URL('worker/base.js', import.meta.url);
export function _testOnlyReplaceWorkerPath(replacement) {
workerPath = replacement;
}
const additionalExecArgv = ['--enable-source-maps'];
const createWorker = (options, execArgv) => {
let worker;
let postMessage;
let close;
if (options.workerThreads) {
worker = new Worker(workerPath, {
argv: options.workerArgv,
env: {NODE_ENV: 'test', ...process.env, ...options.environmentVariables},
execArgv: [...execArgv, ...additionalExecArgv],
workerData: {
options,
},
trackUnmanagedFds: true,
stdin: true,
stdout: true,
stderr: true,
});
postMessage = worker.postMessage.bind(worker);
close = async () => {
try {
await worker.terminate();
} finally {
// No-op
}
};
} else {
worker = childProcess.fork(fileURLToPath(workerPath), options.workerArgv, {
cwd: options.projectDir,
silent: true,
env: {NODE_ENV: 'test', ...process.env, ...options.environmentVariables},
execArgv: [...execArgv, ...additionalExecArgv],
serialization: 'advanced',
});
postMessage = controlFlow(worker);
close = async () => worker.kill();
}
return {
worker,
postMessage,
close,
};
};
export default function loadFork(file, options, execArgv = process.execArgv) {
let finished = false;
const emitter = new Emittery();
const emitStateChange = evt => {
if (!finished) {
emitter.emit('stateChange', Object.assign(evt, {testFile: file}));
}
};
options = {
baseDir: process.cwd(),
file,
...options,
};
const {worker, postMessage, close} = createWorker(options, execArgv);
worker.stdout.on('data', chunk => {
emitStateChange({type: 'worker-stdout', chunk});
});
worker.stderr.on('data', chunk => {
emitStateChange({type: 'worker-stderr', chunk});
});
let forcedExit = false;
const send = evt => {
if (!finished && !forcedExit) {
postMessage({ava: evt});
}
};
const promise = new Promise(resolve => {
const finish = () => {
finished = true;
resolve();
};
worker.on('message', message => {
if (!message.ava) {
return;
}
switch (message.ava.type) {
case 'worker-finished': {
send({type: 'free-worker'});
break;
}
case 'ready-for-options': {
send({type: 'options', options});
break;
}
case 'shared-worker-connect': {
const {channelId, filename, initialData, port} = message.ava;
emitter.emit('connectSharedWorker', {
filename,
initialData,
port,
signalError() {
send({type: 'shared-worker-error', channelId});
},
});
break;
}
default: {
emitStateChange(message.ava);
}
}
});
worker.on('error', error => {
emitStateChange({type: 'worker-failed', err: serializeError(tagWorkerError(error))});
finish();
});
worker.on('exit', (code, signal) => {
if (forcedExit) {
emitStateChange({type: 'worker-finished', forcedExit});
} else if (code > 0) {
emitStateChange({type: 'worker-failed', nonZeroExitCode: code});
} else if (code === null && signal) {
emitStateChange({type: 'worker-failed', signal});
} else {
emitStateChange({type: 'worker-finished', forcedExit});
}
finish();
});
});
return {
file,
threadId: worker.threadId,
promise,
exit() {
forcedExit = true;
close();
},
notifyOfPeerFailure() {
send({type: 'peer-failed'});
},
onConnectSharedWorker(listener) {
return emitter.on('connectSharedWorker', listener);
},
onStateChange(listener) {
return emitter.on('stateChange', listener);
},
};
}