Skip to content

Fix capacity under-allocation in ESMConditions.init with user conditions - #30479

Closed
robobun wants to merge 1 commit into
mainfrom
farm/00611ee7/fix-esm-conditions-capacity
Closed

Fix capacity under-allocation in ESMConditions.init with user conditions#30479
robobun wants to merge 1 commit into
mainfrom
farm/00611ee7/fix-esm-conditions-capacity

Conversation

@robobun

@robobun robobun commented May 11, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a debug assertion / heap overflow in ESMConditions.init when Bun.build() is called with custom conditions.

The capacity reservation used:

defaults.len + 2 + if (allow_addons) 1 else 0 + conditions.len

which in Zig parses as:

defaults.len + 2 + (if (allow_addons) 1 else (0 + conditions.len))

Since allow_addons defaults to true, conditions.len was never counted toward the reserved capacity, and the subsequent putAssumeCapacity calls overflowed the map once enough user conditions were supplied.

How did you verify your code works?

Reproduced reliably with:

await Bun.build({
  entrypoints: ["entry.js"],
  conditions: Array.from({ length: 64 }, (_, i) => "cond" + i),
});

Crashes before, passes after. Added a regression test in test/bundler/bun-build-api.test.ts.

Found by Fuzzilli (fingerprint 1a2e21437cb0aea9).

The expression `if (allow_addons) 1 else 0 + conditions.len` parses as
`if (allow_addons) 1 else (0 + conditions.len)`, so when allow_addons is
true (the default), conditions.len was dropped from the capacity
calculation and putAssumeCapacity would overflow the map when enough
user conditions were passed to Bun.build.
@robobun

robobun commented May 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:43 AM PT - May 11th, 2026

@robobun, your commit 00b7eab has 1 failures in Build #53267 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30479

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

bun-30479 --bun

@coderabbitai

coderabbitai Bot commented May 11, 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: 2f737d27-c2fb-4236-8c19-29ae1ff64049

📥 Commits

Reviewing files that changed from the base of the PR and between 450072b and 00b7eab.

📒 Files selected for processing (2)
  • src/bundler/options.zig
  • test/bundler/bun-build-api.test.ts

Walkthrough

This PR refactors capacity pre-allocation in the bundler's ESMConditions initialization to compute the extra capacity size once instead of repeatedly, and adds a regression test verifying that builds with 64 custom conditions complete successfully without crashing.

Changes

Condition Map Capacity Refactoring

Layer / File(s) Summary
Capacity Calculation Optimization
src/bundler/options.zig
ESMConditions.init extracts addons capacity from allow_addons once and reuses it in ensureTotalCapacity calls for three condition maps, replacing inline conditional expressions.
Regression Test for Large Conditions
test/bundler/bun-build-api.test.ts
New concurrent test verifies Bun.build accepts 64 custom conditions without crashing, spawning a subprocess and asserting success via exit code and stdout/stderr output.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically identifies the main change: fixing a capacity under-allocation bug in ESMConditions.init related to user conditions.
Description check ✅ Passed The description fully covers both required template sections with clear explanations of the bug, root cause analysis, reproduction steps, and testing approach.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Fix capacity calculation in ESMConditions.init #30481 - Fix capacity calculation in ESMConditions.init (same operator precedence fix using @intFromBool)
  2. Fix crash in Bun.build with many conditions #30466 - Fix crash in Bun.build with many conditions (same fix plus a second instance in loadersFromTransformOptions)

🤖 Generated with Claude Code

@robobun

robobun commented May 11, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #30466, which predates this and also fixes a second instance of the same precedence bug in loadersFromTransformOptions.

@robobun robobun closed this May 11, 2026
@robobun
robobun deleted the farm/00611ee7/fix-esm-conditions-capacity branch May 11, 2026 09:42

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

LGTM — straightforward operator-precedence fix with a regression test.

Extended reasoning...

Overview

This PR fixes a Zig if-expression precedence bug in ESMConditions.init (src/bundler/options.zig) where if (allow_addons) 1 else 0 + conditions.len parsed as if (allow_addons) 1 else (0 + conditions.len), causing ensureTotalCapacity to under-reserve and subsequent putAssumeCapacity calls to overflow when user-supplied conditions were present. The fix hoists the conditional into const addons: usize and uses plain addition. A subprocess-based regression test with 64 conditions is added to test/bundler/bun-build-api.test.ts.

Security risks

None. This is purely a capacity-hint arithmetic fix for an internal hash map. No user input handling, auth, crypto, or permission logic is touched. The change strictly increases reserved capacity to match what the existing code already inserts.

