Skip to content

Fix dynamic import() resolving before TLA module finishes evaluating#29224

Open
robobun wants to merge 11 commits intomainfrom
farm/12e2a671/fix-dynamic-import-tla
Open

Fix dynamic import() resolving before TLA module finishes evaluating#29224
robobun wants to merge 11 commits intomainfrom
farm/12e2a671/fix-dynamic-import-tla

Conversation

@robobun
Copy link
Copy Markdown
Collaborator

@robobun robobun commented Apr 12, 2026

Repro

// temp.mjs
import("./temp2.mjs").then(() => console.log(3));
import("./temp2.mjs").then(() => console.log(4));

// temp2.mjs
console.log(1);
await new Promise((r) => setTimeout(r, 200));
console.log(2);

Node/Deno print 1 2 3 4. Bun printed 1 4 2 3 — both .then() handlers fired before temp2.mjs's top-level await settled, and the second handler ran before the first. Users of #20489 and #22367 see the same root cause surface as Cannot access 'x' before initialization when a concurrent import() reads a named export before the TLA completes.

Cause

The stock WebKit ModuleLoader.js builtin's requestImportModule:

if (entry.evaluated && (mod = entry.module)) {
    return this.getModuleNamespaceObject(mod);   // fast path — fires early
}
...
await this.linkAndEvaluateModule(entry.key, fetcher);

For an async (TLA) module, moduleEvaluation sets entry.evaluated = true synchronously at the start of asyncModuleEvaluation and then awaits the TLA. A second dynamic import() of the same module in the same tick hits that fast path, sees evaluated === true, and returns the namespace without awaiting the in-flight evaluation promise.

The stock builtin never caches the evaluation promise anywhere on the entry, so there's nothing for a concurrent caller to await — this diverges from the spec's [[TopLevelCapability]] behaviour.

Fix

Since Bun ships prebuilt WebKit, patching the vendored ModuleLoader.js isn't enough — any release using the cached tarball would miss the fix. Instead, install an override requestImportModule builtin on the JSModuleLoader at Zig::GlobalObject init time.

JSC's C++ JSModuleLoader::requestImportModule looks the function up dynamically by its private name on the loader object every call, so moduleLoader->putDirectBuiltinFunction(..., requestImportModulePublicName(), ...) cleanly replaces both C++-initiated and JS-initiated dynamic imports.

The override caches the evaluation promise on entry.evaluatingPromise when it first kicks off linkAndEvaluateModule, and on every fast path checks for and awaits that promise before handing back the namespace. Once evaluation settles the field is cleared. Uses $isPromise (the tamper-proof intrinsic) rather than a duck-typed .then check.

Verification

