Skip to content

js_parser: walk binary chains on the heap and check the stack when rewriting decorated #private accesses - #44662

Open
robobun wants to merge 2 commits into
mainfrom
robobun/4da3fff7/decorated-private-walker-stack
Open

robobun wants to merge 2 commits into
mainfrom
robobun/4da3fff7/decorated-private-walker-stack

Conversation

@robobun

@robobun robobun commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A class with a standard decorator on a #private member dies on a long chain of binary operators: exit 139, no message, from Bun.Transpiler, bun build and bun run. Without the decorator the same file transpiles.
  • rewrite_private_accesses_in_expr (src/js_parser/lower/lower_decorators.rs:274) recurses per AST node with no stack check. The parser and the visit pass take a chain in a loop, so their checks never see its length.
  • The source needs no chain. The lowering joins the decorator lists of 17,396 @d #m(){} members with the comma operator, and --minify-syntax joins 9,000 plain statements.

Fix

  • The EBinary arm walks down left in a loop, on the parser's reusable binary_expression_simplify_stack. This walk takes a chain of any length.
  • Its three recursive entry points take the stack check of the visit pass, so a depth it cannot walk ends in Maximum call stack size exceeded.
  • Output unchanged: 288 decorator fixtures compile, and every byte equals main. Verified: es-decorators.test.ts, 13 new tests, 11 fail on main with SIGSEGV. Also the decorator, stack overflow and transpiler.test.js suites, 847 pass.

Background

Downsides

  • A decorated-#private class pays about 12 instructions more per call of the walk: 439,600 to 592,400 for 200 classes, +1.2% of that transform. Other files run 0 of it. Code: +876 B.
  • The check keeps a 128 KB reserve, so in a 200-link window the error arrives where main transpiled: 12,644 call links of one shape against 12,832. Past that window main is SIGSEGV.
  • One input in that window aborts (panic: index out of bounds, exit 134) where main printed output. That panic is main's own, reachable with no class, and js_parser: replace skipped expressions with E::Missing and skip argument checks after a stack overflow #44467 fixes it.
Notes

Repro (main 13a98b0 and Bun 1.4.3-canary: Segmentation fault. This branch: the output.)

const n = 100000;
const src = head => `class A { ${head} m() { return ${"a+".repeat(n)}1 } }`;
new Bun.Transpiler({ loader: "ts" }).transformSync(src("@d #p() {}")); // SIGSEGV
new Bun.Transpiler({ loader: "ts" }).transformSync(src("#p() {}")); // 400,048 characters
new Bun.Transpiler({ loader: "ts" }).transformSync(src("@d p() {}")); // transpiles

