Skip to content

Fix crash in Bun.build() with many conditions - #30557

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

Fix crash in Bun.build() with many conditions#30557
robobun wants to merge 1 commit into
mainfrom
farm/3f395d9a/fix-esm-conditions-capacity

Conversation

@robobun

@robobun robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes an assertion failure in ESMConditions.init when Bun.build() is called with a non-trivial number of conditions.

The capacity reservation used:

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

which Zig parses as:

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

When allow_addons is true (the default), conditions.len was dropped entirely from the capacity calculation. The subsequent putAssumeCapacity calls would then overflow the map and trip assert(self.len < self.capacity) in MultiArrayList.addOneAssumeCapacity.

Repro:

await Bun.build({
  entrypoints: ["./index.js"],
  conditions: ["a", "b", "c", "d", "e", "f", "g"],
});

How did you verify your code works?

  • Added a regression test in test/bundler/bun-build-api.test.ts that passes 20 conditions; it crashes the bundle thread before this change and passes after.
  • bun bd test test/bundler/bun-build-api.test.ts — all 40 tests pass.

Found by Fuzzilli (fingerprint a16293634a0a5123).

… non-empty

The expression `defaults.len + 2 + if (allow_addons) 1 else 0 + conditions.len`
parses as `defaults.len + 2 + (if (allow_addons) 1 else (0 + conditions.len))`,
so when allow_addons is true (the default), conditions.len was dropped from
the capacity calculation, causing putAssumeCapacity to trip an assertion when
enough user-specified conditions were passed to Bun.build.
@robobun

robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:58 AM PT - May 12th, 2026

@robobun, your commit 67f419d has 1 failures in Build #53709 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30557

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

bun-30557 --bun

@coderabbitai

coderabbitai Bot commented May 12, 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: 8a959362-4895-485c-8faa-dbf59c82a109

📥 Commits

Reviewing files that changed from the base of the PR and between 314ffe3 and 67f419d.

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

Walkthrough

This PR refactors capacity calculations in the ESM conditions bundler logic to avoid repeating conditional expressions, then adds a test case that validates builds succeed when many conditions are supplied to Bun.build.

Changes

Build Options Capacity Handling

Layer / File(s) Summary
Capacity calculation refactoring
src/bundler/options.zig
In ESMConditions.init, addon_count is computed once as a local variable derived from allow_addons, then reused for all three capacity calculations (default_condition_amp, import_condition_map, require_condition_map) instead of repeating the inline conditional.
Many-conditions validation test
test/bundler/bun-build-api.test.ts
A new test case builds a simple entrypoint while passing a long conditions array, then asserts build.success is true and exactly one output is produced.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and accurately describes the main fix: resolving a crash in Bun.build() when many conditions are provided.
Description check ✅ Passed The PR description covers both required template sections: it explains what the PR does (the operator precedence bug and its fix) and how the code was verified (regression test and full test suite run).
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 crash in Bun.build with many conditions #30466 - Also fixes the same operator precedence bug in ESMConditions.init capacity calculation that crashes Bun.build() with many conditions

🤖 Generated with Claude Code

@robobun

robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #30466, which already fixes this plus a second instance of the same operator-precedence bug in loadersFromTransformOptions.

@robobun robobun closed this May 12, 2026
@robobun
robobun deleted the farm/3f395d9a/fix-esm-conditions-capacity branch May 12, 2026 10:57

@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 — clean fix for the Zig if-else precedence trap, with a regression test. The two inline notes are pre-existing/adjacent and non-blocking.

Extended reasoning...

Overview

This PR fixes a crash in ESMConditions.init (src/bundler/options.zig) caused by a Zig operator-precedence trap: a + if (cond) 1 else 0 + b parses as a + (if (cond) 1 else (0 + b)), so when allow_addons was true the conditions.len term was dropped from the ensureTotalCapacity calculation, and the subsequent putAssumeCapacity calls overflowed the map and tripped an assertion. The fix hoists if (allow_addons) 1 else 0 into a const addon_count: usize and uses it in the three capacity expressions — a minimal, obviously-correct transformation. A 12-line regression test in bun-build-api.test.ts passes 20 conditions to Bun.build() and asserts success.

Security risks

None. This is purely a capacity-arithmetic fix in the bundler's ESM conditions map initialization. No auth, crypto, permissions, or untrusted-input parsing is touched; the only behavioral change is that the map is now sized correctly instead of under-sized.

Level of scrutiny

