Skip to content

Preserve AsyncLocalStorage context during dynamic import() module evaluation - #32695

Closed
robobun wants to merge 5 commits into
mainfrom
farm/46f85f9b/als-dynamic-import-eval
Closed

Preserve AsyncLocalStorage context during dynamic import() module evaluation#32695
robobun wants to merge 5 commits into
mainfrom
farm/46f85f9b/als-dynamic-import-eval

Conversation

@robobun

@robobun robobun commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

Fixes #32693

Repro

store.mjs

import { AsyncLocalStorage } from 'node:async_hooks';
export const store = new AsyncLocalStorage();

index.mjs

import { store } from './store.mjs';
await store.run('CONTEXT', () => import('./imported.mjs'));

imported.mjs

import { store } from './store.mjs';
console.log('getStore() during module evaluation:', store.getStore());
$ bun index.mjs
getStore() during module evaluation: undefined   # Node prints: CONTEXT

Cause

Since the module-loader rewrite, JSC drives dynamic-import evaluation from an internal microtask (DynamicImportLoadSettled -> module->evaluate) that never restores m_asyncContextData. Unlike await, timers, and queueMicrotask (whose reactions snapshot/restore the async-context slot), that path leaves the slot at whatever was current at evaluation time, so the imported module's top-level code runs with no active store. enterWith() and AsyncLocalStorage.snapshot()/.bind() do not help, because the slot is reset between microtask ticks before the deferred evaluation runs.

Fix

Thread the context through the resolved module key, entirely in Bun's module hooks:

  • moduleLoaderImportModule: if an async context is active at the import() call site, record it keyed by the resolved module key. Drop the entry again when the import settles synchronously (an already-evaluated/cached module never re-evaluates).
  • moduleLoaderEvaluate (regular and eval-entrypoint paths): if the module being evaluated has a recorded context, reinstate it around the module body and restore it afterward, mirroring the existing AsyncContextFrame call pattern. The entry is consumed on evaluation, so the map does not accumulate.

Node preserves this via V8's continuation-preserved embedder data.

Scope

This covers the imported module's synchronous top-level evaluation (the reported case). A top-level-await module's post-await continuations resume through JSC's async-module machinery (AsyncModuleExecutionResume), which runs outside this hook; the context is restored before that path runs, so the synchronous prefix sees the store and the resume path does not misread the wrapped async-context tuple on the current WebKit pin (the type-confusion crash in #32178, addressed separately by #32184 / oven-sh/WebKit#252). Propagating context across a dynamically-imported TLA module's awaits can build on that engine-side work in a follow-up.

Verification

Added to test/js/node/async_hooks/AsyncLocalStorage.test.ts:

  • the imported module's top-level evaluation (and a nested run() inside it) observe the active store; an import with no active context evaluates with an undefined store (no leak into unrelated imports);
  • a dynamically imported top-level-await module sees the store during its synchronous prefix and exits cleanly.

Both fail on the released build (eval:undefined) and pass with this change. test/js/node/async_hooks/ and the dynamic-import/TLA suites in test/js/bun/resolve/ pass.

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.
@robobun

robobun commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:07 AM PT - Jun 25th, 2026

@robobun, your commit 33c7b00 has 2 failures in Build #64634 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32695

That installs a local version of the PR into your bun-32695 executable, so you can run:

bun-32695 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Segfault: AsyncLocalStorage.enterWith() followed by dynamic import() crashes the process #32694 - This PR threads AsyncLocalStorage context through the dynamic import module evaluation path, which should fix the segfault triggered by enterWith() followed by import() since both stem from the same async-context-around-module-evaluation handling

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #32694

🤖 Generated with Claude Code

@robobun

robobun commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks, but this PR does not fix #32694, and I confirmed it: the repro still crashes with this change applied.

The crash is a type confusion in WebKit's async-module resume, not in the dynamic-import evaluation path this PR touches. Backtrace on a debug build of this branch:

reportZappedCellAndCrash                               JSCell.cpp:371
WTF::uncheckedDowncast<JSC::JSModuleRecord>            JSCast.h:361
runInternalMicrotask(task = AsyncModuleExecutionResume) JSMicrotask.cpp:2296
runMicrotask                                           MicrotaskQueue.cpp:60

In enterWith("X"); await import("./target.mjs"), the crashing module is the entry (minimal.mjs), not the imported target.mjs. await import(...) is a top-level await, so the entry yields and resumes through AsyncModuleExecutionResume. With enterWith's context active at the await, resolveWithInternalMicrotaskForAsyncAwait wraps the microtask context in an InternalFieldTuple, and the AsyncModuleExecutionResume dispatch casts it straight to JSModuleRecord without unwrapping. target.mjs is trivial and never reaches that path.

This PR only reinstates the context around a dynamically-imported module's own evaluation (moduleLoaderEvaluate); it does not touch the entry module or the async-module resume path, so the crash is unchanged.

