Skip to content

Fix crash in Bun.build with custom conditions#30528

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

Fix crash in Bun.build with custom conditions#30528
robobun wants to merge 1 commit into
mainfrom
farm/b26c0a16/fix-esm-conditions-capacity

Conversation

@robobun

@robobun robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator

What

Fixes a crash in Bun.build() (and anything going through BundleOptions.fromApi) when passing custom conditions with native addons enabled (the default).

Why

ESMConditions.init reserved capacity for its condition maps using:

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

In Zig the else branch of an if expression consumes the full remaining expression, so this parses as:

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

When allow_addons was true, conditions.len was dropped from the reservation entirely. The subsequent putAssumeCapacity calls then overflowed the MultiArrayList backing store, tripping an assert in debug builds and segfaulting in release.

How

Hoist the conditional into a const addons_count: usize so the arithmetic is unambiguous.

Found by Fuzzilli (f2ec65306ddfaf9f).

ESMConditions.init computed the capacity for the condition maps as
`defaults.len + 2 + if (allow_addons) 1 else 0 + conditions.len`, which
Zig parses as `... + (if (allow_addons) 1 else (0 + conditions.len))`.
When allow_addons was true (the default), conditions.len was dropped
from the reservation and the subsequent putAssumeCapacity calls
overflowed the MultiArrayList, crashing the bundler thread.
@robobun

robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:47 PM PT - May 11th, 2026

@robobun, your commit 481e3b2 has 1 failures in Build #53549 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30528

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

bun-30528 --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: e0607184-6991-4c04-9adc-b4dba6a51c12

📥 Commits

Reviewing files that changed from the base of the PR and between 37bfbed and 481e3b2.

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

Walkthrough

This PR refactors ESMConditions.init to reduce code duplication in capacity calculations and adds a regression test that validates the bundler's stability when handling many custom build conditions.

Changes

ESMConditions capacity refactoring and regression test

Layer / File(s) Summary
Capacity calculation refactor
src/bundler/options.zig
ESMConditions.init precomputes addons_count (1 or 0) once and reuses it in capacity calculations for default, import, and require condition maps, replacing repeated if (allow_addons) 1 else 0 expressions.
Regression test
test/bundler/bun-build-api.test.ts
New Bun.build test spawns a subprocess to run the bundler with many conditions values, asserting no stderr, successful exit, and success:true output.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix crash in Bun.build with custom conditions' directly and clearly summarizes the main change—fixing a crash in Bun.build when custom conditions are provided.
Description check ✅ Passed The PR description comprehensively covers both required template sections: 'What' clearly describes the crash being fixed, and 'How' explains the root cause and solution with technical detail.
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 - Both fix the same ESMConditions.init capacity reservation bug in src/bundler/options.zig caused by Zig operator precedence, using different approaches (@intFromBool vs hoisted const)

🤖 Generated with Claude Code

@robobun

robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator Author

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

@robobun robobun closed this May 12, 2026
@robobun robobun deleted the farm/b26c0a16/fix-esm-conditions-capacity branch May 12, 2026 00:46

@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 — minimal, obviously-correct precedence fix with a regression test; the inline notes are non-blocking nits.

Extended reasoning...

Overview

This PR touches two files: a 4-line change in src/bundler/options.zig (ESMConditions.init) that hoists if (allow_addons) 1 else 0 into a named const addons_count: usize so the capacity arithmetic is unambiguous, and a new regression test in test/bundler/bun-build-api.test.ts that exercises Bun.build with several custom conditions in a subprocess.

Correctness

The Zig precedence issue is well-explained in the PR description and the fix is the canonical remedy — pull the conditional into a local. I verified the new reservation defaults.len + 2 + addons_count + conditions.len covers every putAssumeCapacity call that follows for the import/require/default maps (it actually over-reserves the default map by 1, which is harmless and pre-existing). The style map's reservation was already correct and is untouched. The test mirrors the existing loader map with an empty-string key subprocess test pattern in the same file.

Security risks

None. This is a capacity-reservation arithmetic fix in bundler option setup; no auth, crypto, parsing of untrusted input, or permission logic is involved. The previous behavior was an under-reservation (crash), and the fix only increases the reservation to the intended value.

Level of scrutiny

Low. The change is mechanical, ~4 effective lines of production code, fully covered by a new regression test, and the failure mode it fixes was a hard crash found by fuzzing. No design decisions are involved. Neither file is covered by CODEOWNERS.

Other factors

The two inline comments are non-blocking: one flags a pre-existing instance of the same precedence pattern elsewhere in the file (harmless there because it uses getOrPutValue, so just a missed pre-reservation), and the other is a style nit about test.concurrent. Neither affects the correctness of this fix and both can be addressed in a follow-up if desired.

},
);

