Repository: avajs/ava
Pull request #3391 (2025-07-10 17:45:29)

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


Patch:
diff --git a/lib/fork.js b/lib/fork.js
index 8317774f..82d12ffc 100644
--- a/lib/fork.js
+++ b/lib/fork.js
@@ -104,6 +104,11 @@ export default function loadFork(file, options, execArgv = process.execArgv) {
 			}
 
 			switch (message.ava.type) {
+				case 'worker-finished': {
+					send({type: 'free-worker'});
+					break;
+				}
+
 				case 'ready-for-options': {
 					send({type: 'options', options});
 					break;
diff --git a/lib/worker/base.js b/lib/worker/base.js
index 520107dd..e3d3a313 100644
--- a/lib/worker/base.js
+++ b/lib/worker/base.js
@@ -120,6 +120,15 @@ const run = async options => {
 			return;
 		}
 
+		channel.send({type: 'worker-finished'});
+
+		// Reference the channel until the worker is freed. This should prevent Node.js from terminating the child process
+		// prematurely, which has been witnessed on Windows. See discussion at
+		// <https://github.com/avajs/ava/issues/3390#issuecomment-3056119361>.
+		channel.ref();
+		await channel.workerFreed;
+		channel.unref();
+
 		nowAndTimers.setImmediate(() => {
 			const unhandled = currentlyUnhandled();
 			if (unhandled.length === 0) {
diff --git a/lib/worker/channel.cjs b/lib/worker/channel.cjs
index 0479eeca..177bba35 100644
--- a/lib/worker/channel.cjs
+++ b/lib/worker/channel.cjs
@@ -107,7 +107,9 @@ handle.ref();
 
 exports.options = selectAvaMessage(handle.channel, 'options').then(message => message.ava.options);
 exports.peerFailed = selectAvaMessage(handle.channel, 'peer-failed');
+exports.workerFreed = selectAvaMessage(handle.channel, 'free-worker');
 exports.send = handle.send.bind(handle);
+exports.ref = handle.ref.bind(handle);
 exports.unref = handle.unref.bind(handle);
 
 let channelCounter = 0;


Test patch:
diff --git a/test/child-process/fixtures/package.json b/test/child-process/fixtures/package.json
new file mode 100644
index 00000000..bedb411a
--- /dev/null
+++ b/test/child-process/fixtures/package.json
@@ -0,0 +1,3 @@
+{
+	"type": "module"
+}
diff --git a/test/child-process/fixtures/test.js b/test/child-process/fixtures/test.js
new file mode 100644
index 00000000..f7c5e817
--- /dev/null
+++ b/test/child-process/fixtures/test.js
@@ -0,0 +1,7 @@
+import test from 'ava';
+
+for (let i = 0; i < 50; i++) {
+	test('Test ' + (i + 1), t => {
+		t.true(true);
+	});
+}
diff --git a/test/child-process/test.js b/test/child-process/test.js
new file mode 100644
index 00000000..e75cb29a
--- /dev/null
+++ b/test/child-process/test.js
@@ -0,0 +1,9 @@
+import test from '@ava/test';
+
+import {fixture} from '../helpers/exec.js';
+
+// Regression test for https://github.com/avajs/ava/issues/3390
+test('running 50 tests in a child process works as expected', async t => {
+	const result = await fixture(['--no-worker-threads']);
+	t.is(result.stats.passed.length, 50);
+});