Repository navigation
Conversation
…entry A CommonJS module imported by an ESM graph has its body evaluated while the graph is still loading, and that body can require() an ESM sibling the graph already started fetching on the transpiler thread. The sibling's registry entry is then status Fetching with a pending fetch promise. fetchCommonJSModule re-transpiled the file synchronously but provideFetch() only accepts a New entry, so the fresh source was silently dropped, the synchronous load had nothing to drain, and require() threw a spurious TypeError: require() async module "..." is unsupported. use "await import()" instead. for a module with no top-level await. Intermittent on multi-file mixed ESM/CJS graphs (28/30 runs on the repro below on a loaded machine). Settle the entry's fetch promise with the just-transpiled source instead. The async transpiler's own result lands later on an already-settled promise and is dropped by the existing PromiseFulfillWithoutHandlerJob pending-target guard.
WalkthroughChangesSynchronous ESM loading
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
The overlap with #33184 is real but partial. Both address the same symptom (the false Differences:
Only one of the two should land. If maintainers prefer #33184's placement, the WebKit companion and the ordered test here still stand on their own; otherwise this PR supersedes it for the race. |
There was a problem hiding this comment.
I reviewed this PR and the automated bug-hunting pass found no issues. Because it touches the JSC module-loader race path (directly settling a pipeFrom()'d fetch promise via fulfillPromise and relying on the later async-transpiler result being dropped), a human look from someone familiar with the C++ loader would still be worthwhile.
What was reviewed:
- All four
provideFetchcall sites infetchCommonJSModule/fetchCommonJSModuleNonBuiltinroute through the new helper; the fallthrough still callsprovideFetchwith theJSSourceCode*overload (matches ZigGlobalObject.cpp:741). - The isolation-cache and non-builtin sites now wrap
SourceCodeinJSSourceCode::create— consistent with what the fetch promise expects. - Checked the transitive mid-fetch (diamond) graph concern — ruled out; the entry-status guard applies per-specifier.
- Test spawns 12 concurrent subprocesses with a padded ESM sibling, drains stdout/stderr/exited concurrently, and asserts exact evaluation order; 30s timeout is justified for debug/ASAN.
Extended reasoning...
Overview
This PR fixes a race in src/jsc/bindings/ModuleLoader.cpp where a CJS module require()ing an ESM sibling that is already mid-fetch on the async transpiler thread would spuriously throw require() async module ... is unsupported. The fix introduces provideFetchForSyncLoad(), which detects a Fetching-status registry entry and settles its fetch promise directly with the just-transpiled source (via fulfillPromise, bypassing the first-resolving-function guard set by pipeFrom()), so the synchronous module queue can drain it. Four call sites are rewired; two now wrap raw SourceCode in JSSourceCode::create. A new test reproduces the race with a padded ESM file across 12 concurrent runs.
Security risks
None. This is internal module-loader plumbing; no user-controlled input reaches new parsing or allocation paths, and no auth/crypto/permissions surface is touched.
Level of scrutiny
High. The module loader is on every startup path, and the fix depends on non-obvious JSC internals: that fulfillPromise can safely override a pipeFrom-armed promise, that the FetchSettled reaction lands on the caller's synchronous queue, and that the async transpiler's later resolution is harmlessly dropped by PromiseFulfillWithoutHandlerJob's pending-target check. The PR also notes a related JSC-side assertion (oven-sh/WebKit#396) that covers adjacent doors of the same graph. These are correctness claims about vendored JSC behavior that merit a maintainer's eye.
Other factors
The bug-hunting system raised and refuted two candidates (30s test timeout — reasonable for 12 debug/ASAN spawns; transitive diamond-graph coverage — the fix is per-specifier). The test follows harness conventions (tempDir, bunEnv, concurrent pipe drain, exact-output assertion, exit code last). No prior human review comments. Given the subtlety of the JSC promise/registry interaction and its position in a critical hot path, deferring to human review rather than auto-approving.
|
For whoever takes the human look, the two contracts the review flags are both explicit in the vendored JSC:
CI is green across all lanes on Buildkite build 90527. |
…-esm-in-flight-sibling
A deterministic CJS/ESM cycle shape (a one-line CJS starter whose require chain reaches an ESM module the entry is also importing) and the issue's shape with several large siblings required in a row. Both fail on 1.3.14 through current main with the spurious require() async module TypeError.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/js/bun/resolve/require-esm-in-flight-sibling.test.ts`:
- Around line 10-12: Make the regression deterministic by coordinating the test
loader state so d.cjs invokes require("./e.mjs") only after e.mjs has a registry
entry in the Fetching state, exercising the production guard directly. Update
the test setup around the affected lines to expose or await that state
transition, then remove the twelve-process repetition and 30-second timeout from
the test.
In `@test/regression/issue/33180.test.ts`:
- Around line 1-8: Keep only the issue URL comment in
test/regression/issue/33180.test.ts at lines 1-8; remove the explanatory
comments at lines 13-18 and 53-56, leaving the test behavior unchanged.
🪄 Autofix
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: 9fb33487-62b6-44ff-bb0e-f01938e02871
📒 Files selected for processing (3)
src/jsc/bindings/ModuleLoader.cpptest/js/bun/resolve/require-esm-in-flight-sibling.test.tstest/regression/issue/33180.test.ts
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. It touches the JSC module loader's promise-settling internals and force-fulfills a pipeFrom()'d fetch promise — subtle enough that a human maintainer should confirm the mechanic, and there's a placement decision to make against #33184.
What was reviewed:
provideFetchForSyncLoad: verified theFetching-status branch settles viafulfillPromise(bypassing the first-resolving-function guard) and falls through toprovideFetchforNewentries; the four call sites are the complete set that previously calledprovideFetchunder aSynchronousModuleQueue.- Checked the two call sites that now wrap a raw
SourceCodeinJSSourceCode::create(vm, ...)— matches whatprovideFetchdoes internally. - Tests: 12-way concurrent spawn asserts exact evaluation order (not just absence of the error), plus a deterministic cycle test and a multi-sibling test for #33180; all drain stdout/stderr/exited concurrently.
Extended reasoning...
Overview
The PR adds a static helper provideFetchForSyncLoad in src/jsc/bindings/ModuleLoader.cpp and rewires the four provideFetch call sites inside fetchCommonJSModule / fetchCommonJSModuleNonBuiltin to use it. When a synchronous require(esm) re-transpiles a module whose registry entry is already Status::Fetching (because the async transpiler thread started it as part of a surrounding ESM graph), the helper settles the entry's fetch promise directly with the fresh source instead of calling provideFetch(), which only accepts New entries and was silently dropping the result. Two new test files exercise the 6-file doubly-nested graph and the #33180 shapes.
Security risks
None identified. This is module-loader control flow; no untrusted-input parsing, auth, or crypto is touched.
Level of scrutiny
High. This is core module-loader machinery that relies on non-obvious JSC-internal contracts: (1) fulfillPromise deliberately bypasses the isFirstResolvingFunctionCalledFlag set by pipeFrom(), and (2) the async transpiler's late delivery is expected to be dropped by the pending-target guard in PromiseFulfillWithoutHandlerJob. The author's comment cites the matching precedent in hostLoadImportedModule's synchronous-drive block, and the mechanic reads correctly against the vendored JSC, but a wrong assumption here would produce hard-to-reproduce module-graph corruption. This is not a mechanical change I can approve without a human look.
Other factors
- There is an open competing PR (#33184) applying the same mechanic at a different layer (
functionEsmLoadSync); the description explicitly says only one should land, which is a maintainer decision. - The fix is paired with oven-sh/WebKit#396 for the second face of the same graph (the
innerModuleLoadingassertion); tests for those doors are deferred to the WebKit bump. - The comment-cop bot has flagged four multi-line comments in the new helper. Those comments document the JSC contracts the fix depends on rather than justifying a workaround, but the author may want to trim them.
- CI green on Buildkite build 90527; the gate evidence shows the new test fails on main (both debug/ASAN and release) and passes with the fix.
…ments The test now imports a package named reflect-metadata, which the runtime always transpiles on the main thread, so its body runs in the microtask checkpoint after the graph's fetches are issued and the ESM sibling is mid-fetch on every run instead of most runs. One spawn replaces twelve. provideFetchForSyncLoad keeps one comment on the fulfillPromise call and follows the neighbouring throw-scope convention.
|
Updated 11:27 AM PT - Aug 13th, 2026
✅ @robobun, your commit 9ac5986c3351764665269ff34662e906e7dfbb02 passed in 🧪 To try this PR locally: bunx bun-pr 37185That installs a local version of the PR into your bun-37185 --bun |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/jsc/bindings/ModuleLoader.cpp`:
- Around line 658-676: Root jsSourceCode in provideFetchForSyncLoad with a
JSC::Strong<JSC::JSSourceCode> (or equivalent JS-thread rooted handle) before
ensureModulePromise and ensureFetchPromise, and use that rooted value when
fulfilling the fetch promise. Add a GC-stress regression covering the
isolated-cache and transpiled paths that pass newly created JSSourceCode into
this flow.
🪄 Autofix
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: bd8b9b0a-e1f6-48ce-be1a-a3ea8e7ff705
📒 Files selected for processing (3)
src/jsc/bindings/ModuleLoader.cpptest/js/bun/resolve/require-esm-in-flight-sibling.test.tstest/regression/issue/33180.test.ts
💤 Files with no reviewable changes (1)
- test/regression/issue/33180.test.ts
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes JSC module-loader promise settlement on the require(esm) path — force-fulfilling a pipeFrom()'d fetch promise under the synchronous module queue, racing the async transpiler's late delivery — a human look is still warranted.
What was reviewed:
provideFetchForSyncLoadreplaces all fourprovideFetchsites on the sync require path; the two remaining direct calls (ZigGlobalObject.cpp:750,bindings.cpp:3303) are entry-point loads, not this path.- Confirmed
reflect-metadatais inALWAYS_SYNC_MODULES(src/runtime/jsc_hooks.rs:4391), so the deterministic test's ordering premise holds. jsSourceCodelifetime acrossensure*Promise: stack-local, conservatively scanned; same shape as upstreamModuleRegistryEntry::provideFetch.- Note: build #94024 shows
test/cli/run/require-cache.test.tsfailing on two lanes for e05032d — worth confirming that's an unrelated flake given this PR touches the require path.
Extended reasoning...
Overview
The PR adds a ~20-line static helper provideFetchForSyncLoad in src/jsc/bindings/ModuleLoader.cpp and swaps four existing provideFetch() calls on the synchronous require(esm) path to use it. When the target module's registry entry is already in Fetching status (an async transpile is in flight because the surrounding ESM import graph also imports it), the helper force-settles the entry's pending fetch promise with the source that was just transpiled synchronously, using fulfillPromise to bypass the resolving-function flag that pipeFrom() already claimed. Two new test files exercise the deterministic case (via the ALWAYS_SYNC_MODULES reflect-metadata trick) and the natural issue-shaped cases.
Security risks
None identified. This is internal module-loader plumbing with no user-controlled input reaching new parsing or allocation paths; the source code being registered is the same source the old code passed to provideFetch.
Level of scrutiny
High. This is core runtime infrastructure: the JSC module loader's registry state machine and promise-reaction ordering. The correctness of the fix depends on several JSC-internal contracts (that fulfillPromise is the right bypass for a pipeFrom()'d promise, that the async transpiler's later delivery is dropped by PromiseFulfillWithoutHandlerJob's pending-target check, and that settling under an active SynchronousModuleQueue runs the FetchSettled reaction inline). The PR description and the earlier robobun comment cite the exact vendored-WebKit lines for each of these, and the mechanism mirrors what hostLoadImportedModule's existing synchronous-replay block already does — but verifying those contracts requires someone comfortable with the JSC module loader internals, which is exactly what a human reviewer should sign off on.
Other factors
- All prior review-thread items are resolved: the comment-cop comment-length flags were trimmed, my earlier nit about asymmetric
RETURN_IF_EXCEPTIONafterensure*Promisewas addressed in e05032d, CodeRabbit's GC-rooting concern was withdrawn (stack-local under conservative scan), and the deterministic-test rewrite landed in e06da7d. - The robobun CI status for the head commit e05032d lists
test/cli/run/require-cache.test.tsfailing on both🐧 13 x64-asanand🐧 3.23 x64. That test file is adjacent to the code this PR changes (require-path behavior), and the PR description itself notes overlap with #37187's require-cache bug. A human should confirm whether that failure is a pre-existing flake on main or introduced here before merging. - The PR description also references a pending JSC-side change (oven-sh/WebKit#396) for a related-but-separate
innerModuleLoadingassumption; this PR's tests deliberately load through an ESM entry to avoid that path, so the two are independent, but it's context a human reviewer should be aware of.
|
On the |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs — my earlier nit about the ensureFetchPromise exception-check asymmetry was addressed in e05032d. Because this touches the JSC module loader's synchronous-load path and relies on internal promise semantics (fulfillPromise bypassing the resolving-function flag pipeFrom() claimed, and the later async transpile landing harmlessly on an already-settled promise), a human look would still be worthwhile.
What was reviewed:
- The
Fetching-status branch inprovideFetchForSyncLoadand all four call sites it replaces; the fall-through toprovideFetch()preserves prior behavior forNew/absent entries. - GC rooting of
jsSourceCodeacross theensure*Promiseallocations — it's a stack local under the conservative scanner, same shape as upstreamModuleRegistryEntry::provideFetch. - Confirmed
ALWAYS_SYNC_MODULESinsrc/runtime/jsc_hooks.rscontainsreflect-metadata, so the in-flight-sibling test's determinism claim holds. - The
require-cache.test.tsCI failures on build 94024 look like lane starvation (same lane timed out four unrelated leak tests); worth confirming the retrigger is clean.
Extended reasoning...
Overview
This PR adds a static helper provideFetchForSyncLoad in src/jsc/bindings/ModuleLoader.cpp and swaps four provideFetch() calls in the require-of-ESM path to use it. When require() hits a module whose registry entry is already in Fetching status (because the surrounding import graph kicked off an async transpile), the old provideFetch() silently dropped the synchronously-transpiled source; the new helper fulfills the entry's pending fetch promise directly so the subsequent loadModuleSync drain completes. Two new test files exercise the deterministic case (via the reflect-metadata ALWAYS_SYNC_MODULES fast path) and the issue's natural timing-dependent shapes.
Security risks
None identified. This is internal loader plumbing with no user-controlled input reaching new parsing or allocation. The change narrows an error path (spurious TypeError) rather than widening any acceptance.
Level of scrutiny
High. ModuleLoader.cpp is a critical path — every require() and import flows through here — and the fix depends on JSC-internal invariants that are not visible from Bun's side: that fulfillPromise bypasses the already-claimed resolving functions from pipeFrom(), that the entry's FetchSettled reaction is attached to the promise being fulfilled, and that the async transpile arriving later on an already-settled promise is dropped by PromiseFulfillWithoutHandlerJob's pending-target check. The PR description traces each of these carefully and cites the analogous existing pattern in hostLoadImportedModule's synchronous-replay block, but this is exactly the kind of change where a maintainer familiar with the module-loader rewrite (#29393) should confirm the state-machine reasoning.
Other factors
- All prior review threads (comment-cop on comment length, CodeRabbit on test determinism and GC rooting, my exception-check nit) are resolved with commits.
- The deterministic test's mechanism (
ALWAYS_SYNC_MODULESforcingreflect-metadatato transpile on-thread) checks out againstsrc/runtime/jsc_hooks.rs:4391. - There is stated interaction with #37187 (which covers a separate require-cache bug and stays open) and a related JSC-side change oven-sh/WebKit#396; a human should confirm the boundary between these is drawn correctly.
- Build 94024 had
require-cache.test.tsfailures on two lanes; the author's analysis that the changed code isn't reached by those tests (CJS files return beforeprovideFetchForSyncLoad) is plausible and the same lane timed out several unrelated leak tests, but the retrigger (2e2870f) result should be confirmed clean before merge.
|
Retrigger result (build 94130, same diff as e05032d): |
…-esm-in-flight-sibling
registryEntry() falls back to an entry of any type, so a require() racing an in-flight import of the same file with a type attribute fulfilled that typed entry with default-loader output (and scanned the whole module map whenever there was no entry at all). Look up the JavaScript-typed entry directly, which is the one provideFetch() would create and loadModuleSync() reads. Tests: the typed-import race, and the sync-transpiler configuration in which the sibling's fetch promise is already settled when the require() lands.
|
Pushed 5968a2f after a closer look at
Branch is merged with current main; |
|
On the typed-require point from the review of 5968a2f: agreed, and it is pre-existing on both sides of the require(esm) path (the old Current head 9ac5986: |
There was a problem hiding this comment.
I reviewed this PR and found no bugs; the two nits I left earlier (exception-check symmetry, comment wording) were addressed in e05032d and 9ac5986. Because the fix reaches into JSC module-loader internals — settling a pipeFrom()-claimed fetch promise via fulfillPromise under a SynchronousModuleQueue — a maintainer sign-off would still be worthwhile.
What was reviewed:
provideFetchForSyncLoadstate handling (New / Fetching-pending / Fetching-settled / Fetched) againstModuleRegistryEntrysemantics and the four call sites it replaces.- GC safety of the
JSSourceCode*local acrossensureRegistered/ensure*Promise— conservative stack scan keeps it live; matches upstreamprovideFetch. - Typed-entry keying:
ensureRegistered(..., Type::JavaScript)vs. the earlierregistryEntry()fallback; test 3 pins the typed-import direction. - Transitive in-flight ESM dependencies of the required module — ruled out (each dependency's own fetch goes through
hostLoadImportedModule, not this helper).
Extended reasoning...
Overview
The PR adds provideFetchForSyncLoad in src/jsc/bindings/ModuleLoader.cpp and swaps four moduleLoader()->provideFetch(...) calls in the synchronous require(esm) path (two virtual-module branches, the isolated-cache branch, and the fresh-transpile branch in fetchCommonJSModuleNonBuiltin) for it. The helper looks up the JavaScript-typed registry entry via ensureRegistered, and where the old code silently no-op'd on a Fetching entry, it now fulfils that entry's still-pending fetch promise with the source the synchronous transpile just produced, so loadModuleSync's drain has work to run and the load completes instead of surfacing as "require() async module". Two new test files cover a deterministic reproduction (via ALWAYS_SYNC_MODULES's reflect-metadata), the settled-but-unprocessed state under BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER=1, a typed-import race, and the two natural shapes from #33180.
Security risks
None identified. The change is confined to how already-transpiled source is handed to JSC's module registry; no new user input is parsed, no boundaries are crossed, and the entry keying is narrower than before (typed lookup instead of registryEntry()'s any-type fallback).
Level of scrutiny
High. This is C++ in the module loader, on the hot path for require() of ESM, and it relies on a specific JSC-internal contract: fulfillPromise bypasses the alreadyResolved guard that pipeFrom() set, and the later async transpile landing on the now-settled promise is dropped by PromiseFulfillWithoutHandlerJob's pending-target check. The PR description traces this precisely and cites the identical pattern in hostLoadImportedModule's synchronous-replay block, and the behaviour is pinned by a test that removes the pending check and observes the debug assertion fire. That is convincing, but it is exactly the kind of change where a maintainer who owns the module-loader rewrite (#29393) should confirm the contract holds — particularly the interaction with innerModuleLoading's assumptions the description flags as a separate WebKit-side concern.
Other factors
All prior review threads are resolved: CodeRabbit's determinism concern (fixed via the reflect-metadata fixture), its GC-rooting concern (withdrawn — stack local under conservative scan, matching upstream provideFetch), the comment-cop length flags and my comment-wording nit (9ac5986), and my exception-check-symmetry nit (e05032d). CI on the retriggered build 94130 was green for the touched paths; the one red job was an unrelated Windows dev-server teardown segfault. The remaining open inline (my typed-require note) is explicitly non-blocking and pre-existing on main. Given the depth of the JSC interaction, deferring to a human is the right call rather than auto-approving.
|
Independent verification on a real app: NestJS 12 (ESM-only) with Build from this PR (
Adds a fourth affected stack to jose/jwks-rsa, eventsource and uncrypto. Happy to test another build if needed. |
|
Status after #43022 (WebKit bump to The bump includes oven-sh/WebKit#662. Its second part fixes this bug inside JSC. When a synchronous load finds a registry entry in I ran the two test files of this PR without the
No commit between c6b7fcb and the bump changes the module loader sources. The repros from #33180 agree. The The source change in this PR is no longer needed to fix #33180. It still does one thing: it reuses the source that main has no test for this behavior, because #43022 changed only |
Fixes #33180 (jose required by jwks-rsa / firebase-admin while the app also imports jose; also reported with
eventsourcein that issue and as #37557). Consolidated with #33184, the earlier PR for the same race, which is closed in favour of this one; its issue-shaped test is carried here astest/regression/issue/33180.test.ts.Problem
require()s a plain ESM module (no top-level await) which the same graph also imports, the module's registry entry already exists in statusFetching, with its fetch promise piped from the async transpiler.fetchCommonJSModule(src/jsc/bindings/ModuleLoader.cpp) transpiles the file synchronously and hands the source toJSModuleLoader::provideFetch(), butprovideFetch()only accepts an entry in statusNew, so the source was dropped on the floor.functionEsmLoadSyncthen ranloadModuleSync, whoseloadModulereuses the entry's still-pending fetch promise (JSModuleLoader.cpp, thestatus != Newbranch), so the synchronous drain had nothing to run, the load promise stayed pending, and that pending promise is what gets reported asTypeError: require() async module "..." is unsupported. use "await import()" instead.Fix
provideFetchForSyncLoadreplaces the fourprovideFetch()calls in the require path (fresh transpile, isolated module cache, and both virtual-module branches). It looks up the JavaScript-typed entry for the specifier (ensureRegistered(key, Type::JavaScript), the same O(1) lookupprovideFetch()itself starts with) and then does one of three things: aNewentry is fed the source exactly as before; aFetchingentry whose fetch promise is still pending has that promise settled directly with the source the synchronous transpile just produced; anything else (Fetched, a failed entry, orFetchingwith the fetch promise already settled) is left alone, becauseloadModuleSync()already handles those states itself, the last one throughhostLoadImportedModule's inline replay of the queued makeModule step.registryEntry(), which falls back to an entry of any type, so arequire()racing an in-flightimport ... with { type: "ts" }(or any other loader attribute) of the same file would have fulfilled that typed entry with default-loader output while doing nothing for therequire()itself, which reads the JavaScript entry; it also walked the whole module map whenever the file had no entry yet, which is the common case for a firstrequire()of an ESM file. Typed imports keep their own entry and their own transpile, as on main.FetchSettledreaction is what drives makeModule, and it is attached to the entry's own fetch promise: settling it under theSynchronousModuleQueuethe caller is about to drain runs the rest of the chain inline, soloadModuleSyncafterwards finds the entryFetchedand links and evaluates it synchronously. The promise has to be settled withfulfillPromiserather than the guardedfulfill, becausepipeFrom()already claimed the resolving-function flag; this is the same thinghostLoadImportedModule's existing synchronous-replay block does for dependency edges. When the async transpile finishes later it lands on an already-settled promise and is dropped by the pending-target check inPromiseFulfillWithoutHandlerJob, again the existing contract that replay block relies on.functionEsmLoadSync(module loader: fix false "require() async module" for a module racing a concurrent import #33184, and the overlapping hunk in Fix CommonJS modules silently skipped when require() loads an ESM graph #37187): the source already transpiled synchronously is used instead of re-issuing a third transpile, and the virtual-module and isolated-cache paths get the same treatment. Fix CommonJS modules silently skipped when require() loads an ESM graph #37187's first test (a CJS module inside arequire()d ESM graph requiring an in-flight sibling) also passes on this branch; its other two tests cover a separate require-cache bug that this PR does not touch, so Fix CommonJS modules silently skipped when require() loads an ESM graph #37187 stays open for that half.require-esm-transitive-tla,require-esm-microtask-order,issue/24387andrequire.test.tspass on this build.bun bd test test/js/bun/resolve/require-esm-in-flight-sibling.test.ts test/regression/issue/33180.test.ts(5 pass), plus therequire-esm-*,require.test.ts,issue/24387andmock-modulefiles (which exercise the virtual-module branch). Both files fail on the current release build, and the regression file's two cases failed 3/3 runs there; the cycle shape also failed 10/10 on a debug build of current main before this change and passes 10/10 after. The issue's own syntheticplain-esmpackage shape and a four-sibling variant pass 10/10 on this build.Tests
test/js/bun/resolve/require-esm-in-flight-sibling.test.ts: deterministic. The entry imports an ESM module and a CJS package namedreflect-metadata; the runtime always transpiles that package on the main thread (ALWAYS_SYNC_MODULESinsrc/runtime/jsc_hooks.rs), so its body runs in the microtask checkpoint right after the graph's fetches are issued, before the transpiler thread can have delivered the ESM sibling, and itsrequire()of that sibling hits theFetchingentry on every run. Asserts Node's outpute,polyfill:1,entry. Fails 10/10 on the release build and on main, passes 20/20 here; also passes 5/5 underBUN_JSC_collectContinuously=1. One spawn, no per-test timeout. Two more cases share the fixture:BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER=1, where the sibling's fetch promise is already settled when therequire()lands and only its makeModule reaction is still queued. This is the state the helper has to leave alone; with the pending check removed it fails onASSERTION FAILED: status() == Status::Pending(verified), and it passes on main.require()racing animport ... with { type: "ts" }of the same.jsfile. The file'sa < b > (c)is a generic call under the ts loader and two comparisons under the default loader, so the test can see which transpile each consumer got: the typed import must still seecalled(3)and therequire()false, with the file evaluated once per entry. Fails against the previous revision of this branch (the typed import sawfalse), passes here and on main.test/regression/issue/33180.test.ts(carried over from module loader: fix false "require() async module" for a module racing a concurrent import #33184), two natural shapes of the issue that depend on transpiler timing to reach the bug (they fail 8/10 and 9/10 runs on the release build, 0/15 failures on this branch):require()d in turn by a small CJS sibling.Background
require()of a file that turns out to be ESM works in two steps:fetchCommonJSModuletranspiles it and registers the source with JSC's module loader, thenfunctionEsmLoadSync(ZigGlobalObject.cpp) asks the loader to load it withloadModuleSync, which installs aVM::SynchronousModuleQueueso the loader's internal promise reactions are collected and drained inline instead of going to the microtask queue. A load whose promise is still pending after the drain is treated as a module that needs the event loop, i.e. top-level await.ModuleRegistryEntryis JSC's per-module record: status (New,Fetching,Fetched, ...), a fetch promise that resolves to theJSSourceCode, and a module promise that resolves once makeModule has produced the record. Dependency loads started byimportcreate the entry immediately and pipe its fetch promise from Bun's async transpiler result.require()of an ESM entry,BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER=1,NODE_COMPILE_CACHE) lets nested synchronous loads complete other edges of the referrer during one host-load call, whichJSModuleLoader::innerModuleLoadingmakes assumptions about (needsErrorReaction, and an assertion on debug builds). Tightening that is a JSC-side change, JSModuleLoader: decide needsErrorReaction by membership in innerModuleLoading WebKit#396, independent of this fix; this PR's tests load their graphs through an ESM entry.[review] gate passed · iteration 1 · 3 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 3 passed · 0 rejected · iteration 1
evidence per changed file