Low. The diff is 4 lines of Zig (3 modified + 1 added const) plus a small test. The bug, its root cause (Zig IfExpr else-branch consuming the trailing expression), and the fix are all clearly explained in the PR description and trivially verifiable by reading the diff. The style_condition_map line immediately below already had the correct form (no if), so the fix brings the other three lines in line with it. No CODEOWNER covers these files.

Other factors

The two inline comments from the bug-hunting system are both explicitly flagged as pre-existing and not introduced by this PR: (1) style_condition_map reserves capacity for user conditions but the init loop never inserts into it — a real but orthogonal inconsistency worth a follow-up; (2) the same precedence pattern exists in loadersFromTransformOptions, but it's harmless there because subsequent inserts use getOrPutValue which grows. Neither blocks this crash fix. The full test file passes per the PR description, and the change was found via Fuzzilli with a concrete fingerprint.

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 addon_count: 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.

🟡 nit (pre-existing): the same if (cond) a else 0 + ... precedence pattern this PR fixes also exists in loadersFromTransformOptions (~line 1575): input_loaders.extensions.len + if (target.isBun()) default_loader_ext_bun.len else 0 + if (target == .browser) ... else 0 + default_loader_ext.len. It doesn't crash there because the subsequent inserts use getOrPutValue (which grows) rather than putAssumeCapacity, so the only effect is an unnecessary reallocation — but since this PR is specifically about this precedence trap, it might be worth giving that callsite the same const x: usize = if (...) n else 0; treatment for consistency.

Extended reasoning...

What

loadersFromTransformOptions in the same file computes its hashmap capacity as:

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,

Per Zig grammar, an IfExpr's else branch consumes a full expression, so this parses as:

input_loaders.extensions.len + (if (target.isBun()) 2 else (0 + (if (target == .browser) 1 else (0 + 18))))

This is the exact precedence pitfall this PR fixes in ESMConditions.init.

Step-by-step

With target = .bun and a user-supplied loaders map of 1 entry:

  1. target.isBun() → true, so the whole if evaluates to default_loader_ext_bun.len = 2.
  2. The trailing + if (target == .browser) ... + default_loader_ext.len is part of the else branch and is never evaluated.
  3. Reserved capacity = 1 + 2 = 3.
  4. Intended capacity = 1 + 2 + 0 + 18 = 21.
  5. stringHashMapFromArrays calls ensureTotalCapacity(3) then putAssumeCapacity exactly input_loaders.extensions.len = 1 time → fits.
  6. The 18 default_loader_ext entries and 2 default_loader_ext_bun entries are inserted via getOrPutValue, which grows the map → no crash, just a redundant rehash/realloc.

(Note also that stringHashMapFromArrays only calls ensureTotalCapacity at all when input_loaders.extensions.len > 0, i.e. only when the user passes custom loaders.)

Why it doesn't crash here but did in ESMConditions

ESMConditions.init follows the under-reserved ensureTotalCapacity with putAssumeCapacity for all inserts, so under-allocation overflows the map and trips the assertion. loadersFromTransformOptions only putAssumeCapacitys the first input_loaders.extensions.len items — and that term is the unconditional first addend, always counted regardless of how the if parses — so the assumed-capacity inserts always fit. Everything after that uses getOrPutValue.

Addressing the objection

One verifier argued this shouldn't be flagged because there's no incorrect behavior — just a sub-optimal capacity hint. That's accurate: this is not a correctness bug and would not justify a standalone report. It's flagged here only because the PR's stated purpose is fixing this specific precedence trap in this file, and an identical instance sits ~500 lines away. Filing it as a nit / pre-existing so the author can decide whether to roll it into the same change; it should not block the PR.

Suggested fix

Same shape as the PR's fix:

const bun_count: usize = if (target.isBun()) default_loader_ext_bun.len else 0;
const browser_count: usize = if (target == .browser) default_loader_ext_browser.len else 0;
... input_loaders.extensions.len + bun_count + browser_count + default_loader_ext.len ...

Comment thread src/bundler/options.zig
try default_condition_amp.ensureTotalCapacity(defaults.len + 2 + addon_count + conditions.len);
try import_condition_map.ensureTotalCapacity(defaults.len + 2 + addon_count + conditions.len);
try require_condition_map.ensureTotalCapacity(defaults.len + 2 + addon_count + conditions.len);
try style_condition_map.ensureTotalCapacity(defaults.len + 2 + conditions.len);

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.

