Skip to content

require(esm): do not run an import() started by the required module inside the require() - #41997

Closed
robobun wants to merge 3 commits into
mainfrom
robobun/c975a760/require-esm-dynamic-import
Closed

robobun wants to merge 3 commits into
mainfrom
robobun/c975a760/require-esm-dynamic-import

Conversation

@robobun

@robobun robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • An import() called from a module that a synchronous require() is loading ran inside that require(): wrong order (a y x after-require), an entry linked against half-filled CommonJS exports (Export named 'v' not found), an import() that never settles.
  • Cause: GlobalObject::moduleLoaderImportModule (src/jsc/bindings/ZigGlobalObject.cpp) runs while vm.m_synchronousModuleQueue is set, so the new graph's loader reactions join the queue the require() drains.

Fix

  • moduleLoaderImportModule installs a DynamicImportQueueScope. While a synchronous load is active it pushes its own queue frame for the request, then moves what landed there to the microtask queue.
  • The fetch hook does not change. The import() target is still fetched synchronously, as on main.
  • Verified: test/js/bun/resolve/require-esm-microtask-order.test.ts (29 cases, 22 fail on main).

Background

  • While VM::m_synchronousModuleQueue (JSC fork) points at a stack frame, JSPromise appends module-loader microtasks to it and drainSynchronousModuleQueue runs them. The test is the task type, not the owning load.
  • Considered a boundary in the fork (JSModuleLoader::loadModule for dynamic loads): no re-queue, covers graph.import() and compiled code, needs a WebKit release.

