Skip to content

transpiler: don't inline a single-use binding into a return when that creates a tail call - #37416

Closed
robobun wants to merge 1 commit into
mainfrom
farm/0b46ed68/no-inlined-tail-calls
Closed

transpiler: don't inline a single-use binding into a return when that creates a tail call#37416
robobun wants to merge 1 commit into
mainfrom
farm/0b46ed68/no-inlined-tail-calls

Conversation

@robobun

@robobun robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

A function that stores a call's result in a binding and returns it is missing from the stack traces captured inside that call.

// chain.mjs
function a() { const e = new Error("y"); globalThis.keep = 1; return e; }
function b() { const r = a(); return r; }
function b2() { const r = a(); globalThis.keep = 2; return r; }
const names = s => s.split("\n").filter(l => /^\s+at /.test(l)).map(l => l.trim().split(" ")[1]);
console.log(JSON.stringify(names(b().stack)), JSON.stringify(names(b2().stack)));
$ bun chain.mjs      # 1.4.0 and main
["a","/tmp/chain.mjs:5:34"] ["a","b2","/tmp/chain.mjs:5:68"]
$ bun chain.mjs      # this PR
["a","b","/tmp/chain.mjs:5:34"] ["a","b2","/tmp/chain.mjs:5:68"]
$ node chain.mjs
["a","b","file:///tmp/chain.mjs:5:34",...] ["a","b2","file:///tmp/chain.mjs:5:68",...]

b's frame is gone; b2, which differs only by having a statement between the call and the return, is fine. The same applies to anything else that looks at its caller's frame, such as Error.captureStackTrace(err, fn) based caller detection (depd, bindings, ...) and Function(...) taking its source origin from the calling frame.

Cause

The runtime transpiler (bun run, bun test, Bun.Transpiler with a bun target) runs with minify_syntax on (bundler/options.rs turns it on for bun targets so constants get inlined), and the single-use variable inlining in visit_stmts / P::substitute_single_use_symbol_in_stmt rewrites

function b() { const r = a(); return r; }

into function b() { return a(); }. The source as written has no call in tail position; the rewritten function does. JSC implements proper tail calls in strict mode code (every ES module, plus "use strict" CommonJS and class bodies), so b's frame is replaced by a's before a runs, and b is absent from every stack captured inside a: .stack, Error.prepareStackTrace call sites, Error.captureStackTrace. Generator bodies are affected too; async bodies are not.

Tail calls the user actually writes (return a()) are unchanged by this PR; per #26001 they stay enabled. What this PR stops is the transpiler manufacturing one out of code that did not contain it. #37388 fixes the other rewrite with the same effect (return new Error() turned into the call return Error()); the two are independent.

Fix

