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.&lt;anonymous> (<project-root>/test/fixtures/test-runner/output/output.js:125:11)
-    at <node-internal-frames> {
+    at TestContext.&lt;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.&lt;anonymous> (<project-root>/test/fixtures/test-runner/output/output.js:272:3)
-    at <node-internal-frames> {
+    at TestContext.&lt;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.&lt;anonymous> (<project-root>/test/fixtures/test-runner/output/output.js:334:11)
-    at <node-internal-frames> {
+    at TestContext.&lt;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);
+}));