#32694 is the same root cause as #32178 (the same AsyncModuleExecutionResume downcast), which is addressed by #32184 / oven-sh/WebKit#252. Leaving Fixes #32694 off this PR.

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d07d4890-08d9-443a-8f9d-2b4174844104

📥 Commits

Reviewing files that changed from the base of the PR and between 2a5f1c1 and 58440fe.

📒 Files selected for processing (2)
  • src/jsc/bindings/ModuleLoader.cpp
  • src/jsc/bindings/ZigGlobalObject.cpp

Walkthrough

This PR captures AsyncLocalStorage context at dynamic import() call sites, restores it during module evaluation, clears pending entries on completion or failure, and adds subprocess tests for preserved context, top-level await, and error handling.

Changes

Dynamic import async-context handling

Layer / File(s) Summary
Pending context storage and capture
src/jsc/bindings/ZigGlobalObject.h, src/jsc/bindings/ZigGlobalObject.cpp, src/jsc/bindings/ModuleLoader.cpp
Adds a GC-tracked JSMap on GlobalObject for pending dynamic-import contexts, records captured AsyncLocalStorage state around JSC::importModule, and clears pending entries on module completion or rejection.
Evaluate with captured context
src/jsc/bindings/ZigGlobalObject.cpp
Introduces evaluateModuleWithCapturedAsyncContext and routes both module-evaluation entry points through it to restore the captured store during evaluateNonVirtual.
AsyncLocalStorage import tests
test/js/node/async_hooks/AsyncLocalStorage.test.ts
Expands async-hooks tests with subprocess ESM cases covering preserved context, nested run() scopes, top-level-await evaluation, and cleanup after import failures.

Possibly related issues

Possibly related PRs

  • oven-sh/bun#31169: It also changes ZigGlobalObject::moduleLoaderImportModule, so it is code-adjacent in the same import-resolution path.

Suggested reviewers

  • Jarred-Sumner
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main AsyncLocalStorage dynamic import evaluation fix.
Description check ✅ Passed The description is detailed and covers the fix and verification, though it uses different headings than the template.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/bindings/ZigGlobalObject.cpp (1)

3614-3626: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Clear pending context entries when import rejects before evaluation.

Cleanup currently relies on either a non-pending result or moduleLoaderEvaluate, but pending imports can reject during fetch/parse/dependency loading and never evaluate, leaving the captured store retained and available for a later same-key import. Also clean up on the synchronous exception path at Lines 3616-3617 and attach cleanup to the returned promise’s rejection/settlement path for pending imports that never reach evaluation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/ZigGlobalObject.cpp` around lines 3614 - 3626, The pending
async context entry is only being cleared for cached/non-pending imports, so
rejected imports can still leave stale entries behind. Update the import flow in
ZigGlobalObject::importModule around the JSC::importModule call to also remove
the asyncContextKey on the synchronous exception path and ensure pending results
clean up when the returned promise rejects or settles before reaching
moduleLoaderEvaluate. Keep the cleanup tied to
m_pendingDynamicImportAsyncContexts so same-key imports cannot reuse a retained
store after a failed fetch/parse/dependency load.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/jsc/bindings/ZigGlobalObject.cpp`:
- Around line 3600-3607: The pending dynamic import tracking in
ZigGlobalObject::moduleLoaderImportWithDynamicImportContext should preserve the
first context for a module key instead of overwriting it with later concurrent
imports. Introduce a sentinel entry for “no context” so the initial import is
recorded even when asyncContext is undefined, and change the
m_pendingDynamicImportAsyncContexts update logic to only insert a key if it is
not already present. Keep the fix localized around asyncContextKey creation and
the map->set/get flow so the first import’s call-site state is what drives
evaluation.
- Around line 3591-3610: The virtual-module import path in
ZigGlobalObject::importModule bypasses the new AsyncLocalStorage capture block
because resolveVirtualModule(...) returns early from JSC::importModule(...).
Refactor that branch to reuse the shared async-context capture flow by setting
resolvedIdentifier and falling through to the existing import logic, or extract
the capture/cleanup into a helper and call it from both paths so plugin/virtual
modules preserve the caller’s context.

---

Outside diff comments:
In `@src/jsc/bindings/ZigGlobalObject.cpp`:
- Around line 3614-3626: The pending async context entry is only being cleared
for cached/non-pending imports, so rejected imports can still leave stale
entries behind. Update the import flow in ZigGlobalObject::importModule around
the JSC::importModule call to also remove the asyncContextKey on the synchronous
exception path and ensure pending results clean up when the returned promise
rejects or settles before reaching moduleLoaderEvaluate. Keep the cleanup tied
to m_pendingDynamicImportAsyncContexts so same-key imports cannot reuse a
retained store after a failed fetch/parse/dependency load.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9fb2418f-e585-4774-a4ac-05734e16986e

📥 Commits

Reviewing files that changed from the base of the PR and between d451445 and d783beb.