How the loop keeps the output. The two forms that replace a whole binary node (x.#p = v and #p in x) need a left that is not a binary, so only the innermost link of a chain can be one, and that link goes through the ordinary recursive call. The loop keeps the order of the recursion: the innermost link, then each right operand from the bottom up. The receiver temporaries (_obj$N) follow that order and keep their names.

Measurements (x64 Linux, release builds of main 13a98b0 and of this branch, one build directory)

What main this branch
Largest +, &&, comma, in chain that transpiles, transformSync 17,391 to 17,398 links, then SIGSEGV 2,000,000 (no limit found)
The same with transform() (pool thread) 8,629 2,000,000 (no limit found)
Class of n @d #m{i}() {} members, transformSync / transform() 17,395 / 8,629 1,000,000 / 200,000 (no limit found)
bun build --minify-syntax, n plain statements in a method SIGSEGV from 9,000 builds 20,000
A chain in a field initializer, a computed key or the method body, 100,000 () links SIGSEGV Maximum call stack size exceeded, also at 2,000,000
Nested a?b: or tagged templates through scan(), first depth that errors SIGSEGV from 17,388 19,199, against 27,647 without the decorator
A 20,000-deep chain the minifier builds from flat const statements, run with bun SIGSEGV the error, exit 1
Calls of _in_expr for a chain of n links 2 per link (20,040 for 10,000) 1 per link (100,042 for 100,000)
Native stack of the walk for a chain 480 B per link (4.8 MB for 10,000) 1,760 B at any length
Frame of _in_expr / _in_stmts / _in_binding 480 / 416 / 80 B 448 / 416 / 80 B
Code of the walk (3 functions, 2 instances) 9,610 B 10,486 B
.text 80,760,693 B 80,761,461 B
Instructions in the walk, 200 decorated-#private classes, one transformSync 439,600 in 12,800 calls 592,400 in 12,400 calls
The same with no decorator, with @d p() {} only, or with accessor #x only 0 0
Growths of the reused list in the walk: 200 classes, a + b, a + b + c, 48 links n/a 0
The same for 60 links / 100,000 links n/a 1 / 12 (1.54 MB, held until the parse ends)
  • Instructions: qemu-x86_64 -one-insn-per-tb -d exec,nochain -dfilter over the symbols of the walk. Three calls log exactly three times one call. The whole transformSync of that fixture is about 13.1 M instructions.
  • Bytes and frames: llvm-nm -S and llvm-objdump -d. visit_expr_in_out is 17,694 and 17,555 B on both builds, and the stripped binaries have the same size.
  • Calls, depth and list growths: gdb breakpoints on the release builds. Limits: bisection, one fresh process per probe.
  • valgrind, perf, strace and bloaty are not installed here.

Output unchanged. 145 source strings from es-decorators.test.ts and es-decorators-esbuild.test.ts, plus 9 chains with rewritten accesses, through 4 transpiler configurations: 580 results. The two result files are byte-identical, so EXPECTED_VERSION of the transpiler cache stays 34.

Tests. Each one runs the transpiler in a child process, so a build without the fix fails with signalCode: "SIGSEGV" and not with a dead test runner. The field-initializer and computed-key rows are the ones that do not pass through the entry for a statement list. The two scan() probes are the ones that need !is_safe_to_recurse(): the undecorated source still compiles at those depths, so the reported flag is clear. Each probe fails when no depth reports the overflow. A debug build gets a shorter chain: its frames are larger and it is 20 times slower.

The same defect, not in this PR.

Measured on x64 Linux only. Every number above is from that platform. Windows pool threads reserve 18 MB, so the depths in the tests are sized above their unfixed limit (about 35,700 links). Nothing in the change is platform specific.


[human-review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/transpiler/es-decorators.test.ts
ninja: Entering directory `/workspace/bun/build/debug'
[0/2] cargo plan → /workspace/bun/build/debug/rust-target/plan.json
FAILED: [code=1] rust-target/plan.json /workspace/bun/build/debug/rust-target/plan.json 
/workspace/bun/build/release/bun /workspace/bun/scripts/build/stream.ts cargo --console /workspace/bun/build/release/bun /workspace/bun/scripts/build/rust/plan.ts /workspace/bun/build/debug/rust-target/plan.input.json /workspace/bun/build/debug/rust-target/plan.json
�[1m�[91merror�[0m: failed to load manifest for workspace member `/workspace/bun/src/bundler`
referenced by workspace at `/workspace/bun/Cargo.toml`

Caused by:
  failed to load manifest for dependency `bun_js_parser`

Caused by:
  failed to parse manifest at `/workspace/bun/src/js_parser/Cargo.toml`

Caused by:
  error inheriting `bun_sema` from workspace root manifest's `workspace.dependencies.bun_sema`

Caused by:
  `dependency.bun_sema` was not found in `workspace.dependencies`
error: /opt/rust/bin/cargo build -p bu
... (truncated)

release without fix: 11 FAILED
bun test v1.4.3-canary.1 (367d939d9)

test/bundler/transpiler/es-decorators.test.ts:
(pass) ES Decorators > class decorators > basic class decorator [15.44ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [10.39ms]
(pass) ES Decorators > class decorators > class decorator can replace class [12.06ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [15.37ms]
(pass) ES Decorators > method decorators > instance method decorator [13.27ms]
(pass) ES Decorators > method decorators > static method decorator [15.91ms]
(pass) ES Decorators > method decorators > method decorator context has correct access [15.20ms]
(pass) ES Decorators > getter decorators > getter decorator [13.23ms]
(pass) ES Decorators > setter decorators > setter decorator [13.92ms]
(pass) ES Decorators > field decorators > field decorator receives undefined value [11.26ms]
(pass) ES Decorators > field decorators > multiple field decorators [14.33ms]
(pass) ES Decorators > field decorators > static field decorator [15.53ms]
(pass) ES Decorators > non-ASCII string-literal keys > Bun.Transpiler output preserves the key [2.16ms]
(p
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/transpiler/es-decorators.test.ts
bun test v1.4.3 (367d939d9)

test/bundler/transpiler/es-decorators.test.ts:
(pass) ES Decorators > class decorators > basic class decorator [690.24ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [525.80ms]
(pass) ES Decorators > class decorators > class decorator can replace class [670.92ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [741.06ms]
(pass) ES Decorators > method decorators > instance method decorator [687.28ms]
(pass) ES Decorators > method decorators > static method decorator [687.12ms]
(pass) ES Decorators > method decorators > method decorator context has correct access [681.06ms]
(pass) ES Decorators > getter decorators > getter decorator [646.89ms]
(pass) ES Decorators > setter decorators > setter decorator [655.35ms]
(pass) ES Decorators > field decorators > field decorator receives undefined value [660.18ms]
(pass) ES Decorators > field decorators > multiple field decorators [652.05ms]
(
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1966ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/2] cargo plan → /workspace/bun/build/release/rust-target/plan.json
244 units: 172 lib, 16 proc-macro (host), 19 custom-build (host), 15 run custom-build, 17 lib (host), 4 run custom-build (host), 1 rlib
[1/117] gen generated_host_exports.rs
generated_host_exports.rs: 120 exports (host=5, lazy=10, generic=105, rust=0); 248 extern-C blocks audited
[2/116] gen BunProcess.lut.h
Generating /workspace/bun/build/release/codegen/BunProcess.lut.h from /workspace/bun/src/jsc/bindings/BunProcess.cpp
[3/116] cxx obj/unified/UnifiedSource-src_jsc_bindings-2.cpp.o
[4/116] cxx obj/unified/UnifiedSource-src_runtime_webview-0.cpp.o
[5/116] cxx obj/unified/UnifiedSource-src_jsc_bindings-1.cpp.o
[6/116] cxx obj/unified/UnifiedSource-src_jsc_bindings_webcore-3.cpp.o
[7/116] cxx obj/unified/UnifiedSource-src_jsc_bindings-3.cpp.o
[8/116] cxx obj/unified/UnifiedSource-src_jsc_bindings_node_crypto-0.cpp.o
[9/116] cxx obj/unified/UnifiedSource-src_jsc_bindings_node_crypto-1.cpp.o
[10/116] cxx obj/unified/UnifiedSource-src_js
... (truncated)
diff hotspot
src/js_parser/lower/lower_decorators.rs       |  36 ++++-
 test/bundler/transpiler/es-decorators.test.ts | 207 +++++++++++++++++++++++++-
 2 files changed, 235 insertions(+), 8 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                           reads  edits  tests
src/js_parser/lower/lower_decorators.rs            3      1     31
test/bundler/transpiler/es-decorators.test.ts      4      2     28

…writing decorated #private accesses

The lowering of a class with a decorated #private member walks the class
body a second time to rewrite each access to that member. That walk
recursed once per AST node with no stack check, so a chain of binary
operators, which the parser and the visit pass take in a loop, overflowed
the native stack.

The EBinary arm of the walk now takes the left side of a chain in a loop,
on the parser's reusable stack, so a chain of any length is lowered. Its
three recursive entry points check the stack like the visit pass does, so
a depth the walk cannot take ends in the error the parser reports for
every other deep input.
@robobun

robobun commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

How this was reproduced, for anyone re-running it.

// decorated-private-long-chain.mjs
import { spawnSync } from "node:child_process";
const n = +(process.argv[2] ?? 100000);
const src = (head, n) => `class A { ${head} m() { return ${"a+".repeat(n)}1 } }`;
if (process.argv[3]) {
  const head = { decorated: "@d #p() {}", plain: "#p() {}", public: "@d p() {}" }[process.argv[3]];
  const out = new Bun.Transpiler({ loader: "ts" }).transformSync(src(head, n));
  console.log("transpiled to " + out.length + " characters");
  process.exit(0);
}
let bad = 0;
for (const mode of ["plain", "public", "decorated"]) {
  const r = spawnSync(process.execPath, [import.meta.filename, String(n), mode], { encoding: "utf8" });
  if (r.signal) bad++;
  console.log(mode + ": " + (r.signal ? "killed by " + r.signal : "exit " + r.status + ", " + r.stdout.trim()));
}
process.exit(bad ? 1 : 0);

On main 13a98b0 (release build), 3 of 3 runs:

plain: exit 0, transpiled to 400048 characters
public: exit 0, transpiled to 400048 characters
decorated: killed by SIGSEGV

On this branch: decorated: exit 0, transpiled to 400708 characters, no child dies.

gdb on the main build faults at lower_decorators.rs:319, 17,408 frames of rewrite_private_accesses_in_expr deep, 8,352,896 B below its first frame.

With the decorator on a private method, field, accessor or static method, 30 chain shapes at 100,000 links: main kills 11 of them, this branch kills none (6 transpile, 24 report Maximum call stack size exceeded).

This is not a regression. 1.4.2, 1.4.1, 1.4.0 and 1.3.14 die the same way.

@github-actions github-actions Bot added the claude label Oct 6, 2026
@coderabbitai

coderabbitai Bot commented Oct 6, 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: b4b01484-bd1b-4cd2-a477-d76d90e493a0
📥 Commits

Reviewing files that changed from the base of the PR and between 23a4bd2 and 2be39f4.

📒 Files selected for processing (1)
  • test/bundler/transpiler/es-decorators.test.ts

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


Walkthrough

The decorator private-access rewrite now checks recursion safety and traverses left-associated binary expressions through a simplifier stack. New stress tests cover long chains, overflow reporting, and operand rewrite order.

Changes

Decorator private-access rewriting

Layer / File(s) Summary
Stack-safe private-access traversal
src/js_parser/lower/lower_decorators.rs
The rewrite checks recursion safety for expressions, bindings, and statement slices. It processes nested left-associated binary expressions through a simplifier stack.
Traversal stress tests
test/bundler/transpiler/es-decorators.test.ts
Tests cover long chains, overflow cases, syntax-minified chains, many decorated private methods, build-time statement joining, and source-order rewriting of chain operands and receiver temporaries.

Priority: ⬆️ High

Merge Risk: ⚪ Minimal · up to 2be39

No actionable merge-blocking issue is identified; merge after normal checks.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly summarizes the main change: iteratively walking binary chains and checking stack safety when rewriting decorated #private accesses.
Description check ✅ Passed The description explains the problem, fix, and verification results in detail. It does not use the template’s exact headings, but it provides the information those sections request.
  • Autopilot · 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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked the new iterative EBinary arm against the old recursion: it visits the same nodes in the same order (innermost left, its right, then each parent's right bottom-up), the two whole-node special cases (x.#p = v, #p in x) can only occur at the innermost link and still go through the recursive call, and the reuse of binary_expression_simplify_stack is bounded by stack_bottom, so nested entry from the visit pass cannot pop another frame's entries.

Extended reasoning...

The change touches only the decorator-lowering private-access rewrite in src/js_parser/lower/lower_decorators.rs (three stack-check guards plus a loop-based walk of left-leaning binary chains) and adds subprocess tests in test/bundler/transpiler/es-decorators.test.ts; no security-sensitive surface is involved. Inline findings were posted, so this note only records the control-flow equivalence and stack-reuse checks that were examined and ruled out.

Comment thread src/js_parser/lower/lower_decorators.rs
Comment thread test/bundler/transpiler/es-decorators.test.ts
Comment thread test/bundler/transpiler/es-decorators.test.ts
Comment thread src/js_parser/lower/lower_decorators.rs
…, and assert the one error

The depth probe returned at the first shape that reported, so the tagged
template shape never ran, and it passed when no depth reported at all. Each
shape is now its own test and the probe fails when nothing is reported.

The test for a chain that the minifier builds from flat statements accepted
the printer's error too. It now asserts the error of the walk.

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

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

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