Skip to content

Error.prepareStackTrace: index source URLs by visible frame, not by JSC frame - #37450

Open
robobun wants to merge 4 commits into
mainfrom
farm/f9cfdd2a/prepare-stack-trace-private-frames
Open

Error.prepareStackTrace: index source URLs by visible frame, not by JSC frame#37450
robobun wants to merge 4 commits into
mainfrom
farm/f9cfdd2a/prepare-stack-trace-private-frames

Conversation

@robobun

@robobun robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Repro

main.cjs:

const { outer } = require("./callers.cjs");
function inner() {
  const error = new Error("boom");
  return error;
}
function main() {
  const error = outer(inner.bind(null));
  return error;
}
Error.prepareStackTrace = (_error, callSites) =>
  callSites.map(c => `${c.getFunctionName() || "<anonymous>"} ${c.getFileName()}:${c.getLineNumber()}`).join("\n");
console.log(main().stack);

callers.cjs:

exports.outer = function outer(callback) {
  const error = callback();
  return error;
};
$ bun main.cjs
inner /tmp/x/main.cjs:3
outer /tmp/x/callers.cjs:2
main /tmp/x/main.cjs:7
<anonymous> /tmp/x/main.cjs:12

$ BUN_JSC_showPrivateScriptsInStackTraces=1 bun main.cjs     # same output as any debug build, where the option is on by default
inner /tmp/x/main.cjs:3
outer [native code]:3
main /tmp/x/callers.cjs:7
<anonymous> /tmp/x/main.cjs:12

outer and main report the file of the frame above them ([native code] is the bound function call), and because the remap then runs against the wrong file, outer keeps its generated line. <anonymous> only looks right because main lives in the same file.

The same happens with AsyncLocalStorage#run(store, boundFn): run reports [native code] and the frame below it reports node:async_hooks. That is how the bake dev server hits it: react-server-dom calls components through componentStorage.run(..., callComponentInDEV), a bound function, and builds the error's .stack through its own Error.prepareStackTrace, so a debug-build dev server prints

at run ([native code]:198:29)
at renderFunctionComponent (node:async_hooks:1272:198)

Cause

computeErrorInfoWithPrepareStackTrace (src/jsc/bindings/FormatStackTraceForJS.cpp) builds the CallSites from JSCStackTrace::fromExisting, which skips frames with private implementation visibility (a bound function call is one, VM::getBoundFunction marks it private), but it then looked up each CallSite's source URL and owning global object in the unfiltered JSC::StackFrame vector at the same index. Once one private frame is in that vector, every CallSite after it is paired with the previous JSC frame.

JSC itself only keeps private frames in the vector while Options::showPrivateScriptsInStackTraces() is on, which Bun turns on in debug builds (ZigGlobalObject.cpp) and which BUN_JSC_showPrivateScriptsInStackTraces=1 turns on in release builds. With it off, fromExisting filters nothing and the two lists happen to line up, which is why the release default is unaffected. The mismatch dates back to #5802.

Fix

JSCStackFrame now keeps a pointer to the JSC::StackFrame it was built from, and the prepareStackTrace loop reads the JSC frame through the same JSCStackFrame the CallSite came from, so the pairing holds by construction instead of through a second index-aligned list. The per-frame logic (Zig::sourceURL, the node:vm global object check, the remap) is unchanged, so frames that were already lined up produce exactly the same CallSites as before, and the CallSite list now comes out the same whether or not JSC kept the private frames, which is what fromExisting's filtering is for.

Making that pointer unconditional means removing JSCStackFrame's other constructor, the one taking a StackVisitor, which has had no callers since #19238. The call-frame pointer only that constructor populated goes with it: it has been null for every frame since then, so CallSite#getThis() already returns undefined for every frame (a JSC::StackFrame carries no receiver), and CallSite::finishCreation now says so directly and drops the globalObject parameter it only needed for that path. No behavior changes there.

JSCStackTrace::getStackTraceForThrownValue, the only other user of fromExisting, is deleted as well: it had no callers, and it built a trace over a vector owned by a JSC::Exception or ErrorInstance, which the frames would now point into.

