From d783beb7f863b910c3ee72bcb154849a1d38647d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:30:58 +0000 Subject: [PATCH 1/5] Preserve AsyncLocalStorage context during dynamic import() evaluation A module loaded via dynamic import() from inside AsyncLocalStorage.run() evaluated its top-level code with no active store: getStore() returned undefined during module initialization, whereas Node returns the active store. JSC drives dynamic-import evaluation from an internal microtask (DynamicImportLoadSettled -> module->evaluate) that never restores m_asyncContextData, so the imported module's body ran with whatever context happened to be current at evaluation time (undefined). Capture the async context active at the import() call site in moduleLoaderImportModule, keyed by the resolved module key, and reinstate it around the module body in moduleLoaderEvaluate (both the regular and eval-entrypoint paths). The entry is dropped when the import settles synchronously (an already-evaluated module never re-evaluates) and consumed when the body evaluates, so it does not accumulate. This covers the module's synchronous top-level evaluation. A top-level-await module's post-await continuations resume through JSC's async-module machinery outside this hook; the context is restored before that path runs, which keeps it from misreading the wrapped async-context tuple on the current engine. --- src/jsc/bindings/ZigGlobalObject.cpp | 68 +++++++++++++- src/jsc/bindings/ZigGlobalObject.h | 5 + .../async_hooks/AsyncLocalStorage.test.ts | 93 ++++++++++++++++++- 3 files changed, 161 insertions(+), 5 deletions(-) diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 4b3fa3da0d69..43db4ba821f7 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -3588,6 +3588,26 @@ JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalO sourceOriginZ.deref(); } + // If this import() runs inside an AsyncLocalStorage context, record that + // context keyed by the resolved module key so the imported module's + // top-level evaluation can run with it (see moduleLoaderEvaluate). Node + // preserves the context across dynamic-import evaluation via V8's + // continuation-preserved embedder data; JSC's dynamic-import microtasks + // never touch m_asyncContextData, so we thread it through the module key. + JSC::JSString* asyncContextKey = nullptr; + if (globalObject->isAsyncContextTrackingEnabled()) { + JSC::JSValue asyncContext = globalObject->m_asyncContextData.get()->getInternalField(0); + if (!asyncContext.isUndefined()) { + JSC::JSMap* map = globalObject->m_pendingDynamicImportAsyncContexts.get(); + if (!map) { + map = JSC::JSMap::create(vm, globalObject->mapStructure()); + globalObject->m_pendingDynamicImportAsyncContexts.set(vm, globalObject, map); + } + asyncContextKey = jsString(vm, resolvedIdentifier.string()); + map->set(globalObject, asyncContextKey, asyncContext); + } + } + // The C++ module loader now extracts `with.type` into a // ScriptFetchParameters before calling this hook, so `parameters` is // already the parsed RefPtr (or null). Just forward it. @@ -3597,6 +3617,14 @@ JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalO return JSC::JSPromise::rejectedPromiseWithCaughtException(globalObject, scope); } + // Only a still-pending import reaches moduleLoaderEvaluate to consume the + // entry above; an already-evaluated (cached) module settles synchronously + // and never re-evaluates, so drop its entry to avoid retaining the context. + if (asyncContextKey && (!result || result->status() != JSC::JSPromise::Status::Pending)) { + if (auto* map = globalObject->m_pendingDynamicImportAsyncContexts.get()) + map->remove(globalObject, asyncContextKey); + } + ASSERT(result); return result; } @@ -3703,13 +3731,45 @@ JSC::JSObject* GlobalObject::moduleLoaderCreateImportMetaProperties(JSGlobalObje return Zig::ImportMetaObject::create(globalObject, key); } +// Reinstate the AsyncLocalStorage context captured at this module's import() +// call site (see moduleLoaderImportModule) around its synchronous top-level +// evaluation. JSC drives dynamic-import evaluation from an internal microtask +// that never restores m_asyncContextData, so getStore() would otherwise be +// undefined during module init (#32693). A top-level-await module's post-await +// continuations resume through JSC's async-module machinery outside this hook +// and are not covered here. +static JSC::JSValue evaluateModuleWithCapturedAsyncContext(Zig::GlobalObject* globalObject, + JSModuleLoader* moduleLoader, JSValue key, JSValue moduleRecordValue, + RefPtr&& scriptFetcher, JSValue sentValue, JSValue resumeMode) +{ + auto& vm = JSC::getVM(globalObject); + + JSC::JSValue capturedAsyncContext; + if (auto* map = globalObject->m_pendingDynamicImportAsyncContexts.get()) { + if (map->size() && map->has(globalObject, key)) { + capturedAsyncContext = map->get(globalObject, key); + map->remove(globalObject, key); + } + } + + if (!capturedAsyncContext || capturedAsyncContext.isUndefined() || !globalObject->isAsyncContextTrackingEnabled()) + return moduleLoader->evaluateNonVirtual(globalObject, key, moduleRecordValue, WTF::move(scriptFetcher), sentValue, resumeMode); + + auto* asyncContextData = globalObject->m_asyncContextData.get(); + JSC::JSValue restoreAsyncContext = asyncContextData->getInternalField(0); + asyncContextData->putInternalField(vm, 0, capturedAsyncContext); + JSC::JSValue result = moduleLoader->evaluateNonVirtual(globalObject, key, moduleRecordValue, WTF::move(scriptFetcher), sentValue, resumeMode); + asyncContextData->putInternalField(vm, 0, restoreAsyncContext); + return result; +} + JSC::JSValue GlobalObject::moduleLoaderEvaluate(JSGlobalObject* lexicalGlobalObject, JSModuleLoader* moduleLoader, JSValue key, JSValue moduleRecordValue, RefPtr scriptFetcher, JSValue sentValue, JSValue resumeMode) { - return moduleLoader->evaluateNonVirtual(lexicalGlobalObject, key, moduleRecordValue, - WTF::move(scriptFetcher), sentValue, resumeMode); + return evaluateModuleWithCapturedAsyncContext(uncheckedDowncast(lexicalGlobalObject), + moduleLoader, key, moduleRecordValue, WTF::move(scriptFetcher), sentValue, resumeMode); } extern "C" bool Bun__VM__specifierIsEvalEntryPoint(void*, EncodedJSValue); @@ -3724,8 +3784,8 @@ JSC::JSValue EvalGlobalObject::moduleLoaderEvaluate(JSGlobalObject* lexicalGloba auto& vm = globalObject->vm(); auto scope = DECLARE_THROW_SCOPE(vm); - JSC::JSValue result = moduleLoader->evaluateNonVirtual(lexicalGlobalObject, key, moduleRecordValue, - WTF::move(scriptFetcher), sentValue, resumeMode); + JSC::JSValue result = evaluateModuleWithCapturedAsyncContext(globalObject, moduleLoader, key, + moduleRecordValue, WTF::move(scriptFetcher), sentValue, resumeMode); // The new C++ loader propagates the module body's throw out of // evaluateNonVirtual; the old JS-side ModuleLoader.js swallowed it before // dispatching here. Don't call back into native code (which opens an diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index eae5d1cc5a6f..fd5d251e65ee 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -12,6 +12,7 @@ namespace JSC { class Structure; class Identifier; +class JSMap; class LazyClassStructure; class ScriptFetcher; class ScriptFetchParameters; @@ -502,6 +503,10 @@ class GlobalObject : public Bun::GlobalScope { \ V(public, WriteBarrier, m_nextTickQueue) \ \ + /* AsyncLocalStorage context captured at an import() call site, keyed by resolved module key, */ \ + /* reinstated around the dynamically imported module's top-level evaluation (#32693). */ \ + V(public, WriteBarrier, m_pendingDynamicImportAsyncContexts) \ + \ /* WriteBarrier m_JSBunDebuggerValue; */ \ V(private, ThenablesArray, m_thenables) \ \ diff --git a/test/js/node/async_hooks/AsyncLocalStorage.test.ts b/test/js/node/async_hooks/AsyncLocalStorage.test.ts index a0be6a2db4f0..df02e93d490b 100644 --- a/test/js/node/async_hooks/AsyncLocalStorage.test.ts +++ b/test/js/node/async_hooks/AsyncLocalStorage.test.ts @@ -1,6 +1,6 @@ import { AsyncLocalStorage, AsyncResource } from "async_hooks"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, tempDir } from "harness"; describe("AsyncLocalStorage", () => { test("throw inside of AsyncLocalStorage.run() will be passed out", () => { @@ -567,3 +567,94 @@ describe("async context passes through", () => { expect(a).toBe("value"); }); }); + +describe("dynamic import() preserves the AsyncLocalStorage context (#32693)", () => { + test("during the imported module's top-level evaluation", async () => { + using dir = tempDir("als-dynamic-import", { + "store.mjs": ` + import { AsyncLocalStorage } from 'node:async_hooks'; + export const store = new AsyncLocalStorage(); + `, + // Imported lazily from inside store.run(). Its top-level code (module + // evaluation) must observe the store that was active at the import() site, + // matching Node. A nested run() inside the body must still scope correctly + // and restore to the imported context afterwards. + "imported.mjs": ` + import { store } from './store.mjs'; + console.log("eval:" + store.getStore()); + store.run("NESTED", () => { + console.log("nested:" + store.getStore()); + }); + console.log("after-nested:" + store.getStore()); + `, + // Imported with no active context: must evaluate with an undefined store, + // proving the captured context does not leak into unrelated imports. + "no-context.mjs": ` + import { store } from './store.mjs'; + console.log("no-context-eval:" + store.getStore()); + `, + "index.mjs": ` + import { store } from './store.mjs'; + await store.run('CONTEXT', () => import('./imported.mjs')); + console.log("after-import:" + store.getStore()); + await import('./no-context.mjs'); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe( + [ + "eval:CONTEXT", + "nested:NESTED", + "after-nested:CONTEXT", + "after-import:undefined", + "no-context-eval:undefined", + "", + ].join("\n"), + ); + expect(exitCode).toBe(0); + expect(stderr).not.toContain("CONTEXT"); + }); + + // A top-level-await module resumes through JSC's async-module machinery, which + // runs outside this hook; only the synchronous prefix observes the context. The + // guarantee here is that capturing the context does not crash that path. + test("a top-level-await module sees the context during its synchronous prefix and does not crash", async () => { + using dir = tempDir("als-dynamic-import-tla", { + "store.mjs": ` + import { AsyncLocalStorage } from 'node:async_hooks'; + export const store = new AsyncLocalStorage(); + `, + "tla.mjs": ` + import { store } from './store.mjs'; + console.log("tla-sync:" + store.getStore()); + await Promise.resolve(); + console.log("done"); + `, + "index.mjs": ` + import { store } from './store.mjs'; + await store.run('CONTEXT', () => import('./tla.mjs')); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe("tla-sync:CONTEXT\ndone\n"); + expect(exitCode).toBe(0); + }); +}); From 69a3175c23fe64f66e82335012f6f31d7b1b27e6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:55:28 +0000 Subject: [PATCH 2/5] address review: cover virtual modules, clean up captured context on import failure Extract the capture into a helper and apply it to the plugin virtual-module import path as well, so dynamically imported virtual modules also evaluate with the caller's AsyncLocalStorage context. Remove the captured entry when an import never reaches evaluation: on the synchronous importModule exception path, and on async load failure via Bun__onFulfillAsyncModule's reject path. Previously only cached (synchronously settled) imports were cleaned up, so a pending import that later rejected (syntax/transpile error, failed dependency) retained the captured context for the lifetime of the VM. Concurrent imports of the same not-yet-loaded module keep last-writer-wins semantics, which self-heals a stale entry on the next import of that key. Adds a regression test for a failing dynamic import inside a run() scope. --- src/jsc/bindings/ModuleLoader.cpp | 7 ++ src/jsc/bindings/ZigGlobalObject.cpp | 68 ++++++++++++------- .../async_hooks/AsyncLocalStorage.test.ts | 35 ++++++++++ 3 files changed, 85 insertions(+), 25 deletions(-) diff --git a/src/jsc/bindings/ModuleLoader.cpp b/src/jsc/bindings/ModuleLoader.cpp index 7c5ba80bb6a3..e0b70049fe47 100644 --- a/src/jsc/bindings/ModuleLoader.cpp +++ b/src/jsc/bindings/ModuleLoader.cpp @@ -470,6 +470,13 @@ extern "C" void Bun__onFulfillAsyncModule( JSC::JSPromise* promise = uncheckedDowncast(JSC::JSValue::decode(encodedPromiseValue)); if (!res->success) { + // The module failed to load and will never evaluate, so no + // moduleLoaderEvaluate call will consume a dynamic-import async context + // captured for it (#32693); drop it here so it can't pin the store. + if (auto* map = globalObject->m_pendingDynamicImportAsyncContexts.get()) { + if (map->size()) + map->remove(globalObject, JSC::jsString(vm, specifier->toWTFString(BunString::ZeroCopy))); + } RELEASE_AND_RETURN(scope, promise->reject(vm, JSValue::decode(res->result.err.value))); } diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 43db4ba821f7..4c10e036b9d0 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -3483,6 +3483,37 @@ JSC::Identifier GlobalObject::moduleLoaderResolve(JSGlobalObject* jsGlobalObject } } +// Record the AsyncLocalStorage context active at an import() call site, keyed by +// the resolved module key, so the imported module's top-level evaluation can run +// with it (see evaluateModuleWithCapturedAsyncContext). JSC's dynamic-import +// microtasks never restore m_asyncContextData; Node preserves it via V8's +// continuation-preserved embedder data. Returns the key to remove on cleanup, or +// null when nothing was recorded. +static JSC::JSString* captureDynamicImportAsyncContext(Zig::GlobalObject* globalObject, JSC::VM& vm, const JSC::Identifier& resolvedIdentifier) +{ + if (!globalObject->isAsyncContextTrackingEnabled()) + return nullptr; + JSC::JSValue asyncContext = globalObject->m_asyncContextData.get()->getInternalField(0); + if (asyncContext.isUndefined()) + return nullptr; + JSC::JSMap* map = globalObject->m_pendingDynamicImportAsyncContexts.get(); + if (!map) { + map = JSC::JSMap::create(vm, globalObject->mapStructure()); + globalObject->m_pendingDynamicImportAsyncContexts.set(vm, globalObject, map); + } + JSC::JSString* key = jsString(vm, resolvedIdentifier.string()); + map->set(globalObject, key, asyncContext); + return key; +} + +static void dropDynamicImportAsyncContext(Zig::GlobalObject* globalObject, JSC::JSString* key) +{ + if (!key) + return; + if (auto* map = globalObject->m_pendingDynamicImportAsyncContexts.get()) + map->remove(globalObject, key); +} + JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalObject, JSModuleLoader*, JSString* moduleNameValue, @@ -3532,10 +3563,14 @@ JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalO if (auto resolution = globalObject->onLoadPlugins.resolveVirtualModule(moduleName, sourceURL.protocolIsFile() ? sourceOriginStringHolder : String())) { resolvedIdentifier = JSC::Identifier::fromString(vm, resolution.value()); + JSC::JSString* asyncContextKey = captureDynamicImportAsyncContext(globalObject, vm, resolvedIdentifier); auto result = JSC::importModule(globalObject, resolvedIdentifier, JSC::Identifier(), parameters, nullptr, /* deferred */ false, referrerAsyncOrder); if (scope.exception()) [[unlikely]] { + dropDynamicImportAsyncContext(globalObject, asyncContextKey); return JSC::JSPromise::rejectedPromiseWithCaughtException(globalObject, scope); } + if (asyncContextKey && (!result || result->status() != JSC::JSPromise::Status::Pending)) + dropDynamicImportAsyncContext(globalObject, asyncContextKey); return result; } } @@ -3588,25 +3623,7 @@ JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalO sourceOriginZ.deref(); } - // If this import() runs inside an AsyncLocalStorage context, record that - // context keyed by the resolved module key so the imported module's - // top-level evaluation can run with it (see moduleLoaderEvaluate). Node - // preserves the context across dynamic-import evaluation via V8's - // continuation-preserved embedder data; JSC's dynamic-import microtasks - // never touch m_asyncContextData, so we thread it through the module key. - JSC::JSString* asyncContextKey = nullptr; - if (globalObject->isAsyncContextTrackingEnabled()) { - JSC::JSValue asyncContext = globalObject->m_asyncContextData.get()->getInternalField(0); - if (!asyncContext.isUndefined()) { - JSC::JSMap* map = globalObject->m_pendingDynamicImportAsyncContexts.get(); - if (!map) { - map = JSC::JSMap::create(vm, globalObject->mapStructure()); - globalObject->m_pendingDynamicImportAsyncContexts.set(vm, globalObject, map); - } - asyncContextKey = jsString(vm, resolvedIdentifier.string()); - map->set(globalObject, asyncContextKey, asyncContext); - } - } + JSC::JSString* asyncContextKey = captureDynamicImportAsyncContext(globalObject, vm, resolvedIdentifier); // The C++ module loader now extracts `with.type` into a // ScriptFetchParameters before calling this hook, so `parameters` is @@ -3614,16 +3631,17 @@ JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalO auto result = JSC::importModule(globalObject, resolvedIdentifier, JSC::Identifier(), WTF::move(parameters), nullptr, /* deferred */ false, referrerAsyncOrder); if (scope.exception()) [[unlikely]] { + dropDynamicImportAsyncContext(globalObject, asyncContextKey); return JSC::JSPromise::rejectedPromiseWithCaughtException(globalObject, scope); } // Only a still-pending import reaches moduleLoaderEvaluate to consume the - // entry above; an already-evaluated (cached) module settles synchronously - // and never re-evaluates, so drop its entry to avoid retaining the context. - if (asyncContextKey && (!result || result->status() != JSC::JSPromise::Status::Pending)) { - if (auto* map = globalObject->m_pendingDynamicImportAsyncContexts.get()) - map->remove(globalObject, asyncContextKey); - } + // entry; a cached (already-evaluated) module settles synchronously and never + // re-evaluates. A pending import that later rejects without evaluating is + // cleaned up at its fetch-failure seam (Bun__onFulfillAsyncModule) or, for + // transitive failures, overwritten on the next import of the same key. + if (asyncContextKey && (!result || result->status() != JSC::JSPromise::Status::Pending)) + dropDynamicImportAsyncContext(globalObject, asyncContextKey); ASSERT(result); return result; diff --git a/test/js/node/async_hooks/AsyncLocalStorage.test.ts b/test/js/node/async_hooks/AsyncLocalStorage.test.ts index df02e93d490b..16857f48caaf 100644 --- a/test/js/node/async_hooks/AsyncLocalStorage.test.ts +++ b/test/js/node/async_hooks/AsyncLocalStorage.test.ts @@ -657,4 +657,39 @@ describe("dynamic import() preserves the AsyncLocalStorage context (#32693)", () expect(stdout).toBe("tla-sync:CONTEXT\ndone\n"); expect(exitCode).toBe(0); }); + + // A dynamic import that fails to load never evaluates, so the captured context + // is cleaned up at the fetch-failure seam instead of being retained. Exercises + // that path and confirms it neither crashes nor disturbs a later import. + test("a dynamic import that fails to load is caught and does not disturb later imports", async () => { + using dir = tempDir("als-dynamic-import-fail", { + "store.mjs": ` + import { AsyncLocalStorage } from 'node:async_hooks'; + export const store = new AsyncLocalStorage(); + `, + "bad.mjs": `export const x = ;`, + "good.mjs": ` + import { store } from './store.mjs'; + console.log("good-eval:" + store.getStore()); + `, + "index.mjs": ` + import { store } from './store.mjs'; + const outcome = await store.run('A', () => import('./bad.mjs').then(() => 'loaded', () => 'caught')); + console.log("bad-import:" + outcome); + await store.run('B', () => import('./good.mjs')); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe("bad-import:caught\ngood-eval:B\n"); + expect(exitCode).toBe(0); + }); }); From 2a5f1c1b177170e082f2470ea9730641e6779067 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:04:08 +0000 Subject: [PATCH 3/5] clear a stale dynamic-import context when the same key is re-imported without one A dynamic import whose own source fetches but then fails to link (a transitive dependency with a syntax/resolution error, an async plugin onLoad rejection) never reaches moduleLoaderEvaluate and has no Bun-side fetch-failure seam, so its captured-context entry was left in the map. A later import of that same key with no active context previously early-returned without touching the entry, so the stale context could be applied to that evaluation. Clear the entry for the key when capturing finds no active context. Combined with the existing overwrite on a contextful import, the map now always reflects the most recent import's call-site state, so a stale entry can never drive a later evaluation and is released on the next import of that key. Not done via a reaction on the returned import promise: attaching one marks the user's promise handled and would suppress (or, re-thrown, spuriously emit) the unhandled-rejection report for a fire-and-forget failed import(). --- src/jsc/bindings/ZigGlobalObject.cpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 4c10e036b9d0..84bdfa8b6e46 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -3488,15 +3488,21 @@ JSC::Identifier GlobalObject::moduleLoaderResolve(JSGlobalObject* jsGlobalObject // with it (see evaluateModuleWithCapturedAsyncContext). JSC's dynamic-import // microtasks never restore m_asyncContextData; Node preserves it via V8's // continuation-preserved embedder data. Returns the key to remove on cleanup, or -// null when nothing was recorded. +// null when nothing was recorded. When there is no active context, any entry a +// prior import of this key left behind without evaluating (e.g. a load that +// failed after its own fetch succeeded, so no fetch-failure seam fired) is +// dropped here, so a stale context is never applied to this evaluation. static JSC::JSString* captureDynamicImportAsyncContext(Zig::GlobalObject* globalObject, JSC::VM& vm, const JSC::Identifier& resolvedIdentifier) { if (!globalObject->isAsyncContextTrackingEnabled()) return nullptr; + JSC::JSMap* map = globalObject->m_pendingDynamicImportAsyncContexts.get(); JSC::JSValue asyncContext = globalObject->m_asyncContextData.get()->getInternalField(0); - if (asyncContext.isUndefined()) + if (asyncContext.isUndefined()) { + if (map && map->size()) + map->remove(globalObject, jsString(vm, resolvedIdentifier.string())); return nullptr; - JSC::JSMap* map = globalObject->m_pendingDynamicImportAsyncContexts.get(); + } if (!map) { map = JSC::JSMap::create(vm, globalObject->mapStructure()); globalObject->m_pendingDynamicImportAsyncContexts.set(vm, globalObject, map); From 58440fe6a18669141e771134490a578459b8011a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:51:32 +0000 Subject: [PATCH 4/5] check exceptions after the pending-import-context JSMap operations The JSMap set/get/remove on m_pendingDynamicImportAsyncContexts each open an internal JSC ThrowScope, and every throw scope records a need-exception-check on return. Without a check before the next scope (JSC::importModule -> requestImportModule, or evaluateNonVirtual), the exception-check validator (BUN_JSC_validateExceptionChecks, enabled in the x64-asan CI lane) aborts with "unchecked exception", failing the new AsyncLocalStorage test and bun-server. Check the scope after each JSMap operation: - moduleLoaderImportModule: RETURN_IF_EXCEPTION after captureDynamicImportAsyncContext (which does the set), and assertNoException after the cached-module drop. - evaluateModuleWithCapturedAsyncContext: function-level scope, RETURN_IF_EXCEPTION after get/remove, RELEASE_AND_RETURN / release() around evaluateNonVirtual. Use get (returns jsUndefined() when absent) instead of has+get to avoid two lookups. - Bun__onFulfillAsyncModule: assertNoException after remove. These maps are JSString-keyed builtins, so the operations only throw on OOM; assertNoException is used where the op cannot allocate (remove/find), propagation where it can (set/get). The validator's simulated throws set the check flag but not a real exception, so capture still runs normally (verified with BUN_JSC_validateExceptionChecks=1 BUN_JSC_dumpSimulatedThrows=1). --- src/jsc/bindings/ModuleLoader.cpp | 4 ++- src/jsc/bindings/ZigGlobalObject.cpp | 39 ++++++++++++++++++++-------- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/jsc/bindings/ModuleLoader.cpp b/src/jsc/bindings/ModuleLoader.cpp index e0b70049fe47..fd598b51dfc4 100644 --- a/src/jsc/bindings/ModuleLoader.cpp +++ b/src/jsc/bindings/ModuleLoader.cpp @@ -474,8 +474,10 @@ extern "C" void Bun__onFulfillAsyncModule( // moduleLoaderEvaluate call will consume a dynamic-import async context // captured for it (#32693); drop it here so it can't pin the store. if (auto* map = globalObject->m_pendingDynamicImportAsyncContexts.get()) { - if (map->size()) + if (map->size()) { map->remove(globalObject, JSC::jsString(vm, specifier->toWTFString(BunString::ZeroCopy))); + scope.assertNoException(); // JSMap::remove (non-allocating) cannot throw + } } RELEASE_AND_RETURN(scope, promise->reject(vm, JSValue::decode(res->result.err.value))); } diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 84bdfa8b6e46..e90a9b32ecd4 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -3499,6 +3499,8 @@ static JSC::JSString* captureDynamicImportAsyncContext(Zig::GlobalObject* global JSC::JSMap* map = globalObject->m_pendingDynamicImportAsyncContexts.get(); JSC::JSValue asyncContext = globalObject->m_asyncContextData.get()->getInternalField(0); if (asyncContext.isUndefined()) { + // JSMap::remove cannot throw. Clears a stale entry left by a prior import + // of this key that never evaluated, so no context leaks into this one. if (map && map->size()) map->remove(globalObject, jsString(vm, resolvedIdentifier.string())); return nullptr; @@ -3508,6 +3510,8 @@ static JSC::JSString* captureDynamicImportAsyncContext(Zig::GlobalObject* global globalObject->m_pendingDynamicImportAsyncContexts.set(vm, globalObject, map); } JSC::JSString* key = jsString(vm, resolvedIdentifier.string()); + // JSMap::set opens an internal throw scope (it can only fail on OOM). The + // caller checks the scope right after this returns, so don't add one here. map->set(globalObject, key, asyncContext); return key; } @@ -3516,6 +3520,7 @@ static void dropDynamicImportAsyncContext(Zig::GlobalObject* globalObject, JSC:: { if (!key) return; + // JSMap::remove cannot throw. if (auto* map = globalObject->m_pendingDynamicImportAsyncContexts.get()) map->remove(globalObject, key); } @@ -3570,13 +3575,15 @@ JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalO resolvedIdentifier = JSC::Identifier::fromString(vm, resolution.value()); JSC::JSString* asyncContextKey = captureDynamicImportAsyncContext(globalObject, vm, resolvedIdentifier); + RETURN_IF_EXCEPTION(scope, JSC::JSPromise::rejectedPromiseWithCaughtException(globalObject, scope)); auto result = JSC::importModule(globalObject, resolvedIdentifier, JSC::Identifier(), parameters, nullptr, /* deferred */ false, referrerAsyncOrder); if (scope.exception()) [[unlikely]] { - dropDynamicImportAsyncContext(globalObject, asyncContextKey); return JSC::JSPromise::rejectedPromiseWithCaughtException(globalObject, scope); } - if (asyncContextKey && (!result || result->status() != JSC::JSPromise::Status::Pending)) + if (asyncContextKey && (!result || result->status() != JSC::JSPromise::Status::Pending)) { dropDynamicImportAsyncContext(globalObject, asyncContextKey); + scope.assertNoException(); // JSMap::remove (non-allocating) cannot throw + } return result; } } @@ -3630,6 +3637,7 @@ JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalO } JSC::JSString* asyncContextKey = captureDynamicImportAsyncContext(globalObject, vm, resolvedIdentifier); + RETURN_IF_EXCEPTION(scope, JSC::JSPromise::rejectedPromiseWithCaughtException(globalObject, scope)); // The C++ module loader now extracts `with.type` into a // ScriptFetchParameters before calling this hook, so `parameters` is @@ -3637,17 +3645,20 @@ JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalO auto result = JSC::importModule(globalObject, resolvedIdentifier, JSC::Identifier(), WTF::move(parameters), nullptr, /* deferred */ false, referrerAsyncOrder); if (scope.exception()) [[unlikely]] { - dropDynamicImportAsyncContext(globalObject, asyncContextKey); + // A synchronous importModule failure leaves the captured entry; it is + // cleared on the next import of this key (see captureDynamicImportAsyncContext). return JSC::JSPromise::rejectedPromiseWithCaughtException(globalObject, scope); } // Only a still-pending import reaches moduleLoaderEvaluate to consume the // entry; a cached (already-evaluated) module settles synchronously and never - // re-evaluates. A pending import that later rejects without evaluating is - // cleaned up at its fetch-failure seam (Bun__onFulfillAsyncModule) or, for - // transitive failures, overwritten on the next import of the same key. - if (asyncContextKey && (!result || result->status() != JSC::JSPromise::Status::Pending)) + // re-evaluates, so drop its entry now. A pending import that later rejects + // without evaluating is cleaned up at its fetch-failure seam + // (Bun__onFulfillAsyncModule) or overwritten on the next import of the key. + if (asyncContextKey && (!result || result->status() != JSC::JSPromise::Status::Pending)) { dropDynamicImportAsyncContext(globalObject, asyncContextKey); + scope.assertNoException(); // JSMap::remove (non-allocating) cannot throw + } ASSERT(result); return result; @@ -3768,20 +3779,26 @@ static JSC::JSValue evaluateModuleWithCapturedAsyncContext(Zig::GlobalObject* gl { auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); JSC::JSValue capturedAsyncContext; - if (auto* map = globalObject->m_pendingDynamicImportAsyncContexts.get()) { - if (map->size() && map->has(globalObject, key)) { - capturedAsyncContext = map->get(globalObject, key); + if (auto* map = globalObject->m_pendingDynamicImportAsyncContexts.get(); map && map->size()) { + // get/remove each open an internal throw scope (they can only fail on + // OOM), so check after each. get returns jsUndefined() when absent. + capturedAsyncContext = map->get(globalObject, key); + RETURN_IF_EXCEPTION(scope, {}); + if (!capturedAsyncContext.isUndefined()) { map->remove(globalObject, key); + RETURN_IF_EXCEPTION(scope, {}); } } if (!capturedAsyncContext || capturedAsyncContext.isUndefined() || !globalObject->isAsyncContextTrackingEnabled()) - return moduleLoader->evaluateNonVirtual(globalObject, key, moduleRecordValue, WTF::move(scriptFetcher), sentValue, resumeMode); + RELEASE_AND_RETURN(scope, moduleLoader->evaluateNonVirtual(globalObject, key, moduleRecordValue, WTF::move(scriptFetcher), sentValue, resumeMode)); auto* asyncContextData = globalObject->m_asyncContextData.get(); JSC::JSValue restoreAsyncContext = asyncContextData->getInternalField(0); asyncContextData->putInternalField(vm, 0, capturedAsyncContext); + scope.release(); JSC::JSValue result = moduleLoader->evaluateNonVirtual(globalObject, key, moduleRecordValue, WTF::move(scriptFetcher), sentValue, resumeMode); asyncContextData->putInternalField(vm, 0, restoreAsyncContext); return result; From 33c7b00a78d9e19f250e1ed16afb283c5be65c8c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:26:52 +0000 Subject: [PATCH 5/5] ci: retrigger