Skip to content

bundler: fix capacity miscalculation in ESMConditions.init - #30577

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

bundler: fix capacity miscalculation in ESMConditions.init#30577
robobun wants to merge 1 commit into
mainfrom
farm/499d95e7/fix-esm-conditions-capacity

Conversation

@robobun

@robobun robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator

Fuzzer fingerprint: 800f7f8cb62e7ad8

What

ESMConditions.init reserved capacity with:

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

In Zig this parses as defaults.len + 2 + (if (allow_addons) 1 else (0 + conditions.len)). When allow_addons is true (the default), the user-supplied conditions.len was dropped entirely from the reserved capacity. The subsequent putAssumeCapacity calls then wrote past the allocation once enough custom conditions were provided, tripping a debug assertion in MultiArrayList.addOneAssumeCapacity (and a heap buffer overflow in release).

Minimal repro:

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

Fix

Pull the addon count out into a usize so the addition is unambiguous.

The expression `if (allow_addons) 1 else 0 + conditions.len` parsed as
`if (allow_addons) 1 else (0 + conditions.len)`, so when allow_addons was
true the user-supplied conditions count was dropped from the reserved
capacity. With enough custom conditions passed to Bun.build, the
subsequent putAssumeCapacity calls overflowed and tripped an assertion
(or wrote past the allocation in release).
@robobun

robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:10 PM PT - May 12th, 2026

@robobun, your commit 2f1b793 has 1 failures in Build #53809 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30577

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

bun-30577 --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: bb6957cf-bd1e-43f8-83e7-babca5960ac1

📥 Commits

Reviewing files that changed from the base of the PR and between 314ffe3 and 2f1b793.

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

Walkthrough

ESMConditions capacity preallocation in options.zig is refactored to compute addon_count once rather than repeating the boolean-to-integer conditional. A regression test validates that Bun.build succeeds with many custom conditions.

Changes

ESMConditions optimization

Layer / File(s) Summary
ESMConditions capacity refactoring and validation
src/bundler/options.zig, test/bundler/bun-build-api.test.ts
ESMConditions.init now computes addon_count once for capacity calculations instead of repeating if (allow_addons) 1 else 0 inline. Regression test verifies builds with 10 custom conditions succeed.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title clearly and concisely summarizes the main change: a capacity miscalculation bug fix in ESMConditions.init.
Description check ✅ Passed The pull request description provides comprehensive context: it explains the bug mechanism, provides a minimal repro, and describes the fix. However, it lacks the structured template sections 'What does this PR do?' and 'How did you verify your code works?'.
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 crash in Bun.build with many custom conditions by correcting the capacity calculation in ESMConditions.init

🤖 Generated with Claude Code

@robobun

robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #30466, which also catches the second instance of this precedence bug in loadersFromTransformOptions.

@robobun robobun closed this May 12, 2026
@robobun
robobun deleted the farm/499d95e7/fix-esm-conditions-capacity branch May 12, 2026 19:10

@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 precedence fix with a regression test; the inline notes are non-blocking.

Extended reasoning...

Overview

This PR fixes a Zig operator-precedence bug in ESMConditions.init (src/bundler/options.zig). The expression defaults.len + 2 + if (allow_addons) 1 else 0 + conditions.len parsed such that conditions.len was swallowed into the else branch, so when allow_addons was true (the default) the reserved capacity omitted the user-supplied conditions count entirely. Subsequent putAssumeCapacity calls then overflowed the allocation. The fix extracts @intFromBool(allow_addons) into a usize local and uses plain addition, making the arithmetic unambiguous. A 13-line regression test in test/bundler/bun-build-api.test.ts exercises Bun.build with 10 custom conditions.

Security risks

The change removes a heap buffer overflow reachable from user-controlled input (the conditions array passed to Bun.build). It introduces no new attack surface — it only corrects a capacity reservation. No auth, crypto, or permission code is touched.

Level of scrutiny

Low. This is a 4-line mechanical correction whose intent and correctness are verifiable by inspection: counting the putAssumeCapacity calls against each map confirms the new capacities are sufficient (and slightly over-reserve for default and style, which is harmless). The accompanying test is a minimal does-not-crash check with no shared state.

Other factors