Level of scrutiny

Low. The diff is 4 lines of Zig that mechanically rewrite an expression to avoid a well-understood parser footgun, plus a test that mirrors an existing pattern in the same file (spawning a subprocess so a crash surfaces as a test failure). I verified the new capacity (defaults.len + 2 + addons + conditions.len) exactly covers the worst-case map (import/require: 1 sentinel + conditions + defaults + node-addons + "default") and over-covers the others.

Other factors

  • No CODEOWNERS apply to the touched paths.
  • The one inline comment is an FYI about a pre-existing, benign instance of the same pattern in loadersFromTransformOptions; it's explicitly not a blocker and doesn't affect correctness.
  • No prior reviewer comments to address; CI is building.

Comment thread src/bundler/options.zig
try default_condition_amp.ensureTotalCapacity(defaults.len + 2 + if (allow_addons) 1 else 0 + conditions.len);
try import_condition_map.ensureTotalCapacity(defaults.len + 2 + if (allow_addons) 1 else 0 + conditions.len);
try require_condition_map.ensureTotalCapacity(defaults.len + 2 + if (allow_addons) 1 else 0 + conditions.len);
const addons: usize = if (allow_addons) 1 else 0;

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.

🟣 Heads up: the same if ... else 0 + ... precedence footgun this PR fixes also exists in loadersFromTransformOptions in this file (lines 1575-1578). It's harmless there — the under-reserved capacity still covers the putAssumeCapacity calls and the remaining inserts use getOrPutValue which grows the map — so it's just a missed pre-allocation, but you may want to fix both occurrences together. (Pre-existing, not introduced by this PR.)

Extended reasoning...

What the bug is

This PR fixes a Zig operator-precedence footgun in ESMConditions.init where if (allow_addons) 1 else 0 + conditions.len parses as if (allow_addons) 1 else (0 + conditions.len). The identical pattern still exists in the same file at loadersFromTransformOptions (src/bundler/options.zig:1575-1578):

input_loaders.extensions.len +
    if (target.isBun()) default_loader_ext_bun.len else 0 +
        if (target == .browser) default_loader_ext_browser.len else 0 +
            default_loader_ext.len,

In Zig grammar, an IfExpr's else-branch greedily consumes a full expression, so each trailing + ... binds into the preceding else rather than into the outer sum. The indentation suggests four addends, but the parser sees nested if-expressions.

Step-by-step proof

Take target = .bun (so target.isBun() is true, target == .browser is false). With default_loader_ext_bun.len = 2, default_loader_ext_browser.len = 1, default_loader_ext.len = 18:

  1. The expression is extensions.len + (if (true) 2 else (0 + (if (false) 1 else (0 + 18)))).
  2. The outer if takes the then branch → 2. Everything after else (including default_loader_ext.len) is discarded.
  3. Result: extensions.len + 2.
  4. Intended result: extensions.len + 2 + 0 + 18 = extensions.len + 20.

So when target.isBun(), the capacity hint drops 18 entries. (When the target is neither bun nor browser, the nested elses do happen to sum to 0 + 0 + 18, which is accidentally correct.)

Why it doesn't crash

Unlike ESMConditions.init, this instance is benign:

  • stringHashMapFromArrays only calls putAssumeCapacity for keys = input_loaders.extensions. Since input_loaders.extensions.len is always the first addend (outside any if), the reserved capacity is always ≥ the number of putAssumeCapacity calls regardless of how the rest mis-parses.
  • The subsequent default_loader_ext / default_loader_ext_bun / default_loader_ext_browser inserts use try loaders.getOrPutValue(...), which grows the map on demand.
  • Additionally, when keys.len == 0 (no user-supplied loaders, the common case), stringHashMapFromArrays skips ensureTotalCapacity entirely, so the hint is already best-effort.

Impact

Zero correctness impact — purely a missed pre-allocation that causes one or two extra rehashes during options setup. No crash, no behavioral difference.

How to fix

Same approach as this PR — hoist the conditionals into locals so the addition is unambiguous:

const bun_exts: usize = if (target.isBun()) default_loader_ext_bun.len else 0;
const browser_exts: usize = if (target == .browser) default_loader_ext_browser.len else 0;
// ...
input_loaders.extensions.len + bun_exts + browser_exts + default_loader_ext.len,

This is pre-existing and untouched by the diff; mentioning it only because the PR's express purpose is eliminating this exact footgun in options.zig, so fixing both occurrences together seems natural. Not a blocker.

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