Skip to content

Fix capacity miscount in ESMConditions.init - #30692

Closed
robobun wants to merge 3 commits into
mainfrom
farm/5a888910/fix-esm-conditions-capacity
Closed

Fix capacity miscount in ESMConditions.init#30692
robobun wants to merge 3 commits into
mainfrom
farm/5a888910/fix-esm-conditions-capacity

Conversation

@robobun

@robobun robobun commented May 14, 2026

Copy link
Copy Markdown
Collaborator

What

ESMConditions.init computed the hash map capacity as:

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 from the capacity request entirely. With enough user conditions to exceed the allocator's rounded-up capacity (8), the subsequent putAssumeCapacity calls wrote past the reserved slots.

  • In debug builds this tripped the MultiArrayList.addOneAssumeCapacity assert (found by Fuzzilli, fingerprint 6a3e4efbcd4ab6e8).
  • In release builds this was UB that caused export condition matching to silently break — with 5+ --conditions flags, resolution would fall back to default or fail with "Cannot find package".

Fix

Hoist the if into a typed local so the addition associates correctly.

Repro

await Bun.build({
  entrypoints: ["./entry.ts"],
  target: "bun",
  conditions: ["a", "b", "c", "d", "e", "f"],
});

or at runtime:

bun --conditions a --conditions b --conditions c --conditions d --conditions e ./index.js

Fixes #30619

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 for Bun.build), conditions.len was dropped from
the ensureTotalCapacity request. With enough user conditions this
overflowed the reserved capacity and tripped the putAssumeCapacity
assert.
@robobun

robobun commented May 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:46 AM PT - May 14th, 2026

@robobun, your commit 28128c6 has 1 failures in Build #54267 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30692

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

bun-30692 --bun

@coderabbitai

coderabbitai Bot commented May 14, 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: 73cd77ed-eb5b-46b8-ada9-f34c3ba90646

📥 Commits

Reviewing files that changed from the base of the PR and between e3e0878 and 28128c6.

📒 Files selected for processing (1)
  • src/bundler/options.zig

Walkthrough

Explicitly regrouped additive terms when sizing loader-capacity in bundler options and added two tests that exercise many condition entries/flags to ensure bundling and CLI resolution do not crash.

Changes

ESM Condition Capacity Refactor and Tests

Layer / File(s) Summary
ESMConditions capacity calculation refactor
src/bundler/options.zig
Adjust ensureTotalCapacity expression in loadersFromTransformOptions to use explicitly parenthesized additive terms for Bun-specific, browser-specific, and base default loader-extension counts.
Bun.build regression test for many user conditions
test/bundler/bun-build-api.test.ts
Add a concurrent test that builds with a large conditions list and asserts bundle.success is true.
CLI resolution test with many --conditions flags
test/js/bun/resolve/import-custom-condition.test.ts
Add a test that spawns Bun with many --conditions flags across iterations and asserts exit code 0 and stdout equals 1\n for each run.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the core fix: correcting a capacity miscount bug in ESMConditions.init, which is the main change addressed in the PR.
Description check ✅ Passed The PR description comprehensively covers the issue (operator precedence bug), its impact (debug asserts and release UB), the fix, and reproduction examples. However, it does not explicitly address the verification methodology required by the template.
Linked Issues check ✅ Passed The PR directly addresses issue #30619 by fixing the capacity miscount that caused export condition matching to break with 4+ conditions. The code changes fix the precedence bug preventing conditions.len from being included in capacity calculations.
Out of Scope Changes check ✅ Passed All changes are in scope: options.zig fixes the capacity bug in loadersFromTransformOptions, and the two test files add regression tests for the conditions handling, all directly supporting the fix for issue #30619.

✏️ 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

Found 1 issue this PR may fix:

  1. bun --conditions: export condition matching breaks with 4+ conditions (falls back to default) #30619 - Fixes the capacity miscount in ESMConditions.init that causes export condition matching to break/fall back to default with 4+ custom conditions

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #30619

🤖 Generated with Claude Code

@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 ESMConditions.init capacity miscount caused by Zig operator precedence, which crashes Bun.build with many user conditions

