Skip to content

js_parser: walk conditional chains in import()/require() and define keys in a loop - #44864

Open
robobun wants to merge 1 commit into
mainfrom
robobun/27237d80/import-transpose-stack-check
Open

robobun wants to merge 1 commit into
mainfrom
robobun/27237d80/import-transpose-stack-check

Conversation

@robobun

@robobun robobun commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • A long conditional chain in import(), require() or require.resolve() ends the process with Segmentation fault and no message. So does a define key of 54,000 parts.
  • Bun 1.4.2 dies on the require() and define cases. Bun 1.3.13 handles all.
  • Three walks in src/js_parser/p.rs recurse per link with no stack check: maybe_transpose_if_import and four copies (:1059), conditional_namespace_records (:1356), is_dot_define_match (:7342).

Fix

  • transpose_each_branch replaces the five transposers. It loops along no and recurses into yes behind the stack check. On overflow it returns None and the call stays.
  • conditional_namespace_records takes the same loop and check. is_dot_define_match becomes a loop.
  • Correct: a chain grows along no, where a loop takes no stack. In yes the visit pass recurses first, in a 640-byte frame against 176.
  • Verified: test/bundler/transpiler/transpiler.test.js, 6 new tests, 5 fail on main. Self-reviewed: 11 concerns raised, 8 addressed, 3 moved to the highlighter's own PR.

Background

  • A transposer turns import(a ? b : c) into a ? import(b) : import(c), so the bundler sees a string in each branch.
  • The parser and the visit pass check the stack at each level. Near its end they log Maximum call stack size exceeded and the parse fails.
  • Considered a stack check per link of the recursive walk: it refuses nested chains that main transpiles.

Downsides

Notes

Found by a fuzz ledger and a read of the code. There is no issue and no user report. No real input comes near these depths.

Repro (release builds, Linux x64, 8 MB stack. "died" is SIGSEGV with nothing on stderr.)

const chain = n => "a?'b':".repeat(n) + "'c'";
const js = new Bun.Transpiler({ loader: "js" });
js.scan("x=import(" + chain(26000) + ")");                                                 // 1
js.scan("x=" + "g(".repeat(12400) + "require(" + chain(4000) + ")" + ")".repeat(12400));   // 2
js.scan("x=" + "c?".repeat(11400) + "require(" + chain(11400) + ")" + ":0".repeat(11400)); // 3
new Bun.Transpiler({ define: { ["a" + ".b".repeat(60000)]: "1" } }).transformSync("x=a" + ".b".repeat(60000)); // 4
Line 1.3.13 1.4.2 main e655c58 this branch
1 reports reports died returns
2 reports reports died returns
3 reports died died returns
4 x = 1; died died x = 1;

"reports" is Maximum call stack size exceeded. conditional_namespace_records needs the bundler: Bun.build of const ns = c ? require("./a") : … : null with a long chain inside a deep nest of calls dies on main on a bundler thread.

Why only some depths died. The parser recurses once per link of a chain, 304 bytes a link, and asks the stack check. The visit pass takes no stack along no: e_if visits no last, and this toolchain compiles that call as a jump. So a chain can be as long as the parser takes. The walks then recursed over it with no check:

  • maybe_transpose_if_import took 336 bytes a link, more than the parser. A chain alone ran it out of stack from 24,852 links. Bun 1.4.2 was compiled with recursion for no in the visit pass, which reported first.
  • The require walks took 128 and 144 bytes a link. They ran out when the call was deep in a nest, where the visit pass had left little stack. On 1.4.2 the visit pass reported the depth, and e_call still handed the whole chain to the walk.
  • is_dot_define_match took 160 bytes a part (208 with TypeScript), and no other pass bounds a define key.

Per function

Function Now On a stack overflow
transpose_each_branch (replaces maybe_transpose_if_import, maybe_transpose_if_require, transpose_known_to_be_if_require, maybe_transpose_if_require_resolve, transpose_known_to_be_if_require_resolve) loop along no, recursion into yes behind the stack check logs the error once and returns None. The caller leaves the import() or require() call in place, and the parse fails
conditional_namespace_records the same loop and check returns None, which its caller already takes as "leave the namespace escaped"
is_dot_define_match loop no recursion is left

The check in the walker is the usual pair, !stack_check.is_safe_to_recurse() || reported_stack_overflow.get(). The second half is what stops the walk after the visit pass gave up inside the argument. Without it each branch that was not visited logs an error of its own ("This require() expression will not be bundled because the argument is not a string literal").

Measured (release builds of the merge base e655c58 and of this branch, Linux x64)