📒 Files selected for processing (3)
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/ZigGlobalObject.h
  • test/js/node/async_hooks/AsyncLocalStorage.test.ts

Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
Comment thread src/jsc/bindings/ZigGlobalObject.cpp
…mport 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/jsc/bindings/ZigGlobalObject.cpp`:
- Around line 3638-3644: The cleanup in ZigGlobalObject::moduleLoaderEvaluate
only drops the async context for the current fetch-failure key, so the root
dynamic import context can remain pinned on transitive failures. Update the
dynamic import rejection/failure path for resolvedIdentifier to also call
dropDynamicImportAsyncContext for the root asyncContextKey, not just the
Bun__onFulfillAsyncModule fetch-failure seam. Keep the existing pending-status
guard logic, but ensure the root import’s captured store is released when its
promise rejects before evaluation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 554d0465-a5f4-4203-9580-5abc0f6fe421

📥 Commits

Reviewing files that changed from the base of the PR and between d783beb and 69a3175.

📒 Files selected for processing (3)
  • src/jsc/bindings/ModuleLoader.cpp
  • src/jsc/bindings/ZigGlobalObject.cpp
  • test/js/node/async_hooks/AsyncLocalStorage.test.ts

Comment thread src/jsc/bindings/ZigGlobalObject.cpp
… 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().

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/bindings/ZigGlobalObject.cpp (1)

3486-3494: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Trim new comments to the 3-line limit.

These added comments exceed the repository’s 3-line max. Please compress them or move extended rationale to the PR description.

As per coding guidelines, "Keep code comments to 3 lines max."

Also applies to: 3644-3648, 3758-3764

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/ZigGlobalObject.cpp` around lines 3486 - 3494, The newly
added explanatory comments in ZigGlobalObject.cpp exceed the repository’s 3-line
comment limit and need to be compressed. Shorten the comment blocks around the
dynamic import async-context logic in the relevant areas, including the sections
associated with evaluateModuleWithCapturedAsyncContext and the other noted
comment blocks, while preserving only the essential summary and moving extra
rationale to the PR description.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/jsc/bindings/ZigGlobalObject.cpp`:
- Around line 3486-3494: The newly added explanatory comments in
ZigGlobalObject.cpp exceed the repository’s 3-line comment limit and need to be
compressed. Shorten the comment blocks around the dynamic import async-context
logic in the relevant areas, including the sections associated with
evaluateModuleWithCapturedAsyncContext and the other noted comment blocks, while
preserving only the essential summary and moving extra rationale to the PR
description.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 218b3cec-df32-470e-af3c-2b061d823fc5

📥 Commits

Reviewing files that changed from the base of the PR and between 69a3175 and 2a5f1c1.

📒 Files selected for processing (1)
  • src/jsc/bindings/ZigGlobalObject.cpp

Comment thread src/jsc/bindings/ZigGlobalObject.cpp
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).
Comment thread src/jsc/bindings/ModuleLoader.cpp
@robobun

robobun commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator Author

Update after build 64634 finished: the diff is green; every CI red is on a test this PR does not touch (it only changes the dynamic-import hooks in ZigGlobalObject.cpp/ModuleLoader.cpp and test/js/node/async_hooks/AsyncLocalStorage.test.ts).

  • The x64-asan lane (which caught the real bug here, an unchecked exception after the pending-context JSMap ops, fixed in 58440fe) builds and tests clean, and the new AsyncLocalStorage cases pass on every lane.
  • Streams leak heuristics (flaky): :alpine: x64 streams-leak.test.ts in build 64628 (passed on the re-run), and :darwin: 14 aarch64 "should not leak using readable stream" in 64634.
  • NAPI (consistent, not flake): napi_is_arraybuffer > distinguishes ArrayBuffer from SharedArrayBuffer (and napi_wrap > has the right lifetime) fail on :windows: 2019 x64, :windows: 2019 x64-baseline, and :windows: 11 aarch64. Failing identically across all Windows lanes points to a pre-existing Windows issue in the napi_is_arraybuffer area (napi: napi_is_arraybuffer returns false for SharedArrayBuffer #32629), not this change, and a re-run will not clear it.

So the NAPI reds are systematic and unrelated rather than flaky; the diff itself is green. Could a maintainer confirm the Windows NAPI failures are pre-existing on main and merge, or advise if anything here is actually expected from this change? I have used my one re-roll.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Terrible implementation. If we do want this, it should be done in WebKit.

@robobun

robobun commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator Author

Understood, thanks. Agreed the engine is the right layer. The clean place is the dynamic-import pipeline itself: capture the active context at the import() call site and restore it around the module's evaluation (ContinueDynamicImport -> module->evaluate(), plus the AsyncModuleExecutionResume microtask for TLA), mirroring how the promise-reaction path already snapshots/restores m_asyncContextData, rather than a side map bolted onto the loader hooks. I went the src-side route only because WebKit is a pinned prebuilt here, but that doesn't justify this shape.

Closing this. The proper fix belongs in oven-sh/WebKit; #32693 stays open to track it.

@robobun robobun closed this Jun 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AsyncLocalStorage store is lost during dynamic import() module evaluation

2 participants