🤖 Generated with Claude Code

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/resolve/import-custom-condition.test.ts`:
- Around line 172-188: Replace the manual for-loop inside the "many custom
conditions resolve correctly" test with a parameterized test so each `n` is its
own case: convert the current it(...) block in import-custom-condition.test.ts
to use test.concurrent.each([4,5,6,8,12,20]) (or test.each if concurrency is
undesired) and move the body that builds `flags`, calls `Bun.spawnSync({...})`,
and asserts the result into the per-case callback; keep the same `flags`
construction logic, same `Bun.spawnSync` call and the expect comparing `{ n,
exitCode, stdout, stderr }` to the expected object, but run it once per `n` so
failures report independently.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 82323416-8876-4ed1-ac10-8381bf5d54dc

📥 Commits

Reviewing files that changed from the base of the PR and between 1e2c22c and e3e0878.

📒 Files selected for processing (1)
  • test/js/bun/resolve/import-custom-condition.test.ts

Comment on lines +172 to +188
it("many custom conditions resolve correctly", async () => {
for (const n of [4, 5, 6, 8, 12, 20]) {
const flags = [];
for (let i = 1; i < n; i++) flags.push(`--conditions=c${i}`);
flags.push("--conditions=first");
const { exitCode, stdout, stderr } = Bun.spawnSync({
cmd: [bunExe(), ...flags, `${dir}/test.js`],
env: bunEnv,
cwd: import.meta.dir,
});
expect({ n, exitCode, stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8") }).toMatchObject({
n,
exitCode: 0,
stdout: "1\n",
});
}
});

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.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Use parameterized test cases instead of a manual loop.

This should be split into parameterized cases so each n reports independently and can run concurrently across the matrix.

♻️ Suggested rewrite
-it("many custom conditions resolve correctly", async () => {
-  for (const n of [4, 5, 6, 8, 12, 20]) {
-    const flags = [];
-    for (let i = 1; i < n; i++) flags.push(`--conditions=c${i}`);
-    flags.push("--conditions=first");
-    const { exitCode, stdout, stderr } = Bun.spawnSync({
-      cmd: [bunExe(), ...flags, `${dir}/test.js`],
-      env: bunEnv,
-      cwd: import.meta.dir,
-    });
-    expect({ n, exitCode, stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8") }).toMatchObject({
-      n,
-      exitCode: 0,
-      stdout: "1\n",
-    });
-  }
-});
+it.concurrent.each([4, 5, 6, 8, 12, 20])("many custom conditions resolve correctly (n=%i)", async n => {
+  const flags = [];
+  for (let i = 1; i < n; i++) flags.push(`--conditions=c${i}`);
+  flags.push("--conditions=first");
+
+  const { exitCode, stdout, stderr } = Bun.spawnSync({
+    cmd: [bunExe(), ...flags, `${dir}/test.js`],
+    env: bunEnv,
+    cwd: import.meta.dir,
+  });
+
+  expect({ n, exitCode, stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8") }).toMatchObject({
+    n,
+    exitCode: 0,
+    stdout: "1\n",
+  });
+});

Based on learnings, in test/js/bun/**/*.test.ts, prefer test.each()/test.concurrent.each() over manual loops when each parameter value maps to a single assertion.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it("many custom conditions resolve correctly", async () => {
for (const n of [4, 5, 6, 8, 12, 20]) {
const flags = [];
for (let i = 1; i < n; i++) flags.push(`--conditions=c${i}`);
flags.push("--conditions=first");
const { exitCode, stdout, stderr } = Bun.spawnSync({
cmd: [bunExe(), ...flags, `${dir}/test.js`],
env: bunEnv,
cwd: import.meta.dir,
});
expect({ n, exitCode, stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8") }).toMatchObject({
n,
exitCode: 0,
stdout: "1\n",
});
}
});
it.concurrent.each([4, 5, 6, 8, 12, 20])("many custom conditions resolve correctly (n=%i)", async n => {
const flags = [];
for (let i = 1; i < n; i++) flags.push(`--conditions=c${i}`);
flags.push("--conditions=first");
const { exitCode, stdout, stderr } = Bun.spawnSync({
cmd: [bunExe(), ...flags, `${dir}/test.js`],
env: bunEnv,
cwd: import.meta.dir,
});
expect({ n, exitCode, stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8") }).toMatchObject({
n,
exitCode: 0,
stdout: "1\n",
});
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/bun/resolve/import-custom-condition.test.ts` around lines 172 - 188,
Replace the manual for-loop inside the "many custom conditions resolve
correctly" test with a parameterized test so each `n` is its own case: convert
the current it(...) block in import-custom-condition.test.ts to use
test.concurrent.each([4,5,6,8,12,20]) (or test.each if concurrency is undesired)
and move the body that builds `flags`, calls `Bun.spawnSync({...})`, and asserts
the result into the per-case callback; keep the same `flags` construction logic,
same `Bun.spawnSync` call and the expect comparing `{ n, exitCode, stdout,
stderr }` to the expected object, but run it once per `n` so failures report
independently.

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/bundler/options.zig:1090-1094 — This edits src/bundler/options.zig, which per CLAUDE.md:141 is a non-compiled porting reference — the shipping implementation is src/bundler/options.rs, and that file already hoists let addon_extra = if allow_addons { 1 } else { 0 }; (line 832) and uses .insert() instead of assume-capacity inserts (line 838: // PERF(port): was assume_capacity). So the described putAssumeCapacity/addOneAssumeCapacity debug-assert crash cannot occur in the current binary, the .zig change has zero runtime effect, and the new test passes with USE_SYSTEM_BUN=1 (which CLAUDE.md:130 says makes it invalid). Suggest re-scoping this as "sync .zig reference with .rs (no behavior change)" and dropping the test/Fuzzilli framing — or, if Fuzzilli really tripped this, identifying where, because it can't be options.rs.

    Extended reasoning...

    What this PR actually changes vs. what it claims

    The PR description says the capacity miscount in ESMConditions.init caused putAssumeCapacity to trip a debug assert in MultiArrayList.addOneAssumeCapacity, found by Fuzzilli. The fix and the test are both framed as a crash repro + fix. But the only source change is to src/bundler/options.zig, and per the repo's own contributor docs that file is not compiled and not shipped:

    CLAUDE.md:141: "You will see .zig files alongside many .rs files … These are the original Zig implementation, kept only as a porting reference — they are not compiled and not shipped. New code goes in .rs. … Never add new behavior to a .zig file."

    src/CLAUDE.md:15-17: ".zig siblings … are the original implementation kept as a porting reference for behavior; they are not compiled and are not where new code goes."

    There is no top-level build.zig in the repo and no build configuration references options.zig. Editing it is a documentation-sync change, not a behavior change.

    The shipping code already has this fix

    The actual implementation is src/bundler/options.rs:820-875 (ESMConditions::init), last touched in #30412 (the Rust rewrite), well before this PR. It already does exactly what this PR adds to the .zig:

    // src/bundler/options.rs:832-836
    let addon_extra = if allow_addons { 1 } else { 0 };
    default_condition_amp.reserve(defaults.len() + 2 + addon_extra + conditions.len());
    import_condition_map.reserve(defaults.len() + 2 + addon_extra + conditions.len());
    require_condition_map.reserve(defaults.len() + 2 + addon_extra + conditions.len());

    And critically, the Rust port replaced putAssumeCapacity with growable .insert():

    // src/bundler/options.rs:838-841
    // PERF(port): was assume_capacity
    import_condition_map.insert(b"import".as_slice().into(), ());

    So even without the hoist, the Rust build would not assert — .insert() reallocates on overflow. The crash narrative in the PR description ("putAssumeCapacity calls tripped a debug assert in MultiArrayList.addOneAssumeCapacity") describes Zig-only machinery that does not exist in the shipped binary.

    Step-by-step proof the test is non-validating

    1. The new test calls Bun.build({ target: "bun", conditions: ["a"…"l"] }).
    2. This flows to ESMConditions::init in options.rs (the only compiled implementation), with allow_addons = true (default per options.rs:1955) and 12 user conditions.
    3. options.rs:832 computes addon_extra = 1, then reserves defaults.len() + 2 + 1 + 12 — correct.
    4. Even if step 3 under-reserved, options.rs:838+ uses .insert(), which grows the map; no assert can fire.
    5. Therefore bundle.success === true on a bun binary built without this PR.
    6. Per CLAUDE.md:130: "Your test is NOT VALID if it passes with USE_SYSTEM_BUN=1." This test does, so it validates nothing about this change.

    Why this matters / what to do

    Merging as-is lands a misleading commit ("Fix capacity miscount … Found by Fuzzilli") that future readers will assume describes a real shipped bug, plus a test that can never regress. Two reasonable paths:

    • Re-scope as a doc sync: retitle to something like "sync options.zig reference with options.rs", drop the Fuzzilli/crash framing from the description, and drop (or relabel) the test since it doesn't gate any behavior.
    • Or investigate the Fuzzilli fingerprint: if 6a3e4efbcd4ab6e8 really fired, it wasn't on this code path in the current Rust build — the repro/fingerprint may be stale (pre-#30412 Zig build) or pointing at a different site.

@robobun

robobun commented May 14, 2026

Copy link
Copy Markdown
Collaborator Author

Closing — this bug was already fixed on main by #30412. src/bundler/options.rs:832 hoists let addon_extra = if allow_addons { 1 } else { 0 }; and uses growable .insert() instead of putAssumeCapacity. The .zig file edited here is a non-compiled porting reference.

The Fuzzilli fingerprint 6a3e4efbcd4ab6e8 was found on commit 0d9b296af3, which predates #30412. A fresh debug build from current main does not reproduce the crash:

$ bun bd && ./build/debug/bun-debug repro.js
ok

@robobun robobun closed this May 14, 2026
Comment thread src/bundler/options.zig
Comment on lines +1575 to +1577
(if (target.isBun()) default_loader_ext_bun.len else 0) +
(if (target == .browser) default_loader_ext_browser.len else 0) +
default_loader_ext.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.

🔴 This PR's only source change is to src/bundler/options.zig, but per src/CLAUDE.md the .zig files are non-compiled porting references — the shipped binary is built from src/bundler/options.rs, where ESMConditions::init already hoists addon_extra and uses growing .insert(), so the bug described in the PR body cannot occur there. On top of that, commit 28128c6 reverted the ESMConditions.init hunk from 1e2c22c, so the net diff doesn't even touch that function — only the cosmetic parenthesization of loadersFromTransformOptions (which uses getOrPutValue and was never UB) survives. Net effect: the .zig edit changes nothing in the built binary and the title/description are misleading; only the two new regression tests add value. Please verify whether #30619 still reproduces on main (post-Rust-rewrite) — if not, retitle as a tests-only PR; if it does, the fix needs to go in .rs.

Extended reasoning...

What this PR actually changes

The PR title is "Fix capacity miscount in ESMConditions.init" and the description claims to fix #30619 by hoisting if (allow_addons) 1 else 0 into a typed local. But the net diff against base for src/bundler/options.zig shows zero changes to ESMConditions.init — only loadersFromTransformOptions (lines 1574-1577) is modified, adding parentheses around two if/else expressions in a capacity hint.

This happened because commit 28128c6 ("Also fix if/else precedence in loadersFromTransformOptions capacity hint") accidentally reverted the ESMConditions.init fix from 1e2c22c. git show 28128c64 -- src/bundler/options.zig shows it removing const addons_extra: usize = if (allow_addons) 1 else 0; and restoring the original buggy expression:

-        const addons_extra: usize = if (allow_addons) 1 else 0;
-        try default_condition_amp.ensureTotalCapacity(defaults.len + 2 + addons_extra + conditions.len);
+        try default_condition_amp.ensureTotalCapacity(defaults.len + 2 + if (allow_addons) 1 else 0 + conditions.len);

At HEAD, src/bundler/options.zig:1090-1092 still reads the unparenthesized form, which Zig parses as defaults.len + 2 + (if (allow_addons) 1 else (0 + conditions.len)).

Why none of this affects the binary

More fundamentally, src/CLAUDE.md (lines 15-17) states:

You will see .zig siblings next to many .rs files — those are the original implementation kept as a porting reference for behavior; they are not compiled and are not where new code goes.

The base of this PR is one commit after 23427db "Rewrite Bun in Rust (#30412)". There is no top-level build.zig, and all CI build failures in this PR's timeline are in .rs files (Blob.rs, server/mod.rs, h2_frame_parser.rs, JSSecrets.rs, VirtualMachine.rs, process.rs) — there is no Zig build step. The compiled implementation is src/bundler/options.rs.

Inspecting src/bundler/options.rs:832-835, the Rust port of ESMConditions::init already computes capacity correctly:

let addon_extra = if allow_addons { 1 } else { 0 };
default_condition_amp.reserve(defaults.len() + 2 + addon_extra + conditions.len());

and at line ~838 it uses .insert() (which grows on demand) rather than an assume-capacity variant — the comment // PERF(port): was assume_capacity confirms this was intentionally changed during the port. So even if the reserve hint were wrong, the Rust code could not exhibit the putAssumeCapacity overflow / debug assert described in the PR body. Similarly, loaders_from_transform_options in options.rs already sums its terms correctly (Rust if/else with braces doesn't have Zig's precedence trap).

Step-by-step proof

  1. Net source diff: git diff 19d8ade2..HEAD -- src/bundler/options.zig touches only lines 1574-1577 in loadersFromTransformOptions. ESMConditions.init is byte-identical to base.
  2. Surviving change is cosmetic: loadersFromTransformOptions calls stringHashMapFromArrays (which only putAssumeCapacity's input_loaders.extensions — always ≤ the hint regardless of precedence) and then uses getOrPutValue for the default extensions, which grows the map safely. The added parentheses fix the hint arithmetic but there was never UB here.
  3. File is not compiled: src/bundler/options.zig is a reference-only sibling of src/bundler/options.rs per project conventions. Editing it cannot change the built binary.
  4. Rust path is already correct: ESMConditions::init in options.rs already hoists addon_extra and uses growing .insert(). With 12 user conditions and allow_addons=true, it reserves 2 + 2 + 1 + 12 = 17 and inserts via .insert() — no overflow possible.
  5. Therefore: the two new regression tests should pass on main with or without this PR's .zig edit, because they exercise the Rust runtime. The .zig change has zero runtime effect.

Impact and how to fix

The PR as written does not deliver what its title and "Fixes #30619" claim. The actionable outcomes are:

@suvorovrain

Copy link
Copy Markdown

автоботы ролл аут

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.

bun --conditions: export condition matching breaks with 4+ conditions (falls back to default)

2 participants