Skip to content

Evaluate private method call receivers once in decorator lowering - #31426

Merged
Jarred-Sumner merged 7 commits into
mainfrom
farm/22b2afc9/fix-decorator-private-call-receiver
Jun 6, 2026
Merged

Evaluate private method call receivers once in decorator lowering#31426
Jarred-Sumner merged 7 commits into
mainfrom
farm/22b2afc9/fix-decorator-private-call-receiver

Conversation

@robobun

@robobun robobun commented May 26, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes unbounded memory growth in the transpiler (found by fuzzing) when a decorated class contains chained private method calls, plus a receiver double-evaluation bug in the same lowering.

Repro (fuzzer input, ~555 bytes — 5+ GB RSS and climbing before this change, 5 KB output in ~10 ms after):

new Bun.Transpiler({ loader: "js", target: "bun" }).transformSync(
  `class Foo {
    static #x = -0;
    static #m = function() {};
    @decorator()est() {
      return [
        o?.Foo.#m()?.Foo.#m()?.Foo.#m()  /* …×44 total… */ ?.Foo.#m(),
      ];
    }
  }`
);

Cause

rewrite_private_accesses_in_expr in src/js_parser/lower/lower_decorators.rs lowers recv.#m(args) to __privateGet(recv, _m).call(recv, args), inserting the same receiver expression twice (the two copies share AST nodes). Two consequences:

  1. Exponential output: when the receiver itself contains another lowered .#m() call, each chain link doubles what the printer has to emit — ?.Foo.#m() ×44 is ~2^44 worth of text, so the printer allocates until it OOMs. Measured with the old lowering: a 16-link chain prints 2.9 MB, 18 links 11.8 MB, 20 links 47 MB (×4 per 2 links).
  2. Double evaluation: side effects in the receiver run twice, e.g. getCounter().#m(42) called getCounter() two times.

Fix

