From ef8d21bd157ea52b9aea23fefaa3c4dcff309249 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 16 Oct 2026 17:33:23 +0200 Subject: [PATCH] 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 --- lib/internal/test_runner/harness.js | 39 +++---------------- lib/internal/test_runner/test.js | 30 ++++++++++++++ lib/internal/test_runner/utils.js | 9 +++-- .../test-runner/output/describe_it.snapshot | 1 - .../output/junit_reporter.snapshot | 9 ++--- .../test-runner/output/output.snapshot | 1 - .../test-runner/output/output_cli.snapshot | 1 - test/parallel/test-runner-no-async-hooks.js | 22 +++++++++++ 8 files changed, 66 insertions(+), 46 deletions(-) create mode 100644 test/parallel/test-runner-no-async-hooks.js diff --git a/lib/internal/test_runner/harness.js b/lib/internal/test_runner/harness.js index 16f9392bf776..53f6771a0912 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 34f3dfaae9df..7019ec15fcef 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 38b54c31685c..e8043a881770 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 9be9d988f0ff..772a90d98918 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. (/test/fixtures/test-runner/output/describe_it.js:255:3) - ... # 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 5130996dd2fd..3ff11ba24154 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 Error [ERR_TEST_FAILURE]: thrown from subtest sync throw fail - at TestContext.<anonymous> (/test/fixtures/test-runner/output/output.js:125:11) - at { + at TestContext.<anonymous> (/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 Error [ERR_TEST_FAILURE]: callback invoked multiple times - at TestContext.<anonymous> (/test/fixtures/test-runner/output/output.js:272:3) - at { + at TestContext.<anonymous> (/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 Error [ERR_TEST_FAILURE]: thrown from subtest sync throw fails at first - at TestContext.<anonymous> (/test/fixtures/test-runner/output/output.js:334:11) - at { + at TestContext.<anonymous> (/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 79ac90bd667d..5768dfd2bb83 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. (/test/fixtures/test-runner/output/output.js:272:3) - ... # 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 27a144d3e804..ee84352d36e0 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. (/test/fixtures/test-runner/output/output.js:272:3) - ... # 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 000000000000..4f71f8df7431 --- /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); +}));