Skip to content

bundler: define __promiseAll when an unwrapped file awaits two async ESM wrappers - #42046

Open
robobun wants to merge 4 commits into
mainfrom
robobun/3018ce41/bundler-promiseall-unwrapped-importer
Open

robobun wants to merge 4 commits into
mainfrom
robobun/3018ce41/bundler-promiseall-unwrapped-importer

Conversation

@robobun

@robobun robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A file that is not wrapped and imports two or more top-level-await modules that are also import()ed prints await __promiseAll([...]), but the bundle never defines the helper. bun build exits 0 and the output throws ReferenceError: __promiseAll is not defined.
  • Cause: should_remove_import_export_stmt (src/bundler/LinkerContext.rs:2301) joins the awaits for any importer. Only the WrapKind::Esm arm of create_wrapper_for_file (LinkerContext.rs:3541) marked __promiseAll as used, so for an unwrapped importer tree shaking dropped the helper.

Fix

  • Step 6 of scan_imports_and_exports now counts, per importing file, the import statements whose target is an async ESM wrapper. From the second one on, the part gets a __promiseAll runtime use, next to the __toESM and __reExport uses.
  • The count in create_wrapper_for_file is removed. The step 6 count covers wrapped importers too, and skips import() and require() records, which never join the await.
  • Verified: test/bundler/bundler_promiseall_deadcode.test.ts (5 new cases, 4 fail on stock bun), plus six neighbouring suites (see Notes).
  • Self-reviewed: 4 concerns, 2 addressed, 2 declined (print an unbound Promise.all and delete the helper, the shape bundler: evaluate a wrapped module's dependencies in source order and await every async one #41601 proposes, see Notes).

Background

  • Without --splitting, a module that something import()s is wrapped: var init_a = __esm(async () => {...}). A static import of it becomes init_a(), awaited when the module or a dependency has top-level await.
  • fix(build): Promise.all() async module dependencies #22704 made a file await several async dependencies together through the runtime helper __promiseAll (src/runtime.js).
  • Tree shaking keeps a runtime helper only if a live part records a use of it (generate_runtime_symbol_import_and_use).
Notes

Repro (1.4.2 and 1.4.3-canary.1+f42e98025):

printf 'await Promise.resolve();\nconsole.log("p");\nexport const p = 1;\n' > p.js
printf 'await Promise.resolve();\nconsole.log("q");\nexport const q = 1;\n' > q.js
printf 'import "./p.js";\nimport "./q.js";\nimport("./p.js"); import("./q.js");\nconsole.log("entry");\n' > entry.js
bun build ./entry.js --outfile=out.mjs && bun out.mjs
# ReferenceError: __promiseAll is not defined

With this change the same bundle defines var __promiseAll = (args) => Promise.all(args); and prints p, q, entry under bun and node, with and without --minify.

Relationship to #41601: that open PR reworks the order in which a wrapper evaluates its dependencies and replaces __promiseAll with an unbound Promise.all, which removes this bug as a side effect. This PR is the standalone fix for the ReferenceError and does not change evaluation order. If #41601 lands first, this one is redundant and I will close it. Earlier standalone attempts were #36189 (closed in favour of #33337) and #33337 (closed in favour of #41601), so the error is still on main.

Alternative shape, considered and not taken here: print await Promise.all([...]) through an unbound Promise symbol (the renamer already reserves Promise, and unbound_module_ref is the pattern) and delete __promiseAll from src/runtime.js and src/ast/runtime.rs. That removes the liveness bookkeeping instead of adding a counter, but it drops the helper #22704 introduced and renumbers the runtime import table, and #41601 carries exactly that change. This PR keeps the helper so that the fix stays two hunks.

Precision of the count. The count runs over all parts of a file before tree shaking. The printer joins the awaits within one printed part range. The two differ only toward a defined but unused 45-byte helper, in two cases: one of the two import parts is tree-shaken or lands in a separate part range, or one of the statements is an un-aliased export * from of an async wrapper. That last form does not go through should_remove_import_export_stmt: convertStmtsForChunk.rs:273 prints a bare init_x() for it with no await (esbuild prints the same), so a consumer of such a re-export can read its bindings before the module finished. That is a separate, pre-existing ordering bug which #41601 addresses, and this PR leaves that site alone. The reverse mismatch, a printed __promiseAll with no definition, cannot happen: every part that holds the second or a later qualifying import carries the use, and a part must be live to be printed.

New tests, all in test/bundler/bundler_promiseall_deadcode.test.ts:

  • unwrapped entry, two async wrappers (the repro): fails on stock bun
  • unwrapped entry, three async wrappers (array append path): fails on stock bun
  • unwrapped non-entry importer: fails on stock bun
  • unwrapped entry, one async wrapper: control, no helper, passes both ways
  • wrapped importer whose second async dependency is an import(): stock bun emitted an unused var __promiseAll, now it does not

The three existing cases in that file (wrapped importers from #22704) are unchanged and pass, including the inline snapshot that contains var __promiseAll.

Other suites run with the debug build, all green: bundler_edgecase, bundler_dynamic_import_dce, bundler_cjs2esm, bundler_splitting, bundler_regressions, esbuild/default.


[human-review] gate passed · iteration 0 · 3 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_promiseall_deadcode.test.ts
ninja: Entering directory `/workspace/bun/build/debug'
[1/162] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 242 extern-C blocks audited
[2/162] gen cpp.rs (cppbind)
[2/162] cargo bun_runtime → libbun_runtime.a
FAILED: rust-target/x86_64-unknown-linux-gnu/debug/libbun_runtime.a 
/workspace/bun/build/release/bun /workspace/bun/scripts/build/stream.ts rust --console --cwd=/workspace/bun --env=CARGO_TERM_COLOR=always --env=BUN_CODEGEN_DIR=/workspace/bun/build/debug/codegen --env=CC=/usr/lib/llvm-21/bin/clang --env=CXX=/usr/lib/llvm-21/bin/clang++ --env=AR=/usr/lib/llvm-21/bin/llvm-ar --env=CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=/usr/lib/llvm-21/bin/clang++ --env=CARGO_HOME=/root/.cargo --env=RUSTUP_HOME=/root/.rustup --env=RUSTUP_TOOLCHAIN=nightly-2026-07-20 --env=CARGO_PROFILE_RELEASE_LTO=off --env=CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 --env=CARGO_PROFILE_RELEASE_DEBUG_ASSERTIONS=true --env=CARGO_ENCODED_RUSTFLAGS='-Cre
... (truncated)

release without fix: 4 FAILED
bun test v1.4.3-canary.1 (f42e98025)

test/bundler/bundler_promiseall_deadcode.test.ts:
(pass) bundler > bundler/__promiseAll is tree-shaken when only one async import exists but __esm remains [25.08ms]
(pass) bundler > bundler/__promiseAll is included when multiple async imports exist with __esm [12.02ms]
(pass) bundler > bundler/__promiseAll is tree-shaken when no async imports despite circular deps with __esm [16.10ms]
426 |       stdout: "p\nq\nentry",
427 |     },
428 |     onAfterBundle(api) {
429 |       const bundled = api.readFile("out.js");
430 |       expect(bundled).toMatch(/await\s+__promiseAll\s*\(\s*\[\s*init_p\(\),\s*init_q\(\)\s*\]\s*\)/);
431 |       expect(bundled).toContain("var __promiseAll = ");
                            ^
error: expect(received).toContain(expected)

Expected to contain: "var __promiseAll = "
Received: "var __esm = (fn, res, err) => () => {\n  if (fn)\n    try {\n      res = fn(fn = 0);\n    } catch (e) {\n      err = [e];\n    }\n  if (err)\n    throw err[0];\n  return res;\n};\n\n// p.js\nvar init_p = __esm(async () => {\n  await Promise.resolve();\n  console.log(\"p\");\n});\n\n// q.js\nvar init_q = __esm(async () => {\n  
... (truncated)
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/bundler/bundler_promiseall_deadcode.test.ts
bun test v1.4.3 (f42e98025)

test/bundler/bundler_promiseall_deadcode.test.ts:
(pass) bundler > bundler/__promiseAll is tree-shaken when only one async import exists but __esm remains [1138.66ms]
(pass) bundler > bundler/__promiseAll is included when multiple async imports exist with __esm [966.10ms]
(pass) bundler > bundler/__promiseAll is tree-shaken when no async imports despite circular deps with __esm [721.84ms]
(pass) bundler > bundler/__promiseAll is defined when an unwrapped entry awaits two async ESM wrappers [542.82ms]
(pass) bundler > bundler/__promiseAll is defined when an unwrapped entry awaits three async ESM wrappers [428.03ms]
(pass) bundler > bundler/__promiseAll is defined when an unwrapped non-entry file awaits two async ESM wrappers [466.48ms]
(pass) bundler > bundler/__promiseAll is tree-shaken when an unwrapped entry awaits one async ESM wrapper [392.45ms]
(pass) bundler > bundler/__promiseAll is tree-shaken when a wrapper's second async dependency is an import() [61
... (truncated)

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     38cacdaaa2
  features     baseline

23 deps, 131 codegen, 1172 objects in 830ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1244] install /workspace/bun
bun install v1.4.3-canary.1 (f42e98025)

Checked 22 installs across 61 packages (no changes) [25.00ms]
[2/1244] gen ErrorCode+*.h
[3/1244] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (f42e98025)

Checked 1 install across 2 packages (no changes) [12.00ms]
[4/1244] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[5/1217] gen bindgenv2
[6/1217] fetch zlib
[zlib] up to date
[7/1217] fetch tinycc
[tinycc] up to date
[8/1216] gen .bind.ts → GeneratedBindings.cpp
[9/1216] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (f42e98025)

Checked 111 installs across 104 packages (no changes) [69.00ms]
[10/1216] gen node-fallbacks/react-refresh.js
Bundled 1 module in 15ms

  react-refresh.js  4.81 KB  (entry point)

[11/1216] gen bake.{client,server,error}.js
-> bake.client.js, bake.ser
... (truncated)
diff hotspot
src/bundler/LinkerContext.rs                       |  56 +--------
 .../linker_context/scanImportsAndExports.rs        |  21 ++++
 test/bundler/bundler_promiseall_deadcode.test.ts   | 132 +++++++++++++++++++++
 3 files changed, 155 insertions(+), 54 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                 reads  edits  tests
src/bundler/LinkerContext.rs                             4      2      9
src/bundler/linker_context/scanImportsAndExports.rs      3      3      9
test/bundler/bundler_promiseall_deadcode.test.ts         2      2      9

…ESM wrappers

An unwrapped file with two or more import statements of lazily wrapped
modules that contain top-level await prints
await __promiseAll([init_a(), init_b()]), but only create_wrapper_for_file's
WrapKind::Esm arm marked the helper as used. The bundle then threw
ReferenceError: __promiseAll is not defined at load.

Count these imports per importing file in step 6 of
scan_imports_and_exports, where the other runtime helper uses are
registered, and drop the wrapper-only count it supersedes.
@github-actions github-actions Bot added the claude label Sep 8, 2026
@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:30 PM PT - Sep 8th, 2026

❌ @robobun, your commit bfba7d2 has 1 failures in Build #113226 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 42046

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

bun-42046 --bun

@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on 1.4.2 and 1.4.3-canary.1+f42e98025 with the three-file input in the PR body: bun build ./entry.js --outfile=out.mjs exits 0, and bun out.mjs throws ReferenceError: __promiseAll is not defined at out.mjs:26. With this branch the same bundle defines the helper and prints p, q, entry under bun and node, with and without --minify.

Test proof: test/bundler/bundler_promiseall_deadcode.test.ts. USE_SYSTEM_BUN=1 bun test fails 4 of the 5 new cases, bun bd test passes all 8 in the file.

CI (build 113226) at bfba7d2: the new tests and the rest of test/bundler/ pass on every lane that has run. The one red job is test/js/node/test/parallel/test-crypto-dh-leak.js on debian x64-asan, which fails the same way on main and does not touch the bundler. All 181 other jobs passed. Ready for review.

@coderabbitai

coderabbitai Bot commented Sep 8, 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: 7abd6357-74c5-44c9-833e-add145e88c0d

📥 Commits

Reviewing files that changed from the base of the PR and between d745f03 and bfba7d2.

📒 Files selected for processing (3)
  • src/bundler/LinkerContext.rs
  • src/bundler/linker_context/scanImportsAndExports.rs
  • test/bundler/bundler_promiseall_deadcode.test.ts

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


Walkthrough

Changes

Promise aggregation linking

Layer / File(s) Summary
Runtime usage calculation and wrapper dependencies
src/bundler/LinkerContext.rs, src/bundler/linker_context/scanImportsAndExports.rs
Import scanning counts asynchronous ESM initializations and adds __promiseAll when at least two static dependencies require aggregation. ESM wrappers retain only the __esm dependency.
Promise aggregation regression coverage
test/bundler/bundler_promiseall_deadcode.test.ts
Tests cover multiple static async dependencies, single-dependency tree-shaking, non-entry modules, and dynamic imports.

Suggested reviewers: jarred-sumner, sosukesuzuki

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to bfba7

The bundler now emits the promise aggregation helper when an unwrapped module statically initializes multiple async ESM wrappers, preventing the prior runtime ReferenceError. Regression coverage includes helper generation and tree-shaking cases, with no current merge-blocking risk identified.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: defining __promiseAll for unwrapped files that await multiple async ESM wrappers. It is concise and specific.
Description check ✅ Passed The description provides detailed problem, cause, fix, verification results, regression coverage, and implementation context. It does not use the template headings exactly, but it contains the require…
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.

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 and didn't find any bugs; the counter relocation follows the neighboring to_esm_uses/preload_uses pattern exactly and the soundness argument (every part holding a 2nd+ qualifying import carries the use, so a printed __promiseAll always has a live definition) holds. Given this is linker tree-shaking liveness bookkeeping and overlaps with the in-flight #41601 rework, a maintainer glance would still be worthwhile.

What was reviewed:

  • Verified generate_runtime_symbol_import_and_use early-returns on count == 0 (LinkerGraph.rs:585), so the unconditional call with promise_all_uses is safe.
  • Checked the new guard (ImportKind::Stmt + WrapKind::Esm + is_async_or_has_async_dependency + valid wrapper_ref) matches the printer's append_async_dependency reach condition at LinkerContext.rs:2301.
  • Traced the file-scoped counter vs. per-part use split against part-liveness edge cases — over-inclusion only, never under-inclusion.
  • New itBundled cases assert both runtime output and emitted-code shape in both directions (helper present/absent).
Extended reasoning...

Overview

The PR relocates where the bundler's linker records a use of the __promiseAll runtime helper. Previously only create_wrapper_for_file's WrapKind::Esm arm counted async import records and marked the helper as used on the wrapper part; that block is deleted. Instead, step 6 of scan_imports_and_exports now maintains a file-wide async_esm_init_count and, from the second qualifying static import of an async ESM wrapper onward, sets promise_all_uses = 1 on the current part, registered via the same generate_runtime_symbol_import_and_use call chain used for __toESM/__toCommonJS/__require/__preload. This makes the helper survive tree-shaking when an unwrapped importer (entry or intermediate) statically imports two or more async ESM wrappers — the case where the printer emits await __promiseAll([...]) at chunk top level but the old bookkeeping had no wrapper part to hang the use on, producing a ReferenceError at runtime. Five new itBundled cases cover both directions.

Security risks

None. This is compile-time bundler bookkeeping deciding whether a 45-byte runtime helper definition is emitted. No untrusted input parsing, no auth/crypto/permissions, no network or filesystem effects beyond what itBundled already sandboxes.

Level of scrutiny

Moderate. The Rust change is small (two hunks) and mechanically follows the established local pattern — I confirmed generate_runtime_symbol_import_and_use no-ops on count == 0 at LinkerGraph.rs:585, so the unconditional call is safe. The new condition sits inside the other_flags.wrap != WrapKind::None && wrapper_ref.is_valid() block and gates on ImportKind::Stmt + WrapKind::Esm + is_async_or_has_async_dependency, which matches exactly what the printer checks before calling append_async_dependency at LinkerContext.rs:2301-2304. That said, this is linker tree-shaking liveness — a subsystem where part-liveness, wrap decisions, and code-splitting interact subtly, and where an alternative design (#41601, replacing the helper with unbound Promise.all) is in flight. A maintainer familiar with that rework should confirm this doesn't conflict.

Other factors

I traced the file-scoped-counter / per-part-use split against tree-shaking edge cases: since every part containing the 2nd or later qualifying import carries the use, and a part must be live to be printed, a printed __promiseAll call always has a live definition. The reverse — a defined but unused helper when one of the counted import parts is later tree-shaken or when the second qualifying record is an un-aliased export * from — is a benign 45-byte over-inclusion the PR description already acknowledges. The old code also over-counted by including dynamic import() records; the new ImportKind::Stmt gate is strictly more precise, and the fifth test verifies a wrapped importer whose second async dep is import() no longer emits the unused helper. Tests follow test/CLAUDE.md conventions (existing file, itBundled, both run and onAfterBundle shape assertions, no ports/timeouts/network). No CODEOWNERS entry covers src/bundler/. No outstanding reviewer objections in the timeline.

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.

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.

1 participant