Skip to content

Fix capacity under-reservation in ESMConditions.init#30531

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

Fix capacity under-reservation in ESMConditions.init#30531
robobun wants to merge 1 commit into
mainfrom
farm/809344a8/fix-esm-conditions-capacity

Conversation

@robobun

@robobun robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a debug assertion / out-of-bounds write in Bun.build() when passing several custom conditions.

In ESMConditions.init, 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))

So when allow_addons is true (the default for Bun.build), conditions.len was dropped from the reservation entirely. The subsequent putAssumeCapacity calls would then exceed the backing MultiArrayList capacity once enough user conditions were supplied, tripping assert(self.len < self.capacity) in debug (and writing past the allocation in release).

Repro that crashes before this change:

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

How did you verify your code works?

  • Reproduced the assertion failure with the script above against the unpatched debug build.
  • bun bd test test/bundler/bun-build-api.test.ts — all 39 pass + 1 todo, including the new many conditions does not crash test which sweeps 1–20 conditions across node/bun/browser targets.
  • Verified the new test segfaults against the unpatched canary (USE_SYSTEM_BUN=1).
  • bun bd test test/bundler/esbuild/packagejson.test.ts -t Conditions — existing conditions tests still pass.

Fuzzer fingerprint: c9b09baffd7363d6

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 user-provided conditions were not counted toward the reserved
capacity. With enough conditions passed to Bun.build(), putAssumeCapacity
would overflow the backing MultiArrayList and trip a debug assertion (or
write past the end in release).
@robobun

robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:38 PM PT - May 11th, 2026

@robobun, your commit 0d44be3 has 1 failures in Build #53564 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30531

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

bun-30531 --bun

@coderabbitai

coderabbitai Bot commented May 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR improves capacity planning in ESMConditions.init by extracting the addon count as a variable and adds a stress test validating that builds succeed with up to 20 conditions across multiple targets.

Changes

ESMConditions Capacity Handling

Layer / File(s) Summary
Capacity calculation refactor
src/bundler/options.zig
ESMConditions.init now computes addons_count once from the allow_addons boolean and reuses it in ensureTotalCapacity calculations for condition maps.
Stress test with many conditions
test/bundler/bun-build-api.test.ts
New test iterates targets (node, bun, browser) and condition counts (1–20), asserting Bun.build succeeds for each combination without crashing.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main fix: correcting a capacity reservation issue in ESMConditions.init.
Description check ✅ Passed The description comprehensively covers both required template sections: what the PR does (explains the bug and the fix in detail) and how it was verified (lists multiple verification steps including reproduction, testing, and existing test validation).
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.

@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/bundler/bun-build-api.test.ts`:
- Around line 658-667: The test currently runs nested loops over targets and
counts inside one test body, which hides which specific (target, count) failed;
refactor the looped scenarios into parameterized tests using describe.each or
test.each so each (target, count) pair is a separate test case. Replace the for
(...) loops that call Bun.build({ entrypoints: [join(dir, "entry.js")], target,
conditions }) with a data table of [target, count] and use test.each to generate
individual tests that construct conditions (Array.from({ length: count }, (_, i)
=> `custom${i}`)), call Bun.build, and assert result.success; keep the same
assertion and inputs but ensure each case is reported 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: a3a72f41-f717-494d-a1c1-f0ca465f94a4

📥 Commits

Reviewing files that changed from the base of the PR and between 37bfbed and 0d44be3.

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

Comment on lines +658 to +667
for (const target of ["node", "bun", "browser"] as const) {
for (let count = 1; count <= 20; count++) {
const conditions = Array.from({ length: count }, (_, i) => `custom${i}`);
const result = await Bun.build({
entrypoints: [join(dir, "entry.js")],
target,
conditions,
});
expect(result.success).toBe(true);
}

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 cases instead of nested loops for clearer failures.

This currently packs 60 scenarios into one test body, so a single failure obscures which target/count case broke. Prefer describe.each()/test.each() so each case is independently reported.

As per coding guidelines: "Use describe.each() for parameterized tests".

🤖 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/bundler/bun-build-api.test.ts` around lines 658 - 667, The test
currently runs nested loops over targets and counts inside one test body, which
hides which specific (target, count) failed; refactor the looped scenarios into
parameterized tests using describe.each or test.each so each (target, count)
pair is a separate test case. Replace the for (...) loops that call Bun.build({
entrypoints: [join(dir, "entry.js")], target, conditions }) with a data table of
[target, count] and use test.each to generate individual tests that construct
conditions (Array.from({ length: count }, (_, i) => `custom${i}`)), call
Bun.build, and assert result.success; keep the same assertion and inputs but
ensure each case is reported independently.