In substitute_single_use_symbol_in_stmt, when the statement is a return, skip the substitution if

  • the initializer ends in a call: ECall, a tagged template, require() / require.resolve() (printed as calls), or one of those reached through the tail positions of ,, ||, &&, ?? and either branch of ?: (the same set of positions JSC's bytecode generator propagates tail position through), and
  • the single use sits in a tail position of the returned expression (the return value itself, or reached through the same operators; the latter happens for @__PURE__ calls, which the inliner may move into a branch).

Returning false leaves the declaration in place, exactly as when the substitution fails for any other reason. Every other shape keeps inlining: uses that are not the returned value (return r.x, return g(r), throw r, if (r)), and initializers that do not end in a call (new F(), await f(), f().x, f() + 1, import()), none of which can become a tail call.

This only applies when not bundling (options.bundle == false), the same gate the arrow body collapse and the unused function/class name removal use: when the user asks bun build for --minify-syntax the rewrite is what they asked for and matches esbuild, and most of that output does not run on JSC anyway. As with the arrow case this also means bun build --no-bundle --minify-syntax and Bun.Transpiler with minify.syntax keep the binding; the cost is one local variable that the JIT eliminates.

Why this is the right fix and not a workaround: the substitution is a minifier transform that is supposed to be unobservable, and on JSC it is observable in exactly this shape, so the transform is what has to be narrowed. The alternatives are worse: disabling JSC tail calls was rejected for performance (#26001), disabling minify_syntax at runtime would lose the constant inlining it exists for, and gating on the parser's notion of strict mode would miss the file in the repro (no import/export, so nothing in the source says it is strict, but a .mjs file is still a module).

RuntimeTranspilerCache EXPECTED_VERSION is bumped to 26 so cached output produced by the old inliner is regenerated.

How did you verify your code works?

New tests, all of which fail on the unfixed build (USE_SYSTEM_BUN=1) and pass with bun bd test:

  • test/bundler/transpiler/runtime-transpiler.test.ts: a table of 19 shapes whose binding must stay (plain/method/optional/.call/curried calls, tagged template, require, require.resolve, the ?: / || / && / ?? / , forms on both sides, the two-binding chain) plus 19 shapes that must still be inlined, run through Bun.Transpiler with the runtime's settings; and a spawned .mjs fixture checking that a function, a generator and an arrow all stay in the stack captured by their callee (on main the second frame is the module's top level for all three).
  • test/js/bun/test/stack.test.ts: in-process version of the report (plain binding and the @__PURE__ conditional shape), since test files go through the same transpiler.
  • test/bundler/bundler_minify.test.ts: minify/KeepSingleUseBindingBeforeReturnWhenNotBundling (fails on main) and minify/InlineSingleUseBindingIntoReturn, which pins that bundling with minifySyntax still inlines.

Also ran test/bundler/bundler_minify.test.ts, test/bundler/transpiler/, test/js/bun/transpiler/, test/js/bun/test/, test/js/node/v8/, the stack related files in test/regression/issue/ and test/js/bun/util/, and test/js/node/util/ against the debug build. The only failures are unrelated to this change: debug-build timeouts (jsx-production.test.ts, util-inspect.test.js, which pass with a longer timeout), the pre-existing at require (51:24) debug-only frame in inspect-error.test.js (handled in #37388), files that pollute Error.prepareStackTrace when several are run in one process (they pass individually), and the fuzz suite's redis abort tracked in #37340.

… creates a tail call

The runtime transpiler runs with minify_syntax on, so `const r = f(); return r;`
was rewritten into `return f();`. In strict mode code (every ES module) JSC
turns that call into a proper tail call, and the returning function's frame is
missing from every stack trace captured inside `f`. Node keeps the frame, and so
does the source as written.

Skip the substitution when the initializer ends in a call (directly, or through
the tail positions of `,`, `||`, `&&`, `??` and `?:`) and the use is in tail
position of the returned expression. Only the runtime transpiler (bundle=false)
is affected; `bun build --minify` keeps inlining, like esbuild.

Bump the runtime transpiler cache version so cached output is regenerated.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

Next review available in: 9 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

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.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 98622b81-6a3f-4991-ba2d-324ccf1bdf51

📥 Commits

Reviewing files that changed from the base of the PR and between 9fcdea8 and 873a29f.

📒 Files selected for processing (5)
  • src/js_parser/p.rs
  • src/jsc/RuntimeTranspilerCache.rs
  • test/bundler/bundler_minify.test.ts
  • test/bundler/transpiler/runtime-transpiler.test.ts
  • test/js/bun/test/stack.test.ts

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

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:43 PM PT - Aug 10th, 2026

@robobun, your commit 873a29f5552268ded537ea1655479e87d1094cf5 passed in Build #91925! 🎉


🧪   To try this PR locally:

bunx bun-pr 37416

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

bun-37416 --bun

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on bun 1.4.0 and a debug build of main with the chain.mjs snippet in the description (b's frame missing, node keeps it). Fix and tests are in this PR; the new tests fail on the unfixed build and pass with the fix. Waiting on CI.

@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 changes runtime transpiler output for every bun run/bun test invocation and adds new recursive tail-position analysis to the parser, a human look at the approach (particularly the !bundle gate choice) would still be worthwhile.

What was reviewed:

  • Checked the !self.options.bundle gate matches the existing arrow-collapse pattern at visit_expr.rs:2539 — it does.
  • Verified identifier_is_use_of is a clean extraction of the pre-existing inline check at the old p.rs:2191.
  • Checked stack-overflow guards on both new recursive helpers follow the hoist_symbols pattern; return false on overflow is the conservative direction (skips the optimization gate, falls through to substitution).
  • Confirmed the tail-position operator set (, / || / && / ?? / ?:) and the ECall/tagged-template/require cases are covered by the 38-case test table.
Extended reasoning...

Overview

This PR narrows the single-use-binding inliner in src/js_parser/p.rs so that const r = f(); return r; is no longer rewritten to return f(); when not bundling. The rewrite creates a tail call the user didn't write, and JSC's proper-tail-call implementation then drops the caller's frame from stack traces. The change adds ~80 lines: a guard in substitute_single_use_symbol_in_stmt's SReturn arm, three helpers (has_call_in_tail_position, is_ref_in_tail_position, is_tail_position_operator), and an extracted identifier_is_use_of. RuntimeTranspilerCache::EXPECTED_VERSION is bumped 25→26. Tests span three files with 40+ assertions.

Security risks

None. This is a pure minifier-transform gate; no untrusted input parsing, no allocation, no FFI. The recursive helpers reuse the existing stack_check/report_stack_overflow guard so adversarial deeply-nested expressions cannot overflow the stack.

Level of scrutiny

High. The runtime transpiler runs on every file bun run/bun test loads, so a defect here affects every user. The change is well-scoped and the !options.bundle gate mirrors the existing arrow-body-collapse gate (visit_expr.rs:2539), but the decision to gate on bundle rather than, say, target or strict-mode is a design call the PR description argues for explicitly — a maintainer should confirm they agree.

Other factors

  • Test coverage is unusually thorough: a 19-row "keeps binding" table, a 19-row "still inlines" table, an awaited-call case, a spawned .mjs fixture verifying function/generator/arrow frames survive, an in-process stack test, and two itBundled cases pinning both bundling and non-bundling behavior.
  • The PR description explicitly enumerates and rejects alternatives (disabling JSC TCO per #26001, disabling minify_syntax, gating on parser strict-mode) with reasons.
  • The cache version bump is correctly included.
  • No prior human or bot review comments to address; CI is still building.

Given the scope (core parser hot path, runtime-wide behavior change) this should get a human sign-off even though the implementation looks correct.

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

On the two points from the review above:

  • The !options.bundle gate is deliberate. It is the same condition the arrow body collapse and the unused function/class name removal use in visit_expr.rs (e_arrow, e_function, e_class), for the same reason: the runtime transpiler turns minify_syntax on by itself, so rewrites that are only justified by smaller output are restricted to bun build. The "Fix" section of the description covers the alternatives considered (gating on the parser's strict mode misses the .mjs repro, which has no import/export).
  • The return false in the overflow branch of the two helpers is not load bearing either way: report_stack_overflow records an error, so the parse fails and no output is produced regardless of whether the substitution happens. The guard is there so the helpers recurse under the same check as every other expression walker in the parser; the expression was already visited by visit_expr with the same guard, so in practice it never fires here.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Not a bug.

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Understood, the inliner's output is working as intended and the stack trace difference is accepted behavior. Closing out; nothing further planned here.

@robobun
robobun deleted the farm/0b46ed68/no-inlined-tail-calls branch August 11, 2026 05:19
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