Skip to content

require(esm): reject top-level await before evaluating, with ERR_REQUIRE_ASYNC_MODULE - #41788

Open
robobun wants to merge 13 commits into
mainfrom
robobun/6e997f79/require-esm-tla-before-eval
Open

robobun wants to merge 13 commits into
mainfrom
robobun/6e997f79/require-esm-tla-before-eval

Conversation

@robobun

@robobun robobun commented Sep 7, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • require() of an ES module graph with top-level await runs the graph up to the first await, then throws TypeError: require() async module "<path>" is unsupported. use "await import()" instead. Node throws ERR_REQUIRE_ASYNC_MODULE and runs nothing.
  • Cause: functionEsmLoadSync (src/jsc/bindings/ZigGlobalObject.cpp) evaluates while it loads (loadModuleSync()). Only a pending promise afterwards shows the await.

Fix

  • Load the graph first (loadModule without ModuleLoadFlag::Evaluate). Throw if findPendingTopLevelAwait finds a module whose await has not ended. Otherwise link and evaluate.
  • The throw is Node's Error with code ERR_REQUIRE_ASYNC_MODULE. The message names the requiring file and the awaiting module.
  • A graph whose awaits have all ended is still handed over.
  • Verified: require-esm-transitive-tla.test.ts (6 fail on main), 24387.test.ts. See notes.

Background

  • functionEsmLoadSync runs JSC's loader under a VM::SynchronousModuleQueue: its promise reactions run inline.
  • [[HasTLA]] is set per record at parse time. The check follows ReadyForSyncExecution (import defer proposal), but a module in mid-evaluation stays a cycle.
  • Node's rule (v8::Module::IsGraphAsync) also refuses a finished graph. Not taken: module-graph.test.ts pins the opposite.

Downsides

  • Not mergeable yet. A --aot program keeps the old behaviour (its require() is in WebKit), and test/aot compares both.
  • Breaking: require() returned a module whose awaits did not run or waited for settled promises, and now throws. test/aot/generate.ts requires a file that awaits under if (import.meta.main): 29 test/aot files fail in CI.
  • Each first require() of an ES module runs the load chain twice (9 more cells, counted).
