Fix crash in Bun.build when passing many conditions - #30498
Conversation
…s true The expression `defaults.len + 2 + if (allow_addons) 1 else 0 + conditions.len` was parsed as `defaults.len + 2 + (if (allow_addons) 1 else (0 + conditions.len))`, dropping conditions.len from the reserved capacity when allow_addons was true (the default). With enough user-supplied conditions, putAssumeCapacity would overflow the map and crash.
|
Updated 7:18 AM PT - May 11th, 2026
❌ @robobun, your commit 8fba47a has some failures in 🧪 To try this PR locally: bunx bun-pr 30498That installs a local version of the PR into your bun-30498 --bun |
|
Duplicate of #30466. |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
LGTM — targeted fix for the Zig if-expression precedence footgun in capacity reservation, with a regression test.
Extended reasoning...
Overview
This PR fixes a crash in Bun.build() when many conditions are passed. The root cause is a Zig if-expression precedence footgun in ESMConditions.init (src/bundler/options.zig): defaults.len + 2 + 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), conditions.len is dropped from the capacity reservation and subsequent putAssumeCapacity calls overflow the map. The fix replaces the if-expression with @as(usize, @intFromBool(allow_addons)), which is unambiguous and matches the style used elsewhere in the file. A regression test in bun-build-api.test.ts exercises 16 conditions.
Security risks
None. This is a pure arithmetic fix to a hashmap capacity reservation. No user input parsing, auth, crypto, or permissions are involved. The change strictly increases the reserved capacity to the intended value.
Level of scrutiny
Low. The diff is 3 identical one-line expression rewrites plus a 12-line regression test. I verified the new capacity matches the actual number of putAssumeCapacity calls that follow (1 sentinel + conditions.len + defaults.len + optional node-addons + default ≤ defaults.len + 2 + intFromBool(allow_addons) + conditions.len for all three maps). The style_condition_map line is untouched and was already correct (it doesn't insert node-addons or user conditions).
Other factors
The one inline comment from the bug-hunting system is explicitly flagged as pre-existing and not a blocker — it points out the same precedence pattern in loadersFromTransformOptions, but that site uses getOrPutValue (which grows the map) so there's no correctness impact, just a missed pre-reservation. It's a reasonable cleanup suggestion but orthogonal to this fix. No CODEOWNERS apply, no outstanding reviewer comments, and CodeRabbit found nothing actionable.
| try default_condition_amp.ensureTotalCapacity(defaults.len + 2 + @as(usize, @intFromBool(allow_addons)) + conditions.len); | ||
| try import_condition_map.ensureTotalCapacity(defaults.len + 2 + @as(usize, @intFromBool(allow_addons)) + conditions.len); | ||
| try require_condition_map.ensureTotalCapacity(defaults.len + 2 + @as(usize, @intFromBool(allow_addons)) + conditions.len); |
There was a problem hiding this comment.
🟣 Heads-up (pre-existing, not a blocker): the same if-expression precedence footgun this PR fixes also lives in loadersFromTransformOptions() at src/bundler/options.zig:1574-1577 — when target.isBun() is true, the else branch greedily swallows the trailing + ... + default_loader_ext.len, so the capacity reservation drops 16+ entries. It doesn't crash there because the subsequent inserts use getOrPutValue() (which grows the map) rather than putAssumeCapacity(), so the only effect is a missed pre-reservation; might be worth applying the same @intFromBool fix while you're here for consistency.
Extended reasoning...
What & where
loadersFromTransformOptions() computes the initial capacity for the loaders map 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,This is the identical Zig if-expression precedence footgun that this PR just fixed in ESMConditions.init. Zig parses the else branch greedily, so the trailing + expressions bind into the else rather than into the outer sum.
Step-by-step evaluation
Take target = .bun (so target.isBun() == true, target == .browser == false), default_loader_ext_bun.len = 2, default_loader_ext_browser.len = 1, default_loader_ext.len = 18, and say input_loaders.extensions.len = 0:
- Innermost:
if (target == .browser) 1 else (0 + 18)→ since false, evaluates to18. (This branch is fine-ish on its own.) - Next:
if (target.isBun()) 2 else (0 + <inner>)→ since true, evaluates to2. The entireelse (0 + 18)arm — includingdefault_loader_ext.len— is discarded. - Outer:
0 + 2= 2.
The author clearly intended 0 + 2 + 0 + 18 = 20. So the map is reserved for 2 entries instead of 20.
Why it doesn't crash (addressing the "already handled" objection)
Unlike ESMConditions.init, this site is saved by two things:
stringHashMapFromArraysonly callsputAssumeCapacityfor theinput_loaders.extensionskeys, andinput_loaders.extensions.lenis the one term that's always included in the sum (it's outside theif), so those inserts never overflow.- The subsequent
default_loader_ext/default_loader_ext_bun/default_loader_ext_browserinserts usegetOrPutValue(), which grows the map on demand.
So there is no correctness or memory-safety impact today — just a few unnecessary rehashes during option setup. I agree with the objection that this is not a functional bug.
Why it's still worth mentioning on this PR
This PR's entire premise is that a + if (b) 1 else 0 + c is a footgun that silently miscomputes capacity and that @intFromBool is the safer spelling. The same miscomputed-by-construction expression sits ~480 lines away in the same file. The intent of that code was clearly to reserve space for all the default extensions; it just happens that the consumer is forgiving. If anyone later swaps getOrPutValue for putAssumeCapacity (matching the pattern this PR just fixed), it becomes the same crash.
Suggested fix
Same transformation as in this PR:
input_loaders.extensions.len +
default_loader_ext_bun.len * @as(usize, @intFromBool(target.isBun())) +
default_loader_ext_browser.len * @as(usize, @intFromBool(target == .browser)) +
default_loader_ext.len,(or just parenthesize the if expressions). Purely a consistency/cleanup suggestion — not a blocker for this PR.
What does this PR do?
Fixes a crash in
Bun.build()(and the CLI bundler) when the user passes enoughconditionsto exceed the under-reserved capacity of the ESM conditions maps.Root cause
In
ESMConditions.init, the capacity reservation used:Due to Zig's
if-expression parsing, this is actually:When
allow_addonsistrue(the default),conditions.lenwas dropped from the capacity calculation entirely. The subsequentputAssumeCapacitycalls then overflowed the map, tripping an assertion in debug builds and corrupting memory / segfaulting in release builds.Fix
Replace the
ifexpression with@intFromBool(allow_addons), matching the style already used elsewhere in this file and avoiding the precedence footgun.How did you verify your code works?
Added a regression test in
test/bundler/bun-build-api.test.tsthat callsBun.buildwith 16 conditions. This segfaults on current canary and passes with the fix.Also verified existing
packagejson/ExportsCustomConditions*bundler tests still pass.Found by Fuzzilli (fingerprint
d5aa656b6197a654).