Skip to content

bundler: don't cache a module evaluation that threw as a success - #33053

Closed
robobun wants to merge 3 commits into
mainfrom
farm/420cdcee/esm-eval-error-memoized
Closed

robobun wants to merge 3 commits into
mainfrom
farm/420cdcee/esm-eval-error-memoized

Conversation

@robobun

@robobun robobun commented Jun 29, 2026 •

Copy link
Copy Markdown
Collaborator

bun build without --splitting emits a lazy evaluator for dynamically imported ESM modules that clears its init function before calling it:

var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);

If the module body throws, fn is already 0, so the module is recorded as successfully initialized with res = undefined. The next import() skips evaluation and resolves with whatever part of the namespace existed at the throw. ECMA-262 memoizes a failed module evaluation, so every later import must reject with the same error. Node, Bun's own runtime on unbundled source, and the --splitting output all behave correctly; only the non-splitting bundle is wrong.

// thrower.mjs
export let v = 1;
throw new Error("boom");

// entry.mjs
for (let i = 1; i <= 2; i++)
  await import("./thrower.mjs").then(
    ns => console.log(i, "FULFILLED", JSON.stringify(ns)),
    e => console.log(i, "rejected", e.message));
$ bun build entry.mjs --outfile o.js && node o.js
1 rejected boom
2 FULFILLED {"v":1}   <- half-initialized namespace; should be "2 rejected boom"

__commonJS has the same defect: mod is installed before the callback runs, so a CJS module that throws during evaluation hands its partial exports to the next require() as a success. Node removes a module from require.cache when its evaluation throws, so a later require() must re-evaluate it.

Fix

