Skip to content

Error instead of crashing on deeply nested statements in the transpiler#31333

Merged
Jarred-Sumner merged 7 commits into
mainfrom
farm/ba58623e/fix-nested-stmt-stack-overflow
May 24, 2026
Merged

Error instead of crashing on deeply nested statements in the transpiler#31333
Jarred-Sumner merged 7 commits into
mainfrom
farm/ba58623e/fix-nested-stmt-stack-overflow

Conversation

@robobun

@robobun robobun commented May 24, 2026

Copy link
Copy Markdown
Collaborator

Problem

Fuzzing found a second transpiler stack overflow (sig:SIGSEGV:nostack): ~600 nested { blocks crash the process.

new Bun.Transpiler({ loader: "tsx", target: "bun", minifyWhitespace: true, deadCodeElimination: true })
  .transformSync("{".repeat(600) + 'class Test1 { static "prop1" = 0; }' + "}".repeat(600));

#31242 guarded the expression recursion (visit_expr_in_out, print_expr, DCE helpers), but the statement recursion was left unguarded. Nested blocks stay under MAX_STMT_DEPTH (1000) in parse_stmt, then the visit pass recurses through visit_stmts → visit_and_append_stmt → s_block → visit_stmts with no stack check — each level stacks several multi-KB frames, so a few hundred levels exhaust the thread's stack (reproduces at depth 800 on a debug build's 8 MB main stack; smaller stacks crash at 600):

#5  visit_stmts                 src/js_parser/visit/mod.rs:1280
#6  s_block                     src/js_parser/visit/visit_stmt.rs:1627
#7  visit_and_append_stmt       src/js_parser/visit/visit_stmt.rs:108
#8  visit_stmts                 src/js_parser/visit/mod.rs:1336
... (repeats until SIGSEGV)

Fix

Guard the statement recursion the same way the expression recursion already is:

  • visit_and_append_stmt now checks stack_check.is_safe_to_recurse() (plus the reported_stack_overflow fast-path) and reports "Maximum call stack size exceeded" instead of descending, mirroring visit_expr_in_out.
  • print_stmt and print_if (which self-recurses for else if chains without passing through print_stmt) get the same guard print_expr/print_binding already have, so a deep AST printed on a thread with less stack headroom errors instead of overflowing.
  • Removed the MAX_STMT_DEPTH/parse_stmt_depth hard cap from parse_stmt (review feedback): recursion depth in every phase is now governed by StackCheck alone, matching the Zig parser.
  • Guarded hoist_symbols the same way: it walks the scope tree before the visit pass at the full depth the parser allowed, and was only kept safe previously by the now-removed cap (the 15k-deep lots-of-for-loop.js fixture overflowed it in release builds otherwise).

With this, every arbitrarily-nestable AST recursion (statements, expressions, bindings) is stack-checked in all three phases (parse, visit, print); deep inputs throw a catchable Maximum call stack size exceeded error.

Verification

New test deeply nested statement blocks error instead of crashing the process in test/bundler/transpiler/transpiler.test.js transpiles nested-block and else if-chain shapes at depths 600/800/990 (below the parse-time cap, deep enough to overflow an unguarded visitor) in a subprocess and asserts it exits cleanly.

  • Without the fix: the subprocess dies with SIGSEGV at depth 800+ (debug build), so the test fails.
  • With the fix: bun bd test test/bundler/transpiler/transpiler.test.js → 147 pass, 0 fail; the repro above now throws Maximum call stack size exceeded.

Deeply nested blocks (~600-1000 levels of '{') parsed fine but overflowed
the stack in the visit pass: the visit_stmts -> visit_and_append_stmt ->
s_block cycle had no stack check, unlike the expression path. Add the same
is_safe_to_recurse() guard to visit_and_append_stmt and print_stmt so deep
statement nesting reports 'Maximum call stack size exceeded' instead of
dying with SIGSEGV.
@robobun

robobun commented May 24, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:07 PM PT - May 24th, 2026

@robobun, your commit 66e1e33 has 1 failures in Build #57731 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31333

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

bun-31333 --bun

@coderabbitai

coderabbitai Bot commented May 24, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, we couldn't start this review because you've used your available PR reviews for now.

Your plan includes 5 reviews of capacity. Refill in 53 minutes and 24 seconds.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more review capacity refills, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than trial, open-source, and free plans. In all cases, review capacity refills continuously over time.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fc0acd62-8b40-446e-b8fe-519294cd381f

📥 Commits

Reviewing files that changed from the base of the PR and between 2fbfcb9 and 66e1e33.

📒 Files selected for processing (5)
  • src/js_parser/p.rs
  • src/js_parser/parse/parse_stmt.rs
  • src/js_parser/visit/visit_stmt.rs
  • src/js_printer/lib.rs
  • test/bundler/transpiler/transpiler.test.js

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

@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 application of the existing expression-recursion stack guards (#31242) to the statement recursion path.

Extended reasoning...

Overview

This PR adds stack-depth guards to two recursive statement-processing functions (visit_and_append_stmt in the visitor and print_stmt in the printer), updates stale comments in p.rs/parse_stmt.rs that previously stated the visitor/printer were unguarded, and adds a subprocess-based regression test mirroring the existing deeply nested expressions test directly above it.

Security risks

None. This is a crash-hardening fix: it converts a fuzzer-found SIGSEGV (stack overflow on ~600 nested { blocks) into a catchable Maximum call stack size exceeded error. No new attack surface, no parsing/semantic changes for valid inputs.

Level of scrutiny

Low-to-medium. The change is mechanical: the new guard in visit_and_append_stmt is byte-for-byte the same pattern as visit_expr_in_out (visit_expr.rs:49-52), and the new guard in print_stmt is identical to those already in print_expr (lib.rs:3273-3276) and print_binding (lib.rs:4970-4973). The early return Ok(()) is safe because report_stack_overflow / stack_overflowed ensure an error surfaces to the caller, and the only effect on well-formed shallow code is one cheap stack-pointer check per statement.

Other factors

The bug-hunting system found no issues. The regression test is well-constructed (runs in a subprocess so a crash fails the test rather than the test runner, exercises three nesting shapes at three depths, checks both tsx+DCE and plain js loaders). The comment updates are accurate. CI build is in progress with no prior reviewer comments to address.

Comment thread src/js_printer/lib.rs
else-if chains recurse print_if -> print_if directly without passing
through print_stmt, and t_if parses them iteratively so they are not
bounded by MAX_STMT_DEPTH either. Give print_if the same stack check as
print_stmt/print_expr, and cover else-if chains in the nesting test.

@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 — the print_if guard and else-if test shape address my earlier nit, and all four new guards mirror the existing visit_expr_in_out/print_expr pattern exactly.

Extended reasoning...

Overview

This PR adds stack-depth guards to the statement-recursion paths of the transpiler, mirroring the expression-recursion guards added in #31242. Four is_safe_to_recurse() checks are added (entry of visit_and_append_stmt, print_stmt, and print_if), plus comment-only updates to p.rs/parse_stmt.rs reflecting that the visitor/printer are no longer unguarded, and a subprocess-based regression test covering four nesting shapes at depths 600/800/990.

Security risks

None. The change is purely additive early-return guards on pathological-depth inputs; it converts a fuzzer-found SIGSEGV into a catchable error. No new attack surface, no parsing/semantic changes for normal inputs.

Level of scrutiny

Low-to-moderate. Each guard is a 4-line copy of the pattern already in visit_expr_in_out (visit_expr.rs:49-52), print_expr (lib.rs:3273-3276), and print_binding (lib.rs:4970-4973) — same stack_check.is_safe_to_recurse() predicate, same reported_stack_overflow/stack_overflowed flag, same early return. The only design question (whether print_if's direct self-recursion on else-if chains needed its own guard) was raised in my earlier review and addressed in ab83e14. Since is_safe_to_recurse() already fires on every visit_expr_in_out call, adding it to the (less frequent) per-statement entry introduces no new false-positive risk surface.

Other factors

  • My earlier inline nit is resolved: the print_if guard was added and an else if chain shape was added to the test.
  • The new test follows the same subprocess-isolation pattern as the adjacent deeply nested expressions test, and the /Maximum call stack size exceeded|StackOverflow/ regex matches both the visitor's reported message and the parse-time err!("StackOverflow").
  • No bugs found by the bug-hunting system on the latest revision.

@robobun

robobun commented May 24, 2026

Copy link
Copy Markdown
Collaborator Author

CI status (latest run, build 57731 on 66e1e33, and the two runs before it): every lane that actually executes tests is green — 73/73 checks passed, including Linux glibc/musl x64/aarch64 (release + ASAN), Windows, and macOS x64, all of which run test/bundler/transpiler/transpiler.test.js with the new regression test.

The only red checks on each run are the macOS aarch64 test lanes (darwin-14-aarch64, darwin-26-aarch64), marked Expired: Buildkite never assigned them an agent, so they ran zero tests. This has repeated across three consecutive builds, so it's agent capacity rather than anything in the diff. They need a retry from someone with Buildkite access, or the PR can be evaluated on the green lanes.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delete this max statement depth. It should never have been added. Use bun.StackCheck instead.

parse_stmt now guards recursion with stack_check.is_safe_to_recurse()
alone, like the Zig parser. The hard cap existed to protect the
visitor/printer while they were unguarded; they now carry their own
stack checks.
@robobun

robobun commented May 24, 2026

Copy link
Copy Markdown
Collaborator Author

Done in 63bedce — removed MAX_STMT_DEPTH and the parse_stmt_depth counter. parse_stmt now relies on StackCheck::is_safe_to_recurse() alone (same as the Zig parser), and the visitor/printer statement paths are covered by the stack checks this PR adds. The existing 15k-deep lots-of-for-loop.js stack-overflow tests and the new nested-statement test still pass — the stack check surfaces the same "Maximum call stack size exceeded" error.

Comment thread src/js_parser/parse/parse_stmt.rs
Without the parse-depth cap, the pre-visit scope-tree walk in
hoist_symbols sees the full nesting depth the parser allowed; in release
builds the small parse frames let ~15k-deep inputs through, and the
unguarded recursion overflowed the stack.
Comment thread test/bundler/transpiler/transpiler.test.js Outdated
robobun added 2 commits May 24, 2026 21:56
Comment thread src/js_parser/p.rs
@Jarred-Sumner Jarred-Sumner merged commit 159a4b7 into main May 24, 2026
75 of 78 checks passed
@Jarred-Sumner Jarred-Sumner deleted the farm/ba58623e/fix-nested-stmt-stack-overflow branch May 24, 2026 23:57
springmin pushed a commit to springmin/bun that referenced this pull request May 25, 2026
* oven/main (20 new commits):
  webcore: free Blob's owned content type on drop (oven-sh#31358)
  Support cross-compiling macOS binaries from Linux (oven-sh#31303)
  test: forward keep-alive requests in proxy.test.ts's mock proxy (oven-sh#31352)
  Port Bun.stringWidth to C++ with explicit SIMD (oven-sh#31351)
  Fix quadratic hang reporting duplicate-binding parse errors in the transpiler (oven-sh#31341)
  shell: don't abort when a glob's directory prefix doesn't exist (oven-sh#31367)
  Error instead of crashing on deeply nested statements in the transpiler (oven-sh#31333)
  Fix JSX transform panic when a bare `key` prop precedes `key` with a value (oven-sh#31350)
  Cap ANSI markdown indentation so deeply nested lists render in linear time (oven-sh#31366)
  css: bound selector-list expansion when compiling nesting for older targets (oven-sh#31277)
  node:http2: reassemble HEADERS+CONTINUATION before HPACK decoding (oven-sh#31323)
  Fix `await using` expression printing `using` as `await` (oven-sh#31324)
  Parenthesize `async` when it starts a for-of loop initializer (oven-sh#31326)
  Print Infinity and negative numeric property keys as computed properties (oven-sh#31328)
  css: keep required grouping parens in @container conditions when minifying (oven-sh#31330)
  Fix panic on anonymous export default class with an auto-accessor field (oven-sh#31331)
  node:http2: send GOAWAY frames on stream 0 (oven-sh#31353)
  parser: fix Scope mismatch while visiting panic from decorators on dropped class members (oven-sh#31340)
  webcrypto: reject oversized BufferSource inputs instead of aborting (oven-sh#31356)
  Error instead of crashing on deeply nested TypeScript types in the transpiler (oven-sh#31361)

Resolved conflicts:
  - scripts/build.ts: kept both OHOS and macOS-cross argv entries
  - scripts/build/config.ts: kept both OHOS and macOS-cross config fields
  - scripts/build/deps/webkit.ts: kept OHOS fno-pic exclusion, adopted upstream -flto=thin
Jarred-Sumner pushed a commit that referenced this pull request May 25, 2026
…tests throw on Windows (#31382)

### Problem

Every PR build since #31333 merged fails the Windows test lanes (2019
x64, 2019 x64-baseline, 11 aarch64) on the same two tests in
`test/bundler/transpiler/transpiler.test.js`:

```
✗ Bun.Transpiler.transformSync stack overflows
✗ Bun.Transpiler.transform stack overflows

error: expect(received).toThrow(expected)
Expected substring: "Maximum call stack size exceeded"
Received function did not throw
Received value: "let counter = 0;\nfor (let i = 0;i ..."
```

It reproduces on unrelated PRs (checked ~10 recent PR builds — all carry
the identical annotation on a Windows lane) and doesn't show on `main`'s
own CI because main builds don't run the Windows test lanes.

#31333 removed the parser's fixed `MAX_STMT_DEPTH` cap; recursion is now
bounded only by `StackCheck`. The old `lots-of-for-loop.js` fixture is
~15k levels deep, which still exhausts the ~8 MB main stacks on the
Linux/macOS lanes but fits comfortably inside the 18 MiB stack reserve
bun links with on Windows (`/STACK:0x1200000`), so the transpile now
succeeds there and `toThrow` fails.

### Fix

Per review: keep the assertions exactly as they are and make the fixture
big enough that it overflows everywhere.

- `fixtures/lots-of-for-loop.js` is regenerated from 15,112 to **320,000
nested `for` loops** (~9 MB, same shape: `let counter = 0;` → nested
fors → `counter++;` → `console.log(counter);`). Against Windows' 18 MiB
reserve minus the 256 KiB `StackCheck` headroom, 320k levels need only
~58 bytes of stack per recursion level to trip the guard — far below any
realistic parse/visit/print frame, and leaves the same margin against
future codegen getting leaner. The smaller Linux/macOS stacks keep
throwing exactly as before.
- The two `Bun.Transpiler.*` tests get a `60_000` ms timeout (the value
the neighboring deeply-nested subprocess tests already use): on Windows
the parser now recurses through ~18 MiB of stack before the guard trips,
which costs a few seconds of wall time; the default 5 s would be tight.

No other changes; no comments added.

### Verification

- `bun bd test test/bundler/transpiler/transpiler.test.js` on current
`main` + this change: 157 pass / 0 fail; the three stack-overflow tests
throw in 57–360 ms on Linux (debug build).
- Same three tests pass against a release build as well.
- The Windows lanes on this PR are the proof that 320k levels overflow
the 18 MiB reserve; if they somehow still don't throw there, the fixture
gets another bump.
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