Commit 66986755265 for nodejs
commit 669867552654aaaea51f095f36136ba324d402f4
Author: Matteo Collina <hello@matteocollina.com>
Date: Tue Oct 20 07:00:22 2026 -0700
test_runner: use AsyncLocalStorage instead of async_hooks
Track the test that owns the current async context with an
AsyncLocalStorage instead of a createHook() init/destroy map.
Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: https://github.com/nodejs/node/pull/66795
Refs: https://github.com/nodejs/node/pull/61030
Reviewed-By: Chemi Atlow <chemi@atlow.co.il>
Reviewed-By: Paolo Insogna <paolo@cowtech.it>
diff --git a/lib/internal/test_runner/harness.js b/lib/internal/test_runner/harness.js
index 16f9392bf77..53f6771a091 100644
--- a/lib/internal/test_runner/harness.js
+++ b/lib/internal/test_runner/harness.js
@@ -6,14 +6,9 @@ const {
Promise,
PromiseResolve,
PromiseWithResolvers,
- SafeMap,
SafePromiseAllReturnVoid,
} = primordials;
const { getCallerLocation } = internalBinding('util');
-const {
- createHook,
- executionAsyncId,
-} = require('async_hooks');
const { relative } = require('path');
const {
codes: {
@@ -26,6 +21,7 @@ const {
parseCommandLine,
reporterScope,
shouldColorizeTestFiles,
+ testStorage,
setupGlobalSetupTeardownFunctions,
parsePreviousRuns,
} = require('internal/test_runner/utils');
@@ -35,12 +31,9 @@ const { queueMicrotask } = require('internal/process/task_queues');
const { TIMEOUT_MAX } = require('internal/timers');
const { clearInterval, setImmediate, setInterval } = require('timers');
const { bigint: hrtime } = process.hrtime;
-const testResources = new SafeMap();
let globalRoot;
let globalSetupExecuted = false;
-testResources.set(reporterScope.asyncId(), reporterScope);
-
function createTestTree(rootTestOptions, globalOptions) {
const buildPhaseDeferred = PromiseWithResolvers();
const isFilteringByName = globalOptions.testNamePatterns ||
@@ -124,7 +117,7 @@ function createProcessEventHandler(eventName, rootTest) {
throw err;
}
- const test = testResources.get(executionAsyncId());
+ const test = testStorage.getStore();
// Check if this error is coming from a reporter. If it is, throw it.
if (test === reporterScope) {
@@ -230,27 +223,6 @@ function setupFailureStateFile(rootTest, globalOptions) {
}
function setupProcessState(root, globalOptions) {
- const hook = createHook({
- __proto__: null,
- init(asyncId, type, triggerAsyncId, resource) {
- if (resource instanceof Test) {
- testResources.set(asyncId, resource);
- return;
- }
-
- const parent = testResources.get(triggerAsyncId);
-
- if (parent !== undefined) {
- testResources.set(asyncId, parent);
- }
- },
- destroy(asyncId) {
- testResources.delete(asyncId);
- },
- });
-
- hook.enable();
-
const exceptionHandler =
createProcessEventHandler('uncaughtException', root);
const rejectionHandler =
@@ -282,7 +254,6 @@ function setupProcessState(root, globalOptions) {
root.harness.globalTeardownFunction = null;
}
- hook.disable();
process.removeListener('uncaughtException', exceptionHandler);
process.removeListener('unhandledRejection', rejectionHandler);
process.removeListener('beforeExit', exitHandler);
@@ -389,7 +360,7 @@ async function startSubtestAfterBootstrap(subtest) {
function runInParentContext(Factory) {
function run(name, options, fn, overrides) {
- const parent = testResources.get(executionAsyncId()) || lazyBootstrapRoot();
+ const parent = testStorage.getStore() || lazyBootstrapRoot();
const subtest = parent.createSubtest(Factory, name, options, fn, overrides);
if (parent instanceof Suite) {
return PromiseResolve();
@@ -422,7 +393,7 @@ function runInParentContext(Factory) {
function hook(hook) {
return (fn, options) => {
- const parent = testResources.get(executionAsyncId()) || lazyBootstrapRoot();
+ const parent = testStorage.getStore() || lazyBootstrapRoot();
parent.createHook(hook, fn, {
__proto__: null,
...options,
@@ -434,7 +405,7 @@ function hook(hook) {
}
function getTestContext() {
- const test = testResources.get(executionAsyncId());
+ const test = testStorage.getStore();
// Exclude the reporter sentinel
if (test === undefined || test === reporterScope) {
return undefined;
diff --git a/lib/internal/test_runner/test.js b/lib/internal/test_runner/test.js
index 34f3dfaae9d..7019ec15fce 100644
--- a/lib/internal/test_runner/test.js
+++ b/lib/internal/test_runner/test.js
@@ -44,6 +44,12 @@ const { exitCodes: { kGenericUserError } } = internalBinding('errors');
const { addAbortListener } = require('internal/events/abort_listener');
const { queueMicrotask } = require('internal/process/task_queues');
const { AsyncResource } = require('async_hooks');
+const AsyncContextFrame = require('internal/async_context_frame');
+const {
+ emitAfter,
+ emitBefore,
+ hasAsyncIdStack,
+} = require('internal/async_hooks');
const { AbortController } = require('internal/abort_controller');
const {
AbortError,
@@ -67,6 +73,7 @@ const {
isTestFailureError,
reporterScope,
testChannel,
+ testStorage,
} = require('internal/test_runner/utils');
const {
kEmptyObject,
@@ -574,9 +581,12 @@ class Test extends AsyncResource {
outerSignal;
#reportedSubtest;
#tagsArray = null;
+ #asyncContextFrame;
constructor(options) {
super('Test');
+ // The context captured by AsyncResource, with this test as the owner.
+ this.#asyncContextFrame = new AsyncContextFrame(testStorage, this);
let { fn, name, parent } = options;
const { concurrency, entryFile, expectFailure, loc, only, timeout, todo, skip, signal, plan } = options;
@@ -858,6 +868,26 @@ class Test extends AsyncResource {
return this.#tagsArray;
}
+ // Same as AsyncResource#runInAsyncScope(), but the restored context also
+ // makes this test the owner of everything that runs or is scheduled inside
+ // fn. This is how the harness maps uncaught errors and nested test() or
+ // hook calls back to the right test. It is inlined rather than wrapping
+ // super.runInAsyncScope() to avoid adding frames to user stack traces.
+ runInAsyncScope(fn, thisArg, ...args) {
+ const asyncId = this.asyncId();
+ emitBefore(asyncId, this.triggerAsyncId(), this);
+
+ const prior = AsyncContextFrame.exchange(this.#asyncContextFrame);
+ try {
+ return ReflectApply(fn, thisArg, args);
+ } finally {
+ AsyncContextFrame.set(prior);
+ if (hasAsyncIdStack()) {
+ emitAfter(asyncId);
+ }
+ }
+ }
+
applyFilters() {
if (this.error) {
// Never filter out errors.
diff --git a/lib/internal/test_runner/utils.js b/lib/internal/test_runner/utils.js
index 38b54c31685..e8043a88177 100644
--- a/lib/internal/test_runner/utils.js
+++ b/lib/internal/test_runner/utils.js
@@ -33,7 +33,7 @@ const {
parseTagFilterExpression,
} = require('internal/test_runner/tag_filter');
-const { AsyncResource } = require('async_hooks');
+const { AsyncLocalStorage, AsyncResource } = require('async_hooks');
const { tracingChannel } = require('diagnostics_channel');
const { relative, sep, resolve } = require('path');
const { createWriteStream, readFileSync } = require('fs');
@@ -235,6 +235,8 @@ async function getReportersMap(reporters, destinations) {
}
const reporterScope = new AsyncResource('TestReporterScope');
+// Tracks the Test (or reporterScope) that owns the current async context.
+const testStorage = new AsyncLocalStorage();
const testChannel = tracingChannel('node.test');
let globalTestOptions;
@@ -397,7 +399,7 @@ function parseCommandLine() {
randomize = true;
}
- const setup = reporterScope.bind(async (rootReporter) => {
+ const setup = reporterScope.bind((rootReporter) => testStorage.run(reporterScope, async () => {
const reportersMap = await getReportersMap(reporters, destinations);
for (let i = 0; i < reportersMap.length; i++) {
const { reporter, destination } = reportersMap[i];
@@ -405,7 +407,7 @@ function parseCommandLine() {
}
reporterScope.reporters = reportersMap;
- });
+ }));
globalTestOptions = {
__proto__: null,
@@ -734,6 +736,7 @@ module.exports = {
kMaxRandomSeed,
parseCommandLine,
reporterScope,
+ testStorage,
shouldColorizeTestFiles,
getCoverageReport,
setupGlobalSetupTeardownFunctions,
diff --git a/test/fixtures/test-runner/output/describe_it.snapshot b/test/fixtures/test-runner/output/describe_it.snapshot
index 9be9d988f0f..772a90d9891 100644
--- a/test/fixtures/test-runner/output/describe_it.snapshot
+++ b/test/fixtures/test-runner/output/describe_it.snapshot
@@ -439,7 +439,6 @@ not ok 50 - callback called twice
code: 'ERR_TEST_FAILURE'
stack: |-
TestContext.<anonymous> (<project-root>/test/fixtures/test-runner/output/describe_it.js:255:3)
- <node-internal-frames>
...
# Subtest: callback called twice in different ticks
ok 51 - callback called twice in different ticks
diff --git a/test/fixtures/test-runner/output/junit_reporter.snapshot b/test/fixtures/test-runner/output/junit_reporter.snapshot
index 5130996dd2f..3ff11ba2415 100644
--- a/test/fixtures/test-runner/output/junit_reporter.snapshot
+++ b/test/fixtures/test-runner/output/junit_reporter.snapshot
@@ -132,8 +132,7 @@ true !== false
<testcase name="+sync throw fail" time="*" classname="subtest sync throw fail" file="*" failure="thrown from subtest sync throw fail">
<failure type="testCodeFailure" message="thrown from subtest sync throw fail">
Error [ERR_TEST_FAILURE]: thrown from subtest sync throw fail
- at TestContext.<anonymous> (<project-root>/test/fixtures/test-runner/output/output.js:125:11)
- at <node-internal-frames> {
+ at TestContext.<anonymous> (<project-root>/test/fixtures/test-runner/output/output.js:125:11) {
code: 'ERR_TEST_FAILURE',
failureType: 'testCodeFailure',
cause: Error: thrown from subtest sync throw fail
@@ -229,8 +228,7 @@ Error [ERR_TEST_FAILURE]: thrown from subtest sync throw fail
<testcase name="callback called twice" time="*" classname="test" file="*" failure="callback invoked multiple times">
<failure type="multipleCallbackInvocations" message="callback invoked multiple times">
Error [ERR_TEST_FAILURE]: callback invoked multiple times
- at TestContext.<anonymous> (<project-root>/test/fixtures/test-runner/output/output.js:272:3)
- at <node-internal-frames> {
+ at TestContext.<anonymous> (<project-root>/test/fixtures/test-runner/output/output.js:272:3) {
code: 'ERR_TEST_FAILURE',
failureType: 'multipleCallbackInvocations',
cause: 'callback invoked multiple times'
@@ -292,8 +290,7 @@ Error [ERR_TEST_FAILURE]: thrown from callback async throw
<testcase name="sync throw fails at first" time="*" classname="subtest sync throw fails" file="*" failure="thrown from subtest sync throw fails at first">
<failure type="testCodeFailure" message="thrown from subtest sync throw fails at first">
Error [ERR_TEST_FAILURE]: thrown from subtest sync throw fails at first
- at TestContext.<anonymous> (<project-root>/test/fixtures/test-runner/output/output.js:334:11)
- at <node-internal-frames> {
+ at TestContext.<anonymous> (<project-root>/test/fixtures/test-runner/output/output.js:334:11) {
code: 'ERR_TEST_FAILURE',
failureType: 'testCodeFailure',
cause: Error: thrown from subtest sync throw fails at first
diff --git a/test/fixtures/test-runner/output/output.snapshot b/test/fixtures/test-runner/output/output.snapshot
index 79ac90bd667..5768dfd2bb8 100644
--- a/test/fixtures/test-runner/output/output.snapshot
+++ b/test/fixtures/test-runner/output/output.snapshot
@@ -457,7 +457,6 @@ not ok 50 - callback called twice
code: 'ERR_TEST_FAILURE'
stack: |-
TestContext.<anonymous> (<project-root>/test/fixtures/test-runner/output/output.js:272:3)
- <node-internal-frames>
...
# Subtest: callback called twice in different ticks
ok 51 - callback called twice in different ticks
diff --git a/test/fixtures/test-runner/output/output_cli.snapshot b/test/fixtures/test-runner/output/output_cli.snapshot
index 27a144d3e80..ee84352d36e 100644
--- a/test/fixtures/test-runner/output/output_cli.snapshot
+++ b/test/fixtures/test-runner/output/output_cli.snapshot
@@ -457,7 +457,6 @@ not ok 50 - callback called twice
code: 'ERR_TEST_FAILURE'
stack: |-
TestContext.<anonymous> (<project-root>/test/fixtures/test-runner/output/output.js:272:3)
- <node-internal-frames>
...
# Subtest: callback called twice in different ticks
ok 51 - callback called twice in different ticks
diff --git a/test/parallel/test-runner-no-async-hooks.js b/test/parallel/test-runner-no-async-hooks.js
new file mode 100644
index 00000000000..4f71f8df743
--- /dev/null
+++ b/test/parallel/test-runner-no-async-hooks.js
@@ -0,0 +1,22 @@
+// Flags: --expose-internals
+'use strict';
+
+// The test runner tracks which test owns the current async context with
+// AsyncLocalStorage, so it must not install any async_hooks.
+
+const common = require('../common');
+const assert = require('assert');
+const { setTimeout } = require('timers/promises');
+const { enabledHooksExist } = require('internal/async_hooks');
+const { test } = require('node:test');
+
+test('no async hooks are enabled', common.mustCall(async (t) => {
+ assert.strictEqual(enabledHooksExist(), false);
+
+ await t.test('subtest', common.mustCall(async () => {
+ await setTimeout(1);
+ assert.strictEqual(enabledHooksExist(), false);
+ }));
+
+ assert.strictEqual(enabledHooksExist(), false);
+}));