Match esbuild, which fixed both helpers in 0.28.1 (evanw/esbuild#4461):

  • __esm boxes the thrown value in a third closure-local and re-throws it on every later call. Top-level-await modules were already correct (the rejected promise is memoized in res) and are unaffected.
  • __commonJS resets mod to 0 on throw so the next require() re-evaluates.

Both helpers are only ever emitted with one argument, so the added closure-local parameters are always undefined on the first call. The private __commonJS copies inside the vendored single-module bundles in src/js/ (wasi, querystring, glob's minimatch) are untouched: each runs once at module init and never throws.

Three test files embed the runtime helper source in snapshots and were regenerated. Two tests pin values derived from the minified runtime preamble, which grew with the helper body; the new values were re-derived from the generated output and sourcemap, and in each case the mapped text at the new position is unchanged:

  • bundler_edgecase.test.ts: the mappingsExactMatch string's leading generated-column VLQ.
  • bundler_npm.test.ts (npm/ReactSSR): expectExactFilesize (221969 -> 222006) and the two line-1 generated columns in snapshotSourceMap.mappings (+37 each). Pins on later output lines are unaffected since a newline resets the column.

Tests

edgecase/ESMEvaluationThrowIsMemoized and edgecase/CommonJSEvaluationThrowIsNotCached in test/bundler/bundler_edgecase.test.ts. Both fail on the released bun (the second import reports FULFILLED {"v":1} / loaded {"v":1}) and pass with the fix. With the fix, the bundled output matches node and esbuild for ESM import(), CJS import(), and CJS require(), including under --minify.

The __esm helper cleared its init function before calling it, so a throw
during evaluation left the module recorded as successfully initialized.
A second import() then fulfilled with the partially built namespace
instead of re-rejecting. ECMA-262 memoizes a failed module evaluation,
so box the thrown value and re-throw it on every later call.

__commonJS had the same defect: `mod` was installed before the callback
ran, so a throw left the partial exports in the cache. Node removes a
module from require.cache when its evaluation throws, so reset `mod` on
throw and let the next require() re-evaluate.

Matches esbuild 0.28.1 (evanw/esbuild#4461).
@coderabbitai

coderabbitai Bot commented Jun 29, 2026 •

Copy link
Copy Markdown
Contributor

Review 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: Pro

Run ID: b29995d1-055d-4070-be86-b01a0bda950f

📥 Commits

Reviewing files that changed from the base of the PR and between f789198 and ba49831.

⛔ Files ignored due to path filters (1)
  • test/bundler/transpiler/__snapshots__/react-compiler.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (5)
  • src/runtime.js
  • test/bundler/bundler_edgecase.test.ts
  • test/bundler/bundler_npm.test.ts
  • test/bundler/bundler_promiseall_deadcode.test.ts
  • test/regression/issue/cyclic-imports-async-bundler.test.js

Walkthrough

__commonJS in src/runtime.js is rewritten to use try/catch, resetting mod to 0 on failure so subsequent calls re-evaluate. __esm gains an err parameter that memoizes the first thrown error and rethrows it on repeated imports. New edge-case tests verify both behaviors; existing snapshots are updated to match the expanded helper output.

Changes

ESM/CJS evaluation error semantics

Layer / File(s) Summary
__commonJS and __esm runtime helper rewrites
src/runtime.js
__commonJS wraps cb evaluation in try/catch and resets mod = 0 on throw so the next require() re-evaluates. __esm gains an err parameter; the first thrown error is boxed into err = [e] and rethrown; subsequent calls immediately rethrow err[0] without re-evaluating.
New edge-case tests for throw behavior
test/bundler/bundler_edgecase.test.ts
Adds ESMEvaluationThrowIsMemoized (dynamic import rejects with the same error, module body runs once) and CommonJSEvaluationThrowIsNotCached (each require() re-evaluates, globalThis.evaluations reaches 2). Also updates the EmitInvalidSourceMap2 mapping snapshot.
Snapshot updates for new helper shape
test/bundler/bundler_promiseall_deadcode.test.ts, test/regression/issue/cyclic-imports-async-bundler.test.js, test/bundler/bundler_npm.test.ts
Inline snapshots updated to reflect the expanded __esm helper body and changed debugId values; npm/ReactSSR bundle filesize expectation raised from 221969 to 222006.

Suggested reviewers

  • Jarred-Sumner
  • alii
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: fixing bundler caching of failed module evaluations.
Description check ✅ Passed It explains what the PR does and how it was verified, though it uses custom headings instead of the template's exact section names.
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.

@robobun

robobun commented Jun 29, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:32 AM PT - Jun 29th, 2026

❌ @robobun, your commit 0e9305c has 1 failures in Build #66732 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33053

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

bun-33053 --bun

Comment thread src/runtime.js
The minified __commonJS helper grew by 37 bytes, which shifts the output
file size and every line-1 generated column by the same amount. The new
values were re-derived from the generated sourcemap; the mapped text at
each position is unchanged, and the pins on later lines are unaffected.

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

Thanks for updating bundler_npm.test.ts — the +37 delta is consistent across the file size and both line-1 column offsets, so that looks right. The runtime.js change itself reads correctly and matches esbuild's fix, but since __esm/__commonJS are emitted into every bundle and this changes error-path semantics, I'd like a human to give it a quick look.

Extended reasoning...

Overview

This PR fixes spec-compliance bugs in two bundler runtime helpers in src/runtime.js:

  • __esm now memoizes a thrown error in a boxed closure-local (err = [e]) and re-throws it on every later call, matching ECMA-262's requirement that a failed module evaluation rejects all subsequent imports with the same error.
  • __commonJS now resets mod = 0 on throw so the next require() re-evaluates, matching Node's behavior of removing a throwing module from require.cache.

Both changes mirror esbuild 0.28.1's fix for the same defect. Two new tests in bundler_edgecase.test.ts cover the behavior. The remaining changes are mechanical: regenerated inline snapshots in three test files, a bumped VLQ segment in mappingsExactMatch, and — added in commit ba49831 after my prior comment — updated pinned file size and line-1 column offsets in bundler_npm.test.ts. The +37-byte delta is internally consistent across the file size (221969→222006) and both column offsets (5623→5660, 7685→7722), which strongly suggests these were obtained by re-running the test rather than guessed.

Security risks

None. This is error-handling control flow in generated bundle preamble code; no auth, crypto, I/O, or untrusted input is involved.

Level of scrutiny

Moderate-to-high. src/runtime.js is emitted verbatim into every bundle Bun produces, so the blast radius is the entire user base. The logic is small and follows esbuild's proven implementation, but it changes observable runtime semantics (what happens on the second import()/require() of a module that threw). A human reviewer familiar with the bundler should confirm the helper signatures still match every emission site (the PR asserts both are only ever called with one argument, which I spot-checked and looks right) and that the TLA path — where fn is async and res becomes a rejected promise — is indeed unaffected as claimed.

Other factors

  • My prior inline comment about bundler_npm.test.ts has been addressed in the latest commit.
  • No bugs were found by the bug-hunting system on this revision.
  • The new tests are well-targeted: they verify rejection on both attempts, error identity (errors[0] === errors[1]) for ESM, and re-evaluation count (evaluations: 2) for CJS.
  • The vendored __commonJS copies in src/js/ were intentionally left untouched per the PR description; that's reasonable since they wrap single non-throwing modules.

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

Thanks for the bundler_npm.test.ts update — that resolves my earlier comment and I see no remaining issues, but since src/runtime.js ships in every bun build output I'd like a maintainer to sign off on the helper-semantics change.

Extended reasoning...

Overview

This PR rewrites the __esm and __commonJS runtime helpers in src/runtime.js so a module whose evaluation throws is not cached as a success. __esm now memoizes the thrown value (boxed in a closure-local array so falsy throws still count) and re-throws it on every later call, matching ECMA-262's module-evaluation semantics. __commonJS resets mod = 0 on throw so the next require() re-evaluates, matching Node's require.cache removal behavior. The fix mirrors esbuild 0.28.1 (evanw/esbuild#4461). Two new itBundled tests cover both helpers, and four snapshot/pin files were regenerated for the larger helper bodies. My earlier comment about bundler_npm.test.ts not being updated was addressed in ba49831 (filesize 221969→222006, line-1 columns +37) and is now resolved.

Security risks

None. The helpers wrap user-provided module bodies and only change error-caching behavior; no new attack surface, no auth/crypto/permission code touched.

Level of scrutiny

High. src/runtime.js is the bundler runtime preamble — these two helpers are inlined into every non-splitting bundle that contains a CJS module or a lazily-evaluated ESM module. A regression here would silently break all downstream bun build output. The change is small and the logic is sound (I verified the bundler only ever emits __esm(fn) / __commonJS(cb) with one argument, so the new closure-local parameters are always undefined initially), but the blast radius warrants a maintainer's eyes rather than bot-only approval.

Other factors

  • New tests edgecase/ESMEvaluationThrowIsMemoized and edgecase/CommonJSEvaluationThrowIsNotCached directly exercise the fixed paths and assert evaluation counts and error identity.
  • The lone CI failure (test/js/node/test/parallel/test-net-connect-memleak.js on x64/x64-baseline) is unrelated to bundler runtime; it's a known-flaky net test.
  • The vendored private __commonJS copies in src/js/{node/wasi.ts,node/querystring.ts,internal/fs/glob.ts} are intentionally untouched, as noted in the PR description.
  • No CODEOWNERS entry covers src/runtime.js.

@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the change is green; both CI runs are blocked by a pre-existing flake unrelated to this PR.

Across build 66710 and the clean re-run 66732, the only test failure is test/js/node/test/parallel/test-net-connect-memleak.js on the linux-x64-musl lanes (Alpine 3.23 x64 and x64-baseline), both times with the same assertion:

assert.strictEqual(collected, true);
AssertionError: Expected values to be strictly equal: false !== true

That is #33044 ("CI: test-net-connect-memleak.js fails on half of PR builds on linux-x64-musl since June 28 ~23:00 UTC"), which was opened before this PR existed. It is a node:net garbage-collection test. This PR only changes a JavaScript string embedded in the bundler's runtime module (__esm and __commonJS), which is identical on every platform and is never executed when bun test runs a node:net file, so it cannot produce a musl-only GC result. The binary-size annotation shows a near-zero delta on every target.

For completeness, build 66710 also had one darwin 26 aarch64 - test-bun job that failed before any test ran (buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'), and a handful of Windows install/hot CLI tests that passed on retry. None touch the bundler.

Everything related to this change passes locally against the debug build: the new edgecase/ESMEvaluationThrowIsMemoized and edgecase/CommonJSEvaluationThrowIsNotCached, the full bundler_edgecase.test.ts (121 tests), bundler_cjs.test.ts, bundler_cjs2esm.test.ts, bundler_splitting.test.ts, bundler_npm.test.ts, bundler_promiseall_deadcode.test.ts, the eight esbuild-ported suites under test/bundler/esbuild/ (564 tests), transpiler/react-compiler.test.ts, and regression/issue/cyclic-imports-async-bundler.test.js.

This is ready for review; it just needs a maintainer to merge past (or re-kick) the #33044 flake. I am not going to keep pushing empty retriggers at it.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-06-29, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 2026
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