From f2da5c52c89f4940417b43af014e69fbad986314 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 26 Jun 2026 11:09:57 +0200 Subject: [PATCH 1/2] async_hooks: clear context frame for thrown microtasks Signed-off-by: Matteo Collina --- lib/internal/process/task_queues.js | 26 +++++++++---- ...microtask-async-context-frame-exception.js | 39 +++++++++++++++++++ 2 files changed, 57 insertions(+), 8 deletions(-) create mode 100644 test/parallel/test-queue-microtask-async-context-frame-exception.js diff --git a/lib/internal/process/task_queues.js b/lib/internal/process/task_queues.js index 484fa4ea2f9d..06afba8bfb50 100644 --- a/lib/internal/process/task_queues.js +++ b/lib/internal/process/task_queues.js @@ -145,14 +145,24 @@ function nextTick(callback) { } function runMicrotask() { - this.runInAsyncScope(() => { - const callback = this.callback; - try { - callback(); - } finally { - this.emitDestroy(); - } - }); + try { + this.runInAsyncScope(() => { + const callback = this.callback; + try { + callback(); + } finally { + this.emitDestroy(); + } + }); + } catch (error) { + // V8 restores the continuation-preserved embedder data for each + // microtask, but currently does not clear it on exception paths before + // reporting the exception. Clear it here so user code re-entered during + // exception formatting cannot observe this microtask's AsyncLocalStorage + // context. + AsyncContextFrame.set(undefined); + throw error; + } } const defaultMicrotaskResourceOpts = { requireManualDestroy: true }; diff --git a/test/parallel/test-queue-microtask-async-context-frame-exception.js b/test/parallel/test-queue-microtask-async-context-frame-exception.js new file mode 100644 index 000000000000..659c50c56a6b --- /dev/null +++ b/test/parallel/test-queue-microtask-async-context-frame-exception.js @@ -0,0 +1,39 @@ +// Flags: --async-context-frame +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { AsyncLocalStorage } = require('async_hooks'); + +const asyncLocalStorage = new AsyncLocalStorage(); +const sensitive = { secret: 'sensitive' }; +let toPrimitiveStore = 'not called'; +let downstreamStore = 'not called'; + +const thrown = { + [Symbol.toPrimitive]: common.mustCall(() => { + toPrimitiveStore = asyncLocalStorage.getStore(); + queueMicrotask(common.mustCall(() => { + downstreamStore = asyncLocalStorage.getStore(); + assert.strictEqual(downstreamStore, undefined); + })); + return 'thrown'; + }), +}; + +process.on('uncaughtException', common.mustCall((err) => { + assert.strictEqual(err, thrown); + assert.strictEqual(asyncLocalStorage.getStore(), undefined); +})); + +asyncLocalStorage.run(sensitive, () => { + queueMicrotask(() => { + throw thrown; + }); +}); + +setImmediate(common.mustCall(() => { + assert.strictEqual(toPrimitiveStore, undefined); + assert.strictEqual(downstreamStore, undefined); + assert.strictEqual(asyncLocalStorage.getStore(), undefined); +})); From e846ea8a933a138c5061d6f2d099a822ce80562e Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Mon, 24 Aug 2026 05:32:35 +0000 Subject: [PATCH 2/2] test: async context for thrown microtasks The AsyncLocalStorage context of a throwing microtask remains current while the exception is being reported: the uncaughtException handler, exception formatting, and microtasks queued during formatting all observe it. Add a regression test documenting this current behavior. Signed-off-by: Matteo Collina --- lib/internal/process/task_queues.js | 26 ++++++------------- ...microtask-async-context-frame-exception.js | 20 +++++++++----- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/lib/internal/process/task_queues.js b/lib/internal/process/task_queues.js index 06afba8bfb50..484fa4ea2f9d 100644 --- a/lib/internal/process/task_queues.js +++ b/lib/internal/process/task_queues.js @@ -145,24 +145,14 @@ function nextTick(callback) { } function runMicrotask() { - try { - this.runInAsyncScope(() => { - const callback = this.callback; - try { - callback(); - } finally { - this.emitDestroy(); - } - }); - } catch (error) { - // V8 restores the continuation-preserved embedder data for each - // microtask, but currently does not clear it on exception paths before - // reporting the exception. Clear it here so user code re-entered during - // exception formatting cannot observe this microtask's AsyncLocalStorage - // context. - AsyncContextFrame.set(undefined); - throw error; - } + this.runInAsyncScope(() => { + const callback = this.callback; + try { + callback(); + } finally { + this.emitDestroy(); + } + }); } const defaultMicrotaskResourceOpts = { requireManualDestroy: true }; diff --git a/test/parallel/test-queue-microtask-async-context-frame-exception.js b/test/parallel/test-queue-microtask-async-context-frame-exception.js index 659c50c56a6b..d69ba35f01a9 100644 --- a/test/parallel/test-queue-microtask-async-context-frame-exception.js +++ b/test/parallel/test-queue-microtask-async-context-frame-exception.js @@ -7,15 +7,20 @@ const { AsyncLocalStorage } = require('async_hooks'); const asyncLocalStorage = new AsyncLocalStorage(); const sensitive = { secret: 'sensitive' }; -let toPrimitiveStore = 'not called'; +let formattingStore = 'not called'; let downstreamStore = 'not called'; const thrown = { [Symbol.toPrimitive]: common.mustCall(() => { - toPrimitiveStore = asyncLocalStorage.getStore(); + // Exception formatting re-enters JavaScript while the throwing + // microtask's context frame is still current, so the context is + // available here. + formattingStore = asyncLocalStorage.getStore(); + // A microtask queued while the frame is still current captures that + // context. queueMicrotask(common.mustCall(() => { downstreamStore = asyncLocalStorage.getStore(); - assert.strictEqual(downstreamStore, undefined); + assert.strictEqual(downstreamStore, sensitive); })); return 'thrown'; }), @@ -23,7 +28,9 @@ const thrown = { process.on('uncaughtException', common.mustCall((err) => { assert.strictEqual(err, thrown); - assert.strictEqual(asyncLocalStorage.getStore(), undefined); + // The AsyncLocalStorage context of the throwing microtask is still + // available to the uncaughtException handler. + assert.strictEqual(asyncLocalStorage.getStore(), sensitive); })); asyncLocalStorage.run(sensitive, () => { @@ -33,7 +40,8 @@ asyncLocalStorage.run(sensitive, () => { }); setImmediate(common.mustCall(() => { - assert.strictEqual(toPrimitiveStore, undefined); - assert.strictEqual(downstreamStore, undefined); + // Once the microtask queue has drained, the context frame is no longer + // current. + assert.strictEqual(formattingStore, sensitive); assert.strictEqual(asyncLocalStorage.getStore(), undefined); }));