What main this branch
Stack of the transposer, bytes a no link 336 import (352 with TypeScript), 128 require, 144 require.resolve 0
Stack of the transposer, bytes a yes level the same 176. The visit pass takes 640 (656 with TypeScript)
conditional_namespace_records, bytes a no link 96 0
x=import(chain) through scan(), 3% steps scans to 24,602 links, dies from 25,341, reports from 27,693 scans to 26,886, reports from 27,693, the same as f(chain)
the same through transform() on a pool thread dies at 12,467 and 12,842 no process dies
require(chain) in 12,455 nested calls dies from a chain of 4,000 links (import from 2,000) every chain gives what f(chain) gives
Deepest nest of conditionals around a chain of 100, 1,000 and 10,000 links 12,835 to 12,841 for f, import, require and require.resolve alike
10 entry points at 6,000 / 12,700 / 20,000 / 26,000 / 40,000 links (scan, transformSync, transform, Bun.build, import(), require(), Worker, bun FILE, bun test, bun build) a child dies at 8 of the 10 entry points no child dies in 50 probes
define key that matches 52,000 parts match, dies from 54,000 1,000,000 parts match in 0.2 s
Parser instructions for x=import("a") 3,083 3,108 (+25)
for x=require("a") 3,528 3,533 (+5)
for x=require.resolve("a") 4,346 4,326 (-20)
for x=import(c?"a":"b") 4,818 4,879 (+61)
for x=require(c?"a":"b") 5,200 5,247 (+47)
for x=import(c?"a":d?"b":e?"f":"g") 8,288 8,369 (+81)
for x=f("a"), the control 3,379 3,379
is_dot_define_match for one matched a.b.c.d 294 instructions, one 160-byte frame a part 226 instructions, one frame. Self-calls in the disassembly: 0
Calls into mimalloc for one statement: import("a"), import(c?"a":"b"), the 3-link chain 5.0 / 9.0 / 17.0 5.0 / 9.0 / 17.0
.text 65,658,709 B 65,662,037 B (+3,328)
  • Instructions: qemu-x86_64 -one-insn-per-tb -d exec,nochain -dfilter over the address range of the parser crate, for 1,000 and 2,000 identical statements through transformSync. The number is the difference divided by 1,000.
  • Frames: llvm-nm -S and llvm-objdump -d on the unstripped binaries. Both self-calls in the compiled walker are the yes call. The walker tests the EIf tag first, so an argument that is not a conditional runs no check. The +25 for import("a") is one more call level: main inlined the leaf into the recursive function.
  • Allocations: calls into the 12 mimalloc entry points, whole process, same two fixtures.
  • Depths: one fresh process per probe, ulimit -s 8192.

