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.
|
Warning Review limit reached
On-demand reviews are free for the next 13 days. After that, they cost $0.25 per reviewed file. Or wait 58 minutes for your next included review. View limit detailsLimit details: You’ve used all 10 included reviews currently available. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (3)
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 5:16 PM PT - Sep 7th, 2026
❌ @Jarred-Sumner, your commit 90cdf57 has 4 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. |
|
Heads-up from consolidating the |
…-esm-nested-cjs-skip
Three more doors into the same gap, all from an ESM entry rather than a require()d one: a CJS dependency listed first that require()s an ESM sibling of the entry (deterministic TypeError before), a diamond where a CJS sibling require()s what an ESM sibling imports, and the same with top-level-await siblings elsewhere in the graph (both timing-dependent before, roughly 4/5 and 1/4 of runs). All pass with this branch.
|
Pushed e6833fa: merged current main (the branch had drifted ~1000 commits;
All six tests in the file fail on 1.4.2 for the reported reason and pass on this branch. PR body rewritten to the current state; details and rates are in its Notes. |
Problem
require()of an ES module whose registry entry is mid-fetch (a surrounding graph load already started it) threwTypeError: require() async module "..." is unsupported. use "await import()" instead.with no top-level await anywhere. Deterministic doors:require()of an ESM entry whose CJS member requires an in-flight sibling, and an ESM entry whose first import is a CJS file that requires a sibling of the entry.commonJSModuleSyntheticSourceCode(JSCommonJSModule.cpp) built an empty module from the missing entry (JSMap::getreturnsundefined, truthy as aJSValue). The module's top-level code never ran, its error vanished, exit 0.Fix
functionEsmLoadSync(ZigGlobalObject.cpp): when the target entry isFetchingwith a pending fetch promise, re-issue the fetch synchronously and settle that promise on the nested synchronous module queue before loading, mirroring the replayhostLoadImportedModuledoes for dependency edges.JSCommonJSModule::load) record that error on the registry entry.test/js/bun/resolve/require-esm-nested-cjs-sibling.test.ts(6 tests, all fail on 1.4.2, pass here). Alsotest/js/bun/resolve,test/js/node/module,test/js/bun/test/mock,bundler_splitting.Background
require(esm)loads a graph without yielding:VM::m_synchronousModuleQueuediverts the loader's promise reactions to a queue the caller drains inline. Nested requires push nested queues.generatestep runs the CJS body, so user code (and nestedrequire()) runs while the outer graph is still loading.Notes
Original 8-file reproduction (CommonJS entry):
With
f.cjsending inthrow new Error("boom"), stock bun still printedN=6and exited 0. With this change it exits 1 withboom from f.cjsand the right stack, as Node does.Traced sequence on a debug build: f.cjs's body runs during makeModule and requires h.mjs, which is mid-fetch with its reactions on the outer queue. The nested load reused the pending fetch promise, stayed pending, and was reported as an async module. The TypeError aborted f's body, the generator evicted f from the require cache and rethrew into a reaction that was itself stranded and dropped. A later nested load (c.cjs requiring e.mjs) replayed makeModule for f, found
undefinedin the require cache, passed theif (entry)check, and produced an empty record. e and d linked against it and the graph "succeeded".ESM-entry shapes verified against this branch (stock 1.4.2 rates in parentheses), now tests 4 to 6 in the file:
main.mjs: import "./req.cjs"; import d from "./p.mjs"where req.cjs doesrequire("./p.mjs"):ok true, one module record,r.default === d(stock: TypeError 5/5). The esm-first permutation and the permutation with an extra sibling between: 30/30 and 40/40 ok (stock: 0/60 and 3/100 here).entry -> a.mjs -> c.mjs,entry -> b.cjs -> require(c.mjs): 40/40B-req-ok(stock: 42/50B-req-ERR).p1,p2importt.mjswithawait 0) plusk.cjs -> require(s.mjs)without TLA: 40/40K-req-ok, TLA modules still evaluate after (stock: 17/60K-req-ERR).In the diamond and TLA shapes the CJS sibling still evaluates ahead of earlier-listed ESM siblings (
B C ... Awhere Node printsC A B ...). That ordering difference is pre-existing and separate; the tests assert membership, not order.Other checks:
.js-extension flavor of the 8-file graph,NODE_COMPILE_CACHEcold and warm,BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER=1 bun main.cjsall print N=7.BUN_JSC_validateExceptionChecks=1over both new paths reports no violations. The debug-onlyASSERTION FAILED: module->loadedModules().size() <= loadedModulesCountBefore + 1on some of these graphs (for example the 8-file graph run asBUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER=1 bun a.mjs) is pre-existing, unchanged here, and addressed by oven-sh/WebKit#396.Related PRs: #37185 fixes the sibling door where the require target goes through
fetchCommonJSModulewith already-transpiled source (different file, no conflict); #33184, which pre-settled the same way infunctionEsmLoadSyncfor the concurrent-import race in #33180, was closed into #37185. #33184 alone fixed the silent skip but not the swallowed error. The concurrent-import race test from #33184 passes with this change.