test("does not crash with many custom conditions", async () => {

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: Per test/CLAUDE.md, tests that spawn processes or write files should use test.concurrent — the nearly-identical test just above (loader map with an empty-string key...) follows that pattern. Consider switching this to test.concurrent for consistency and to avoid serializing the suite.

Extended reasoning...

What

test/CLAUDE.md (line 22) states:

Prefer concurrent tests over sequential tests: When multiple tests in the same file spawn processes or write files, make them concurrent with test.concurrent or describe.concurrent unless it's very difficult to make them concurrent.

The new test does not crash with many custom conditions does both — it writes files via tempDirWithFiles and spawns a subprocess via Bun.spawn — but is declared with plain test() rather than test.concurrent().

Why this applies here

The most directly comparable test in the same describe block is loader map with an empty-string key is ignored without leaving uninitialized slots (test/bundler/bun-build-api.test.ts:390). It has effectively identical structure:

  1. tempDirWithFiles(...) to create an isolated entry file
  2. await using proc = Bun.spawn({ cmd: [bunExe(), "-e", ...], env: bunEnv, stdout: "pipe", stderr: "pipe" })
  3. await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited])
  4. Assertions on stdout/stderr/exit code

That test uses test.concurrent, as do the surrounding subprocess-spawning tests in this file (rebuilding busts the directory entries cache, hash considers cross chunk imports, etc.). The new test is structurally indistinguishable from these and should follow the same convention.

Safety check

There is nothing preventing this test from running concurrently:

  • It uses a unique temp directory (tempDirWithFiles("bun-build-api-conditions", ...)), so there is no filesystem contention.
  • It does not call process.chdir or mutate any global/shared state (unlike the tsconfig option describe block, which intentionally stays sequential because it changes cwd).
  • The subprocess is fully isolated.

So the "unless it's very difficult" escape hatch in the guideline does not apply.

Impact

Purely a style/convention nit. The test is functionally correct as written; using plain test() just serializes it against other sequential tests in the file, marginally slowing the suite. The file is admittedly not 100% consistent (some older tests in this describe block predate the convention and still use plain test()), but both the documented guideline and the nearest precedent point to test.concurrent.

Fix

-  test("does not crash with many custom conditions", async () => {
+  test.concurrent("does not crash with many custom conditions", async () => {

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

🟣 Heads up: the same if-expression precedence footgun also exists in loadersFromTransformOptions in this file (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 inserts use getOrPutValue rather than putAssumeCapacity, so it just under-reserves and rehashes — but since this PR is specifically about this parsing gotcha, it might be worth fixing that occurrence too while you're here. (Pre-existing, not introduced by this PR.)

Extended reasoning...

What

This PR fixes a Zig if-expression precedence bug in ESMConditions.init where defaults.len + 2 + if (allow_addons) 1 else 0 + conditions.len parsed the else branch as (0 + conditions.len), dropping conditions.len from the reservation when allow_addons was true. The exact same pattern exists elsewhere in the same file, in loadersFromTransformOptions:

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,

Why this is the same bug

Per the Zig grammar (and as the PR description itself explains), the else branch of an if expression consumes the full remaining expression. So this parses as:

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

Step-by-step example

Take target = .bun with no custom loaders (input_loaders.extensions.len == 0):

  1. target.isBun() is true, so the first if evaluates to default_loader_ext_bun.len == 2.
  2. Because the then-branch was taken, the entire trailing expression (the second if and + default_loader_ext.len) is part of the untaken else branch and is skipped.
  3. Total capacity passed to stringHashMapFromArrays = 0 + 2 = 2.
  4. The intended capacity was 0 + 2 + 0 + 18 = 20 (since default_loader_ext.len == 18).

Similarly with target = .browser: the first if is false → 0 + (second if); the second if is true → default_loader_ext_browser.len == 1; default_loader_ext.len is again dropped. Total = extensions.len + 1 instead of extensions.len + 1 + 18.

Why it doesn't crash here

Unlike ESMConditions.init, this occurrence is harmless in practice:

  1. stringHashMapFromArrays only calls ensureTotalCapacity when keys.len > 0, and the only putAssumeCapacity calls it makes are for input_loaders.extensions — and total_capacity >= input_loaders.extensions.len holds in every branch of the mis-parsed expression.
  2. The subsequent inserts of default_loader_ext, default_loader_ext_bun, and default_loader_ext_browser all use getOrPutValue (with try), which grows the map on demand rather than assuming capacity.

So the only effect is a missed pre-reservation and an extra rehash/realloc when custom loaders are passed with a bun/browser target — a minor perf inefficiency, not a correctness issue.

Suggested fix

Same approach as the PR's fix — hoist the conditionals into locals so the arithmetic is unambiguous:

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;
var loaders = try stringHashMapFromArrays(
    bun.StringArrayHashMap(Loader),
    allocator,
    input_loaders.extensions.len + bun_count + browser_count + default_loader_ext.len,
    input_loaders.extensions,
    loader_values,
);

Severity

Pre-existing — the PR doesn't touch loadersFromTransformOptions, and there's no functional/correctness impact. But it's the identical footgun, in the same file, that this PR is explicitly about, so it seemed worth flagging as a "while you're here".

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