Skip to content

bundler: evaluate a wrapped module's dependencies in source order and await every async one - #41601

Open
robobun wants to merge 8 commits into
mainfrom
robobun/46e46b5b/bundler-wrapper-init-order
Open

robobun wants to merge 8 commits into
mainfrom
robobun/46e46b5b/bundler-wrapper-init-order

Conversation

@robobun

@robobun robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun build reorders the evaluation of a lazily wrapped module's dependencies. InsideWrapperPrefix (src/bundler/LinkerContext.rs) inserted sync init_x() calls at a cursor, put async ones in one await, and appended export * inits, require_x() calls and __reExport calls after both. An export * of an async module was never awaited: the re-exporter and the entry ran while it was suspended, and its rejection did not stop them (exit 0).
  • import() of an async module in the same bundle printed init_a().then(...) (src/js_printer/lib.rs). Inside a's own first synchronous segment init_a() returns undefined: TypeError: undefined is not an object (evaluating 'init_a().then').
  • propagate_async_dependencies (src/bundler/LinkerGraph.rs) read a dependency's async flag while that dependency was still on the DFS stack. A module on an import cycle stayed sync and printed await inside a plain arrow: error: "await" can only be used inside an "async" function, build exit 0.

Fix

  • The prefix keeps source order. The first async dependency becomes await init_a(). Every dependency after it joins that statement, await Promise.all([init_a(), init_b(), ns = require_c()]), with var ns; hoisted above. Each dependency starts before the wrapper suspends, as the module graph evaluates them, and the body runs after all settle. export * of a wrapped module is the same kind of dependency as import "path". Promise is an unbound symbol, so the __promiseAll runtime helper and its liveness bookkeeping go away.
  • import() of an async module prints (init_a() || Promise.resolve().then(() => init_a())).then(() => exports_a). The re-entrant call resolves after a settles instead of throwing. A first call still starts the target at once, so a top-level await import() whose target imports the awaiter back keeps working, the same self-deadlock skip the runtime applies (moduleLoaderImportModule: thread referrer asyncEvaluationOrder for TLA self-deadlock skip #32437).
  • Async propagation is a worklist over reverse import edges, so it reaches a fixpoint around cycles.
  • Verified: test/bundler/bundler_edgecase.test.ts (9 new cases checked against node on the unbundled files, 8 fail on main, 1 guards the re-export join). Also all of test/bundler/ and test/js/bun/resolve/dynamic-import-tla-cycle.test.ts.

Background

  • A module that something import()s without --splitting is wrapped in var init_x = __esm(() => { ... }). The wrapper runs the module once, on first call. If the module or one of its imports has top-level await, the arrow is async and returns a promise.
  • Inside the wrapper each import of another wrapped module becomes init_dep(). The module graph evaluates dependencies in source order: an async one runs up to its first await, the next one starts, and the importer's body waits for all of them.
  • fix(build): Promise.all() async module dependencies #22704 introduced the Promise.all join so that siblings on an async cycle all start before any is awaited. esbuild instead awaits each async dependency where it stands.
Notes

Ordering choice. esbuild's rule (await init_a(); init_b();) is order-preserving but linearises: b does not start until a has fully settled. The join keeps #22704's cycle behaviour and matches node's trace on the reported graphs (a start, b, a end, x body), at the cost that a sync dependency's body can run before an earlier async sibling finishes its post-await part, exactly as in the unbundled module graph. #33337 implemented the esbuild rule for the sync-ordering subset of this; this PR supersedes it (it also covers export *, import(), the cycle propagation, and removes the helper whose liveness #33337 had to patch).

import() of an async module is still started synchronously on a first call, like esbuild. Deferring it to a microtask would match node for import('./c'); console.log(await 1) (node prints 1 then 0, the bundle prints 0 then 1, unchanged from main), but it turns the await import() back-edge cycle in dynamic_import_dce/NoSplitCycleThroughImporter into a silent hang, because bun's runtime deliberately lets that cycle through (#32437, dynamic-import-tla-cycle.test.ts). That test is unchanged.

Changes visible in existing tests: two inline snapshots (await Promise.all([ instead of await __promiseAll([, and the (init_x() || ...) form), the bundler_promiseall_deadcode assertions now check for Promise.all and the absence of the helper, and the libraries.js hash in bundler_bytecode_portable moved because three wrappers in that corpus now call their dependencies in source order (same hash on every CI platform).

A separate runtime divergence found while testing (a static import of an evaluating-async module reached through import() does not wait for it; node does) is tracked separately.

Suites run with the debug build: all of test/bundler/, test/bake/dev/, test/js/bun/resolve/dynamic-import-tla-cycle.test.ts. The only local failures are 5 s timeouts of builds under the debug binary (bun-build-api bytecode sweeps, bake/dev/production), identical on main.


no test proof · iteration 2 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/bundler/bundler_bytecode_portable.test.ts

… await every async one

The statements a wrapper runs before its body (init_x() calls, require_x()
calls, __reExport calls) were grouped by kind: sync init calls first, then
one await of the async ones, then everything else. An export * of a wrapped
module was never awaited. Both reorder module evaluation against the module
graph. They now stay in source order. Once an async dependency is seen,
every later dependency joins that statement as
await __promiseAll([init_a(), init_b(), ns = require_c()]), so each one
starts before the wrapper suspends and the body runs after all settle.

import() of an async module in the same bundle printed init_a().then(...).
Called from inside a's own first synchronous segment, init_a() returns
undefined and the .then throws. It now defers the call to a microtask,
like import() of a sync module already did.

A module that imports an async module through an import cycle could miss
the async flag: the depth-first walk read a dependency's flag while that
dependency was still on the stack. The propagation is now a worklist over
reverse edges, so it reaches a fixpoint.

__promiseAll is marked live from the parts that hold the import statements
that join it, so an unwrapped entry point gets the helper too.
@coderabbitai

coderabbitai Bot commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 14 days. After that, they cost $0.25 per reviewed file.

Or wait 3 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 56aba224-0417-44df-a42b-945f585e2dae

📥 Commits

Reviewing files that changed from the base of the PR and between d316760 and 184b4e0.

📒 Files selected for processing (11)
  • src/ast/runtime.rs
  • src/bundler/LinkerContext.rs
  • src/bundler/LinkerGraph.rs
  • src/bundler/linker_context/convertStmtsForChunk.rs
  • src/js_printer/lib.rs
  • src/runtime.js
  • test/bundler/bundler_bytecode_portable.test.ts
  • test/bundler/bundler_dynamic_import_dce.test.ts
  • test/bundler/bundler_edgecase.test.ts
  • test/bundler/bundler_promiseall_deadcode.test.ts
  • test/regression/issue/cyclic-imports-async-bundler.test.js

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

@robobun

robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:42 PM PT - Sep 6th, 2026

❌ @robobun, your commit 184b4e0 has 6 failures in Build #111827 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 41601

That installs a local version of the PR into your bun-41601 executable, so you can run:

bun-41601 --bun

…der change

The bundle of libraries.js now calls each module's dependencies in source
order. The same js and jsc hashes were produced on every CI platform.
Comment thread src/bundler/LinkerContext.rs Outdated
Comment thread src/bundler/LinkerContext.rs Outdated
Comment thread src/bundler/LinkerContext.rs Outdated
Comment thread src/bundler/LinkerContext.rs Outdated
Comment thread src/bundler/LinkerContext.rs Outdated
Comment thread src/bundler/LinkerGraph.rs Outdated
Comment thread src/bundler/LinkerGraph.rs Outdated
Comment thread src/bundler/linker_context/convertStmtsForChunk.rs Outdated
Comment thread src/bundler/linker_context/scanImportsAndExports.rs Outdated
Comment thread src/js_printer/lib.rs Outdated
Comment thread src/bundler/LinkerContext.rs Outdated
Comment thread src/bundler/LinkerGraph.rs
Comment thread src/bundler/LinkerGraph.rs
Comment thread src/bundler/linker_context/scanImportsAndExports.rs Outdated
Comment thread src/js_printer/lib.rs Outdated
Comment thread src/bundler/linker_context/scanImportsAndExports.rs Outdated
… export star

The re-exporter appends __reExport(exports, exports_r) right after the
init_r() of the same record, so it joins the awaited list even though no
later record exists.
Comment thread src/bundler/linker_context/scanImportsAndExports.rs Outdated
Comment thread src/bundler/linker_context/scanImportsAndExports.rs Outdated

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

…argets before deferring

Emit await Promise.all([...]) through an unbound Promise symbol instead of
the __promiseAll runtime helper. The helper needed a per-part liveness count
that mirrored the prefix's join rule, which broke twice; an unbound symbol
is reserved by the renamer and needs no bookkeeping. __promiseAll leaves
runtime.js and the runtime imports table.

import() of an async module calls init_foo() first and only defers when the
call returns undefined, which happens inside foo's own first synchronous
segment. This keeps a top-level await import() whose target imports the
awaiter back evaluating at once, the same self-deadlock skip the runtime
applies, while the re-entrant case resolves after foo settles instead of
throwing on undefined.then.
Comment thread src/bundler/LinkerContext.rs Outdated
Comment thread src/js_printer/lib.rs

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

This pull request has been reviewed before and this review found new issues. Where they share a root cause, one fix may close them together.

Comment thread test/bundler/bundler_promiseall_deadcode.test.ts

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

@robobun

robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced all five reported divergences with bun build on 1.4.2 and main, each against node on the unbundled files, and reduced them to the nine edgecase/* cases added in test/bundler/bundler_edgecase.test.ts (eight fail on main, all pass here). At 184b4e0 the branch builds on all 13 platforms and all of test/bundler/ passes on every test lane.

The red jobs at 184b4e0 are not touched by this diff: verify-baseline (the llint_op_wide16 INVLPGB scan hit, red on main too; no C++ or build changes here), the pre-existing test-crypto-dh-leak.js on x64-asan, and registry or network failures on single lanes (bun-update-transitive.test.ts with Verdaccio exiting, test-tonic.test.ts, a bun install timeout for test/package.json on darwin aarch64, test-child-process-fork-net-server.js on Windows 2019). The previous run of the same bundler code (9f8c400) had none of those and only the first two red.

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

This pull request has been reviewed before and this review found new issues. Where they share a root cause, one fix may close them together.

Comment thread src/bundler/LinkerContext.rs
…se.all never awaits it

A CommonJS module's exports, or the namespace object __reExport returns,
can be a thenable. As a bare element of Promise.all([...]) it would be
awaited. void (...) keeps the evaluation order and drops the value.
Comment thread src/bundler/LinkerContext.rs
Comment thread src/bundler/LinkerContext.rs
Comment thread src/bundler/LinkerContext.rs

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

@robobun

robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

One more case this branch fixes, found independently. It is the plain synchronous form of the first Problem bullet: no top-level await anywhere, one CommonJS import followed by one import of an __esm-wrapped module.

// entry.js
import "./legacy.js";   // touches `module`, so it is __commonJS-wrapped
import "./a.js";        // also import()ed below, so it is __esm-wrapped
console.log("entry");
import("./a.js");

// legacy.js
if (typeof module !== "undefined") module.exports = 1;
console.log("legacy");

// a.js
console.log("a");
export const A = 1;

node entry.js prints legacy, a, entry. bun build on 1.4.3 and main emits init_a(); above var import_legacy = __toESM(require_legacy()); and the bundle prints a, legacy, entry. With this branch applied to current main (d745f03) the two statements come out in source order and the output matches node. The same holds for the external require() replacement in --format=cjs (import fs from "node:fs"; import "./a.js";).

None of the nine new edgecase/* cases is purely synchronous with a CommonJS dependency first, so a case like this may be worth adding as a guard:

itBundled("edgecase/WrappedCommonJSDependencyKeepsImportOrder", {
  files: {
    "/entry.js": `
      import "./legacy.js";
      import "./a.js";
      console.log("entry");
      import("./a.js");
    `,
    "/legacy.js": `if (typeof module !== "undefined") module.exports = 1; console.log("legacy");`,
    "/a.js": `console.log("a"); export const A = 1;`,
  },
  run: { stdout: "legacy\na\nentry" },
});

Jarred-Sumner pushed a commit that referenced this pull request Sep 16, 2026
…or a top-level using (#42981)

### Problem
- `bun build` without `--splitting` prints the missing wrapper of a
module as `__INVALID__REF__`. Two inputs reach it on 1.4.2 and main
(`b8eacead4`).
- A dead top-level await: `export function f() {}` plus `false && await
0;`. `import()` of it prints `await __INVALID__REF__()`:
`ReferenceError: __INVALID__REF__ is not defined`. The parser records
the `await` before it folds away, so the linker marks the module async.
`require_or_import_meta_for_source`
(`src/bundler/LinkerContext.rs:2528`) reports an async wrapper that does
not exist.
- A top-level `using` with `--target=bun`: `await using a = null;`. The
linker keeps it inside the wrapper
(`generateCodeForFileInChunkJS.rs:755`, #29538). `needs_wrapper_ref`
(`src/js_parser/p.rs:9580`) misses that rule. Output: `var
__INVALID__REF__ = __esm(...)`, then `ReferenceError: __esm is not
defined`.

### Fix
- `is_wrapper_async` is true only when the module has a wrapper. Without
one, `import()` prints as for a sync module: `Promise.resolve()`, plus
`.then(() => exports_t)` if code reads the namespace.
- `needs_wrapper_ref` returns true for a `using` declaration.
- Correct because a module with no wrapper has no import statement and
nothing that stays inside a wrapper.
- Verified: `dynamic_import_dce/ElideDeadTopLevelAwaitInImportee` and
`edgecase/UsingWithHoistableInitializerKeepsWrapper` fail on canary
`c6b7fcb5b`. Suites in Notes.

### Background
- Without `--splitting`, a module that `import()` or `require()` reaches
is wrapped: `var init_t = __esm(() => {...})`. `import()` prints
`Promise.resolve().then(() => (init_t(), exports_t))`, or
`init_t().then(...)` for an async module.
- Function declarations and constant initializers hoist out of the
wrapper. `needs_wrapper_ref` decides if anything stays inside. If
nothing does, `wrapper_ref` is `Ref::NONE`.
- The printer resolves `Ref::NONE` to symbol 0 of `runtime.js`, named
`__INVALID__REF__`.

<details><summary>Notes</summary>

**Repro 1, dead top-level await.** One entry point is enough.

```sh
printf 'const { f } = await import("./f3.ts");\nf();\nconsole.log("done");\n' > entry1.ts
printf 'export function f() {}\nfalse && await 0;\n' > f3.ts
bun build ./entry1.ts --outdir out --target=bun && bun out/entry1.js
# out/entry1.js:  await __INVALID__REF__();
# ReferenceError: __INVALID__REF__ is not defined
```

Every form of the call site printed the missing wrapper:

| source | before | after |
| --- | --- | --- |
| `const { f } = await import("./f3.ts");` (every name bound to an
export) | `await __INVALID__REF__();` | `await Promise.resolve();` |
| `const ns = await import("./f3.ts");` with only `ns.x` reads | `await
__INVALID__REF__().then(() => ({}));` | `await Promise.resolve().then(()
=> ({}));` |
| `void await import("./f3.ts");` | `await __INVALID__REF__().then(() =>
exports_f3);` | `await Promise.resolve().then(() => exports_f3);` |
| `import("./f3.ts");` | `__INVALID__REF__();` | `Promise.resolve();` |

With `--minify-identifiers` the printed name is a short one:
`ReferenceError: n is not defined`.

**Repro 2, top-level `using`.**

```sh
printf 'const { f } = await import("./f3.ts");\nf();\nconsole.log("done");\n' > entry1.ts
printf 'await using x = null;\nexport function f() {}\n' > f3.ts
bun build ./entry1.ts --outdir out --target=bun && bun out/entry1.js
# out/entry1.js:  var __INVALID__REF__ = __esm(async () => { await using x = null; });
#                 await __INVALID__REF__();
# ReferenceError: __esm is not defined
```

A debug build stops earlier, at
`debug_assert!(!ast.wrapper_ref.is_empty())` in
`generateCodeForFileInChunkJS.rs`.

The sync forms fail the same way: `using x = 1;`, `using x = () => {};`,
also through `require("./f3.ts")`. With an unused `import()` result the
module was dropped from the bundle, so the `TypeError` of `using b = 1;`
never ran. After the fix the module keeps `var init_f3 = __esm(...)`,
and the bundle throws the same `TypeError` as the unbundled program. A
review comment on this PR pointed at this input.

**Where the check lives.** The first commit put the check in the
printer. The last commit moves it to
`require_or_import_meta_for_source`, which builds the
`RequireOrImportMeta` that the printer reads. The printer is the only
reader of `is_wrapper_async`, and it is unchanged from main. The sync
`import()` and `require()` path in the printer and the static import
path (`LinkerContext.rs:2329`) already check the wrapper.

**Modules that keep the async path.** An importee with an import
statement always has a wrapper, so `import "./tla.ts"` in the importee,
or a live `await`, still prints `await init_f3()`. An unwrapped
`require()` of the packages in `DEFAULT_UNWRAP_COMMONJS_PACKAGES` also
adds an `S.Import` statement (`parse_entry.rs:1405`), so that importee
keeps its wrapper too. I checked these forms with the debug build.

**History.** A fuzz run found the first output through another door: two
entry points, and an importee whose only statement is a type-only import
of a module with top-level await. A namespace request from the second
entry point loaded the target of the import that TypeScript dropped, so
the linker marked the statement-less importee async. #42737 closed that
door: 1.4.2 fails, main passes. The dead `await` still reaches the same
state on main.

**Not changed.** The parser keeps one top-level-await range per file and
records it before dead code folds away. So a `require()` of a module
whose only `await` is dead code is still a build error (`This require
call is not allowed because the transitive dependency "t.js" contains a
top-level await`), and a wrapped module with a dead `await` still gets
an `async` wrapper. The second case is valid output.

**Overlap.** #41601 rewrites the expression that the async `import()`
path prints. It does not touch `require_or_import_meta_for_source`.

**Suites run with the debug build (linux x64, ASAN).**
`test/bundler/bundler_dynamic_import_dce.test.ts`,
`test/bundler/bundler_edgecase.test.ts`,
`test/bundler/esbuild/default.test.ts`,
`test/bundler/bundler_browser.test.ts`,
`test/js/bun/resolve/lower-using-bun-target.test.ts`,
`test/regression/issue/11100.test.ts`.

</details>

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