Output unchanged. 153 fixtures under test/bundler, test/js/bun/transpiler and test/js/web that contain import( or require(, through 4 transpiler configurations each: 13,691,165 bytes of printed output and import scans, byte-identical between the two builds. 19,200 random conditional trees in import(), require(), require.resolve() and await import() (8 seeds, 8 option sets): 20,531,605 bytes, byte-identical. 88,332 random member expressions against 18,000 random define keys (dot, index, optional chain, import.meta): byte-identical.

Tests

Test main 1.4.2 this branch
import() and require() of a conditional move into each branch pass pass pass. It pins the output of the rewritten walk
a long conditional chain in import() does not crash the process SIGSEGV pass pass
a conditional chain in import() or require() in a deep nest does not crash the process SIGSEGV SIGSEGV pass
Bun.build logs only the stack overflow when the argument of require() is too deep 2 errors 2 errors pass
Bun.build takes a conditional chain inside a deep nest SIGSEGV pass pass
a define key of many parts is matched instead of crashing the process SIGSEGV SIGSEGV pass
  • The tests search for each depth and do not use fixed depths. The nest test takes the visit pass to 98% of its limit, adds the longest chain that the parser takes there, and requires each call to give what f(chain) gives.
  • On a debug build the second and fifth test are skipped. A debug build has no chain length that only the walk cannot take, and the fifth needs about 30 builds. The third, fourth and sixth run on every build. An unfixed debug + ASAN build fails them.
  • No test probes require(…) at the exact first depth that the visit pass refuses. See Downsides.

Still open, not in this PR

#28693 and #40857 edit other arms of is_dot_define_match and keep its recursion. Their edits apply to the loop.

Suites on the release build: transpiler.test.js 243 pass, bundler_dynamic_import_dce 333, esbuild/default 155, esbuild/extra 222, bundler_splitting 200, bundler_edgecase 182, esbuild/dce 87, esbuild/importstar 99, esbuild/ts 59, bundler_jsx 64, esbuild/splitting 26, esbuild/importstar_ts 23, bundler_bun 12, bundler_env 7. On the debug + ASAN build: transpiler.test.js 241 pass, 3 skip.

…eys in a loop

The walk that turns import(a ? b : c) into a ? import(b) : import(c), and
its copies for require() and require.resolve(), recursed once for each link
of a conditional chain with no stack check. So did
conditional_namespace_records and is_dot_define_match. They run after the
visit pass, on the stack that it left, so a long chain or a long define key
ended the process with a SIGSEGV.

transpose_each_branch replaces the five transposers. It loops along `no`
and recurses only into `yes`, behind the stack check of the visit pass. On
an overflow it returns None and the caller keeps the call as it is.
conditional_namespace_records takes the same loop and check.
is_dot_define_match peels one key part for each member access in a loop.
@robobun

robobun commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Status

@coderabbitai

coderabbitai Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: 650c73d2-a289-4ec7-9504-7120a2b679fc

📥 Commits

Reviewing files that changed from the base of the PR and between e655c58 and f1551c4.


📒 Files selected for processing (3)
  • src/js_parser/p.rs
  • src/js_parser/visit/visit_expr.rs
  • test/bundler/transpiler/transpiler.test.js

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 6 remain after this review.



Walkthrough

The parser now traverses conditional chains iteratively in transposition and namespace-record collection, and traverses define-key accessors iteratively. Transposition helpers return optional expressions, and the visitor preserves the original expression when transposition fails. Regression tests cover conditional expressions and deep inputs.

Changes

Parser stack safety

Layer / File(s) Summary
Conditional argument transposition
src/js_parser/p.rs, src/js_parser/visit/visit_expr.rs, test/bundler/transpiler/transpiler.test.js
Conditional import, require, and require.resolve transposition uses a shared branch helper and returns an optional expression. The visitor replaces the expression only when transposition succeeds. Tests cover branch structure, dynamic-import scans, and stack-depth behavior.
Conditional namespace-record collection
src/js_parser/p.rs, test/bundler/transpiler/transpiler.test.js
Namespace-record collection iterates through conditional no branches and checks stack safety before processing yes branches. Build tests cover namespace conditionals and conditional specifiers.
Deep define-key matching
src/js_parser/p.rs, test/bundler/transpiler/transpiler.test.js
Define-key matching iterates through dot and eligible string-index accessors. Tests exercise deep dotted and indexed keys in synchronous and asynchronous transforms.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to f1551

The stack-safety changes appear mergeable after normal checks.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check Passed The title clearly identifies the main changes: iterative handling of conditional chains in import()/require() and define keys. It is specific and concise.
Description check Passed The description thoroughly explains the problem, implementation, limitations, verification steps, test results, and performance impact. It does not use the template headings exactly, but it contains t…
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.


  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

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

I reviewed this PR and didn't find any bugs. Because it restructures the parser's conditional-transposer and define-matcher walks and adds stack-depth-searching subprocess tests, a human look at the test cost on debug/ASAN lanes would still be worthwhile.

What was reviewed:

  • transpose_each_branch against the five deleted transposers: leaf call order (yes-subtree, then no) and node construction are unchanged; the stack guard matches the existing pattern at p.rs:2922 and scan_side_effects.rs:128.
  • The None path at the three visit_expr.rs call sites: the untouched import()/require() call is a shape the base already emits for non-string arguments, and report_stack_overflow fails the parse.
  • is_dot_define_match loop: traced each base arm (short parts on EDot/EIndex, non-utf8 index string, EImportMeta, EIdentifier) and each still reaches the same result.
  • No remaining callers of the deleted transpose_known_to_be_if_* helpers.
Extended reasoning...

The change touches the JS parser's import()/require()/require.resolve() conditional transposer, the bundler's conditional namespace-record walk, and the define-key matcher in src/js_parser/p.rs, plus three call sites in visit_expr.rs and ~250 lines of new tests. It touches no security-sensitive surface; it converts unbounded recursion into loops with the parser's existing stack guard, fixing SIGSEGVs on adversarial input. I found the refactor behavior-preserving on every path I traced and the deleted helpers have no stale callers. I deferred rather than approved because the change is non-trivial parser-core code and the new tests binary-search stack limits in spawned subprocesses, where runtime on debug/ASAN lanes (one of the search tests is not skipped on debug) is best judged by a maintainer.

This branch has not been deployed

No deployments
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.

2 participants