test/regression/issue/29221.test.ts has two cases:

  1. .then() ordering — two concurrent import() calls for a TLA module. Pre-fix: ["tla-start","then-b","then-a","tla-end"]. Post-fix: ["tla-start","tla-end","then-a","then-b"], matching Node and Deno.
  2. Named export TDZ — five concurrent import() calls that read mod.arr.length and typeof mod.fn. Pre-fix: imports 2..5 fail with Cannot access 'arr' before initialization (reproducing Concurrent dynamic import with top level await trigger temporal dead zone #20489 / Dynamic Import fails when multiple Vite plugins import same file #22367). Post-fix: all five see fully initialized bindings.
USE_SYSTEM_BUN=1 bun test test/regression/issue/29221.test.ts   → 2 fail
bun bd test       test/regression/issue/29221.test.ts           → 2 pass

Fixes #29221
Fixes #20489
Fixes #22367

When a module with top-level await is dynamically import()-ed a second
time while the first import's evaluation is still pending, Bun resolved
the second promise before the module finished evaluating, causing .then
handlers to run in the wrong order.

The stock WebKit `requestImportModule` builtin sets `entry.evaluated`
synchronously at the start of `asyncModuleEvaluation`, then has a fast
path that returns the namespace as soon as `entry.evaluated && entry.module`.
A second import reaching that fast path skipped awaiting the in-flight
TLA and fulfilled its promise early.

Fix: install an override `requestImportModule` builtin on the
JSModuleLoader at global-object init time. The override caches the
evaluation promise on the registry entry and awaits it on concurrent
imports, matching the ECMA-262 ContinueDynamicImport semantics and
Node/Deno behaviour.

Closes #29221
@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 12, 2026

Updated 1:05 PM PT - Apr 12th, 2026

@robobun, your commit 7f50881 has some failures in Build #45387 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 29224

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

bun-29224 --bun

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 12, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a builtin override for JSModuleLoader.requestImportModule to serialize dynamic-import resolution with in-flight top-level-await evaluation, and adds end-to-end regression tests exercising concurrent dynamic imports and TLA synchronization.

Changes

Cohort / File(s) Summary
C++ binding
src/bun.js/bindings/ZigGlobalObject.cpp
Registers the requestImportModule builtin override on the global object's module loader at startup as a non-enumerable builtin.
Module loader override implementation
src/js/builtins/ModuleLoaderOverrides.ts
Adds a private builtin requestImportModule(this, moduleName, referrer, parameters, fetcher): Promise<any> that checks entry.evaluated, awaits entry.evaluatingPromise when present, uses requestSatisfy if entry isn’t ready, calls linkAndEvaluateModule, caches/clears entry.evaluatingPromise to synchronize concurrent callers, and returns the module namespace object.
Regression tests
test/regression/issue/29221.test.ts
Adds two end-to-end tests that spawn Bun child processes: one validating .then() ordering for repeated dynamic imports of a TLA module, and one validating concurrent dynamic imports all observe initialized exports; captures stdout/stderr and asserts exact outcomes.
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title directly and clearly summarizes the main change: fixing dynamic import() to wait for top-level await (TLA) module evaluation to complete before resolving.
Description check ✅ Passed The description covers both required sections: 'What does this PR do?' (detailed explanation of the issue, cause, and fix) and 'How did you verify your code works?' (specific test cases with expected outcomes).

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


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

@github-actions
Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Concurrent dynamic import with top level await trigger temporal dead zone #20489 - Concurrent dynamic imports of a TLA module cause "Cannot access before initialization" errors because all but the first import resolve before TLA evaluation completes
  2. Dynamic Import fails when multiple Vite plugins import same file #22367 - Multiple Vite plugins concurrently importing the same file get "Cannot access 'default' before initialization" due to the same premature resolution race condition

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

Fixes #20489
Fixes #22367

🤖 Generated with Claude Code

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

This PR touches JSC's module loading critical path (async TLA evaluation) with non-trivial concurrency logic — deferring to a human reviewer.

Extended reasoning...

Overview

The PR installs a custom requestImportModule builtin on JSModuleLoader via ZigGlobalObject::addBuiltinGlobals to fix a spec-compliance bug where concurrent dynamic import() calls for the same TLA module could resolve before evaluation completed. It introduces ModuleLoaderOverrides.ts (new builtin), modifies ZigGlobalObject.cpp, and adds a regression test.

Security Risks

No direct security risks. The change is limited to module evaluation ordering semantics — it cannot expose private data, bypass auth, or allow injection. The entry.evaluatingPromise field is a locally-scoped cache on the registry entry.

Level of Scrutiny

High. This is a subtle concurrency fix in the module loading hot path. Correctness depends on precise knowledge of JSC's async module evaluation lifecycle (when entry.evaluated and entry.module are set, how asyncModuleEvaluation interacts with the promise cache, etc.). The logic is well-commented and the test reproduces the exact regression, but the interaction with the C++ JSModuleLoader::requestImportModule dispatch mechanism deserves a review by someone familiar with JSC internals.

Other Factors

A nit-level dead-code block (lines 61–65 of ModuleLoaderOverrides.ts) was flagged by the bug hunter — the third evaluatingPromise check is unreachable because Fast Path 2 already covers that scenario. This has no runtime impact but warrants cleanup. The overall fix direction looks correct, and a regression test is included. The PR should be reviewed by someone with JSC/Bun module loader expertise before merge.

robobun and others added 2 commits April 12, 2026 13:45
- ModuleLoaderOverrides: swap duck-typed `.then` check for $isPromise
  so `delete Promise.prototype.then` can't defeat the fix, matching the
  pattern in CommonJS.ts loadEsmIntoCjs
- ModuleLoaderOverrides: drop the unreachable mid-path `evaluatingPromise`
  check — Fast Path 2 already handles that scenario since
  `entry.evaluated`/`entry.module` are set synchronously inside
  `linkAndEvaluateModule`
- test: use `cwd` + relative path instead of concatenated `/entry.mjs`,
  split combined toEqual into separate stdout-first + exitCode assertions
- test: add second test case covering the TDZ scenario from #20489 and
  #22367 (concurrent dynamic imports seeing uninitialized bindings)
robobun and others added 2 commits April 12, 2026 14:17
- Correct 'private-name' → 'public-name' in the comments describing how
  JSC's C++ `JSModuleLoader::requestImportModule` looks up the builtin.
  The code already uses `requestImportModulePublicName()`; the old wording
  was misleading.
- Assert test 1's stderr is empty (filtering the ASAN warning WebKit
  unconditionally prints from Options.cpp in ASAN builds) instead of
  silently discarding it. Use the same canonical helper for test 2.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/js/builtins/ModuleLoaderOverrides.ts`:
- Line 51: The code in ModuleLoaderOverrides constructs a public Set for
internal tracking (new Set()) when calling requestSatisfy; replace that with the
intrinsic private global $Set to avoid user-tampering in builtin paths. Update
the call site in requestSatisfy invocation (entry = await
this.requestSatisfy(entry, parameters, fetcher, new Set());) to pass a new
$Set() instead, ensuring you reference the built-in private global $Set (not a
local variable) so internal tracking uses the private intrinsic.

In `@test/regression/issue/29221.test.ts`:
- Around line 21-30: Replace brittle assertions that a spawned subprocess
produced empty stderr (e.g. checks comparing cleanStderr(stderr) === "" or
stderr === "") with assertions on the subprocess exitCode to detect
crashes/failures; keep the cleanStderr function and only assert on stderr
contents when the test is explicitly validating an error message. Update the
test cases that call cleanStderr (and any assertions near where cleanStderr is
used) to assert exitCode === 0 (or the expected non-zero code) instead of empty
stderr, and only use cleanStderr(...) comparisons when validating specific error
text.
🪄 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: 1a9b6ee1-1abe-4a93-b1ef-09f8a8619775

📥 Commits

Reviewing files that changed from the base of the PR and between 5b90abf and f308312.

📒 Files selected for processing (3)
  • src/bun.js/bindings/ZigGlobalObject.cpp
  • src/js/builtins/ModuleLoaderOverrides.ts
  • test/regression/issue/29221.test.ts

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
test/regression/issue/29221.test.ts (1)

65-66: ⚠️ Potential issue | 🟡 Minor

Remove brittle empty-stderr success assertions.

At Line 65 and Line 108, asserting cleanStderr(stderr) === "" is still flaky for spawned subprocess tests. Keep exitCode as the success signal, and only assert stderr when validating specific expected error output.

Proposed change
-  expect(cleanStderr(stderr)).toBe("");
   expect(exitCode).toBe(0);
@@
-  expect(cleanStderr(stderr)).toBe("");
   expect(exitCode).toBe(0);

Based on learnings: in test/regression/issue/*.test.ts, avoid empty-stderr assertions for spawned Bun subprocesses (ASAN/CI can emit benign stderr); use exitCode as the primary crash/failure signal.

Also applies to: 108-109

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/regression/issue/29221.test.ts` around lines 65 - 66, Remove the brittle
assertions that require cleanStderr(stderr) to be exactly "" in the test
(instances calling expect(cleanStderr(stderr)).toBe("") in
test/regression/issue/29221.test.ts), and rely on expect(exitCode).toBe(0) as
the primary success signal; leave or add stderr assertions only where a specific
error message is being validated (i.e., replace or delete the
expect(cleanStderr(stderr)).toBe("") lines around the exits at the two locations
and keep the existing expect(exitCode).toBe(0) checks).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@test/regression/issue/29221.test.ts`:
- Around line 65-66: Remove the brittle assertions that require
cleanStderr(stderr) to be exactly "" in the test (instances calling
expect(cleanStderr(stderr)).toBe("") in test/regression/issue/29221.test.ts),
and rely on expect(exitCode).toBe(0) as the primary success signal; leave or add
stderr assertions only where a specific error message is being validated (i.e.,
replace or delete the expect(cleanStderr(stderr)).toBe("") lines around the
exits at the two locations and keep the existing expect(exitCode).toBe(0)
checks).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ca284a1e-e3d1-4fb0-a47d-d7bb216e08ce

📥 Commits

Reviewing files that changed from the base of the PR and between f308312 and ef7faad.

📒 Files selected for processing (1)
  • test/regression/issue/29221.test.ts

robobun and others added 2 commits April 12, 2026 14:49
…t tests

- ModuleLoaderOverrides: replace `new Set()` with `new $Set()` so the
  visited-set used by `requestSatisfy` is tamper-proof to user-modified
  `globalThis.Set`.
- test: drop the brittle `cleanStderr(stderr) === ""` assertions and the
  helper entirely. Rely on `exitCode === 0` as the crash/failure signal;
  test 2 still asserts the specific TDZ error string (#20489/#22367's
  symptom) doesn't appear on stderr.
- test: switch both cases to `test.concurrent` — they spawn independent
  subprocesses against separate tempDirs with no shared state, so there's
  no reason to serialize them.
The override was installed with only `Builtin | DontEnum`, which meant a
bug in a Bun builtin (the module loader is reachable from any builtin via
the `$Loader` private name) could silently overwrite or delete the
property and revert to the stock WebKit `requestImportModule`,
reintroducing the TLA race. Match the defensive pattern used for other
safety-critical builtins in `addBuiltinGlobals` by adding `DontDelete`
and `ReadOnly`.

JSC's C++ `JSModuleLoader::requestImportModule` uses `get()` to look up
the function every call, which works unchanged on ReadOnly properties.
Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

All previously flagged issues have been addressed — for tamper-proofing, $Set for builtin safety, DontDelete+ReadOnly lock-down, dead-code removal, accurate public-name comments, test.concurrent, and the cwd pattern. LGTM.

Extended reasoning...

Overview

This PR adds a requestImportModule override on the JSModuleLoader to fix a TLA (top-level await) race condition. The override caches the evaluation promise on entry.evaluatingPromise so concurrent dynamic import() calls await it rather than taking an early-return fast path. Three files are changed: the C++ install site (ZigGlobalObject.cpp), the new builtin (ModuleLoaderOverrides.ts), and regression tests (29221.test.ts).

Security risks

No direct security risks. The module loader override is installed with DontDelete | ReadOnly so internal builtins cannot accidentally overwrite it. The tamper-proof $isPromise intrinsic is used instead of duck-typing, preventing delete Promise.prototype.then from bypassing the fix.

Level of scrutiny

This touches a core module-loading code path; high scrutiny is warranted. Multiple rounds of review have gone through the code, and all flagged issues (🔴 $isPromise, 🟡 $Set, 🟡 DontDelete+ReadOnly, 🟡 dead code, 🟡 comment accuracy, 🟡 test conventions) have been addressed in subsequent commits.

Other factors

The fix is spec-aligned (mirrors [[TopLevelCapability]] semantics). Test coverage is solid with two end-to-end subprocess tests exercising both the ordering and TDZ invariants. The remaining nit (Test 1 silently discarding stderr) does not affect correctness or CI signal.

robobun added 2 commits April 12, 2026 16:50
No-op comment tweak in ModuleLoaderOverrides.ts — `finishCreation`
was imprecise; the `putDirectBuiltinFunction` call lives in
`GlobalObject::addBuiltinGlobals`.
Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

LGTM — all prior feedback addressed.

Extended reasoning...

Overview

This PR installs a requestImportModule builtin override on the JSModuleLoader at GlobalObject init time to fix a TLA race condition: the stock WebKit builtin sets entry.evaluated = true synchronously at the start of async evaluation, so a concurrent import()\ in the same tick took a fast-path that returned the namespace before TLA settled. The fix caches the in-flight evaluation promise on entry.evaluatingPromise` and awaits it before handing the namespace to any caller.

