Skip to content

Commit ef8d21b

Browse files
committed
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>
1 parent 9df97a7 commit ef8d21b

8 files changed

Lines changed: 66 additions & 46 deletions

File tree

‎lib/internal/test_runner/harness.js‎

Lines changed: 5 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,9 @@ const {
66
Promise,
77
PromiseResolve,
88
PromiseWithResolvers,
9-
SafeMap,
109
SafePromiseAllReturnVoid,
1110
} = primordials;
1211
const { getCallerLocation } = internalBinding('util');
13-
const {
14-
createHook,
15-
executionAsyncId,
16-
} = require('async_hooks');
1712
const { relative } = require('path');
1813
const {
1914
codes: {
@@ -26,6 +21,7 @@ const {
2621
parseCommandLine,
2722
reporterScope,
2823
shouldColorizeTestFiles,
24+
testStorage,
2925
setupGlobalSetupTeardownFunctions,
3026
parsePreviousRuns,
3127
} = require('internal/test_runner/utils');
@@ -35,12 +31,9 @@ const { queueMicrotask } = require('internal/process/task_queues');
3531
const { TIMEOUT_MAX } = require('internal/timers');
3632
const { clearInterval, setImmediate, setInterval } = require('timers');
3733
const { bigint: hrtime } = process.hrtime;
38-
const testResources = new SafeMap();
3934
let globalRoot;
4035
let globalSetupExecuted = false;
4136

42-
testResources.set(reporterScope.asyncId(), reporterScope);
43-
4437
function createTestTree(rootTestOptions, globalOptions) {
4538
const buildPhaseDeferred = PromiseWithResolvers();
4639
const isFilteringByName = globalOptions.testNamePatterns ||
@@ -124,7 +117,7 @@ function createProcessEventHandler(eventName, rootTest) {
124117
throw err;
125118
}
126119

127-
const test = testResources.get(executionAsyncId());
120+
const test = testStorage.getStore();
128121

129122
// Check if this error is coming from a reporter. If it is, throw it.
130123
if (test === reporterScope) {
@@ -230,27 +223,6 @@ function setupFailureStateFile(rootTest, globalOptions) {
230223
}
231224

232225
function setupProcessState(root, globalOptions) {
233-
const hook = createHook({
234-
__proto__: null,
235-
init(asyncId, type, triggerAsyncId, resource) {
236-
if (resource instanceof Test) {
237-
testResources.set(asyncId, resource);
238-
return;
239-
}
240-
241-
const parent = testResources.get(triggerAsyncId);
242-
243-
if (parent !== undefined) {
244-
testResources.set(asyncId, parent);
245-
}
246-
},
247-
destroy(asyncId) {
248-
testResources.delete(asyncId);
249-
},
250-
});
251-
252-
hook.enable();
253-
254226
const exceptionHandler =
255227
createProcessEventHandler('uncaughtException', root);
256228
const rejectionHandler =
@@ -282,7 +254,6 @@ function setupProcessState(root, globalOptions) {
282254
root.harness.globalTeardownFunction = null;
283255
}
284256

285-
hook.disable();
286257
process.removeListener('uncaughtException', exceptionHandler);
287258
process.removeListener('unhandledRejection', rejectionHandler);
288259
process.removeListener('beforeExit', exitHandler);
@@ -389,7 +360,7 @@ async function startSubtestAfterBootstrap(subtest) {
389360

390361
function runInParentContext(Factory) {
391362
function run(name, options, fn, overrides) {
392-
const parent = testResources.get(executionAsyncId()) || lazyBootstrapRoot();
363+
const parent = testStorage.getStore() || lazyBootstrapRoot();
393364
const subtest = parent.createSubtest(Factory, name, options, fn, overrides);
394365
if (parent instanceof Suite) {
395366
return PromiseResolve();
@@ -422,7 +393,7 @@ function runInParentContext(Factory) {
422393

423394
function hook(hook) {
424395
return (fn, options) => {
425-
const parent = testResources.get(executionAsyncId()) || lazyBootstrapRoot();
396+
const parent = testStorage.getStore() || lazyBootstrapRoot();
426397
parent.createHook(hook, fn, {
427398
__proto__: null,
428399
...options,
@@ -434,7 +405,7 @@ function hook(hook) {
434405
}
435406

436407
function getTestContext() {
437-
const test = testResources.get(executionAsyncId());
408+
const test = testStorage.getStore();
438409
// Exclude the reporter sentinel
439410
if (test === undefined || test === reporterScope) {
440411
return undefined;

‎lib/internal/test_runner/test.js‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ const { exitCodes: { kGenericUserError } } = internalBinding('errors');
4444
const { addAbortListener } = require('internal/events/abort_listener');
4545
const { queueMicrotask } = require('internal/process/task_queues');
4646
const { AsyncResource } = require('async_hooks');
47+
const AsyncContextFrame = require('internal/async_context_frame');
48+
const {
49+
emitAfter,
50+
emitBefore,
51+
hasAsyncIdStack,
52+
} = require('internal/async_hooks');
4753
const { AbortController } = require('internal/abort_controller');
4854
const {
4955
AbortError,
@@ -67,6 +73,7 @@ const {
6773
isTestFailureError,
6874
reporterScope,
6975
testChannel,
76+
testStorage,
7077
} = require('internal/test_runner/utils');
7178
const {
7279
kEmptyObject,
@@ -574,9 +581,12 @@ class Test extends AsyncResource {
574581
outerSignal;
575582
#reportedSubtest;
576583
#tagsArray = null;
584+
#asyncContextFrame;
577585

578586
constructor(options) {
579587
super('Test');
588+
// The context captured by AsyncResource, with this test as the owner.
589+
this.#asyncContextFrame = new AsyncContextFrame(testStorage, this);
580590

581591
let { fn, name, parent } = options;
582592
const { concurrency, entryFile, expectFailure, loc, only, timeout, todo, skip, signal, plan } = options;
@@ -858,6 +868,26 @@ class Test extends AsyncResource {
858868
return this.#tagsArray;
859869
}
860870

871+
// Same as AsyncResource#runInAsyncScope(), but the restored context also
872+
// makes this test the owner of everything that runs or is scheduled inside
873+
// fn. This is how the harness maps uncaught errors and nested test() or
874+
// hook calls back to the right test. It is inlined rather than wrapping
875+
// super.runInAsyncScope() to avoid adding frames to user stack traces.
876+
runInAsyncScope(fn, thisArg, ...args) {
877+
const asyncId = this.asyncId();
878+
emitBefore(asyncId, this.triggerAsyncId(), this);
879+
880+
const prior = AsyncContextFrame.exchange(this.#asyncContextFrame);
881+
try {
882+
return ReflectApply(fn, thisArg, args);
883+
} finally {
884+
AsyncContextFrame.set(prior);
885+
if (hasAsyncIdStack()) {
886+
emitAfter(asyncId);
887+
}
888+
}
889+
}
890+
861891
applyFilters() {
862892
if (this.error) {
863893
// Never filter out errors.

‎lib/internal/test_runner/utils.js‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const {
3333
parseTagFilterExpression,
3434
} = require('internal/test_runner/tag_filter');
3535

36-
const { AsyncResource } = require('async_hooks');
36+
const { AsyncLocalStorage, AsyncResource } = require('async_hooks');
3737
const { tracingChannel } = require('diagnostics_channel');
3838
const { relative, sep, resolve } = require('path');
3939
const { createWriteStream, readFileSync } = require('fs');
@@ -235,6 +235,8 @@ async function getReportersMap(reporters, destinations) {
235235
}
236236

237237
const reporterScope = new AsyncResource('TestReporterScope');
238+
// Tracks the Test (or reporterScope) that owns the current async context.
239+
const testStorage = new AsyncLocalStorage();
238240
const testChannel = tracingChannel('node.test');
239241
let globalTestOptions;
240242

@@ -397,15 +399,15 @@ function parseCommandLine() {
397399
randomize = true;
398400
}
399401

400-
const setup = reporterScope.bind(async (rootReporter) => {
402+
const setup = reporterScope.bind((rootReporter) => testStorage.run(reporterScope, async () => {
401403
const reportersMap = await getReportersMap(reporters, destinations);
402404
for (let i = 0; i < reportersMap.length; i++) {
403405
const { reporter, destination } = reportersMap[i];
404406
compose(rootReporter, reporter).pipe(destination);
405407
}
406408

407409
reporterScope.reporters = reportersMap;
408-
});
410+
}));
409411

410412
globalTestOptions = {
411413
__proto__: null,
@@ -734,6 +736,7 @@ module.exports = {
734736
kMaxRandomSeed,
735737
parseCommandLine,
736738
reporterScope,
739+
testStorage,
737740
shouldColorizeTestFiles,
738741
getCoverageReport,
739742
setupGlobalSetupTeardownFunctions,

‎test/fixtures/test-runner/output/describe_it.snapshot‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,6 @@ not ok 50 - callback called twice
439439
code: 'ERR_TEST_FAILURE'
440440
stack: |-
441441
TestContext.<anonymous> (<project-root>/test/fixtures/test-runner/output/describe_it.js:255:3)
442-
<node-internal-frames>
443442
...
444443
# Subtest: callback called twice in different ticks
445444
ok 51 - callback called twice in different ticks

‎test/fixtures/test-runner/output/junit_reporter.snapshot‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,7 @@ true !== false
132132
<testcase name="+sync throw fail" time="*" classname="subtest sync throw fail" file="*" failure="thrown from subtest sync throw fail">
133133
<failure type="testCodeFailure" message="thrown from subtest sync throw fail">
134134
Error [ERR_TEST_FAILURE]: thrown from subtest sync throw fail
135-
at TestContext.&lt;anonymous> (<project-root>/test/fixtures/test-runner/output/output.js:125:11)
136-
at <node-internal-frames> {
135+
at TestContext.&lt;anonymous> (<project-root>/test/fixtures/test-runner/output/output.js:125:11) {
137136
code: 'ERR_TEST_FAILURE',
138137
failureType: 'testCodeFailure',
139138
cause: Error: thrown from subtest sync throw fail
@@ -229,8 +228,7 @@ Error [ERR_TEST_FAILURE]: thrown from subtest sync throw fail
229228
<testcase name="callback called twice" time="*" classname="test" file="*" failure="callback invoked multiple times">
230229
<failure type="multipleCallbackInvocations" message="callback invoked multiple times">
231230
Error [ERR_TEST_FAILURE]: callback invoked multiple times
232-
at TestContext.&lt;anonymous> (<project-root>/test/fixtures/test-runner/output/output.js:272:3)
233-
at <node-internal-frames> {
231+
at TestContext.&lt;anonymous> (<project-root>/test/fixtures/test-runner/output/output.js:272:3) {
234232
code: 'ERR_TEST_FAILURE',
235233
failureType: 'multipleCallbackInvocations',
236234
cause: 'callback invoked multiple times'
@@ -292,8 +290,7 @@ Error [ERR_TEST_FAILURE]: thrown from callback async throw
292290
<testcase name="sync throw fails at first" time="*" classname="subtest sync throw fails" file="*" failure="thrown from subtest sync throw fails at first">
293291
<failure type="testCodeFailure" message="thrown from subtest sync throw fails at first">
294292
Error [ERR_TEST_FAILURE]: thrown from subtest sync throw fails at first
295-
at TestContext.&lt;anonymous> (<project-root>/test/fixtures/test-runner/output/output.js:334:11)
296-
at <node-internal-frames> {
293+
at TestContext.&lt;anonymous> (<project-root>/test/fixtures/test-runner/output/output.js:334:11) {
297294
code: 'ERR_TEST_FAILURE',
298295
failureType: 'testCodeFailure',
299296
cause: Error: thrown from subtest sync throw fails at first

‎test/fixtures/test-runner/output/output.snapshot‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,6 @@ not ok 50 - callback called twice
457457
code: 'ERR_TEST_FAILURE'
458458
stack: |-
459459
TestContext.<anonymous> (<project-root>/test/fixtures/test-runner/output/output.js:272:3)
460-
<node-internal-frames>
461460
...
462461
# Subtest: callback called twice in different ticks
463462
ok 51 - callback called twice in different ticks

‎test/fixtures/test-runner/output/output_cli.snapshot‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,6 @@ not ok 50 - callback called twice
457457
code: 'ERR_TEST_FAILURE'
458458
stack: |-
459459
TestContext.<anonymous> (<project-root>/test/fixtures/test-runner/output/output.js:272:3)
460-
<node-internal-frames>
461460
...
462461
# Subtest: callback called twice in different ticks
463462
ok 51 - callback called twice in different ticks
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Flags: --expose-internals
2+
'use strict';
3+
4+
// The test runner tracks which test owns the current async context with
5+
// AsyncLocalStorage, so it must not install any async_hooks.
6+
7+
const common = require('../common');
8+
const assert = require('assert');
9+
const { setTimeout } = require('timers/promises');
10+
const { enabledHooksExist } = require('internal/async_hooks');
11+
const { test } = require('node:test');
12+
13+
test('no async hooks are enabled', common.mustCall(async (t) => {
14+
assert.strictEqual(enabledHooksExist(), false);
15+
16+
await t.test('subtest', common.mustCall(async () => {
17+
await setTimeout(1);
18+
assert.strictEqual(enabledHooksExist(), false);
19+
}));
20+
21+
assert.strictEqual(enabledHooksExist(), false);
22+
}));

0 commit comments

Comments
 (0)