Skip to content

Fix CommonJS modules silently skipped when require() loads an ESM graph - #37187

Open
robobun wants to merge 5 commits into
mainfrom
farm/90c2da9a/require-esm-nested-cjs-skip
Open

Fix CommonJS modules silently skipped when require() loads an ESM graph#37187
robobun wants to merge 5 commits into
mainfrom
farm/90c2da9a/require-esm-nested-cjs-skip

Conversation

@robobun

@robobun robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Symptom

require() of an ESM entry silently skips evaluating one CommonJS module of the graph: no throw, exit 0, the module's top-level code just never runs. Deterministic (10/10) on 1.4.0 and on current main; Node evaluates the full graph. Found by fault-injection fuzzing at zero faults; independent of NODE_COMPILE_CACHE.

8-file repro
# main.cjs
require("./a.mjs");
# a.mjs
import "./b.mjs";
import "./d.mjs";
import "./e.mjs";
(globalThis.T ??= []).push("a");
console.log("N=" + globalThis.T.length + " " + globalThis.T.join(","));
# b.mjs
import "./c.cjs";
(globalThis.T ??= []).push("b");
# c.cjs
require("./e.mjs");
(globalThis.T ??= []).push("c");
# d.mjs
import "./f.cjs";
(globalThis.T ??= []).push("d");
# e.mjs
import "./f.cjs";
import "./h.mjs";
(globalThis.T ??= []).push("e");
# f.cjs
require("./h.mjs");
(globalThis.T ??= []).push("f");
console.log("f.cjs evaluated");
# h.mjs
(globalThis.T ??= []).push("h");
bun main.cjs  ->  N=6 h,e,c,b,d,a                      (f.cjs never evaluated)
node main.cjs ->  f.cjs evaluated / N=7 h,f,e,c,b,d,a
bun a.mjs     ->  f.cjs evaluated / N=7 h,f,e,c,b,d,a  (direct ESM entry is fine)

Worse: if f.cjs ends in throw new Error("boom") instead, bun main.cjs still prints N=6 and exits 0. The user's error is swallowed entirely. Extension-independent (same with .js files).

Cause

Three steps, traced with instrumentation on a debug build:

  1. require("./a.mjs") drives the whole graph load on the loader's synchronous module queue. f.cjs (a CJS module imported by two ESM files) is mid-fetch when its body runs during makeModule, and that body does require("./h.mjs") while h.mjs is also mid-fetch. The reactions that would settle h's registry entry sit on the outer drain's queue, which the nested loadModuleSync cannot reach: JSModuleLoader::loadModule reuses the pending fetch promise, nothing drains, and esmLoadSync reports the pending load as require() async module "h.mjs" is unsupported. use "await import()" instead. even though h.mjs has no top-level await.

  2. The spurious TypeError aborts f's body. The synthetic-source error path evicts f from the require cache (so a later require() can retry) and rethrows, but the rejection's reaction is itself stranded on the outer queue and is ultimately dropped.

  3. A later nested load (c.cjs's require("./e.mjs")) replays makeModule for f. The synthetic module generator looks f up in the require cache, gets undefined from JSMap::get, and because undefined is a truthy JSValue the old if (entry) check passed and the missing-entry branch was unreachable. It fell through both branches and produced an empty module record, with no error. e and d then link against empty-f, everything "succeeds", and f's side effects (or its real error) vanish.

Fix

Both halves are in the bindings; no WebKit change needed.

  • functionEsmLoadSync (ZigGlobalObject.cpp): inline loadModuleSync and, when the target's registry entry is status Fetching with a pending fetch promise, re-issue the fetch synchronously and settle the entry's fetch promise while our queue is current, so the whole chain drains here. This mirrors the synchronous-replay path hostLoadImportedModule already has for dependency edges, and is the same tolerance pattern as the in-flight handling in Fix spurious "require() async module" TypeError when a CJS module requires an ESM sibling mid-transpile #37185: the stranded async settle later lands on an already-settled promise and is dropped.

  • commonJSModuleSyntheticSourceCode (JSCommonJSModule.cpp): test the missing require-cache entry explicitly (isUndefinedOrNull) and throw instead of fabricating an empty module. The eviction path now also records the evaluation error on the module's registry entry, so the replay rethrows the original error (e.g. the user's boom) rather than a generic one, matching what Node reports.

Verification

Relation to open PRs in this area


[review] gate passed · iteration 2 · 3 files touched

fails on main (without fix)
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/resolve/require-esm-nested-cjs-sibling.test.ts
bun test v1.4.0 (5a03dbedf)

test/js/bun/resolve/require-esm-nested-cjs-sibling.test.ts:
47 |   });
48 |   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
49 |   expect(stderr).toBe("");
50 |   // Same evaluation order Node prints. Before the fix, f.cjs was silently
51 |   // skipped: "N=6 h,e,c,b,d,a" and no "f.cjs evaluated" line.
52 |   expect(stdout).toBe("f.cjs evaluated\nN=7 h,f,e,c,b,d,a\n");
                      ^
