Fix CommonJS modules silently skipped when require() loads an ESM graph - #37187
Fix CommonJS modules silently skipped when require() loads an ESM graph#37187robobun wants to merge 5 commits into
Conversation
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.
WalkthroughThe 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. ChangesModule loading
Possibly related PRs
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
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.
|
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. |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
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. |
|
Updated 12:39 AM PT - Aug 8th, 2026
❌ @autofix-ci[bot], your commit 5a03dbe has 1 failures in 🧪 To try this PR locally: bunx bun-pr 37187That installs a local version of the PR into your bun-37187 --bun |
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.
|
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. |
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 ofNODE_COMPILE_CACHE.8-file repro
Worse: if
f.cjsends inthrow new Error("boom")instead,bun main.cjsstill printsN=6and exits 0. The user's error is swallowed entirely. Extension-independent (same with.jsfiles).Cause
Three steps, traced with instrumentation on a debug build:
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 doesrequire("./h.mjs")whileh.mjsis also mid-fetch. The reactions that would settle h's registry entry sit on the outer drain's queue, which the nestedloadModuleSynccannot reach:JSModuleLoader::loadModulereuses the pending fetch promise, nothing drains, andesmLoadSyncreports the pending load asrequire() async module "h.mjs" is unsupported. use "await import()" instead.even thoughh.mjshas no top-level await.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.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, getsundefinedfromJSMap::get, and becauseundefinedis a truthyJSValuethe oldif (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): inlineloadModuleSyncand, 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 pathhostLoadImportedModulealready 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'sboom) rather than a generic one, matching what Node reports.Verification
N=7 h,f,e,c,b,d,amatching Node, 10/10, on release-style and debug+ASAN builds. Same with the.js-extension flavor, withNODE_COMPILE_CACHE(cold and warm), and withBUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER=1.throw new Error("boom")variant now exits 1 printingboom from f.cjswith the correct stack (was: exit 0, no output beyondN=6).test/js/bun/resolve/require-esm-nested-cjs-sibling.test.ts; both fail on an unfixed build and pass with this change.test/js/bun/resolve(require/esm/import-defer/dynamic-import suites),test/js/node/module,test/js/bun/test/mock,test/bundler/bundler_splitting.test.tsall pass.Relation to open PRs in this area
importrace in Bun 1.3.14 regression: require() async module error for jose via jwks-rsa/firebase-admin (works fine on 1.3.11) #33180, with the same pre-settle technique in the same function. Verified here: module loader: fix false "require() async module" for a module racing a concurrent import #33184 alone fixes this PR's silent-skip repro, but not the swallowed-error variant (stillN=6, exit 0). The two PRs conflict textually infunctionEsmLoadSync; whichever lands second rebases that hunk. The require-cache half of this PR is independent of that choice.fetchCommonJSModule(source already transpiled) instead ofesmLoadSync; different file, no conflict.ASSERTION FAILED: module->loadedModules().size() <= loadedModulesCountBefore + 1abort on debug builds for some of these graphs (e.g. this graph withBUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER=1 bun a.mjs) is pre-existing, unchanged by this PR, and addressed in JSModuleLoader: decide needsErrorReaction by membership in innerModuleLoading WebKit#396.[review] gate passed · iteration 2 · 3 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 3 passed · 0 rejected · iteration 2
evidence per changed file