SIGN IN SIGN UP
oven-sh / bun UNCLAIMED

Incredibly fast JavaScript runtime, bundler, test runner, and package manager – all in one

0 0 150 Rust

Error instead of crashing on deeply nested expressions in the transpiler (#31242)

### Problem

Parser fuzzing found that long chains of prefix unary operators kill the
process with a hard SIGSEGV instead of a JavaScript error:

```js
new Bun.Transpiler({ loader: "js" }).transformSync("- ".repeat(20000) + "1");
```

40 KB of `-` is a trivial DoS payload against anything that transpiles
untrusted code (`bun run`, `bun build`, `Bun.Transpiler`). Other deep
shapes hit the same thing: `f(f(f(…)))` nested ~6–12K deep and
`[[[…]]]`/ternary chains at lower depths on smaller thread stacks. In
contrast, unclosed `[`/`{`/`(` nesting already fails cleanly with
`Maximum call stack size exceeded` in a few milliseconds.

### Cause

`parse_expr_common` bounds the parser's own recursion with
`StackCheck::is_safe_to_recurse()`, but that check is calibrated for the
*parser's* stack frames. The visit pass (`visit_expr_in_out` →
`e_unary`/`e_call`/…) and the printer (`print_expr`) use noticeably more
stack per AST level and had **no check at all**, so an AST that parses
under the limit still blows the stack one pass later. (This is the
expression-side twin of the statement-side issue documented on
`P::parse_stmt_depth`.)

The ">10 s parse" reading from the fuzzer is the crash path, not the
parser: below the overflow threshold parse time is linear (20K chained
minuses ≈ 7 ms in a release build).

### Fix

Same dynamic guard, applied to the passes that were missing it:

- `visit_expr_in_out` now checks `is_safe_to_recurse()` and, when the
stack is nearly exhausted, logs the existing `Maximum call stack size
exceeded` error (once) and stops descending; `_parse` already halts on
logged errors right after the visit pass.
- `print_expr` does the same check and bails out; the print entry points
(`print_ast`, `print_json`, `print_with_writer_and_platform`,
`print_common_js`) turn that into an error instead of returning
truncated output.
- The recursive side-effect analysis helpers that can walk the
full-depth AST (`expr_can_be_removed_if_unused`, `simplify_unused_expr`,
`SideEffects::to_boolean`,
`SideEffects::is_primitive_with_side_effects`) report the same
stack-overflow error (via `P::report_stack_overflow`, once per parse)
and stop recursing, so the transform fails with `Maximum call stack size
exceeded` instead of degrading silently. `ExprData::known_primitive` (in
`bun_ast`, no logger available) gets its own stack check and answers
`Unknown`, i.e. skips the optimization.
- Once the error is reported the visit pass skips the remaining
expressions, `push_scope_for_visit_pass` re-syncs the parse-recorded
scope order past subtrees the bail skipped (so the "Scope mismatch while
visiting" sanity panic can't fire), and the DCE analyses skip entirely
so they never read identifier refs the visit pass didn't resolve.
- `Expr::join_with_left_associative_op` (used when simplifying unused
ternaries) threads a stack check through its re-association and falls
back to the unflattened node near the limit.
- The binding path gets the same treatment as the expression path
(`parse_binding`, `visit_binding`, `print_binding` for nested
destructuring patterns), and `--minify`'s
`substitute_single_use_symbol_in_expr` reports the overflow instead of
walking iteratively-built chains off the stack.

No hard depth constant: per-level frame sizes differ ~4× between release
and sanitizer builds and thread stacks range from 4 MB workers to the
main thread, so a fixed cap would either reject inputs that work today
or still crash under ASAN. The dynamic check keeps today's capacity and
turns every overflow into a clean error.

### Verification

- Before: `transformSync("- ".repeat(20000) + "1")` → SIGSEGV (release
and ASAN debug builds); nested-call and nested-array inputs crash at
various depths, including on 4 MB worker stacks.
- After: every shape × depth × stack-size combination tested
(unary/`!`/`void` chains, nested calls, nested arrays, ternary chains at
1.5K–100K depth, 4 MB and 8 MB stacks, ASAN debug build) either
transpiles or throws `Maximum call stack size exceeded` / a print error
— no signals, and errors return in milliseconds.
- New tests in `test/bundler/transpiler/transpiler.test.js` next to the
existing stack-overflow tests spawn a subprocess and assert it exits
cleanly (no SIGSEGV) across shapes and depths; they fail on the previous
build (child dies with SIGSEGV) and pass with this change.
- `test/bundler/transpiler/`, `bundler_minify.test.ts`, and
`bundler_edgecase.test.ts` pass with the debug build.


### Related

#7717 reports `illegal hardware instruction` while bundling
vite-prebundled swapkit/libsodium code, and was diagnosed by a
maintainer as a stack overflow from expression-parse/visit recursion on
parser threads — the same failure class this PR guards against. The
original reproduction can't be re-run today (the `@swapkit/*` RC
packages it pins were unpublished), so this PR doesn't claim to close
it: with this change that kind of overflow surfaces as a `Maximum call
stack size exceeded` build error instead of killing the process.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
R
robobun committed
41aa6724d85728eb4fb479d29ad3188b6512c25b
Parent: 3d5fe5e
Committed by GitHub <noreply@github.com> on 5/24/2026, 2:25:31 AM