error: expect(received).toBe(expected)

- "f.cjs evaluated
- N=7 h,f,e,c,b,d,a
+ "N=6 h,e,c,b,d,a
  "

- Expected  - 2
+ Received  + 1

      at <anonymous> (/workspace/bun/test/js/bun/resolve/require-esm-nested-cjs-sibling.test.ts:52:18)
(fail) require() of an ESM entry evaluates every CommonJS module in the graph [383.05ms]
94 |       cwd: String(dir),
95 |       stderr: "pipe",
96 |     });
97 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (830664e40)

test/js/bun/resolve/require-esm-nested-cjs-sibling.test.ts:
(pass) require() of an ESM entry evaluates every CommonJS module in the graph [10.05ms]
94 |       cwd: String(dir),
95 |       stderr: "pipe",
96 |     });
97 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
98 |     // Before the fix: "a evaluated" and exit 0, with f.cjs silently skipped.
99 |     expect(stderr).toContain("boom from f.cjs");
                        ^
error: expect(received).toContain(expected)

Expected to contain: "boom from f.cjs"
Received: "2 |   require(\"./a.mjs\");\n             ^\nerror: Module \"/tmp/require-esm-nested-cjs-caught_sJ8Hbm/f.cjs\" was removed from the require cache while it was being loaded.\n      at <anonymous> (/tmp/require-esm-nested-cjs-caught_sJ8Hbm/main.cjs:2:10)\n\nBun v1.4.0-canary.1+830664e40 (Linux x64)\n"

      at <anonymous> (/workspace/bun/test/js/bun/resolve/require-esm-nested-cjs-sibling.test.ts:99:20)