Downsides

  • It cannot land alone: --aot executables keep the old order, so plain and compiled runs differ and test/aot fails. The fork's AOT runtime must change with it.
  • Two losses against main: a later require() can run the deferred importer inside it, so require("./m3.mjs").v is undefined, and a side-effect-only .cjs dependency runs twice (Decide a file's module type with one rule on every runtime load path #40940).
  • Cost without the bug: about 14 instructions per import(), no allocation, +994 bytes .text (release x64, --lto=off).
Notes

The three symptoms. Each one is the import()'s graph loaded, linked and evaluated by the drain loop of the require().

  • Order: entry.cjs requires a.mjs, which calls import("./x.mjs"), and x.mjs imports y.cjs. main prints before-require a y x after-require z end x-resolved. Node and this branch print before-require a after-require z end y x x-resolved. A throwing or top-level-await target also ran its body inside the require().
  • Half-filled exports: an ES entry imports c.cjs. c.cjs calls require("./e.mjs") and then sets exports.v. e.mjs calls import("./c.cjs") or import("./m0.mjs"). The drain built c.cjs's module record while its body was still running, then linked and evaluated the entry against it. A default import read undefined. A named import failed with Export named 'v' not found. With a CommonJS entry the namespace stayed without v for good. With createRequire in an ES entry the importer read a binding in its temporal dead zone.
  • Never settles: e.mjs requires d.mjs, and d.mjs calls import("./e.mjs"). The drain entered Evaluate() again for a module that was still evaluating. That promise never settled, and no later import() of the module did either.

Why it cannot land alone: --aot.

  • test/aot runs each program plain and compiled with bun build --compile --aot and requires the same output. With this change the plain run has Node's order and the compiled run keeps the old one. In CI, 9 test/aot files of the drawn sample fail, for example 29 of 81 seeds of generated-module-graphs-of-both-kinds.test.ts.
  • Compiled code does not pass moduleLoaderImportModule. The AOT runtime in the fork (Source/JavaScriptCore/aot/runtime/AOTModules.cpp) calls JSModuleLoader::requestImportModuleForKey itself and reproduces what the loader does under a synchronous load. The corpus states the old order as the expected one, for example test/aot/programs/modules/a-module-that-an-import-loads-inside-a-require-reads-variables-of-the-module-that-requires/main.mjs.
  • So the order must change in the fork, for the loader and for the AOT runtime in one commit. A boundary in JSModuleLoader::loadModule for loads with ModuleLoadFlag::Dynamic covers import(), graph.import(), native imports and the compiled imports that go through the loader.

The two losses against main. On main an import() under require() ran to its end before the require() returned. With this change it is an asynchronous load like any other, so it meets two older bugs that main has for import() outside require().

  • Second door. A load waits on a module that is not made yet. A require() of that module from other code makes it synchronously, which triggers the waiting load's reactions inside the drain, so the waiting importer is evaluated inside that require(). Repro: main.cjs requires m1.mjs. m1.mjs calls import("./m2.mjs") and then import("./k.cjs").then(() => require("./m3.mjs")). m2.mjs imports m3.mjs and also calls require("./m3.mjs"). main prints require gives v3, this branch prints require gives undefined. What closes it: the diversion in the fork's JSPromise.cpp must know which load a reaction belongs to, and give reactions of other loads to the microtask queue. No scope in Bun can reach it.
  • Module type. The thread-pool transpiler does not apply the extension rule, so it loads a .cjs file that names neither module, exports nor require as an ES module. If the file was also require()d, it runs twice. The import() target itself is safe here because its fetch stays synchronous. A static dependency of the target is not: main.cjs requires x.cjs and a.mjs, a.mjs calls import("./m.mjs"), m.mjs imports x.cjs: main runs x.cjs once, this branch twice. Decide a file's module type with one rule on every runtime load path #40940 closes it and should land first.

Not covered. Bun.ModuleGraph: graph.import() calls JSModuleLoader::loadModule directly and does not pass this hook. A required ES module that calls it still gets the graph's module evaluated inside the require() (before-require app app-end plugin after-require). JSModuleLoader__import and the bake hook do not pass it either.

How it was measured.

  • ZigGlobalObject.cpp.o built with --profile=release --lto=off at the merge base and at this branch: llvm-size text goes from 190,865 to 191,859 bytes. moduleLoaderImportModule goes from 2,174 to 2,847 bytes. moduleLoaderFetch does not change.
  • Instructions per import() when no synchronous load is active, read from that object's disassembly: 8 at entry (5 stores, one load, test, branch) and 6 at exit.

Other suites. On the previous head (same scope, with the fetch guard) I also ran the other require-esm-* files, import-defer, dynamic-import-tla-cycle, plugins and module-graph one at a time. They passed, except four Bun.ModuleGraph dynamicImport cases that exceed the 5 s timeout on the debug build here. The stock release build needs 5.6 s for the same program.

Details of the fix.

  • The frame stays linked through prev while installed, so VM::visitAggregate keeps marking the outer frames' queued values. Clearing vm.m_synchronousModuleQueue for the request would hide them from the GC.
  • The flush uses JSGlobalObject::queueMicrotask, the call drainSynchronousModuleQueue uses on its exception path. Tasks are queued before the frame is unlinked because queueMicrotask can allocate.
  • NodeVMGlobalObject::moduleLoaderImportModule is untouched. Its default-loader case delegates to this hook. ShadowRealm globals use this hook too.

[human-review] gate passed · iteration 0 · 3 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/resolve/require-esm-microtask-order.test.ts
ninja: Entering directory `/workspace/bun/build/debug'
[1/162] gen cpp.rs (cppbind)
[2/162] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 242 extern-C blocks audited
[2/162] cargo bun_runtime → libbun_runtime.a
FAILED: rust-target/x86_64-unknown-linux-gnu/debug/libbun_runtime.a 
/usr/local/bun/bin/bun /workspace/bun/scripts/build/stream.ts rust --console --cwd=/workspace/bun --env=CARGO_TERM_COLOR=always --env=BUN_CODEGEN_DIR=/workspace/bun/build/debug/codegen --env=CC=/usr/lib/llvm-21/bin/clang --env=CXX=/usr/lib/llvm-21/bin/clang++ --env=AR=/usr/lib/llvm-21/bin/llvm-ar --env=CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=/usr/lib/llvm-21/bin/clang++ --env=CARGO_HOME=/root/.cargo --env=RUSTUP_HOME=/root/.rustup --env=RUSTUP_TOOLCHAIN=nightly-2026-07-20 --env=CARGO_PROFILE_RELEASE_LTO=off --env=CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 --env=CARGO_PROFILE_RELEASE_DEBUG_ASSERTIONS=true --env=CARGO_ENCODED_RUSTFLAGS='-Creloc
... (truncated)

release without fix: 6 FAILED
bun test v1.4.2 (744846f84)

test/js/bun/resolve/require-esm-microtask-order.test.ts:
54 |       "y.cjs": `t.push("y"); module.exports = {};`,
55 |       "z.cjs": `t.push("z"); module.exports = {};`,
56 |     });
57 | 
58 |     // Node: before-require a after-require z end y x x-resolved
59 |     expect(await run(String(dir), "entry.cjs")).toEqual([
                                                     ^
error: expect(received).toEqual(expected)

  [
    "before-require",
    "a",
+   "y",
+   "x",
    "after-require",
    "z",
    "end",
-   "y",
-   "x",
    "x-resolved",
  ]

- Expected  - 2
+ Received  + 2

      at <anonymous> (/workspace/bun/test/js/bun/resolve/require-esm-microtask-order.test.ts:59:49)
90 |       });
91 | 
92 |       const t = await run(String(dir), "entry.cjs");
93 |       // Nothing from either import() target runs before require() returns. The
94 |       // two targets are fetched concurrently, so only assert each one's own order.
95 |       expect(t.slice(0, 3)).toEqual(["before-require", "a", "after-require"]);
                                 ^
error: expect(received).toEqual(expected)

  [
    "before-require",
    "a",
-   "after-requi
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/resolve/require-esm-microtask-order.test.ts
bun test v1.4.3 (f42e98025)

test/js/bun/resolve/require-esm-microtask-order.test.ts:
(pass) async transpiler > require(esm): import() of a throwing or top-level-await module does not run inside the require() [324.97ms]
(pass) sync transpiler > require(esm) does not evaluate an import() the required module starts [297.49ms]
(pass) async transpiler > require(esm): a module can import() and then require() the same file [328.57ms]
(pass) async transpiler > require(esm) does not evaluate an import() the required module starts [461.29ms]
(pass) async transpiler > require(esm) reached from an ESM entry does not evaluate an import() the required module starts [389.54ms]
(pass) sync transpiler > require(esm): a module can import() and then require() the same file [288.88ms]
(pass) require(esm) does not run user `await` continuations ahead of earlier microtasks [302.10ms]
(pass) sync transpiler > require(esm): import() of a throwing or top-level-await module does not run inside the require(
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     4c63e318ca
  features     baseline

23 deps, 131 codegen, 1172 objects in 804ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1248] mkdir stamps
[2/1248] mkdir codegen
[3/1248] mkdir obj
[4/1248] mkdir pch
[5/1248] gen ErrorCode+*.h
[6/1248] install /workspace/bun
bun install v1.4.2 (744846f84)

Checked 22 installs across 61 packages (no changes) [21.00ms]
[7/1248] install /workspace/bun/packages/bun-error
bun install v1.4.2 (744846f84)

Checked 1 install across 2 packages (no changes) [1.00ms]
[8/1248] gen bindgenv2
[9/1248] install /workspace/bun/src/node-fallbacks
bun install v1.4.2 (744846f84)

Checked 111 installs across 104 packages (no changes) [6.00ms]
[10/1248] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[11/1248] fetch tinycc
[tinycc] up to date
[12/1248] gen node-fallbacks/react-refresh.js
Bundled 1 module in 13ms

  react-refresh.js  4.81 KB  (entry point)

[13/1248] fetch zlib
[zlib] up to date
[14/1248] fetch picohttpparser
[picohttpparser] up to date
[15
... (truncated)
diff hotspot
src/jsc/bindings/BunClientData.h                   |   7 +
 src/jsc/bindings/ZigGlobalObject.cpp               |  64 +++++++-
 .../resolve/require-esm-microtask-order.test.ts    | 168 ++++++++++++++++++++-
 3 files changed, 234 insertions(+), 5 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                     reads  edits  tests
src/jsc/bindings/BunClientData.h                             2      2     11
src/jsc/bindings/ZigGlobalObject.cpp                         4      4     11
test/js/bun/resolve/require-esm-microtask-order.test.ts      2      5     11

@coderabbitai

coderabbitai Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 11c3671 and 16b6521.

Files selected for processing: 3
  • src/jsc/bindings/BunClientData.h
  • src/jsc/bindings/ZigGlobalObject.cpp
  • test/js/bun/resolve/require-esm-microtask-order.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 2 remain after this review.

Walkthrough

Dynamic imports started during require(esm) loading now use a nested queue and the asynchronous fetch path. New tests check evaluation and settlement order across both transpiler modes and several module-loading cases.

Changes

require(esm) dynamic imports

Layer / File(s) Summary
Dynamic import queue handling
src/jsc/bindings/BunClientData.h, src/jsc/bindings/ZigGlobalObject.cpp
JSVMClientData records the dynamic-import queue. moduleLoaderImportModule scopes a nested queue, and moduleLoaderFetch uses asynchronous fetching while that queue is active. The scope queues captured reactions as microtasks when it exits.
Dynamic import ordering tests
test/js/bun/resolve/require-esm-microtask-order.test.ts
Tests run with the default and sync transpilers. They check evaluation and settlement order for delayed, throwing, and top-level-await imports, shared modules, and imports involving modules that are still executing.

Suggested reviewers: jarred-sumner, dylan-conway

Priority: Normal

Merge Risk: Minimal · up to 16b65

No actionable issue is established for this change; it is ready for normal merge checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: preventing import() started during require(esm) from executing inside require().
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.
Description check Passed The pull request description is complete and relevant. It explains the problem, root cause, fix, scope, trade-offs, limitations, and verification results. Although it does not use the exact template h…
Full details: Description check

Explanation

The pull request description is complete and relevant. It explains the problem, root cause, fix, scope, trade-offs, limitations, and verification results. Although it does not use the exact template headings, it provides the required information under equivalent sections.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions github-actions Bot added the claude label Sep 8, 2026
@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on main 11c367163a, deterministic:

cd "$(mktemp -d)"; echo '{"name":"r"}' > package.json
cat > entry.cjs <<'E'
globalThis.t=[]; process.on("exit",()=>console.log(t.join(" ")));
t.push("before-require"); require("./a.mjs"); t.push("after-require"); require("./z.cjs"); t.push("end");
E
echo 'globalThis.t.push("a"); import("./x.mjs").then(()=>globalThis.t.push("x-resolved")); export const a=1;' > a.mjs
echo 'import "./y.cjs"; globalThis.t.push("x"); export const x=1;' > x.mjs
echo 'globalThis.t.push("y"); module.exports={};' > y.cjs
echo 'globalThis.t.push("z"); module.exports={};' > z.cjs
node entry.cjs    # before-require a after-require z end y x x-resolved
bun entry.cjs     # before-require a y x after-require z end x-resolved   (main)
                  # before-require a after-require z end y x x-resolved   (this branch)

The same cause has three more effects on main. This branch has Node's result for each:

  • An ES entry imports c.cjs, c.cjs requires e.mjs and then sets exports.v, and e.mjs calls import("./c.cjs"). main links the entry against the half-filled exports: m0 reads undefined, or Export named 'v' not found and exit 1.
  • lib.cjs requires e.mjs, e.mjs calls import("./m.mjs"), and m.mjs imports lib.cjs. main: at load: undefined, and the namespace never gets v. With an ES entry and createRequire: ReferenceError.
  • e.mjs requires d.mjs, and d.mjs calls import("./e.mjs"). main: the promise never settles, and no later import("./e.mjs") settles.

The regression test is test/js/bun/resolve/require-esm-microtask-order.test.ts: 29 cases over the thread-pool and the BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER=1 mode. 22 fail on main. All pass on this branch.

State: closed. This change cannot be the fix. Executables built with --aot keep the old order, and the scope can make require() of a graph with no top-level await throw. The closing comment has the repro. The fix belongs in the loader of the WebKit fork, with its AOT runtime, and needs a maintainer decision first.

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

I reviewed this PR and didn't find any bugs. Because it changes how vm.m_synchronousModuleQueue frames are pushed/popped across moduleLoaderImportModule — a JSC-fork mechanism with GC-visiting and drain-order invariants — a human familiar with that fork addition should confirm the frame-chain assumptions hold.

What was reviewed:

  • DynamicImportQueueScope RAII: constructor is a no-op when no sync load is active; destructor queues before unlinking so tasks stay reachable through prev during queueMicrotask allocation; both m_synchronousModuleQueue and dynamicImportModuleQueue are restored on every exit path.
  • Nesting: a require() inside the import() pushes a newer head, so the moduleLoaderFetch pointer-equality check still routes it to the sync path; m_outerDynamicImportQueue restores correctly for import() → require() → import().
  • Tests: describe.each over both transpiler modes, test.concurrent, Promise.all on stdout/stderr/exited, stderr asserted before exitCode, .toEqual on ordered arrays, Node's order cited per case.
Extended reasoning...

Overview

This PR fixes dynamic import() incorrectly evaluating synchronously when called from a module being loaded by require(esm). It adds a dynamicImportModuleQueue pointer to JSVMClientData (BunClientData.h), a stack-only DynamicImportQueueScope RAII class in ZigGlobalObject.cpp that pushes an isolation frame onto vm.m_synchronousModuleQueue for the duration of moduleLoaderImportModule, and a pointer-equality check in moduleLoaderFetch to skip the synchronous fetch fast path when serving the import() request. Eight new test cases (four scenarios × two transpiler modes) verify the ordering matches Node.

Security risks

None identified. This is a module-evaluation ordering fix; no user input parsing, auth, crypto, or network boundary is touched. The state added is a raw pointer on JSVMClientData that only ever points at a stack frame with strictly nested lifetime.

Level of scrutiny

High. The synchronous module queue is a BUN_JSC_ADDITIONS fork mechanism with two non-local invariants: (1) VM::visitAggregate must be able to walk the prev chain to mark every queued task's JSValues during any GC, and (2) JSPromise's divert sites and JSModuleLoader::drainSynchronousModuleQueue must always see a consistent head. The RAII scope preserves both — the frame stays linked while tasks are flushed, and restore happens on every path including early RETURN_IF_EXCEPTION returns from moduleLoaderImportModule — but these invariants live in the WebKit fork rather than this repo, so someone who owns that fork code should confirm no drain/visit path assumes the head frame is one it pushed itself. The PR description also explicitly names this as a Bun-side stopgap ahead of a fork change and documents a second door (shared registry entry force-settled by hostLoadImportedModule) that this PR does not close; that scoping decision is worth a human sign-off.

Other factors

The implementation follows repo conventions closely: per-VM state on JSVMClientData rather than ZigGlobalObject, WTF_MAKE_NONCOPYABLE + WTF_FORBID_HEAP_ALLOCATION for the scope, comments limited to the load-bearing GC-ordering note. Tests extend the existing require-esm-microtask-order.test.ts file, use tempDir/bunEnv/bunExe/test.concurrent, drain subprocess pipes with Promise.all, assert stderr and exitCode in the right order, and pin only the ordering facts that are stable across concurrent fetches. The bug hunter ran to a dry streak with no findings.

@robobun

robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

On the one open question above (does any drain or visit path assume the head frame is one it pushed itself): I checked every reader of vm.m_synchronousModuleQueue in the fork and in the bindings. None does.

  • JSPromise.cpp (the five divert sites): append to whatever frame is the head. They do not care who pushed it. That is what lets the isolation frame catch the import() request's reactions.
  • JSModuleLoader::drainSynchronousModuleQueue: reads the head once at entry (auto* queue = vm.m_synchronousModuleQueue; auto& tasks = queue->tasks;) and iterates that vector by index. Its callers (loadModuleSync, the four provideFetch sites in ModuleLoader.cpp) call it right after they pushed their own frame, so the head is theirs at entry. A DynamicImportQueueScope can only be constructed inside one of the tasks that loop runs (module evaluation calls import()), and it is destroyed before that task returns, so the head is back to the drain's own frame whenever the loop reads tasks again. The exception path re-queues from the same captured vector.
  • VM::visitAggregateImpl: walks head → prev and marks every frame, including the isolation frame and everything under it.
  • JSModuleLoader::hostLoadImportedModule (the Fetching branch) and moduleLoaderFetch: truthiness only; the latter now also compares against the isolation frame.

The Bun-side frames in ModuleLoader.cpp and functionEsmLoadSync follow the same push, drain, pop shape as loadModuleSync, so the same argument covers them.

robobun added a commit that referenced this pull request Sep 14, 2026
…raining them in place

The wait now hands what lands in the macro's frame to the microtask queue,
which is where the reactions go when no require() is on the stack. This
drops the re-entrancy flag and the exception handling that a direct call
to drainSynchronousModuleQueue needed, and it is the same idiom as the
import() scope in #41997.
robobun added a commit that referenced this pull request Sep 15, 2026
…it leaves open

The queue is no longer named for macros. NestedModuleQueueScope is a standalone
RAII type that MacroModeGuard enters, the innermost queue is recorded in
JSVMClientData::nestedModuleQueue where C++ callers can see it, and the replay
loop is Bun::requeueSynchronousModuleQueueAsMicrotasks in ModuleLoader.h, so
the import() isolation in #41997 can use the same pieces.

Tests: a macro that starts an import() and does not await it, and two todo
tests for the shapes that still spin (the macro's own file already in the
require()'s graph).
…nside the require()

A dynamic import() called while require(esm) was synchronously loading a
module graph joined that synchronous load: moduleLoaderFetch transpiled it
on the spot and the loader's reactions for it landed on the queue that the
require() was draining, so the imported module and its whole static graph
evaluated before require() returned.

moduleLoaderImportModule now pushes its own queue frame for the duration of
the request when one is active, keeps the fetch asynchronous, and hands the
reactions the request queued to the microtask queue on the way out.
…ll-running module

Adds the cases where the import() target is a module the entry is still
loading, a module that imports the requiring module, or the requiring
module itself. On main these read half-filled exports, hit the temporal
dead zone, or never settle.
@robobun
robobun force-pushed the robobun/c975a760/require-esm-dynamic-import branch from 4c63e31 to 16b6521 Compare October 28, 2026 14:55
Comment thread src/jsc/bindings/BunClientData.h Resolved
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Resolved
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Resolved
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Resolved

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/jsc/bindings/ZigGlobalObject.cpp Resolved
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Resolved
auto* globalObject = static_cast<Zig::GlobalObject*>(jsGlobalObject);

VM& vm = JSC::getVM(globalObject);
DynamicImportQueueScope dynamicImportQueueScope(globalObject, vm);

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.

🟣 pre-existing, not blocking: Users whose require()d ES module calls Bun.ModuleGraph's graph.import() still get that graph's module evaluated inside the require(), the ordering bug this change fixes for import(). The isolation frame is pushed only in moduleLoaderImportModule at src/jsc/bindings/ZigGlobalObject.cpp:4097. JSModuleGraph::import (src/jsc/bindings/ModuleGraph.cpp:652) calls loader->loadModule directly, so moduleLoaderFetch at ZigGlobalObject.cpp:4242 sees the require's frame as head and fetches synchronously. Fix: push the frame for every dynamic-load entry point, e.g. move DynamicImportQueueScope to a header and instantiate it in JSModuleGraph::import as well.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Trigger: a CJS entry does require("./app.mjs") and app.mjs calls graph.import("./plugin.ts") on a Bun.ModuleGraph at top level, so vm.m_synchronousModuleQueue is the require()'s frame while jsModuleGraphPrototypeFunction_import runs (src/jsc/bindings/ModuleGraph.cpp:709-716). JSModuleGraph::import at src/jsc/bindings/ModuleGraph.cpp:652 calls loader->loadModule(..., { ModuleLoadFlag::Evaluate, ModuleLoadFlag::Dynamic }) without going through GlobalObject::moduleLoaderImportModule, so no DynamicImportQueueScope is installed and JSVMClientData::dynamicImportModuleQueue stays null. In GlobalObject::moduleLoaderFetch (src/jsc/bindings/ZigGlobalObject.cpp:4242) the condition vm.m_synchronousModuleQueue != dynamicImportModuleQueue is true, so Bun::fetchESMSourceCodeSync runs and returns a settled promise; the loader's reactions settle while the require's frame is head and are diverted into it. The graph's main module and its static graph therefore evaluate inside the require(), before require() returns, exactly as import() did on the base branch.

Verification: pre-existing (the base already evaluates a graph.import() target inside the require()). The isolation frame is pushed only in GlobalObject::moduleLoaderImportModule (src/jsc/bindings/ZigGlobalObject.cpp:4097). JSModuleGraph::import (src/jsc/bindings/ModuleGraph.cpp:652) calls loader->loadModule directly, so the condition at ZigGlobalObject.cpp:4242 is true and Bun::fetchESMSourceCodeSync runs.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed on this branch. A required ES module that calls graph.import() still gets the graph's module evaluated inside the require(): before-require app app-end plugin after-require plugin-resolved. The PR notes now list it as not covered.

I do not want to add one scope for each entry point. JSModuleLoader__import and the bake hook do not pass moduleLoaderImportModule either. Every dynamic load passes JSModuleLoader::loadModule with ModuleLoadFlag::Dynamic, so I am checking a boundary there, in the fork, before I change this PR.

@robobun
robobun marked this pull request as draft October 28, 2026 16:16
The isolation scope no longer switches moduleLoaderFetch to the thread
pool for an import() issued during require(esm). The scope alone keeps
the import()'s loader reactions out of the require()'s queue, and the
thread-pool path loads a .cjs file that names neither module, exports
nor require as an ES module, so a file that was also require()d ran
twice. This removes JSVMClientData::dynamicImportModuleQueue.

Tests: assert that the in-flight import() settles, and add the
side-effect-only .cjs target in both require orders.
@robobun

robobun commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator Author

Closing. This change cannot be the fix, for two reasons.

1. Compiled code does not pass the hook. Executables built with --aot do not call moduleLoaderImportModule. The AOT runtime in the WebKit fork calls the loader itself. So the plain run and the compiled run of one program differ, and test/aot fails.

2. The scope starves the require(). It also takes loader reactions that the require() itself needs, and gives them to the microtask queue. Then a graph with no top-level await can throw:

cd "$(mktemp -d)"; echo '{}' > package.json
cat > entry.cjs <<'E'
try { require("./a.mjs"); console.log("require ok"); } catch (e) { console.log("require threw:", e.message); }
E
printf 'import "./p1.mjs";\nimport "./p2.mjs";\n' > a.mjs
printf 'import "./c.cjs";\n' > p1.mjs
printf 'import "./c.cjs";\nimport "./b.mjs";\n' > p2.mjs
printf 'import("./b.mjs");\n' > c.cjs
printf 'export const v = 1;\n' > b.mjs
bun entry.cjs   # main and Node: require ok
                # this branch:   require threw: require() async module ".../a.mjs" is unsupported. use "await import()" instead.

c.cjs calls import("./b.mjs") while the require() loads the graph, and p2.mjs imports b.mjs. The import() request settles the registry entry of b.mjs inside the scope's frame. The step that makes the module record of b.mjs lands in that frame and leaves for the microtask queue. The require() still needs that step, so its load stays pending.

The diversion in the fork does not know which load a reaction belongs to. A scope in Bun cannot decide that either. The fix belongs in the fork's loader: JSModuleLoader::loadModuleSync must load and link its own graph without the shared queue, and the AOT runtime must follow in the same commit. That shape needs a maintainer decision first.

The 29 cases in test/js/bun/resolve/require-esm-microtask-order.test.ts on this branch stay valid for that work.

@robobun robobun closed this Oct 29, 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.

2 participants