Skip to content

Fix spurious "require() async module" TypeError when a CJS module requires an ESM sibling mid-transpile - #37185

Open
robobun wants to merge 1 commit into
mainfrom
farm/7d8432f2/require-esm-in-flight-sibling
Open

Fix spurious "require() async module" TypeError when a CJS module requires an ESM sibling mid-transpile#37185
robobun wants to merge 1 commit into
mainfrom
farm/7d8432f2/require-esm-in-flight-sibling

Conversation

@robobun

@robobun robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Symptom

On a mixed ESM/CJS graph where a CommonJS module require()s an ESM module that an ancestor also imports statically, bun entry.mjs intermittently fails (28/30 runs on a loaded machine, 6/30 elsewhere) with:

TypeError: require() async module "/tmp/r/e.mjs" is unsupported. use "await import()" instead.
      at require (57:24)
      at <anonymous> (/tmp/r/d.cjs:2:10)

even though e.mjs has no top-level await.

6-file repro
# entry.mjs
import "./a.mjs";
import "./c.mjs";
console.log((globalThis.o ??= []).concat("entry").join(","));
# a.mjs
import "./b.cjs"; (globalThis.o ??= []).push("a"); export const a = 1;
# b.cjs
require("./c.mjs"); (globalThis.o ??= []).push("b"); module.exports = {};
# c.mjs
import "./d.cjs"; import "./e.mjs"; (globalThis.o ??= []).push("c"); export const c = 1;
# d.cjs
require("./e.mjs"); (globalThis.o ??= []).push("d"); module.exports = {};
# e.mjs
(globalThis.o ??= []).push("e"); export const e = 1;

Expected output (what Node prints): e,d,c,b,a,entry.

Cause

A CommonJS module imported by an ESM graph has its body evaluated while the graph is still loading (the loader's makeModule step runs the CJS body to learn its exports). That body can require() an ESM sibling the graph already started fetching on the transpiler thread, so the sibling's registry entry is status Fetching with a pending fetch promise.

fetchCommonJSModule re-transpiles the file synchronously, but provideFetch() only accepts a New entry, so the fresh source was silently dropped. The synchronous require(esm) load then had nothing to drain, its load promise stayed pending, and the pending promise was misreported as an async module.

Fix

Settle the entry's fetch promise directly with the just-transpiled source. The FetchSettled reaction lands on the synchronous module queue the caller drains, so the require(esm) load completes without yielding. The async transpiler's own result arrives later on an already-settled promise and is dropped by the existing PromiseFulfillWithoutHandlerJob pending-target guard (the same tolerance hostLoadImportedModule's synchronous replay path relies on).

Verification

  • Repro above: 0/40 failures after, 28/30 before (release), 2/5 before (debug).
  • New test test/js/bun/resolve/require-esm-in-flight-sibling.test.ts pads e.mjs so its transpile reliably loses the race, runs 12 concurrent instances, and asserts the exact evaluation order. Fails on an unfixed build, passes with this change.
  • test/js/bun/resolve require/esm suites and test/js/node/module pass.

Related

Fixes #33180 (the same race, hit in the wild through jose via jwks-rsa/firebase-admin). #33184 is an earlier PR for that issue using the same mechanic in functionEsmLoadSync; see the comparison in the comments, only one of the two should land.

The same graph has a second, pre-existing face: with require() of the ESM entry (or BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER=1, or NODE_COMPILE_CACHE), debug builds abort with ASSERTION FAILED: module->loadedModules().size() <= loadedModulesCountBefore + 1 in JSModuleLoader::innerModuleLoading because nested synchronous loads can complete other edges of the referrer module during one host-load call, which also loses ModuleGraphLoadingError reactions on release builds. That is fixed on the JSC side in oven-sh/WebKit#396; tests for those doors follow with the WebKit version bump once it merges. Verified locally against a WebKit build with both changes: all doors print e,d,c,b,a,entry.


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

fails on main (without fix)
ASAN without fix: 1 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-in-flight-sibling.test.ts
bun test v1.4.0 (f6c9b25e3)

test/js/bun/resolve/require-esm-in-flight-sibling.test.ts:
44 |         env: bunEnv,
45 |         cwd: String(dir),
46 |         stderr: "pipe",
47 |       });
48 |       const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
49 |       expect(stderr).toBe("");
                          ^
error: expect(received).toBe(expected)