@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 Zig operator precedence bug in ESMConditions.init causing capacity under-reservation when Bun.build() is called with custom conditions

🤖 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 bug in loadersFromTransformOptions.

@robobun robobun closed this May 12, 2026
@robobun robobun deleted the farm/809344a8/fix-esm-conditions-capacity branch May 12, 2026 01:37

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

Extended reasoning...

Overview

This PR fixes a Zig operator-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), dropping conditions.len from the capacity reservation whenever allow_addons was true. The fix extracts @as(usize, @intFromBool(allow_addons)) into a local and adds it explicitly, removing the ambiguity. A regression test sweeps 1–20 user conditions across node/bun/browser targets.

Security risks

None introduced. This actually fixes a memory-safety issue: the under-reservation led to putAssumeCapacity writing past the backing allocation in release builds (and tripping an assertion in debug). The new arithmetic is straightforward and matches the actual number of putAssumeCapacity calls that follow.

Level of scrutiny

Low-to-moderate. The diff is 4 lines of arithmetic in option setup plus a self-contained test. The transformation from if (b) 1 else 0 to @intFromBool(b) is mechanical and idiomatic Zig; the resulting sum (defaults.len + 2 + addons_count + conditions.len) is easy to verify against the subsequent inserts. No control-flow, API, or behavioral changes beyond reserving the correct capacity.

Other factors

  • The one inline finding is explicitly pre-existing and benign (a similar precedence pattern in loadersFromTransformOptions that only causes a harmless rehash because it uses getOrPutValue); it's a non-blocking suggestion, not a defect in this change.
  • CodeRabbit's nit about test.each is a style preference and doesn't affect correctness.
  • A duplicate-PR bot flagged #30466 as covering the same fix — maintainers may want to dedupe, but that doesn't change the correctness of this patch.
  • Author verified the new test segfaults on unpatched canary and passes after the fix; existing conditions tests still pass.

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 = @as(usize, @intFromBool(allow_addons));

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 by this PR), but the same if (x) a else 0 + b precedence footgun also exists ~480 lines down in loadersFromTransformOptionsinput_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. That instance is benign (the subsequent inserts use getOrPutValue, which grows, so it just under-reserves and rehashes rather than corrupting memory), but since this PR is specifically about fixing this pattern in this file, it might be worth applying the same @intFromBool treatment there for consistency.

Extended reasoning...

What the issue is

This PR fixes a Zig operator-precedence bug in ESMConditions.init where if (allow_addons) 1 else 0 + conditions.len was being parsed as if (allow_addons) 1 else (0 + conditions.len). The exact same pattern exists further down 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, the else branch of an if expression consumes a full Expr, so if (c) a else b + x parses as if (c) a else (b + x). Walking through the cases:

  • target.isBun() is true: the outer if evaluates to default_loader_ext_bun.len (which is 2), and everything in the else branch — including the nested if and + default_loader_ext.len — is skipped. Result: extensions.len + 2, when the intended value is extensions.len + 2 + 0 + 18 = extensions.len + 20.
  • target == .browser: outer if is false, so we evaluate 0 + (if (target == .browser) default_loader_ext_browser.len else ...), which is 0 + 1. Result: extensions.len + 1, intended extensions.len + 19.
  • target == .node: both conditions false, so 0 + (0 + default_loader_ext.len) = extensions.len + 18. Coincidentally correct.

Why it doesn't crash

Unlike ESMConditions.init, this under-reservation is harmless at runtime:

  1. stringHashMapFromArrays only calls putAssumeCapacity for keys.len items (i.e. input_loaders.extensions.len), and the computed capacity is always ≥ extensions.len in every branch above, so the assume-capacity inserts are within bounds.
  2. The subsequent default_loader_ext / default_loader_ext_bun / default_loader_ext_browser inserts all use try loaders.getOrPutValue(...), which grows the map on demand.

So the only effect is one or two unnecessary rehashes during the loop — no UB, no observable misbehavior.

Why mention it

This is pre-existing and not introduced by this PR. But the PR's entire purpose is fixing this exact if (x) a else 0 + b precedence trap in this file, and the author already has the fix pattern (@intFromBool) right above. Applying the same treatment here would prevent someone from later "optimizing" the getOrPutValue calls to putAssumeCapacity and reintroducing the same class of bug.

Suggested fix

input_loaders.extensions.len +
    @as(usize, @intFromBool(target.isBun())) * default_loader_ext_bun.len +
    @as(usize, @intFromBool(target == .browser)) * default_loader_ext_browser.len +
    default_loader_ext.len,

or simply parenthesize each if expression. Non-blocking either way.

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