The other code that walks these vectors (formatStackTrace for the default .stack string, populateStackTrace in ZigException.cpp, and getFramesForCaller, which filters the vector in place before it gets here) each works on one list, so this was the only site pairing two of them.

Verification

New test in test/js/node/v8/capture-stack-trace.test.js: a two-file fixture covering both the plain bound call and AsyncLocalStorage#run with a bound callback, run with BUN_JSC_showPrivateScriptsInStackTraces set to 0 and to 1, asserting both runs report the same file and line for every named CallSite. Before the fix the 1 run (and therefore any debug build) reports outer at [native code], main in callers.cjs, run at [native code] and viaAsyncLocalStorage in node:async_hooks; the 0 run pins the release behavior that must not change.

USE_SYSTEM_BUN=1 bun test test/js/node/v8/capture-stack-trace.test.js -t "hidden frame"   # fails as above
bun bd test test/js/node/v8/capture-stack-trace.test.js                                      # 44 pass

Also passing on the debug build: test/js/bun/sourcemap, test/js/bun/util/reportError.test.ts, test/js/node/vm/vm.test.ts, test/js/node/util/util.test.js, test/js/web/workers/structured-clone.test.ts, test/regression/issue/{013880,fix-bindings-stack-trace,prepare-stack-trace-crash}.test.ts, and the node:test prepareStackTrace / getCallSites / shadow realm parallel tests (these cover getThis/getFunction on strict and sloppy frames). test/js/bun/util/inspect-error.test.js "Error inside minified file" fails on debug builds with and without this change (the private require frame shows up in the default .stack path, which this PR does not touch).

Two nearby issues seen while reproducing are already owned by open PRs and are left alone here: #37388 (new Error() minified to Error() becomes a tail call in strict code and drops the creating frame) and #36602 (printing an error whose .stack was already materialized source-maps the non-top frames a second time, #15859).


[review] gate passed · iteration 0 · 6 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/node/v8/capture-stack-trace.test.js"
bun test v1.4.0 (ba050a44e)

test/js/node/v8/capture-stack-trace.test.js:
(pass) Regular .stack [14.74ms]
(pass) throw inside Error.prepareStackTrace doesnt crash [6.55ms]
(pass) capture stack trace [6.77ms]
(pass) capture stack trace with message [7.10ms]
(pass) capture stack trace with constructor [5.18ms]
(pass) capture stack trace limit [22.46ms]
(pass) prepare stack trace [11.14ms]
(pass) capture stack trace second argument [17.41ms]
(pass) capture stack trace edge cases [11.19ms]
(pass) Error.captureStackTrace installs .stack as non-enumerable [21.72ms]
(pass) prepare stack trace call sites [11.83ms]
(pass) sanity check [12.35ms]
(pass) CallFrame isEval works as expected [7.12ms]
(pass) CallFrame isTopLevel returns false for Function constructor [7.65ms]
(pass) CallFrame.p.getThisgetFunction: strict/sloppy mode interaction [9.51ms]
(pass) CallFrame.p.isConstructor [3.46ms]
(pass) CallFrame.p.isNative [2.94ms]
(pass) return non-strings from Error.prepareStackTrace [3.31ms]
(pass) CallF
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (9008ae7ab)