Evaluate the receiver exactly once:

  • this and identifier receivers are reused directly (output unchanged for the common this.#m() / obj.#m() cases).
  • Any other receiver is captured in a temporary: __privateGet(_obj = recv, _m).call(_obj, args).

Temporaries created inside method/function/arrow bodies are declared at the top of that body (run(id) { var _obj; return __privateGet(_obj = make(id), ...).call(_obj); }), so each invocation gets a fresh binding; a binding shared across invocations could be clobbered when a getter-backed private call reenters the same site, since __privateGet(obj, member, getter) runs the user getter between the temp write and the .call(_obj) read. Temporaries created outside function bodies (field initializers, static blocks, decorate expressions, which run at most once per class evaluation) are declared alongside the other lowering variables (_dec, WeakMaps, _init), covering both class statements and class expressions. Both placements match where esbuild declares the corresponding _a temps.

Output for the fuzz chain is now linear in chain length (n=44 → ~5 KB).

Note: optional-chain short-circuiting across a lowered private access (o?.Foo.#m() when o is nullish) still throws like it did before this change; that is a separate, pre-existing gap in the decorator lowering, tracked in #31910, and is not affected by this PR.

Verification

New tests in test/bundler/transpiler/es-decorators.test.ts ("private member calls in lowered classes"):

  • transpiled output for a 20-link ?.Foo.#m() chain stays under 50 KB and reparses (old lowering: 47,186,639 bytes → fails)
  • double-call .#method()() chains in decorated static field initializers stay linear (old lowering: ~64 MB for 20 links → fails)
  • private method call receiver is evaluated exactly once (old lowering evaluates it twice → fails)
  • receiver temps are scoped per invocation: a private getter reentering the same call site must not clobber the outer call's receiver (fails with a class-scope hoisted temp, output matches untranspiled Node)
  • chained optional private method calls return the right value
  • this / identifier receivers keep working, decorated class expressions evaluate receivers once

bun bd test test/bundler/transpiler/es-decorators.test.ts — 43 pass with the fix; the size/evaluation tests above fail without it (USE_SYSTEM_BUN=1). Also ran es-decorators-esbuild.test.ts, decorators.test.ts, decorator-metadata.test.ts, bundler_decorator_metadata.test.ts, and transpiler.test.js — no regressions (400+ tests), plus a minified Bun.build bundle to confirm the renamer keeps function-scoped temps collision-free.

@coderabbitai

coderabbitai Bot commented May 26, 2026

Copy link
Copy Markdown
Contributor

PR changed again? Review this PR in Change Stack to compare snapshots and stay oriented.

Review Change Stack

Warning

Review limit reached

@robobun, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 5 minutes and 54 seconds. Learn how PR review limits work.

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

⌛ How to resolve this issue?

After more reviews become available, 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 include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 98934d79-853b-4ebd-a23d-5a7506fab5e3

📥 Commits

Reviewing files that changed from the base of the PR and between ebefd55 and 5025eae.

📒 Files selected for processing (1)
  • src/js_parser/lower/lower_decorators.rs

Walkthrough

Capture non-trivial private-call receivers into temporaries and hoist declarations into the correct scope; add compile-time and runtime tests verifying single evaluation and linear lowered output size.

Changes

Private member call receiver safety

Layer / File(s) Summary
Receiver-capture rewrite logic
src/js_parser/lower/lower_decorators.rs (759–793)
rewrite_private_accesses_in_expr now captures complex private-call receivers into generated temporaries and uses the captured value for both the __privateGet(...).call(...) target and the .call(...) receiver; identifiers and this are reused directly.
Per-function temp declaration injection
src/js_parser/lower/lower_decorators.rs (866–934)
When rewriting inside function/arrow bodies, the pass snapshots pending temp refs, rewrites the body, inserts a var declaration for newly created receiver-capture temps at the top of the body, and restores the snapshot.
Baseline snapshot and final temp emission
src/js_parser/lower/lower_decorators.rs (1108–1113, 2441–2451)
lower_impl snapshots temp_refs_to_declare.len() before rewriting and drains any remaining receiver-capture temporaries created outside function bodies into a single var declaration pushed into prefix statements before final output.
Compile-time and runtime tests
test/bundler/transpiler/es-decorators.test.ts (756–927)
Adds tests that assert lowered output size stays linear and re-parses, plus runtime tests verifying receiver expressions are evaluated exactly once across chained optional private calls, static initializers, this/identifier receivers, reentrancy, invoking chain results, and decorated class expressions.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the main change: fixing private method call receivers to be evaluated once in decorator lowering.
Description check ✅ Passed The description is comprehensive and well-structured, covering what the PR does, the cause of the bugs, the fix, and verification with detailed test information.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@robobun

robobun commented May 26, 2026

Copy link
Copy Markdown
Collaborator Author

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

No issues found — the receiver-capture approach and temp-ref bookkeeping look correct and the tests cover the key paths well, but this is a transpiler-correctness change in a fairly intricate lowering pass, so it's worth a human pass.

Extended reasoning...

Overview

This PR fixes exponential output growth (and a receiver double-evaluation bug) in rewrite_private_accesses_in_expr within src/js_parser/lower/lower_decorators.rs. The lowering of recv.#m(args)__privateGet(recv, _m).call(recv, args) previously inserted the same receiver AST node twice; the fix reuses this/identifier receivers via fresh nodes and captures any other receiver into a generate_temp_ref temporary (_obj = recv), with the temporaries declared in a var statement pushed to prefix_stmts just before Phase 8 output assembly. Four new tests in es-decorators.test.ts cover output-size linearity, single-evaluation semantics, chained optional calls, and the unchanged this/identifier fast paths.

Security risks

None. This is AST-lowering logic with no auth/crypto/permission/IO surface. The fix actually removes a fuzzer-found unbounded-memory DoS vector in Bun.Transpiler.

Level of scrutiny

Moderate-to-high. The transpiler is a production-critical correctness path — a regression here silently miscompiles user code. The change is small in line count but spans non-local state: a temp_refs_to_declare.len() snapshot is taken at the top of lower_standard_decorators and consumed/truncated ~1300 lines later, relying on the invariant that nothing else in this function pushes to temp_refs_to_declare (verified: line 772 is the only generate_temp_ref call in the file). Phase 8 correctly routes the new S::Local through expr_var_decls for the class-expression path.

Other factors

  • Bug hunter found no issues.
  • No CODEOWNERS for these paths.
  • use_ref in the EIdentifier branch records an extra usage for the second occurrence, which is semantically correct (the identifier really is referenced twice in the output).
  • The PR explicitly scopes out the pre-existing optional-chain short-circuit gap, which is reasonable.
  • Test coverage is good and the description reports the broader decorator/transpiler suites pass, but given this is core transpiler output, a human reviewer familiar with the lowering phases should confirm the temp-ref scope/declaration placement is sound for all class forms (nested classes, class expressions inside decorated classes, etc.).

Comment thread src/js_parser/lower/lower_decorators.rs
@robobun

robobun commented May 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for this PR — the two red builds are unrelated infra/test flake, not the diff:

  • Build 58191 (bbe32c5): 74/75 checks passed. The only failure was darwin-14-aarch64-test-bun with state Expired (0s — the job never got an agent). Every lane that ran was green, including debian-13-x64-asan-test-bun and the other two macOS lanes.
  • Build 58204 (74e165c, empty retrigger of the same diff): the only failing lane is debian-13-x64-asan-test-bun, where test/cli/install/migration/complex-workspace.test.ts failed because the git clone of its install-test1 fixture was killed (git failed with signal 9) during bun install, cascading through the file's assertions, plus the flaky test/js/bun/http/serve-body-leak.test.ts. Both are package-manager/HTTP tests untouched by this change, and the same ASAN lane passed on build 58191 with the identical code.

The change itself is confined to the standard-decorator private-member lowering; the decorator/transpiler suites (including the new regression tests here) pass on every lane that executed them. I've used my one CI retrigger, so leaving it here — ready for maintainer review.

@robobun

robobun commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed regression tests for a second fuzzer-minimized variant of the same root cause: a .#method()() double-call chain in a decorated static field initializer (ts loader) needs only ~30 links / 437 bytes to reach multi-GB allocations before this fix. With the fix the output is 4.1 KB and linear in chain length. Test-only commit, no source changes: both new tests fail on bun-1.4.0-canary.1+61bd9976e and pass on this branch (41/41 in es-decorators.test.ts).

Comment thread src/js_parser/lower/lower_decorators.rs Outdated
@robobun
robobun force-pushed the farm/22b2afc9/fix-decorator-private-call-receiver branch from e4dcfb8 to 36da04c Compare June 2, 2026 00:59
@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (561eb8ff17) — no code changes.

The package-binary-size failure on build 59663 was baseline drift, not a size regression from this diff: the branch was based on May 26 main, and the canary baseline since gained Windows-cross-compile-from-Linux (#31431), cross-language LTO on linux-aarch64-musl (#31432), and zstd-compressed node-fallbacks (#31456) — so every stale-base artifact measured "bigger" than today's canary (+16–18 MB on Windows, +2.4 MB on aarch64-musl, ~+0.7 MB elsewhere, matching those three changes exactly). Rebasing rebuilds against the same base as the baseline.

Re-verified after rebase: all 41 tests in es-decorators.test.ts (including the new double-call static-field chain tests), 340 more across decorators.test.ts / decorator-metadata.test.ts / es-decorators-esbuild.test.ts / transpiler.test.js, and the original fuzz input still transpiles to ~5 KB in ~10 ms at 44 chain links.

@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

CI status after the rebase (build 59704, sha 36da04c, final):

  • package-binary-size now passes — confirms the earlier failure was baseline drift from the stale branch base, resolved by the rebase.
  • debian-13-x64-asan-test-bun passes (the lane that flaked on build 58204).
  • ✅ 278 jobs passed overall.
  • ❌ The only failing lanes are alpine-3.23-aarch64-test-bun and alpine-3.23-x64-baseline-test-bun, both in the "should handle gitlab git dependencies" tests of test/cli/install/bun-install.test.ts, both because gitlab.com returned 502/503 to git clone during the run (fatal: unable to access 'https://gitlab.com/dylan-conway/public-install-test/': The requested URL returned error: 502). A GitLab outage in that time window — unrelated to this change; the decorator/transpiler tests passed on every lane that ran them.

Every red lane across this PR's builds has been a distinct unrelated infra issue (expired macOS agent → git SIGKILL in an install fixture → stale size baseline → gitlab.com outage). The diff itself is green everywhere it's been exercised. Ready for maintainer review/merge.

robobun and others added 4 commits June 5, 2026 23:25
When a class is lowered for standard decorators, `recv.#m(args)` was
rewritten to `__privateGet(recv, _m).call(recv, args)`, duplicating the
receiver expression. Side effects in the receiver ran twice, and for
chained calls like `o.#m().#m().#m()` the duplication compounded, so
printed output grew exponentially with chain length (a 44-link chain
from the fuzzer made the printer allocate without bound).

Reuse `this` and identifier receivers directly; capture any other
receiver in a temporary (`__privateGet(_obj = recv, _m).call(_obj,
args)`) declared alongside the other lowering variables.
…ic fields

A fuzzer-minimized variant of the receiver-duplication blowup reaches the
multi-GB range with only ~30 links: a `.#method()()` chain in a decorated
static field initializer (ts loader). Cover it with a transpiled-size test
(20 links must stay in the kilobytes) and a runtime test asserting each
link's private method is evaluated exactly once.
@robobun

robobun commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 3802549 on top of a rebase onto current main (08226e2):

  • Addressed the review comment: the capture-temp declaration block now builds one Vec with exact capacity upfront; the intermediate refs vec is gone.
  • Added a runtime test for decorated class expressions, which emit the capture temporaries through the is_expr path (var hoisted to the nearest statement list) rather than prefix statements.

Re-verified on this branch: all 42 tests in es-decorators.test.ts pass, plus es-decorators-esbuild.test.ts (147), decorators.test.ts (22), decorator-metadata.test.ts (5), and transpiler.test.js (188). The original 41-link fuzz chain transpiles to 4.5 KB.

Also filed #31910 for the pre-existing optional-chain short-circuit gap this PR's description mentions, and added repros to #28118 for the pre-existing missed rewrites in field initializers. Both predate and are untouched by this change.

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js_parser/lower/lower_decorators.rs`:
- Around line 759-780: The code currently hoists a mutable temp (tmp_ref from
self.generate_temp_ref) into the outer lowering prelude via assign_to/use_ref so
every invocation shares one binding; change the non-identifier/this branch so
the temporary is allocated and consumed per-invocation (not a hoisted outer
binding): instead emit an inline/single-use temp expression for obj_expr (use a
freshly generated tmp_ref but make assign_to produce an immediate assignment
expression (or an expression-sequence) that scopes the temp to this call) and
pass that immediate write as get_obj and the corresponding read expression to
this_arg before calling self.private_get_expr; keep references to obj_expr,
tmp_ref, assign_to, use_ref, generate_temp_ref and private_get_expr to locate
and update the logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: dbffbdec-6be5-4a84-8794-3a79c3a8897b

📥 Commits

Reviewing files that changed from the base of the PR and between 08226e2 and 3802549.

📒 Files selected for processing (2)
  • src/js_parser/lower/lower_decorators.rs
  • test/bundler/transpiler/es-decorators.test.ts

Comment thread src/js_parser/lower/lower_decorators.rs
A temp hoisted to class-statement scope is one shared binding across all
invocations of a method. For getter/accessor-backed private calls,
__privateGet runs the user getter between the temp write and the .call
read, so a getter reentering the same call site overwrote the outer
invocation's receiver. Declare temps created inside function/arrow
bodies at the top of that body instead, matching where esbuild places
them; sites outside function bodies run at most once per class
evaluation and keep the hoisted declaration.
Comment thread test/bundler/transpiler/es-decorators.test.ts Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
…ializer temps

Deduplicate the two copies of the drain-temps-into-var-declaration block
into drain_capture_temp_decls, used by both the function-body and the
class-prelude placement. Extend the class expression test with a
decorated instance field whose initializer has a complex receiver, so
the hoisted-to-nearest-statement-list placement is exercised at runtime,
and reword its comment to describe both placements.
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Instance field initializers run per construction, not once per class
evaluation; they share the hoisted binding the way esbuild's lowering
does. Say that instead of over-claiming once-per-class-evaluation.
@robobun

robobun commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator Author

CI status (build 60965, sha 5025eae, final): 267 jobs passed. Every failing lane (debian/ubuntu/alpine/windows, x64/aarch64/baseline/asan) fails on exactly one file, test/cli/install/bunx.test.ts — the known repo-wide breakage where @angular/cli@latest now requires Node >= 24.15.0 while bun reports 24.3.0 (#31797, fix in #31820; reproduces on stock bun with no changes from this PR). No other test fails anywhere; the decorator/transpiler suites including this PR's regression tests are green on every lane.

All review feedback is addressed and every review thread is resolved. Ready for maintainer review; the bunx lanes clear once #31820 (or a rebase past it) lands.

@Jarred-Sumner
Jarred-Sumner merged commit a7034f2 into main Jun 6, 2026
70 of 78 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/22b2afc9/fix-decorator-private-call-receiver branch June 6, 2026 02:07
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