- ""
+ "1 | })
+ 2 | (function (resolved) {"use strict";
+ 3 |   var exports = @esmNamespaceForCjs(resolved);
+ 4 |     exports = @loadEsmIntoCjs(resolved);
+                                  ^
+ TypeError: require() async module "/tmp/require-esm-in-flight_hHV1mF/c.mjs" is unsupported. use "await import()" instead.
+       at require (57:24)
+       at <anonymous> (/tmp/require-esm-in-flight_hHV1mF/b.cjs:2:10)
+ 
+ Bun v1.4.0-debug+f6c9b25e3 (Linux x64)
+ "

- Expected  - 1
+ Received  + 11

      at <anonymous> (/workspace/bun/test/js/bun/resolve/require-esm-in-flight-sib
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (45ee9556a)

test/js/bun/resolve/require-esm-in-flight-sibling.test.ts:
44 |         env: bunEnv,
45 |         cwd: String(dir),
46 |         stderr: "pipe",
47 |       });
48 |       const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
49 |       expect(stderr).toBe("");
                          ^
error: expect(received).toBe(expected)

- ""
+ "2 |   require("./e.mjs");
+              ^
+ TypeError: require() async module "/tmp/require-esm-in-flight_HOIl10/e.mjs" is unsupported. use "await import()" instead.
+       at <anonymous> (/tmp/require-esm-in-flight_HOIl10/d.cjs:2:10)
+ 
+ Bun v1.4.0-canary.1+45ee9556a (Linux x64)
+ "

- Expected  - 1
+ Received  + 7

      at <anonymous> (/workspace/bun/test/js/bun/resolve/require-esm-in-flight-sibling.test.ts:49:22)
      at async <anonymous> (/workspace/bun/test/js/bun/resolve/require-esm-in-flight-sibling.test.ts:40:17)
(fail) require() of an ESM sibling that is still transpiling completes synchronously [47.35ms]

 0 pass
 1 fail
 22 expect() calls
Ran 1 test across 1 file. [202.00ms]
__F:1:S:0
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-in-flight-sibling.test.ts
bun test v1.4.0 (f6c9b25e3)

test/js/bun/resolve/require-esm-in-flight-sibling.test.ts:
(pass) require() of an ESM sibling that is still transpiling completes synchronously [3824.99ms]

 1 pass
 0 fail
 36 expect() calls
Ran 1 test across 1 file. [6.12s]
__F:0:S:0

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     f6c9b25e30
  features     baseline

22 deps, 107 codegen, 1176 objects in 731ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1238] install /workspace/bun
bun install v1.4.0-canary.1 (45ee9556a)

Checked 107 installs across 153 packages (no changes) [9.00ms]
[2/1238] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (45ee9556a)

Checked 1 install across 2 packages (no changes) [4.00ms]
[3/1238] gen bindgenv2
[4/1238] gen ErrorCode+*.h
[5/1238] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (45ee9556a)

Checked 129 installs across 147 packages (no changes) [15.00ms]
[6/1238] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[7/1238] fetch picohttpparser
[picohttpparser] up to date
[8/1238] fetch zlib
[zlib] up to date
[9/1238] fetch tinycc
[tinycc] up to date
[10/1237] subst deps/zlib/zlib.h
[11/1237] gen .bind.ts → GeneratedBindings.cpp
[12/1237] subst deps/zlib/zconf.h
[13/1188] fetch zstd
[zstd] up to date
[14/1157] fetch nodejs
... (truncated)
diff hotspot
src/jsc/bindings/ModuleLoader.cpp                  | 45 ++++++++++++++++--
 .../resolve/require-esm-in-flight-sibling.test.ts  | 55 ++++++++++++++++++++++
 2 files changed, 96 insertions(+), 4 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                      reads  edits  tests
src/jsc/bindings/ModuleLoader.cpp                             3      2      0
…st/js/bun/resolve/require-esm-in-flight-sibling.test.ts      0      2      0

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

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 25 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ae59d093-8c5d-47b3-a346-95afcc92dcd5

📥 Commits

Reviewing files that changed from the base of the PR and between f972c28 and f6c9b25.

📒 Files selected for processing (2)
  • src/jsc/bindings/ModuleLoader.cpp
  • test/js/bun/resolve/require-esm-in-flight-sibling.test.ts

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

@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 - Fixes the same false require() async module TypeError caused by a registry entry stuck at Status::Fetching during a synchronous require(esm), using the same ensureFetchPromise/fulfillPromise + synchronous-module-queue drain mechanic, just applied in functionEsmLoadSync (ZigGlobalObject.cpp) instead of at the provideFetch call sites in ModuleLoader.cpp.

🤖 Generated with Claude Code

@robobun

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

The overlap with #33184 is real but partial. Both address the same symptom (the false require() async module TypeError for a registry entry still Fetching, reported in #33180) with the same core mechanic: settle the entry's fetch promise under a synchronous module queue, relying on the pending-target guard in PromiseFulfillWithoutHandlerJob to drop the async transpiler's late delivery.

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.

@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 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 provideFetch call sites in fetchCommonJSModule/fetchCommonJSModuleNonBuiltin route through the new helper; the fallthrough still calls provideFetch with the JSSourceCode* overload (matches ZigGlobalObject.cpp:741).
  • The isolation-cache and non-builtin sites now wrap SourceCode in JSSourceCode::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.

@robobun

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

For whoever takes the human look, the two contracts the review flags are both explicit in the vendored JSC:

  • fulfillPromise on a pipeFrom()'d promise: pipeFrom sets isFirstResolvingFunctionCalledFlag on the target (JSPromise.cpp, JSPromise::pipeFrom), and that flag only gates the guarded entry points (fulfill/reject/rejectAsHandled). That is exactly why the helper calls fulfillPromise directly, and it is the same call hostLoadImportedModule's synchronous-drive block makes for the nested case (JSModuleLoader.cpp, the fetchPromise->fulfillPromise(vm, promise->result()) branch under USE(BUN_JSC_ADDITIONS)). This PR extends that existing contract to the require() entry path rather than inventing a new one.
  • The async transpiler's late delivery: the PromiseFulfillWithoutHandlerJob handler in JSMicrotask.cpp opens with a pending-target check added for force-settled promises ("hostLoadImportedModule may have force-settled this promise inline while a synchronous loadModule was active; the queued pipeFrom job is now redundant"), so the piped result lands on a settled promise and is dropped. The stranded-FetchSettled copy on the normal microtask queue is likewise guarded by the pending-modulePromise check in moduleRegistryFetchSettled.

CI is green across all lanes on Buildkite build 90527.

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.

Bun 1.3.14 regression: require() async module error for jose via jwks-rsa/firebase-admin (works fine on 1.3.11)

2 participants