test/js/node/v8/capture-stack-trace.test.js:
(pass) Regular .stack [0.15ms]
(pass) throw inside Error.prepareStackTrace doesnt crash [0.10ms]
(pass) capture stack trace [0.06ms]
(pass) capture stack trace with message [0.06ms]
(pass) capture stack trace with constructor [0.07ms]
(pass) capture stack trace limit [0.21ms]
(pass) prepare stack trace [0.11ms]
(pass) capture stack trace second argument [0.16ms]
(pass) capture stack trace edge cases [0.10ms]
(pass) Error.captureStackTrace installs .stack as non-enumerable [0.28ms]
(pass) prepare stack trace call sites [0.14ms]
(pass) sanity check [0.11ms]
(pass) CallFrame isEval works as expected [0.09ms]
(pass) CallFrame isTopLevel returns false for Function constructor [0.09ms]
(pass) CallFrame.p.getThisgetFunction: strict/sloppy mode interaction [0.11ms]
(pass) CallFrame.p.isConstructor [0.04ms]
(pass) CallFrame.p.isNative [0.03ms]
(pass) return non-strings from Error.prepareStackTrace [0.03ms]
(pass) CallFrame.p.toString [0.03ms]
(pass) err.stack should invoke prepareStackTrace [0.25ms]
(pass) Error.prepareStackTrace inside a node:vm works [2.04ms]
(pass) Error.captureStackTrace i
... (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/mechgate.xml" "test/js/node/v8/capture-stack-trace.test.js"
bun test v1.4.0 (ba050a44e)

test/js/node/v8/capture-stack-trace.test.js:
(pass) Regular .stack [12.45ms]
(pass) throw inside Error.prepareStackTrace doesnt crash [6.42ms]
(pass) capture stack trace [6.91ms]
(pass) capture stack trace with message [7.18ms]
(pass) capture stack trace with constructor [5.14ms]
(pass) capture stack trace limit [21.90ms]
(pass) prepare stack trace [11.15ms]
(pass) capture stack trace second argument [17.52ms]
(pass) capture stack trace edge cases [10.76ms]
(pass) Error.captureStackTrace installs .stack as non-enumerable [20.78ms]
(pass) prepare stack trace call sites [11.73ms]
(pass) sanity check [12.59ms]
(pass) CallFrame isEval works as expected [7.05ms]
(pass) CallFrame isTopLevel returns false for Function constructor [7.82ms]
(pass) CallFrame.p.getThisgetFunction: strict/sloppy mode interaction [8.69ms]
(pass) CallFrame.p.isConstructor [3.52ms]
(pass) CallFrame.p.isNative [2.82ms]
(pass) return non-strings from Error.prepareStackTrace [3.24ms]
(pass) CallF
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 714ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/24] gen generated_host_exports.rs
generated_host_exports.rs: 93 exports (host=3, lazy=10, generic=80, rust=0); 239 extern-C blocks audited
[2/24] gen cpp.rs (cppbind)
[2/24] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92
... (truncated)
diff hotspot
src/jsc/bindings/CallSite.cpp               | 14 ++----
 src/jsc/bindings/CallSite.h                 |  4 +-
 src/jsc/bindings/ErrorStackTrace.cpp        | 71 +--------------------------
 src/jsc/bindings/ErrorStackTrace.h          | 30 ++----------
 src/jsc/bindings/FormatStackTraceForJS.cpp  |  5 +-
 test/js/node/v8/capture-stack-trace.test.js | 75 ++++++++++++++++++++++++++++-
 6 files changed, 88 insertions(+), 111 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                         reads  edits  tests
src/jsc/bindings/CallSite.cpp                    2      3      0
src/jsc/bindings/CallSite.h                      1      2      0
src/jsc/bindings/ErrorStackTrace.cpp             6      4      0
src/jsc/bindings/ErrorStackTrace.h               5      8      0
src/jsc/bindings/FormatStackTraceForJS.cpp       5      6      0
test/js/node/v8/capture-stack-trace.test.js      3      4      0

…SC frame

computeErrorInfoWithPrepareStackTrace builds CallSites from
JSCStackTrace::fromExisting, which skips private-visibility frames, but
looked up each CallSite's source URL and global object in the unfiltered
JSC::StackFrame vector at the same index. Whenever JSC kept a private
frame in the vector (showPrivateScriptsInStackTraces, on by default in
debug builds), every CallSite after it got the previous frame's source
URL and was source-mapped against the wrong file.

fromExisting now also returns the JSC::StackFrame behind each frame it
kept, and the prepareStackTrace path reads from that list. Removes the
unused JSCStackTrace::getStackTraceForThrownValue, the only other caller.
@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on 1.4.0-canary.1 with BUN_JSC_showPrivateScriptsInStackTraces=1 (and on every debug build, where that option is on by default) using the fixture now in test/js/node/v8/capture-stack-trace.test.js; fixed by this PR. Current state is ba050a4: the JSC frame lives on JSCStackFrame, the dead StackVisitor constructor and getStackTraceForThrownValue are removed, all review threads resolved, every CI lane green on build 92236. Ready for a maintainer.