🟣 Pre-existing, not introduced here, but since you're touching this exact block: style_condition_map reserves capacity for conditions.len on the line right below, yet the for (conditions) loop a few lines down only inserts into import/require/default — never style. Meanwhile ESMConditions.appendSlice does add to self.style, so whether a user condition applies to CSS @import package-exports resolution depends on which codepath populated the struct. Probably worth a follow-up (either add style_condition_map.putAssumeCapacity(condition, {}) to the loop, or drop + conditions.len from style's reservation if the omission is intentional).

Extended reasoning...

What the inconsistency is

ESMConditions.init reserves capacity in style_condition_map for the user-supplied conditions slice (src/bundler/options.zig:1094):

try style_condition_map.ensureTotalCapacity(defaults.len + 2 + conditions.len);

but the loop that actually inserts those conditions skips style entirely (lines 1100-1104):

for (conditions) |condition| {
    import_condition_map.putAssumeCapacity(condition, {});
    require_condition_map.putAssumeCapacity(condition, {});
    default_condition_amp.putAssumeCapacity(condition, {});
}

So style ends up with only defaults.len + 2 entries ("style", the target defaults, and "default"). The extra conditions.len of reserved capacity is never filled — harmless on its own, but it's a strong hint the loop was meant to insert into style too.

Why it's a behavioral inconsistency, not just over-reservation

ESMConditions.appendSlice (lines 1152-1163) and append (1166-1171) do insert into self.style:

pub fn appendSlice(self: *ESMConditions, conditions: []const string) bun.OOM!void {
    try self.default.ensureUnusedCapacity(conditions.len);
    try self.import.ensureUnusedCapacity(conditions.len);
    try self.require.ensureUnusedCapacity(conditions.len);
    try self.style.ensureUnusedCapacity(conditions.len);
    for (conditions) |condition| {
        ...
        self.style.putAssumeCapacity(condition, {});
    }
}

The resolver consults r.opts.conditions.style for ImportKind.at / .at_conditional (resolver.zig:1871), i.e. CSS @import "pkg" going through package.json "exports". So whether a given condition is honored for CSS resolution depends on which API added it:

Path Reaches style?
Bun.build({ conditions: ["custom"] })transform.conditionsESMConditions.init
conditions.appendSlice(&.{"development"}) (JSBundleCompletionTask.zig:217, build_command.zig:240, bake.zig:764)
conditions.appendSlice(&.{"react-server"}) (bake.zig:760, build_command.zig:269)
conditions.appendSlice(&.{"node"}) (bake.zig:769)

Step-by-step example

  1. User calls Bun.build({ entrypoints: ["./app.css"], conditions: ["custom"] }).
  2. BundleOptions.fromApi calls ESMConditions.init(allocator, target.defaultConditions(), true, &.{"custom"}).
  3. style_condition_map reserves space for defaults.len + 2 + 1, but the for (conditions) loop never inserts "custom" into it. style ends up as {"style", "browser", "module", "default"}.
  4. app.css contains @import "some-pkg"; where some-pkg/package.json has "exports": { "custom": "./custom.css", "default": "./index.css" }.
  5. Resolver hits ImportKind.at → uses conditions.style"custom" is absent → resolves to ./index.css.
  6. Yet the same "custom" condition is in import/require/default, so JS imports from the same build would see "custom". And if the condition had been added later via appendSlice (as the framework codepaths do for "development"), CSS would see it.

Why nothing prevents it

There's no fallback — style is the only map consulted for CSS @import. The over-reservation is silently tolerated because ensureTotalCapacity just allocates more than needed; nothing asserts that the reserved slots get used. The asymmetry with appendSlice/append has no guard either.

Impact

User-supplied conditions passed to Bun.build() (or bun build --conditions) are silently ignored for CSS @import package-exports resolution, while framework-injected conditions like development/react-server are honored. Niche but surprising; the capacity-reservation line and the symmetry with appendSlice both suggest the omission in the init loop was accidental.

Fix

Add the missing line to the loop:

for (conditions) |condition| {
    import_condition_map.putAssumeCapacity(condition, {});
    require_condition_map.putAssumeCapacity(condition, {});
    default_condition_amp.putAssumeCapacity(condition, {});
    style_condition_map.putAssumeCapacity(condition, {});
}

(Capacity is already reserved on line 1094, so putAssumeCapacity is safe.) Alternatively, if excluding user conditions from CSS resolution is intentional, drop + conditions.len from line 1094 and add a comment — but appendSlice's behavior argues against that being the intent.

Relationship to this PR

Not introduced here — the PR only touches lines 1090-1093. But line 1094 is literally the next line, in the same function, operating on the same conditions parameter that this PR is fixing the capacity math for. Flagging as adjacent context; should be its own PR rather than folded into this crash fix.

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