Security risks

No security risks. The override is installed before any user code runs and is locked down with DontDelete | ReadOnly so it cannot be removed by builtins or user code. $isPromise (tamper-proof intrinsic) is used to detect the evaluation promise.

Level of scrutiny

Moderate. Module loading is a core runtime path, but the change is well-scoped — a single new builtin file plus a one-line registration in addBuiltinGlobals. The two-test regression suite directly reproduces both failure modes (#29221 ordering bug and #20489/#22367 TDZ bug). All feedback from prior rounds has been incorporated.

Other factors

  • Dead branch removed (commit 8106ce2)
  • typeof .then duck-type replaced with $isPromise intrinsic
  • new Set() replaced with new $Set()
  • DontDelete | ReadOnly attributes added to the property installation
  • Comments corrected from private-name to public-name
  • Tests use test.concurrent, cwd spawn pattern, and separate stdout/exitCode assertions
  • No bugs found by the automated bug hunting system

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 12, 2026

Build #45387: darwin-{13,14}-aarch64-test-bun both expired (no runner available); the same two jobs expired on #29233, #29231, #29229, #29228 — fleet-wide macOS aarch64 runner starvation. The darwin x64 test-bun jobs (macOS 13 + 14) passed in 19–25 min, every build job on every platform passed, and every other test-bun job (linux, windows, alpine, debian, ubuntu) passed. Code is fine; just waiting on macOS aarch64 runner capacity.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

1 participant