Neither modified file is covered by CODEOWNERS. The two inline comments from the bug-hunting pass are non-blocking: one points out a pre-existing instance of the same precedence pattern elsewhere in the file (where it only defeats a pre-sizing optimization, not a safety issue), and the other is a test.concurrent style nit per test/CLAUDE.md. Both are reasonable follow-ups but do not affect the correctness of this fix.

},
);

test("many custom conditions does not crash", 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 write files should use test.concurrent when there's no shared state. This test uses a unique tempDirWithFiles dir with no process.chdir or shared globals, and the sibling tests in this describe block (e.g. ignoreDCEAnnotations works, hash considers cross chunk imports) already use test.concurrent for the same pattern.

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 added at test/bundler/bun-build-api.test.ts:653 writes files via tempDirWithFiles and runs Bun.build, but is declared with plain test(...) rather than test.concurrent(...).

Why it qualifies

The test creates its own isolated temp directory with a unique prefix (bun-build-api-many-conditions), does not call process.chdir, does not touch any module-level mutable state, and does not depend on ordering relative to other tests in the file. There is nothing that would make concurrency "very difficult" here — it is exactly the pattern the guideline targets.

Local precedent

Within the same describe("Bun.build") block, structurally identical tests already use test.concurrent:

  • hash considers cross chunk imports
  • ignoreDCEAnnotations works
  • emitDCEAnnotations works
  • loader map with an empty-string key is ignored ...
  • rebuilding busts the directory entries cache

Each of these creates a temp dir, runs Bun.build, and asserts on the result — the same shape as the new test.

Step-by-step

  1. Test calls tempDirWithFiles("bun-build-api-many-conditions", {...}) → unique per-invocation directory, no collision with concurrent siblings.
  2. Test calls await Bun.build({ entrypoints: [...], conditions: [...] }) → in-process build with no shared global state.
  3. Test asserts result.success === true → pure read of the local result.

None of these steps require sequential execution; running concurrently with siblings cannot change the outcome.

Fix

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

This is purely a style/convention nit per the repo's test guidelines — the test is functionally correct as written.

Comment thread src/bundler/options.zig
Comment on lines +1090 to +1093
const addon_count = @as(usize, @intFromBool(allow_addons));
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);

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 (cond) X else 0 + Y precedence pattern this PR fixes still exists ~480 lines down in loadersFromTransformOptions (the stringHashMapFromArrays capacity argument). It doesn't crash there because the subsequent inserts use getOrPutValue which grows on demand, so it only defeats the pre-sizing — but since this PR exists specifically to fix this pattern, it might be worth applying the same parenthesization/extraction there so the broken pattern doesn't get copied again.

Extended reasoning...

Same precedence bug pattern remains in loadersFromTransformOptions

This PR correctly fixes the if (cond) X else 0 + Y operator-precedence bug in ESMConditions.init. However, the identical pattern still exists in the same file, in loadersFromTransformOptions:

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

How it parses

In Zig, an if expression's else branch consumes a full expression, and the whole if is then a primary expression on the left side of +. 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 proof

Take target = .bun (so target.isBun() is true) and input_loaders.extensions.len = 0:

  1. The outer if takes the then branch → yields default_loader_ext_bun.len = 2.
  2. The trailing + if (target == .browser) ... + default_loader_ext.len is entirely inside the else branch, so it is never evaluated.
  3. Total capacity passed = 0 + 2 = 2.
  4. Intended capacity = 0 + 2 + 0 + 18 = 20.

Similarly for target = .browser: the inner if takes the then branch (= 1), dropping default_loader_ext.len, so capacity = 0 + 1 instead of 0 + 1 + 18.

Why it doesn't crash here

Unlike ESMConditions.init, this site does not overflow:

  • stringHashMapFromArrays only calls putAssumeCapacity for the input_loaders.extensions keys, and input_loaders.extensions.len is the one term that is always included in the sum regardless of which branch is taken.
  • All subsequent inserts (default_loader_ext, default_loader_ext_bun, default_loader_ext_browser) go through try loaders.getOrPutValue(...), which grows the map on demand.

So the only effect is that ensureTotalCapacity reserves too little and the map reallocates a few times during the getOrPutValue loop — a defeated optimization, not a memory-safety bug.

Why flag it on this PR

This is pre-existing and not introduced here. But it is the exact bug class this PR exists to fix, in the same file. Fixing it alongside (e.g. by extracting the conditional counts into usize locals, mirroring the addon_count approach) would prevent the next reader from copying the broken pattern back into a putAssumeCapacity context.

Suggested 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;
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,
);

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