(pass) a CommonJS module throwing inside a require()d ESM graph surfaces the error [8.86ms]
(fail) a caught require() of a throwing CJS sibling stil
... (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/mechgate.xml" test/js/bun/resolve/require-esm-nested-cjs-sibling.test.ts
bun test v1.4.0 (5a03dbedf)

test/js/bun/resolve/require-esm-nested-cjs-sibling.test.ts:
(pass) require() of an ESM entry evaluates every CommonJS module in the graph [359.82ms]
(pass) a CommonJS module throwing inside a require()d ESM graph surfaces the error [324.64ms]
(pass) a caught require() of a throwing CJS sibling still fails its import edge with the original error [308.63ms]

 3 pass
 0 fail
 9 expect() calls
Ran 3 tests across 1 file. [2.68s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 650ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/8] cxx obj/unified/UnifiedSource-src_jsc_bindings-2.cpp.o
[2/8] cxx obj/src/jsc/bindings/ZigGlobalObject.cpp.o
[3/8] gen cpp.rs (cppbind)
[3/8] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_brotli v
... (truncated)
diff hotspot
src/jsc/bindings/JSCommonJSModule.cpp              |  83 +++++++++++------
 src/jsc/bindings/ZigGlobalObject.cpp               |  36 ++++++-
 .../resolve/require-esm-nested-cjs-sibling.test.ts | 103 +++++++++++++++++++++
 3 files changed, 195 insertions(+), 27 deletions(-)

gate history · 3 passed · 0 rejected · iteration 2

evidence per changed file
file                                                      reads  edits  tests
src/jsc/bindings/JSCommonJSModule.cpp                         9     18      0
src/jsc/bindings/ZigGlobalObject.cpp                          4     10      0
…t/js/bun/resolve/require-esm-nested-cjs-sibling.test.ts      1      2      0

A CommonJS module evaluated mid-load inside a require()d ESM graph can
itself require() an ESM sibling whose registry entry is mid-fetch. The
reactions that would settle that entry sit on the outer drain's
synchronous module queue, which the nested load cannot reach, so the
load stayed pending and require() threw a spurious 'require() async
module' TypeError. That aborted the CJS body, evicted it from the
require cache, and a replayed makeModule then built an empty module from
the missing cache entry: the module's top-level code never ran, the
error was swallowed, and the process exited 0.

esmLoadSync now re-issues the fetch synchronously and settles the
entry's fetch promise on its own queue before loading, mirroring the
synchronous-replay path hostLoadImportedModule already has for
dependency edges. The synthetic module generator no longer fabricates an
empty module when the require cache entry is gone (JSMap::get returns
undefined, which is truthy as a JSValue, so the old missing-entry branch
was unreachable); it rethrows the recorded evaluation error instead.
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The module loader now preserves CommonJS evaluation errors, handles missing cache entries, and manages synchronous ESM queues. Regression tests cover nested CommonJS evaluation order and error propagation.

Changes

Module loading

Layer / File(s) Summary
CommonJS evaluation error recovery
src/jsc/bindings/JSCommonJSModule.cpp, test/js/bun/resolve/require-esm-nested-cjs-sibling.test.ts
CommonJS loading records evaluation errors, handles missing cache entries, and rethrows preserved failures. Tests verify nested error propagation.
Synchronous ESM queue processing
src/jsc/bindings/ZigGlobalObject.cpp, test/js/bun/resolve/require-esm-nested-cjs-sibling.test.ts
Synchronous ESM loading re-fetches in-progress entries, settles pending promises, evaluates the module graph, drains the queue, and restores the previous queue. Tests verify evaluation order and successful process results.

Possibly related PRs

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly and concisely describes the primary fix for silently skipped CommonJS modules in ESM graphs.
Description check ✅ Passed The description explains the symptom, cause, fix, verification, and related work, although it does not use the template headings.

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

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

Beyond the inline nit, I also checked: vm.m_synchronousModuleQueue = syncQueue.prev is restored unconditionally on every path through the new block (all fallible calls are gated on !scope.exception(), and the restore precedes RETURN_IF_EXCEPTION); the new if (error) check on registryEntry->error() does not repeat the JSValue-truthiness bug this PR fixes — ModuleRegistryEntry::error returns an empty JSValue when unset, not jsUndefined(); and setEvaluationError before scope.throwException in the eviction path does not need an intervening exception check (it's a WriteBarrier store, no ThrowScope).

Extended reasoning...

The change touches the core ESM/CJS interop path in the module loader (synchronous module queue nesting, promise reaction ordering, registry entry state) and textually overlaps with two other open PRs (#33184, #37185) in functionEsmLoadSync. That interaction is subtle enough that a human should confirm the pre-settle approach and the coordination with the sibling PRs before merge; the inline nit is debug-validator-only and does not affect release behavior.

Comment thread src/jsc/bindings/ZigGlobalObject.cpp
@robobun

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the review: the one inline note (exception check between the two ensure* calls) is resolved in-thread with details. Short version: in this fork those helpers are JSPromise::create plus performPromiseThenWithInternalMicrotask, no ThrowScope and no JS entry, matching the same back-to-back shape hostLoadImportedModule uses; and a debug run with BUN_JSC_validateExceptionChecks=1 over both new paths (the repro graph and the throwing-f.cjs variant) reports zero violations, so the code stays as is. No source changes from this review.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. module loader: fix false "require() async module" for a module racing a concurrent import #33184 - Same root cause and same function (functionEsmLoadSync): a registry entry stuck at Status::Fetching cannot be settled from a nested sync load, so a non-async module is misreported as require() async module ... is unsupported, and both fix it with the same synchronous re-fetch + pre-settle of the entry's fetch promise.
  2. Fix spurious "require() async module" TypeError when a CJS module requires an ESM sibling mid-transpile #37185 - Same spurious require() async module TypeError for a mid-flight, non-async ESM sibling, fixed with the same pre-settle-the-fetch-promise mechanic at the sibling entry point fetchCommonJSModule in ModuleLoader.cpp.

🤖 Generated with Claude Code

@robobun

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate, though the overlap is real and is called out in the PR body ("Relation to open PRs in this area").

Summary of the relationship, verified by running each change against this PR's repros:

Conversely, this PR's tests fail on both of those branches' behavior for the error-swallow case, so neither supersedes it.

Comment thread src/jsc/bindings/JSCommonJSModule.cpp Outdated
Comment thread src/jsc/bindings/JSCommonJSModule.cpp
Comment thread src/jsc/bindings/JSCommonJSModule.cpp Outdated
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
@robobun

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:39 AM PT - Aug 8th, 2026

@autofix-ci[bot], your commit 5a03dbe has 1 failures in Build #90541 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 37187

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

bun-37187 --bun

Comment thread src/jsc/bindings/JSCommonJSModule.cpp
Comment thread src/jsc/bindings/JSCommonJSModule.cpp
Comment thread src/jsc/bindings/ZigGlobalObject.cpp
Comment thread src/jsc/bindings/ZigGlobalObject.cpp
Comment thread src/jsc/bindings/JSCommonJSModule.cpp
A CJS sibling evaluated via require() from another CJS module's body
(JSCommonJSModule::load) that throws was evicted from the require cache
without recording its error on the module's registry entry, so the
replayed makeModule for its import edge surfaced the generic removed-
from-cache message instead of the user's error. Mirror the generator
path's setEvaluationError there; plain CJS require has no registry
entry, so this is a no-op outside ESM graphs.
Comment thread src/jsc/bindings/JSCommonJSModule.cpp
@robobun

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff is green across lanes. The only red job in the last two runs (builds 90533 and 90541) is test/js/node/worker_threads/worker-transfer-terminate-stress.test.ts on the x64-asan lane, a pre-existing intermittent abort that test was added to hunt (its header documents the SIGABRT as asan-lane-only and predating this branch; it exercises worker terminate plus builtin require, none of which touches this PR's file-module loading paths). It has been reported for main-side triage. The other entries passed on retry.

Ready for review. Summary of what landed since the PR opened: the original two fixes, plus a reviewer-found sibling (JSCommonJSModule::load eviction now records the evaluation error the same way the generator path does, so a try/catch-swallowed require() of a throwing CJS sibling fails its import edge with the real error). Three tests, each verified to fail without its fix.

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