@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: 13 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: 7865c7ca-9d43-46a4-ba29-02a43ee663fe

📥 Commits

Reviewing files that changed from the base of the PR and between 54d6d16 and ba050a4.

📒 Files selected for processing (6)
  • src/jsc/bindings/CallSite.cpp
  • src/jsc/bindings/CallSite.h
  • src/jsc/bindings/ErrorStackTrace.cpp
  • src/jsc/bindings/ErrorStackTrace.h
  • src/jsc/bindings/FormatStackTraceForJS.cpp
  • test/js/node/v8/capture-stack-trace.test.js

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.

LGTM — targeted index-alignment fix with a solid regression test.

What was reviewed

  • visibleFrames pointer lifetime: points into caller-owned stackFrames, which is not resized between fromExisting and the dereference loop; GC in createCallSitesFromFrames doesn't touch the WTF::Vector storage.
  • stackTrace.size() == visibleFrames.size() — both appended in lockstep, so *visibleFrames[i] is always valid for i < n.
  • Confirmed fromExisting has one caller (updated) and getStackTraceForThrownValue had none.
  • Test drains pipes concurrently, spreads bunEnv, and asserts exact file/line for both env-var states.
Extended reasoning...

Overview

The PR fixes an off-by-one-list bug in computeErrorInfoWithPrepareStackTrace (src/jsc/bindings/FormatStackTraceForJS.cpp): CallSites were built from a filtered frame list (JSCStackTrace::fromExisting skips private-visibility frames) but their source URL and owning global object were then read from the unfiltered JSC::StackFrame vector at the same index. When showPrivateScriptsInStackTraces is on (debug builds default it on), the two lists diverge after the first private frame and every subsequent CallSite reports the wrong file. The fix has fromExisting also return a parallel vector of const JSC::StackFrame* pointing into the input vector at the kept indices, and the caller reads from that instead. getStackTraceForThrownValue (the only other fromExisting caller, itself unused) is deleted. A new test in capture-stack-trace.test.js runs a two-file bound-function + AsyncLocalStorage#run fixture under both env-var values and asserts identical CallSite file/line output.

Security risks

None. This is diagnostic output (CallSite getFileName()/getLineNumber()) and touches no auth, crypto, or untrusted-input parsing. No new allocations or refcounts.

Level of scrutiny

Moderate — C++ JSC bindings, but the change is mechanical index alignment with no new memory ownership, exception scopes, or GC interactions. The pointers stored in visibleFrames reference elements of the caller's Vector<StackFrame>& parameter, which is neither destroyed nor resized before the pointers are last dereferenced; the intervening createCallSitesFromFrames allocates JS objects but does not touch the stackFrames vector, and GC does not move WTF::Vector storage. The per-frame logic (Zig::sourceURL, callee-global-object lookup, sourcemap remap) is byte-identical to before — only which StackFrame it receives changed.

Other factors

  • Verified via grep that fromExisting has exactly one caller (updated here) and getStackTraceForThrownValue has zero, so the deletion is safe and satisfies the "delete dead code in the same PR" rule.
  • The test follows harness conventions: tempDir, {...bunEnv, ...}, concurrent pipe draining, exact-value assertions on structured output for both BUN_JSC_showPrivateScriptsInStackTraces=0 (pins release behavior) and =1 (the previously-broken path).
  • No CODEOWNERS cover the touched files.
  • PR description documents the root cause, the other stack-walking sites that were checked and why they're unaffected, and the USE_SYSTEM_BUN=1 failure / bun bd pass verification.

robobun added a commit that referenced this pull request Aug 11, 2026
…7450 already remove

BUN_NEEDS_PROC_SELF_WORKAROUND, MI_VERBOSE and TODIUM are removed by
#35437, and JSCStackTrace::getStackTraceForThrownValue by #37450, so this
PR keeps only the deletions that are not already open elsewhere.
Pairs each CallSite with its JSC frame by construction rather than through
a second index-aligned list. JSCStackFrame's StackVisitor constructor has
had no callers since #19238, so it goes, along with the call-frame pointer
only it populated (CallSite::getThis has returned undefined for every
frame since then) and the now unused globalObject parameter of
CallSite::finishCreation. fromExisting keeps its original signature.
Comment thread src/jsc/bindings/CallSite.cpp Outdated
Comment thread src/jsc/bindings/ErrorStackTrace.h Outdated
Comment thread src/jsc/bindings/ErrorStackTrace.h Outdated
It built a JSCStackTrace over a vector owned by a JSC::Exception or
ErrorInstance, which the frames now point into, and it has no callers.
@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:34 AM PT - Aug 11th, 2026