Notes
  • A program that ends with exit 1 on main and runs on Node and on this branch. c.cjs requires m2.mjs, whose graph holds a top-level await. Main starts the graph from m2.mjs, so the cycle m2 → m3 → m4 → m1 → m2 is entered at m2 and m1.mjs reads v2 before it exists. The error stays on the records, and the entry's own import "./m3.mjs" then fails.

    // m0.mjs
    import "./c.cjs"; import "./m3.mjs"; console.log("m0 ends");
    // c.cjs
    try { require("./m2.mjs"); } catch (e) { console.log("c caught an error"); }
    // m1.mjs
    import { v2 } from "./m2.mjs"; console.log("m1 reads", v2);
    // m2.mjs
    import "./m3.mjs"; export const v2 = "v2";
    // m3.mjs
    import "./m4.mjs";
    // m4.mjs
    import "./m1.mjs"; await null;

    Node 26.3 and this branch: c caught an error, m1 reads v2, m0 ends. Main at 11c3671: c caught an error, then exit 1. The test "and leaves an import cycle to be entered where the importing graph enters it" holds this program.

  • Behaviour against Node 26.3 (x = a module body ran before the throw):

    case node bun main this branch
    require(tla.mjs), await 0 ERR_REQUIRE_ASYNC_MODULE TypeError, x ERR_REQUIRE_ASYNC_MODULE
    await Promise.resolve() ERR_REQUIRE_ASYNC_MODULE returns the module, x ERR_REQUIRE_ASYNC_MODULE
    a module without the await imports one with it ERR_REQUIRE_ASYNC_MODULE TypeError, x ERR_REQUIRE_ASYNC_MODULE
    a CommonJS file in a loading ES graph requires such a module of that graph ERR_REQUIRE_ASYNC_MODULE TypeError, x ERR_REQUIRE_ASYNC_MODULE
    the module still waits at its await (an import() started it) ERR_REQUIRE_ASYNC_MODULE TypeError ERR_REQUIRE_ASYNC_MODULE
    await import(tla) has ended, then require(tla) ERR_REQUIRE_ASYNC_MODULE returns the module returns the module
    refused require(tla), then import(tla) evaluates once evaluates once evaluates once
    require(esm) whose body throws, 3 times, then import() one evaluation, cached error same same
  • The merge with main on 2026-10-28 changed three things in this PR:

    • Main passes the requiring module object to $esmLoadSync (Bun.ModuleGraph: a context per graph for timers and I/O, per-graph CommonJS #42590). The error reads the file name from that object, so the PR no longer touches CommonJS.ts or builtins.d.ts. A Module._extensions handler passes the module that is being loaded. Its parent is then the requiring file, and a module object without a parent gets no From line.
    • The graph walk follows the edges of a prelinked graph (prelinkedRequestedModule), which are not in [[LoadedModules]].
    • The PR no longer refuses a graph that an import() has finished. Main added tests for that behaviour ("require(esm) by module state" in test/js/bun/module-graph/module-graph.test.ts), and test/aot/generate.ts requires a module whose graph holds the finished module with the await.
  • Expectations of main's tests that this PR changes, all for a module whose await has not run:

    • module-graph.test.ts: one regular expression and one message.includes("await import") read the old message. One list expected the refused tla.mjs in require.cache. It ran after the throw on main and does not run now.
    • require-extensions.test.ts: the row for top-level-await.js expected the old TypeError.
  • The break that CI shows (build 134500). test/aot/generate.ts loads its generators with require(). One of them, test/aot/generate-built-ins-alone.ts, is also a script: its only top-level await is inside if (import.meta.main) { ... }. When the file is required, that branch does not run, so main evaluates the module synchronously and returns it. [[HasTLA]] is a property of the source text, so this branch refuses the file, as Node does. 29 test files of test/aot (built-ins-alone-*, every-program-*, every-program-alone-*, calls-beyond-their-reach-*) fail when they load, all with this one error. I did not change the harness: whether require() may refuse such a file is the decision this PR waits for.

  • The same holds for a module whose awaits all wait for settled promises (export const x = await Promise.resolve(1)): main returns it, this branch and Node refuse it.

  • The --aot difference in detail. TEST_PART=aot/modules AOT_PROGRAMS=require-of-a-module-with-top-level-await bun bd test test/aot/parts.test.ts runs 2 programs and both fail on this branch. Under bun run every require() of a module with a pending await now throws Error. The compiled program still hands over a module whose awaits are settled and throws TypeError for the others. About 30 more programs of test/aot/programs-in-every-run.txt require a module with top-level await (by name: require-of-a-module-with-top-level-await*, a-module-resumed-inside-require-*, require-settles-the-promise-*, each-way-to-require-*). I did not run those. BunCompiledModules.cpp calls AOT::requireModule, and compiled code calls the engine directly, so the host has no place for the check. docs/compiler/compatibility.mdx (the row on require() of modules where one awaits, and the sentence "require() can't wait") describes the old bun run behaviour and is not changed here. It depends on what the engine does.

  • Differences from Node that remain:

    • A finished graph is handed over (see above).
    • A CommonJS file that the refused graph imports still runs, because Bun runs an imported CommonJS file while it loads the graph.
    • if (false) await 0 is accepted, because the transpiler drops the dead branch before the record exists.
    • A module with both a top-level await and a link error reports ERR_REQUIRE_ASYNC_MODULE. Node links first and reports the SyntaxError.
  • After a refusal the graph stays loaded and not evaluated. delete require.cache[file] does not drop it, because main does not evict a module that has not evaluated. On main the module finished later and could then be dropped and read again. Node keeps it too.

  • The cost for a graph without top-level await: the link-and-evaluate step is main's loadModuleSync(), which starts the top-level load chain again over the loaded graph. That is 6 promises, 2 ModuleLoadingContext and 1 ModuleGraphLoadingState per first require() of an ES module (JSModuleLoader::loadModule and moduleLoadTopSettled at WebKit fcc1ea0973a4), plus one walk of the graph. A later require() of the same module returns before any of this. JSModuleLoader::linkAndEvaluateModule in place of loadModuleSync() removes the second chain. It also replaces main's error path, so I left it out of this diff.

  • Loads that cannot finish synchronously for another reason (a plugin with an asynchronous onLoad) keep the old TypeError.

  • Instead of Node's hint at --experimental-print-required-tla (a flag Bun does not have) the message names the module with the await.

  • Supersedes require(esm): throw ERR_REQUIRE_ASYNC_MODULE instead of a bare TypeError #33438 and require(esm): reject any top-level-await graph with ERR_REQUIRE_ASYNC_MODULE #33891. node: require(esm) v26 compat — TLA + cycle error contract (+72 tests) #35693 (draft, on another base) holds a similar two-step load inside a larger change.

  • Suites run on Linux x64 (ASAN debug) at 51adf36: require-esm-transitive-tla, 24387, require-extensions, module-graph, require-esm-evaluating-cycle, require-esm-microtask-order, require-esm-gc-roots, require.test.ts, import-meta, esModule, builtin-esm-lazy-exports, dynamic-import-tla-cycle, isolation-one-module-fetched-twice, require-extensions-override, esm-registry-concurrent-gc, mock-module, plugins, node-module-module, runtime-transpiler, module-graph-compile, module-graph-gc, and compile/splitting/SplitRequireLoadsChunkSynchronously. These time out at the default 5 s on my ASAN debug build with and without the change: 12910.test.ts, the four dynamicImport tests of module-graph.test.ts (and one or two more of that file from run to run), and the five FoldingNeverMakesABundleWorse graphs (all five return ok when checkGraph runs outside the test). With the per-test timeout that CI gives an ASAN build (bun bd test --timeout=270000), all 322 tests of the four changed test files pass on that build. They also pass on a release build with the default timeout. Windows was not run after the merge.

  • Self-reviewed after the merge: 7 concerns raised. 4 led to changes (a module object without a parent, prelinked edges, the finished graph, the CommonJS wording in the docs). 3 are listed above (--aot, require.cache, the second load chain).


[human-review] gate passed · iteration 4 · 8 files touched

fails on main (without fix)
ASAN without fix: 6 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/resolve/require-esm-transitive-tla.test.ts "test/regression/issue/24387.test.ts"
bun test v1.4.3 (c6b7fcb5b)

test/regression/issue/24387.test.ts:
10 |   });
11 | 
12 |   const { stdout, stderr } = proc;
13 | 
14 |   expect(await stderr.text()).toBe("");
15 |   expect(await stdout.text()).toMatchInlineSnapshot(`
                                   ^
error: expect(received).toMatchInlineSnapshot(expected)

  
- "ERR_REQUIRE_ASYNC_MODULE require() cannot be used on an ESM graph with top-level await. Use import() instead.
+ "undefined require() async module "/workspace/bun/test/regression/issue/24387/p.js" is unsupported. use "await import()" instead.
  Module {
    foo: 67,
  }
  "
  

- Expected  - 1
+ Received  + 1

      at <anonymous> (/workspace/bun/test/regression/issue/24387.test.ts:15:31)
(fail) regression: require()ing a module with TLA should error and then wipe the module cache, so that importing it again works [398.16ms]

test/js/bun/resolve/require-esm-transitive-tla.test.ts:
93 |           const second = await imp
... (truncated)

release without fix: all passed
bun test v1.4.3-canary.1 (f12cf1c8c)

test/regression/issue/24387.test.ts:
(pass) regression: require()ing a module with TLA should error and then wipe the module cache, so that importing it again works [107.86ms]

test/js/bun/resolve/require-esm-transitive-tla.test.ts:
(pass) require(esm) rejects top-level await before evaluating anything > with a message that names the requiring module and the one with the await [7.81ms]
(pass) require(esm) rejects top-level await before evaluating anything > whatever the await shape, and a later import() still evaluates each module once [12.17ms]
(pass) require(esm) rejects top-level await before evaluating anything > also when import() already evaluated the graph [10.43ms]
(pass) require(esm) rejects top-level await before evaluating anything > also when the ES module graph that imports it is still loading [9.57ms]
(pass) require(esm) rejects when a transitive dependency has top-level await [7.20ms]
(pass) require(esm) failing on TLA does not delete an entry an outer import() owns [30.89ms]

 7 pass
 0 fail
 1 snapshots, 21 expect() calls
Ran 7 tests across 2 files. [235.00ms]
__F:0: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/pr_gate.xml" test/js/bun/resolve/require-esm-transitive-tla.test.ts "test/regression/issue/24387.test.ts"
bun test v1.4.3 (c6b7fcb5b)

test/regression/issue/24387.test.ts:
(pass) regression: require()ing a module with TLA should error and then wipe the module cache, so that importing it again works [388.70ms]

test/js/bun/resolve/require-esm-transitive-tla.test.ts:
(pass) require(esm) rejects top-level await before evaluating anything > also when import() already evaluated the graph [327.74ms]
(pass) require(esm) rejects top-level await before evaluating anything > also when the ES module graph that imports it is still loading [319.20ms]
(pass) require(esm) rejects top-level await before evaluating anything > with a message that names the requiring module and the one with the await [368.76ms]
(pass) require(esm) rejects top-level await before evaluating anything > whatever the await shape, and a later import() still evaluates each module once [448.37ms]
(pass) require(esm) rejects when a transitive dependency has top-level await [286.85ms]
(pass) re
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 660ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/127] gen ErrorCode+*.h
[2/127] gen cpp.rs (cppbind)
[3/127] gen JS modules (bundle-modules)
Preprocess modules (8255ms)
Bundle modules (80ms)
Postprocesss modules (462ms)
Bundle Functions (692ms)
Generate Code (42ms)

[9.55s] Bundled "src/js" for production
  2603 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[3/11] cargo bun_runtime → libbun_runtime.a
^[[1m^[[33mwarning^[[0m^[[1m: binary `bun_shim_impl` should have a kebab-case name^[[0m
   ^[[1m^[[94m|^[[0m
^[[1m^[[94m 1^[[0m ^[[1m^[[94m|^[[0m /workspace/bun/build/release/rust-target/.../bun_shim_impl
   ^[[1m^[[94m|^[[0m                                              ^[[1m^[[33m^^^^^^^^^^^^^^[[0m
   ^[[1m^[[94m|^[[0m
   ^[[1m^[[94m= ^[[0m^[[1mnote^[[0m: `cargo::non_kebab_case_bins` is set to `warn` by default
^[[1m^[[96mhelp^[[0m: to change the binary name to `bun-shim-impl`, convert `bin.name`
  ^[[1m^[[94m--> ^[[0msrc/install/windows-shim/Cargo.toml:41:8
   ^[[1m^[[94m|^[[0m
^[[1m^[[94m41^[[0m ^[[91m- ^[[0mname = ^[[91m"bun_shim_impl"^[[0m
^[[1m^[[94m41^[
... (truncated)
diff hotspot
docs/runtime/module-resolution.mdx                 |   2 +-
 src/js/builtins.d.ts                               |   4 +-
 src/js/builtins/CommonJS.ts                        |  19 +--
 src/jsc/bindings/ErrorCode.ts                      |   1 +
 src/jsc/bindings/ZigGlobalObject.cpp               | 112 +++++++++++---
 .../bun/resolve/require-esm-transitive-tla.test.ts | 164 +++++++++++++++++++--
 test/regression/issue/24387.test.ts                |   2 +-
 test/regression/issue/24387/entry.js               |   2 +-
 8 files changed, 258 insertions(+), 48 deletions(-)

gate history · 3 passed · 0 rejected · iteration 4

evidence per changed file
file                                                    reads  edits  tests
docs/runtime/module-resolution.mdx                          1      1     12
src/js/builtins.d.ts                                        0      0     12
src/js/builtins/CommonJS.ts                                 4      4     12
src/jsc/bindings/ErrorCode.ts                               1      1     13
src/jsc/bindings/ZigGlobalObject.cpp                        6      7     14
test/js/bun/resolve/require-esm-transitive-tla.test.ts      2      2     10
test/regression/issue/24387.test.ts                         0      0      8
test/regression/issue/24387/entry.js                        0      0     12

…IRE_ASYNC_MODULE

functionEsmLoadSync loaded, linked and evaluated the graph in one
loadModuleSync() call and only inferred top-level await from a promise
that was still pending afterwards. Module bodies therefore ran before
the throw, graphs whose awaits settled on microtasks alone were returned,
a graph that import() had already evaluated was returned, and the error
was a bare TypeError without a code.

Load the graph first, walk the loaded records' [[HasTLA]] flags, and
only then link and evaluate. A graph with top-level await anywhere in it
now throws ERR_REQUIRE_ASYNC_MODULE before any of it runs, also when it
is already evaluated, as in Node. The registry entry is kept, so a later
import() evaluates the module once and a retried require() fails the
same way.
@robobun

robobun commented Sep 7, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:16 PM PT - Oct 28th, 2026

⏳ @robobun, your commit 51adf36 is still building in Build #134500, but has 98 failures so far (All Failures):

@robobun

robobun commented Sep 7, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on main 11c367163a (debug build) against Node 26.3:

// tla.mjs
console.log("side effect"); await 0; export const x = 1;
// main.cjs
try { require("./tla.mjs") } catch (e) { console.log(e.code ?? e.name) }

Node prints ERR_REQUIRE_ASYNC_MODULE and nothing else. main prints side effect, then TypeError. With this branch Bun prints ERR_REQUIRE_ASYNC_MODULE only.

The program with the import cycle in the PR notes (m0.mjs to m4.mjs) exits 1 on main. It exits 0 on Node and on this branch.

The branch contains main again (head 51adf36df1). Verified with bun bd test test/js/bun/resolve/require-esm-transitive-tla.test.ts test/regression/issue/24387.test.ts test/js/node/module/require-extensions.test.ts on Linux x64 (ASAN debug): 30 pass. With the src/ of main, 7 of the 8 tests in the first two files fail.

Not mergeable. Two things need a maintainer's decision.

  1. The change refuses a module that main accepts. test/aot/generate.ts calls require("./generate-built-ins-alone"), and that file has a top-level await inside if (import.meta.main). The await does not run when the file is required, so main returns the module. The check in this PR is static, as in Node, and refuses it. 29 test files of test/aot fail when they load (build 134500), all with this one error.
  2. An executable built with --aot still runs a required module with top-level await first, because its require() belongs to the engine. test/aot compares each program under bun run with the compiled program, so the programs that require such a module differ on this branch (TEST_PART=aot/modules AOT_PROGRAMS=require-of-a-module-with-top-level-await bun bd test test/aot/parts.test.ts: 2 of 2 fail).

The choice: adopt Node's rule (then the harness file and the engine change too), or keep the current behaviour and close this PR.

@coderabbitai

coderabbitai Bot commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 0288c96e-0bbd-4088-a96a-f42f0ff4766e

📥 Commits

Reviewing files that changed from the base of the PR and between dc7d58d and f12cf1c.

📒 Files selected for processing (5)
  • docs/runtime/module-resolution.mdx
  • src/js/builtins.d.ts
  • src/js/builtins/CommonJS.ts
  • src/jsc/bindings/ZigGlobalObject.cpp
  • test/js/bun/resolve/require-esm-transitive-tla.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 2 remain after this review.

Walkthrough

Synchronous CommonJS-to-ESM loading now passes parent filenames, drains dependency graphs, and rejects graphs with transitive top-level await using ERR_REQUIRE_ASYNC_MODULE. Tests, regression snapshots, and documentation reflect the new behavior.

Changes

Synchronous ESM loading

Layer / File(s) Summary
Parent filename propagation
src/js/builtins.d.ts, src/js/builtins/CommonJS.ts
$esmLoadSync, $requireESM, loadEsmIntoCjs, and requireESM accept and forward optional parent filenames.
Synchronous graph rejection
src/jsc/bindings/ErrorCode.ts, src/jsc/bindings/ZigGlobalObject.cpp
Synchronous ESM loading drains dependency graphs, detects transitive top-level await, reports ERR_REQUIRE_ASYNC_MODULE, handles load states, preserves outer import entries, and updates builtin arity.
Require ESM validation and documentation
test/js/bun/resolve/require-esm-transitive-tla.test.ts, test/regression/issue/24387.test.ts, test/regression/issue/24387/entry.js, docs/runtime/module-resolution.mdx
Tests cover graph rejection, evaluation order, caching, repeated failures, and diagnostics. Regression output and documentation use the updated error behavior.

Suggested reviewers: cirospaciari, dylan-conway

Priority: Normal

Merge Risk: Minimal · up to 51dfa

No actionable regression remains; synchronous require behavior appears ready to merge.

Pre-merge checks: Passed 4
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 "require(esm): reject top-level await before evaluating, with ERR_REQUIRE_ASYNC_MODULE" states the main change: require() of an ES module graph with top-level await is now rejected before an…
Description check Passed The description is complete and directly addresses the PR objective. It explains the problem, implementation, behavioral changes, limitations, compatibility differences, and verification results. It d…
Full details: Title check

Explanation

The title "require(esm): reject top-level await before evaluating, with ERR_REQUIRE_ASYNC_MODULE" states the main change: require() of an ES module graph with top-level await is now rejected before any module evaluates, using the ERR_REQUIRE_ASYNC_MODULE code. The changeset matches this: ZigGlobalObject.cpp checks HasTLA before link and evaluate, ErrorCode.ts adds the code mapping, and the tests cover the new behavior. The title is 85 characters, slightly long, but it is specific and accurate.

Full details: Description check

Explanation

The description is complete and directly addresses the PR objective. It explains the problem, implementation, behavioral changes, limitations, compatibility differences, and verification results. It does not use the exact template headings, but it provides the required information under equivalent sections.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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.

I reviewed this PR and didn't find any bugs. Because it restructures functionEsmLoadSync into a two-phase load/evaluate pipeline and changes registry-entry retention semantics on the TLA throw path, a human familiar with the JSC module loader should confirm the approach.

What was reviewed:

  • Exception-scope discipline in the new C++ paths — RETURN_IF_EXCEPTION after toWTFString/value(globalObject), queue restoration ordered before the exception check, and throws routed through Bun::throwError/throwVMTypeError with the scope.
  • findTopLevelAwaitInGraph — only entered when isModuleGraphLoaded proved a non-null cyclic record; dependencies null-checked before enqueue; WTF containers used per convention.
  • Node compat — ERR_REQUIRE_ASYNC_MODULE added via ErrorCode.ts as Error (not TypeError), message stem matches Node verbatim; the extra "top-level await is in" line replaces Node's --experimental-print-required-tla hint as the description states.
  • Tests — test.concurrent subprocess spawns with concurrent pipe drain, tempDir/bunEnv/bunExe, stderr asserted before exitCode, and the updated 24387 snapshot/existing transitive-TLA assertion reflect the intentional error-shape change rather than a weakening.
Extended reasoning...

Overview

This PR reworks require() of ES modules with top-level await to match Node.js semantics. The core change in src/jsc/bindings/ZigGlobalObject.cpp splits functionEsmLoadSync from a single load+link+evaluate step into two phases: first load the graph under a SynchronousModuleQueue without evaluating, walk [[LoadedModules]] via a new findTopLevelAwaitInGraph DFS checking hasTLA(), throw ERR_REQUIRE_ASYNC_MODULE if any record has TLA, and only then link+evaluate. The same walk runs on already-loaded registry entries so require() after a successful import() is rejected consistently. A parentFilename argument is threaded through $requireESM → $loadEsmIntoCjs → $esmLoadSync (builtins.d.ts, CommonJS.ts, arity bump) purely for the error's "From" line. ERR_REQUIRE_ASYNC_MODULE is added to ErrorCode.ts. Tests in require-esm-transitive-tla.test.ts are substantially expanded and 24387.test.ts is updated for the new error shape.

Security risks

None identified. This is module-loading control flow with no auth, crypto, or untrusted-data parsing. The new graph walk operates on JSC-owned module records held by the loader registry; no user-controlled sizes drive allocation, and the DFS is bounded by the visited set.

Level of scrutiny

High. functionEsmLoadSync is on the critical path for every require() that resolves to an ES module, and the change alters when registry entries are retained versus removed on the throw path (kept on ERR_REQUIRE_ASYNC_MODULE so a later import() reuses the record; removed only for genuinely-async fetches this call created). The removal of !cyclic->hasTLA() from the post-evaluate Pending fallback is sound because TLA is now caught before evaluation, but the interaction between the two SynchronousModuleQueue scopes (the new load-only one and the existing loadModuleSync one), async plugin onLoad, and concurrent outer import() is subtle enough that a maintainer who owns this loader glue should sign off.

Other factors

Test coverage is strong: multiple await shapes, transitive TLA, retry consistency, evaluation-order proof that no TLA body runs before rejection, subsequent import() evaluating once, rejection after import() already evaluated, and exact multi-line message. Tests follow harness conventions (test.concurrent, Promise.all pipe drain, tempDir, stderr-before-exitCode). The two existing-test edits (24387 snapshot, transitive-TLA assertion) are legitimate updates to the intentionally changed error contract, not weakenings. The PR description is unusually detailed with a Node-vs-before-vs-after behavior matrix and named out-of-scope items. No prior reviewer objections in the timeline.

Comment thread src/js/builtins/CommonJS.ts Resolved
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Resolved
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Resolved
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Resolved
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Resolved
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Resolved
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Resolved
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Resolved
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Resolved
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Resolved
Comment thread src/js/builtins/CommonJS.ts Resolved
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Resolved
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Resolved

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

Code review found no issues

No high-confidence issues detected in this change.

… is still loading

Since the WebKit bump in #43022, require() of a module whose fetch an import
already started loads it synchronously, so this shape reaches the same check.

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

No new blocking issues. 2 optional suggestions (nits or notes on pre-existing code) were found and not posted. Nothing in this review needs a push before merging.

…nd through a wrapped Module._extensions handler

The entry's own imports decide where the cycle is entered. A require() that
starts to evaluate the graph enters it from the required module, and a module
of the cycle then reads an export before it exists.
…o end

Main's tests pin that require() hands over an ES module graph once an
import() has run its top-level awaits to their end (module-graph.test.ts,
"require(esm) by module state"), and test/aot/generate.ts relies on it. Keep
that: the check before evaluation now looks for a module with top-level await
that has not started or is still waiting, the rule of ReadyForSyncExecution.

Also follow the edges of a prelinked graph, which are not in [[LoadedModules]],
and read the requiring file's name from the module object that main now passes
to $esmLoadSync. A module object without a parent is handled.

Three assertions in main's tests restated the old TypeError text, and one
listed the refused module in require.cache, where it no longer appears because
it never runs.

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 src/jsc/bindings/ZigGlobalObject.cpp — Users of a bun build --compile --aot executable still get the old TypeError: require() async module "..." is unsupported with no code when they require() a compiled ES module with top-level await, while plain Bun now throws ERR_REQUIRE_ASYNC_MODULE. The compiled-module path at ZigGlobalObject.cpp:1150 returns before the new check, and the AOT host table still hands JSC createAsyncModuleRequiredError (BunCompiledModules.cpp:620). Fix: make every synchronous require-of-TLA site throw the same ERR_REQUIRE_ASYNC_MODULE error, including the AOT host entry, and update test/aot/programs/modules/a-require-comes-into-a-cycle-of-modules-that-awaits-while-an-import-is-loading-it.expected.txt, which pins the old message.

    Why this was flagged

    A user builds an executable with bun build --compile --aot whose CommonJS code calls require() on an embedded ES module that has top-level await. functionEsmLoadSync at src/jsc/bindings/ZigGlobalObject.cpp:1149-1153 calls Bun::loadCompiledModuleNamespace and returns before the new findPendingTopLevelAwait/throwRequireAsyncModule path at :1185-1186 runs. The AOT host table at src/jsc/bindings/BunCompiledModules.cpp:620 still supplies &createAsyncModuleRequiredError, which builds the old TypeError at src/jsc/bindings/ModuleLoader.cpp:56-58 with no code property. The unchanged test/aot/programs/modules/a-require-comes-into-a-cycle-of-modules-that-awaits-while-an-import-is-loading-it.expected.txt confirms --aot output is TypeError require() async module slow-b.mjs" is unsupported. use "await import()" instead. After merging, code written against the ERR_REQUIRE_ASYNC_MODULE contract works under plain Bun and --compile but misses the error inside an --aot executable. On the base branch both paths agreed on the TypeError, so there was no inconsistency.

    Verification: src/jsc/bindings/ZigGlobalObject.cpp:1149-1153 returns before the findPendingTopLevelAwait/throwRequireAsyncModule call at :1185-1186. The AOT host table still supplies &createAsyncModuleRequiredError at BunCompiledModules.cpp:620, which builds a TypeError with no code. The pinned test/aot/programs/modules/a-require-comes-into-a-cycle-of-modules-that-awaits-while-an-import-is-loading-it.expected.txt records the old message.

@robobun

robobun commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

The --aot finding is the open question in the Downsides section of the description. I did not change the host entry in this push, for two reasons.

  • The entry (createAsyncModuleRequiredError in BunCompiledModules.cpp) only makes the error object. The engine still runs the required module first, and hands it over when its awaits are settled. A new error there does not make the compiled program agree with bun run. The programs in test/aot that compare the two still differ (for example modules/require-of-a-module-with-top-level-await, where the compiled program returns the module and bun run now throws).
  • a-require-comes-into-a-cycle-of-modules-that-awaits-while-an-import-is-loading-it.expected.txt stores output that is meant to differ from bun run. It changes together with the engine, not before it.

If the engine gets the same check, the host error and that file change with it. If the current behaviour stays, this PR closes. That decision is a maintainer's.

This branch has not been deployed

No deployments
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