@robobun, your commit ba050a44eae7c6974af0b92348aa9133867c9c8e passed in Build #92236! 🎉


🧪   To try this PR locally:

bunx bun-pr 37450

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

bun-37450 --bun

@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 found no bugs; the earlier getStackTraceForThrownValue finding was addressed in ba050a4. Because it adds a raw-pointer field to JSCStackFrame and reworks the CallSite/StackFrame pairing in JSC bindings, a human look would still be worthwhile.

What was reviewed:

  • m_stackFrame lifetime: sole fromExisting caller keeps stackFrames alive and unmutated across the loop; JSCStackTrace is stack-local and CallSite copies data out rather than storing the pointer.
  • Dead-code removal: repo-wide grep confirms zero remaining references to callFrame(), the StackVisitor ctor, and getStackTraceForThrownValue; isVisibleBuiltinFunction is still used by the surviving ctor.
  • CallSite::finishCreation: dropping the callFrame->thisValue() branch is behavior-preserving since m_callFrame was always null on the only remaining construction path.
  • Test: asserts exact file/line for named frames under both BUN_JSC_showPrivateScriptsInStackTraces values, so the release-default path is pinned and the assertion cannot pass vacuously.
Extended reasoning...

Overview

The PR fixes an off-by-one indexing bug in computeErrorInfoWithPrepareStackTrace (FormatStackTraceForJS.cpp): it built CallSite objects from the private-frame-filtered JSCStackTrace but then read source URLs and per-frame global objects from the unfiltered Vector<JSC::StackFrame> at the same index. When showPrivateScriptsInStackTraces is on (all debug builds), a bound-function or ALS run frame in the raw vector shifted every subsequent CallSite onto the wrong file. The fix stores a const JSC::StackFrame* on each JSCStackFrame and reads through it, so the pairing is by construction. It also deletes dead code that the change made riskier to keep: the StackVisitor constructor, the m_callFrame field / callFrame() accessor, the now-always-null thisValue branch in CallSite::finishCreation (plus its unused globalObject param), and getStackTraceForThrownValue.

Security risks

None identified. This is error-stack formatting; no untrusted-input parsing, auth, or crypto. The new raw pointer is into a caller-owned WTF::Vector that is not GC-managed and is not mutated between construction and use, so there is no new UAF surface for the sole call site.

Level of scrutiny

High. This is C++ in src/jsc/bindings/ on a path that runs for every Error.prepareStackTrace invocation, and it introduces a raw-pointer field whose safety depends on the caller keeping the source vector alive. I traced the sole fromExisting caller and its two upstream entry points (errorInstanceLazyStackCustomGetter moves the vector into a local unique_ptr before the call; errorConstructorFuncCaptureStackTrace uses a stack-local vector) — neither reallocates or frees during computeErrorInfoWithPrepareStackTrace, and the JSCStackTrace holding the pointers is itself stack-local to that function. The new stackFrame() accessor has exactly one call site, inside that same scope. Still, raw-pointer lifetime invariants in JSC bindings are exactly where a maintainer familiar with ErrorInstance/StackFrame ownership should confirm the reasoning.

Other factors

All prior review threads are resolved: comment-cop's two long-comment flags were shortened in 22e4045, and my earlier note that getStackTraceForThrownValue was still present was addressed in ba050a4 (verified gone by grep). The new test follows harness conventions (tempDir, bunEnv spread, concurrent pipe drain, exact-value toEqual on a structured result, both env-var states covered). The getThis() behavior change is nominal — the removed branch was unreachable since #19238 left m_callFrame always null — and existing getThis/getFunction tests in the same file continue to pass per the description.

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.

1 participant