Skip to content

Derive the stack trace header from the error's name and message - #33437

Open
robobun wants to merge 1 commit into
mainfrom
farm/eff7dc62/capture-stack-trace-header
Open

Derive the stack trace header from the error's name and message#33437
robobun wants to merge 1 commit into
mainfrom
farm/eff7dc62/capture-stack-trace-header

Conversation

@robobun

@robobun robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Repro

const o = { name: "MyThing", message: "it broke" }; Error.captureStackTrace(o);
const m = { message: "only-message" };              Error.captureStackTrace(m);
const n = { name: "OnlyName" };                     Error.captureStackTrace(n);
console.log([o, m, n].map(x => x.stack.split("\n")[0]));

Error.prepareStackTrace = (err, frames) => err.stack.split("\n")[0];
console.log(new TypeError("boom").stack);
node v26.3.0: [ "MyThing: it broke", "Error: only-message", "OnlyName" ]
              TypeError: boom
bun 1.4.0:    [ "Error",             "Error",               "Error"    ]
              Error: boom

Error.captureStackTrace(obj) on a non-Error target is the canonical pre-class
custom-error idiom:

function MyError(msg) {
  this.name = "MyError";
  this.message = msg;
  Error.captureStackTrace(this, MyError);
}
MyError.prototype = Object.create(Error.prototype);

Under bun every such error's trace was labelled Error and its message was dropped
from the stack string, so logged stacks lost both the error type and what went wrong.

Cause

The first line of a stack trace was composed from a hardcoded "Error" in two places:

  • computeErrorInfoWithoutPrepareStackTrace only read name/message when the target
    was an ErrorInstance (via sanitizedNameString/sanitizedMessageString). A plain
    object fell through to the name = "Error"_s default with an empty message.
  • formatStackTraceToJSValue, which builds the default-formatted string that
    Error.prepareStackTrace receives as err.stack, read message off the object but
    hardcoded the name, so even a real TypeError was labelled Error there.

V8 composes that line with ErrorUtils::ToString, which does a Get(target, "name") /
Get(target, "message") on the object regardless of its type.

Fix

Both sites now go through one getErrorNameAndMessage helper that follows
ErrorUtils::ToString: an undefined name means "Error", an undefined message means
the empty string, the two are joined with ": " only when both are non-empty, and for a
non-ErrorInstance target they are read off the object itself (prototype chain included,
with ToString coercion). ErrorInstance keeps using the existing sanitized accessors,
which also makes the prepareStackTrace default string agree with the non-prepareStackTrace
one (what test/js/node/v8/error-prepare-stack-default-fixture.js asserts).

Reading those two properties off the target can run a getter, and a StackFrame holds
cells the GC does not scan from the vector itself. The captured frames are now rooted in a
MarkedArgumentBuffer across formatting, the same way the lazy .stack getter already
does; that block is factored into protectStackFrameCells.

Known remaining difference

bun materializes .stack eagerly inside captureStackTrace, V8 formats it lazily on first
access. So mutating name/message after the call is still not reflected, and a throwing
name/message getter throws from captureStackTrace rather than from the .stack read.
Both are properties of the eager model, which predates this change (formatStackTraceToJSValue
already did a full Get on message); closing them means storing the frames for a plain
object, which is a larger change.

Rebase note: interaction with #34104

#34104 (merged) asserted that an accessor .message which throws while Error.prepareStackTrace is set makes the first .stack read throw and the second return undefined. This PR routes an ErrorInstance through the sanitized (VMInquiry) accessors for the header, which skip getters, so that specific repro now matches Node: the getter is never invoked, prepareStackTrace runs, and .stack is the string it returned. #34104's test assertion is updated accordingly; its regression guard (signalCode: null, exitCode: 0) is unchanged, and its if (!result) return jsUndefined() safeguard stays.

Verification

test/js/node/v8/capture-stack-trace.test.js gains 4 tests, each asserting a value taken from
node v26.3.0: the header cases above plus {}, {name: "", message: ""}, {name: 42, message: 7},
null/undefined name and message, accessor-defined name and message, prototype-chain lookup,
the pre-class idiom, and the prepareStackTrace default string for TypeError, an
Error subclass, and a plain target.

USE_SYSTEM_BUN=1 bun test capture-stack-trace.test.js   # 4 new tests fail
bun bd test capture-stack-trace.test.js                 # 44 pass, 0 fail
Regression sweep

test/js/node/v8/, test/js/bun/sourcemap/, test/js/bun/util/fuzzy-wuzzy.test.ts,
test/js/deno/v8/error.test.ts, test/js/node/util/node-inspect-tests/parallel/util-format.test.js:
8 fail before the diff (4 of them the new tests), 4 fail after. The 4 remaining
(capture stack trace limit, the WebSocket call-sites test, the message-getter test,
construct-subclass ReadStream) fail identically on unmodified main when those files are run
together, and all pass when each directory is run on its own. test/js/bun/util/error-gc-test.test.js
times out on unmodified main under debug+ASAN too.


[review] gate passed · iteration 3 · 2 files touched

fails on main (without fix)
ASAN without fix: 5 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"
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (9d183d1ce)

test/js/node/v8/capture-stack-trace.test.js:
(pass) Regular .stack [12.82ms]
(pass) throw inside Error.prepareStackTrace doesnt crash [8.98ms]
(pass) capture stack trace [6.42ms]
(pass) capture stack trace with message [7.36ms]
(pass) capture stack trace with constructor [7.37ms]
(pass) capture stack trace limit [26.81ms]
(pass) prepare stack trace [11.12ms]
(pass) capture stack trace second argument [20.49ms]
(pass) capture stack trace edge cases [12.41ms]
(pass) prepare stack trace call sites [16.59ms]
(pass) sanity check [17.40ms]
(pass) CallFrame isEval works as expected [7.41ms]
(pass) CallFrame isTopLevel returns false for Function constructor [10.39ms]
(pass) CallFrame.p.getThisgetFunction: strict/slop
... (truncated)

release without fix: 7 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/node/v8/capture-stack-trace.test.js:
(pass) Regular .stack [0.14ms]
(pass) throw inside Error.prepareStackTrace doesnt crash [0.12ms]
(pass) capture stack trace [0.07ms]
(pass) capture stack trace with message [0.06ms]
(pass) capture stack trace with constructor [0.06ms]
(pass) capture stack trace limit [0.22ms]
(pass) prepare stack trace [0.13ms]
(pass) capture stack trace second argument [0.17ms]
(pass) capture stack trace edge cases [0.11ms]
(pass) prepare stack trace call sites [0.14ms]
(pass) sanity check [0.11ms]
(pass) CallFrame isEval works as expected [0.12ms]
(pass) CallFrame isTopLevel returns false for Function constructor [0.10ms]
(pass) CallFrame.p.getThisgetFunction: strict/sloppy mode interaction [0.10ms]
(pass) CallFrame.p.isConstructor [0.04ms]
(pass) CallFrame.p.isNative [0.04ms]
(pass) return non-strings from Error.prepareStackTrace [0.04ms]
(pass) CallFrame.p.toString [0.03ms]
(pass) err.stack should invoke prepareStackTrace [0.34ms]
(pass) Error.prepareStackTrace inside a node:vm works [4.82ms]
(pass) Error.captureStackTrace inside error constructor works [0.12ms]
(pass) Error.prepareStackTrace has 
... (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"
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (9d183d1ce)

test/js/node/v8/capture-stack-trace.test.js:
(pass) Regular .stack [12.59ms]
(pass) throw inside Error.prepareStackTrace doesnt crash [6.39ms]
(pass) capture stack trace [6.29ms]
(pass) capture stack trace with message [7.00ms]
(pass) capture stack trace with constructor [4.93ms]
(pass) capture stack trace limit [22.20ms]
(pass) prepare stack trace [10.49ms]
(pass) capture stack trace second argument [17.42ms]
(pass) capture stack trace edge cases [11.43ms]
(pass) prepare stack trace call sites [13.38ms]
(pass) sanity check [12.79ms]
(pass) CallFrame isEval works as expected [6.81ms]
(pass) CallFrame isTopLevel returns false for Function constructor [7.97ms]
(pass) CallFrame.p.getThisgetFunction: strict/slopp
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     9d183d1ce3
  features     (none)

22 deps, 106 codegen, 1168 objects in 783ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1231] install /workspace/bun
bun install v1.4.0-canary.1 (1498d7b77)

Checked 124 installs across 170 packages (no changes) [12.00ms]
[2/1231] gen bindgenv2
[3/1231] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b77)

Checked 1 install across 2 packages (no changes) [4.00ms]
[4/1231] gen ErrorCode+*.h
[5/1231] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (1498d7b77)

Checked 129 installs across 147 packages (no changes) [7.00ms]
[6/1231] fetch tinycc
[tinycc] up to date
[7/1230] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[8/1230] gen Proc
... (truncated)
diff hotspot
src/jsc/bindings/FormatStackTraceForJS.cpp  | 128 +++++++++++++++++++---------
 test/js/node/v8/capture-stack-trace.test.js |  75 +++++++++++++++-
 2 files changed, 161 insertions(+), 42 deletions(-)

gate history · 1 passed · 0 rejected · iteration 3

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

@coderabbitai

coderabbitai Bot commented Jul 6, 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: 17 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: 1c61b4c5-c68d-4c06-b2c1-f362250a8ce0

📥 Commits

Reviewing files that changed from the base of the PR and between 48ff9eb and c2c0959.

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

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

@github-actions github-actions Bot added the claude label Jul 6, 2026
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:03 AM PT - Jul 14th, 2026

@robobun, your commit 9d183d1 has 4 failures in Build #72736 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33437

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

bun-33437 --bun

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@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 didn't find any bugs, but this touches GC-sensitive JSC binding code (new user-observable property reads on the error-info path, a changed assumption about what the finalizer can reach, and new MarkedArgumentBuffer rooting) — worth a human look from someone familiar with this file.

Extended reasoning...

Overview

This PR changes how the first line of a formatted stack trace is derived in src/jsc/bindings/FormatStackTraceForJS.cpp. It introduces getErrorNameAndMessage (reads name/message off arbitrary objects via getIfPropertyExists + toWTFString, falling back to the sanitized accessors for ErrorInstance), factors the header-append and the stack-frame GC-rooting loop into shared helpers, and roots captured frames in a MarkedArgumentBuffer at two additional sites in errorConstructorFuncCaptureStackTrace. Four new tests are added to test/js/node/v8/capture-stack-trace.test.js.

Security risks

None in the traditional sense (no auth, no untrusted input parsing beyond what already existed). The relevant risk class is memory safety: the change introduces user-triggerable getters and ToString coercion on a code path that holds unrooted StackFrame cells and that is also reachable (per the pre-existing comment) from a finalizer where allocation is forbidden. The PR addresses both — it adds MarkedArgumentBuffer rooting before formatting and asserts via comment that the finalizer only reaches computeErrorInfoWithoutPrepareStackTrace with a null errorInstance — but both of those are exactly the kind of invariant a JSC-familiar human should sign off on.

Level of scrutiny

High. Per this repo's own review guidance, JSC GC rooting, exception-scope discipline, and finalizer-path constraints are the most-blocked category of change. The diff is small and well-argued, but it (a) widens the set of callers that can run arbitrary user JS during stack formatting, (b) relies on a stated-but-not-mechanically-enforced invariant about the finalizer path, and (c) changes user-visible .stack output. That is not a mechanical change I can rubber-stamp.

Other factors

The PR description is thorough, the tests cover the V8 semantics matrix well (undefined vs null vs empty, coercion, prototype chain, accessors, prepareStackTrace), and the author verified the new tests fail on system Bun and pass on the debug build. No prior human reviews and no outstanding comments to address. Deferring so a maintainer can confirm the finalizer/rooting reasoning.

@robobun
robobun force-pushed the farm/eff7dc62/capture-stack-trace-header branch from aacf895 to 55b7d4b Compare July 6, 2026 11:21
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 55b7d4b. Two things.

The CI failure was a stale base, not this diff

14 test-bun shards went red on every platform. The only failing tests in the whole build were three in test/js/bun/cookie/cookie-map.test.ts:

- "__Host-id=; Path=/; Expires=Fri, 1 Jan 1970 00:00:00 -0000; Secure; SameSite=Lax",
+ "__Host-id=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; SameSite=Lax",

028f2107 ("Fix Bun.Cookie Expires to emit an IMF-fixdate", #32926) changed the serializer and is in my base; 48ff9eb2 ("cookie: update remaining cookie-map Expires assertions to IMF-fixdate", #33425) is the follow-up that updates those assertions, and it landed on main one commit after my base. Rebasing onto main picks it up; bun bd test cookie-map.test.ts capture-stack-trace.test.js is 77 pass / 0 fail locally.

Made the finalizer invariant machine-checked

The review flagged the finalizer assumption as stated-but-not-enforced, which is fair. It is now asserted at the site that relies on it, rather than only described in a comment:

// Reading these two properties can run user code. Only a mutator can reach here with a
// non-ErrorInstance target: the GC's finalizer path formats with a null errorObject, and
// materializeErrorInfoIfNeeded always passes an ErrorInstance, which returned above.
ASSERT(vm.heap.worldIsRunning());

The two entry points, for the record:

  • vm.onComputeErrorInfocomputeErrorInfoWrapperToStringcomputeErrorInfoToString, which is the path ErrorInstance::finalizeUnconditionally takes. It passes errorInstance = nullptr, so the if (errorInstance) guard skips getErrorNameAndMessage entirely.
  • vm.onComputeErrorInfoJSValuecomputeErrorInfoWrapperToJSValue, only ever called from ErrorInstance::materializeErrorInfoIfNeeded, which passes this. That takes the dynamicDowncast<ErrorInstance> early return into the sanitized accessors, which run no user code.

So a plain object only reaches the property reads from errorConstructorFuncCaptureStackTrace, on the mutator. worldIsRunning() rather than collectionScope() because the collector is concurrent: the mutator legitimately runs with a collection in scope, it just never runs with the world stopped.

Stressed with 40k captureStackTrace calls on plain objects (data properties and accessor-defined name/message) interleaved with Bun.gc(), on a debug+ASAN build with the assertion live: clean.

@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 didn't find any bugs, but this touches JSC bindings with GC-rooting and now runs user getters during stack formatting — worth a human look at the memory-safety and finalizer-path assumptions.

Extended reasoning...

Overview

This PR changes how the first line of a .stack string is composed in FormatStackTraceForJS.cpp. Previously, Error.captureStackTrace(obj) on a non-ErrorInstance hardcoded the header as "Error" (dropping both obj.name and obj.message), and formatStackTraceToJSValue hardcoded the name even for real TypeError instances. The fix introduces a shared getErrorNameAndMessage helper that follows V8's ErrorUtils::ToString semantics (undefined name"Error", undefined message → empty, prototype-chain lookup with ToString coercion for plain objects; sanitizedNameString/sanitizedMessageString retained for ErrorInstance). It also extracts the existing header-join and MarkedArgumentBuffer frame-rooting blocks into appendErrorNameAndMessage / protectStackFrameCells, and adds frame rooting to the two eager-format paths in errorConstructorFuncCaptureStackTrace because reading name/message can now run a user getter that triggers GC while raw StackFrame cells are held. Four new tests in capture-stack-trace.test.js cover the header matrix, prototype-chain lookup, the pre-class custom-error idiom, and the prepareStackTrace default string.

Security risks

None identified. This is stack-trace string formatting; no auth, crypto, or filesystem/network surface. The new user-code-reachable path (invoking name/message getters on the target) was already partially present — the old formatStackTraceToJSValue did a full Get on message — and the eager model means a throwing getter propagates from captureStackTrace, which the PR description calls out as a known pre-existing difference from V8's lazy model.

Level of scrutiny

High. This is C++ JSC-bindings code on a hot, widely-used path (Error.captureStackTrace, .stack materialization) with explicit GC-safety implications. The repo's own review guidance flags exactly this class of change: running user JS while holding raw cells, rooting via MarkedArgumentBuffer, and RETURN_IF_EXCEPTION discipline after every call that can enter JS. The exception checks look complete and the new rooting mirrors the existing pattern in errorInstanceLazyStackCustomGetter, but the correctness of the finalizer-path guard (errorInstance is always null when called from finalizeUnconditionally, so getErrorNameAndMessage is unreachable there; asserted via vm.heap.worldIsRunning()) and whether every caller of computeErrorInfoWithoutPrepareStackTrace now has its frames rooted deserve a human eye.

Other factors

The bug-hunting system found nothing. Test coverage is thorough (11 header variants including null/undefined/numeric coercion, accessor properties, prototype chain, and the prepareStackTrace interaction), and the PR description documents a regression sweep across adjacent suites. The refactored helpers are behavior-preserving extractions of existing code. Still, this is not a mechanical change — it alters observable behavior of a core API and adds two new sites where user code runs during native formatting, so I'm deferring rather than approving.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

whether every caller of computeErrorInfoWithoutPrepareStackTrace now has its frames rooted

Worth answering concretely, so here is every path that reaches the formatter holding a live Vector<StackFrame>, and what keeps the frames alive across it. Line numbers are 55b7d4b.

entry point frames what protects them
computeErrorInfoWrapperToString (vm.onComputeErrorInfo) m_stackTrace caller ErrorInstance::computeErrorInfo holds DeferGCForAWhile (ErrorInstance.cpp:371)
computeErrorInfoWrapperToJSValue (vm.onComputeErrorInfoJSValue) m_stackTrace caller ErrorInstance::materializeErrorInfoIfNeeded holds DeferGCForAWhile (ErrorInstance.cpp:412)
errorInstanceLazyStackCustomGetter, no stack emptyTrace empty vector, nothing to root
errorInstanceLazyStackCustomGetter, with stack *ownedStackTrace protectStackFrameCells (:767), pre-existing
errorConstructorFuncCaptureStackTrace, materialized ErrorInstance stackTrace protectStackFrameCells (:820), new
errorConstructorFuncCaptureStackTrace, non-ErrorInstance stackTrace protectStackFrameCells (:847), new
errorConstructorFuncCaptureStackTrace, fresh ErrorInstance moved out setStackFrames transfers them, no formatting
jsFunctionDefaultErrorPrepareStackTrace none takes a JSArray of CallSites; dynamicDowncast<ErrorInstance>-guards its first argument

So every path either roots the frames, runs under DeferGCForAWhile, passes an empty vector, or holds no raw frames at all. The two JSC-side entry points were already GC-deferred, which is why only the two captureStackTrace sites needed the new protectStackFrameCells calls.

The same table answers where user code can run. getErrorNameAndMessage only does property reads when the target is not an ErrorInstance; the first two rows always pass an ErrorInstance (this) or nullptr, and both take the sanitizedNameString/sanitizedMessageString early return, which runs no getters. A plain object therefore reaches the reads from exactly one place, errorConstructorFuncCaptureStackTrace, on the mutator, with its frames rooted. That is what ASSERT(vm.heap.worldIsRunning()) pins down.

Happy to split the protectStackFrameCells extraction into its own commit if that makes the memory-safety delta easier to read separately from the header change.

@robobun
robobun force-pushed the farm/eff7dc62/capture-stack-trace-header branch from 55b7d4b to c2c0959 Compare July 6, 2026 12:44

@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 didn't find any issues, but this touches JSC GC rooting and introduces user-code execution (name/message getters) into the stack-trace formatting path — the finalizer-invariant and frame-rooting analysis look sound, but a maintainer familiar with JSC internals should confirm.

Extended reasoning...

Overview

This PR changes src/jsc/bindings/FormatStackTraceForJS.cpp so that the first line of a formatted stack trace derives from the target object's name and message properties (following V8's ErrorUtils::ToString), rather than a hardcoded "Error". It factors the header logic into getErrorNameAndMessage / appendErrorNameAndMessage, factors the pre-existing frame-cell rooting into protectStackFrameCells, and applies that rooting to two additional sites in errorConstructorFuncCaptureStackTrace now that formatting can run user getters. Four new tests in capture-stack-trace.test.js assert values taken from Node v26.3.0.

Security risks

None in the traditional sense (no auth, crypto, network, or filesystem surface). The relevant risk class here is memory safety: the change deliberately introduces property reads (getIfPropertyExists + toWTFString) on arbitrary user objects into a path that holds a raw Vector<StackFrame> whose cells the GC does not scan. The PR addresses this by rooting those cells in a MarkedArgumentBuffer before the reads, and by asserting vm.heap.worldIsRunning() at the one site where the reads occur. Exception scopes (RETURN_IF_EXCEPTION) are placed after every fallible call.

Level of scrutiny

High. This is C++ in the JSC bindings layer, and the repository's own review guidance calls out GC rooting, "anything that can run user JS can synchronously free your state", and finalizer-path constraints as the most-blocked category. The correctness of this change hinges on a non-local invariant — that the finalizer path always reaches computeErrorInfoWithoutPrepareStackTrace with errorInstance == nullptr, and that materializeErrorInfoIfNeeded always passes an ErrorInstance — which the author has traced and asserted but which a maintainer should independently confirm against current JSC.

Other factors

The PR description and follow-up comments are unusually thorough: every call path to the formatter is tabulated with what protects its frames, the change was stress-tested (40k iterations + Bun.gc() under debug+ASAN), and the bug-hunting system found nothing. The behavioral change itself (V8-compatible header formatting) is straightforward and well-tested. My hesitation is purely about the memory-safety delta in a subsystem where subtle mistakes become UAFs — that warrants a human sign-off rather than bot approval.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Status: rebased, diff is green, CI red is one slow darwin agent

Ready for a maintainer. Rebased onto main at 16c55763 (151 commits) as 9d183d1; see the rebase note for the one semantic interaction with #34104.

Build #72736 (9d183d1, final): 285 jobs passed, 0 failed, 1 timed out. Of the 230 test-bun lanes, 229 passed and 1 timed out. robobun/evidence confirms the test fails without the fix and passes with it on ASAN and release.

The one not-passed lane is :darwin: 14 x64 - test-bun shard 1 on agent darwin-x64-mini-2-1, which timed out at the job level (exit 3) after accumulating dev-server-test timeouts. Every [error] annotation is on that one lane:

test outcome relation to this diff
test/js/node/v8/capture-stack-trace.test.js 44 pass / 1 fail on that lane; passed on all 229 others the one failure is the pre-existing WebSocket test at line 928, not any of the 4 tests this PR adds. Received: "" after 25.9s: the spawned subprocess produced no output.
test/bake/dev/bundle.test.ts 60s timeouts, "killed 1 dangling process" dev-server tests, untouched by this PR
test/bake/dev/ecosystem.test.ts code 1 same
test/bake/dev/hot.test.ts 60s timeouts same

The job log also shows failed to fetch traces: TypeError: fetch failed, so the box had network trouble. None of these annotate on any other lane in this build, and none annotate on main's last four builds.

This PR's own tests (four new ones + the updated #34104 assertion) pass on every lane including that one, which is why the 14 x64 run is 44/45 and not 40/45.

I've already used one re-run on this PR (on the earlier darwin-aarch64 artifact-download timeout, which is now resolved), so I won't push another no-op; the linked build is the evidence. Happy to rebase or re-push if a fresh run is wanted.

Review state

Four automated reviews, no findings. The GC-rooting and finalizer-path questions they flag for a human are answered in-thread: the invariant is ASSERT(vm.heap.worldIsRunning()) at the one site that depends on it, and every path into the formatter is tabulated with what keeps its frames alive. Happy to split protectStackFrameCells into its own commit if that reads better.

The first line of a generated stack trace was composed from a hardcoded
"Error" in two places:

- computeErrorInfoWithoutPrepareStackTrace only read name/message when
  the target was an ErrorInstance, so Error.captureStackTrace(plainObject)
  always produced "Error" and dropped the object's message.
- formatStackTraceToJSValue, which builds the default-formatted string
  handed to Error.prepareStackTrace, hardcoded the name, so a TypeError
  showed up as "Error: boom" there.

Both now go through one helper that follows V8's ErrorUtils::ToString:
an undefined name means "Error", an undefined message means the empty
string, the two are joined with ": " only when both are non-empty, and
for a non-ErrorInstance target they are read off the object itself.

Reading those properties can run a getter, which can collect the cells a
StackFrame holds, so the captured frames are rooted across formatting the
same way the lazy .stack getter already does.
@robobun
robobun force-pushed the farm/eff7dc62/capture-stack-trace-header branch from c2c0959 to 9d183d1 Compare July 14, 2026 04:34
@robobun

robobun commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (151 commits, 9d183d1). The textual conflict was trivial (both #34104 and this PR appended a test at EOF) and both survive, but there is one real interaction worth calling out.

#34104's test assertion is updated (to match Node)

#34104 fixed a crash when the onComputeErrorInfoJSValue hook throws before the default stack string is computed, and its test asserted the specific output for this repro:

Error.prepareStackTrace = (e, s) => "custom-stack";
const e = new Error("x");
Object.defineProperty(e, "message", { get() { throw new TypeError("msg-boom"); } });
before this PR after this PR Node v26.3.0
first e.stack throws msg-boom "custom-stack" "custom-stack"
second typeof e.stack "undefined" "string" "string"
getter invoked yes no no

The reason: before this PR, formatStackTraceToJSValue read .message via a full getIfPropertyExists, invoking the throwing getter. This PR routes an ErrorInstance through the sanitized accessors (sanitizedNameString/sanitizedMessageString), which are VMInquiry and skip accessors, exactly as V8 does when composing that header. So the hook no longer throws for this specific input, prepareStackTrace runs, and .stack is the string it returned. That matches Node.

The test's purpose is unchanged: it still spawns a subprocess and asserts signalCode: null and exitCode: 0, which is the regression guard for #34095. The expected output now encodes the Node-matching values. #34104's if (!result) return jsUndefined() safeguard stays; it is still reachable when the sanitized accessors themselves throw (e.g. e.name = Symbol(), where toWTFString on a Symbol throws), and that case exits 0 without crashing under this build.

45/45 tests pass with the fix, 5 fail against main's src/ (my 4 plus the updated #34104 assertion).

@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 bugs found, but this touches GC-sensitive JSC C++ (new user-code execution paths during stack formatting, MarkedArgumentBuffer rooting of StackFrame cells, finalizer-adjacent invariants) and rewrites an existing test's expected output — worth a maintainer's eyes.

What was reviewed:

  • getErrorNameAndMessage: exception checks after each getIfPropertyExists/toWTFString; ErrorInstance still routes through sanitized (VMInquiry) accessors so the finalizer/materialize paths run no user JS.
  • protectStackFrameCells extraction is byte-equivalent to the pre-existing block in errorInstanceLazyStackCustomGetter; the two new call sites in captureStackTrace root frames before computeErrorInfoToJSValue can allocate/call into JS.
  • The updated #34104 test still guards the original crash (signalCode: null, exitCode: 0); only the stdout expectation changed to the Node-matching value.
Extended reasoning...

Overview

Two files: src/jsc/bindings/FormatStackTraceForJS.cpp (~100 lines net) and test/js/node/v8/capture-stack-trace.test.js (4 new tests, 1 updated assertion). The C++ change factors stack-header composition into getErrorNameAndMessage/appendErrorNameAndMessage, extends it to read name/message off non-ErrorInstance targets via full property lookups (matching V8's ErrorUtils::ToString), and factors the existing frame-cell rooting into protectStackFrameCells, applying it at two additional captureStackTrace sites now that formatting can run getters.

Security risks

None identified. The new property reads are on the caller's own object argument to Error.captureStackTrace, exception-checked, and don't widen any trust boundary.

Level of scrutiny

High. This is JSC bindings C++ in the exact category CLAUDE.md flags as most-blocked: it introduces a path where user JS can run (getIfPropertyExists + toWTFString on a plain object) while raw StackFrame cells are held, adds GC rooting to compensate, and relies on a finalizer-path invariant (the GC's onComputeErrorInfo never passes a non-ErrorInstance) now enforced only by a debug ASSERT. The author's entry-point table and 40k-iteration ASAN+GC stress run are convincing, but a maintainer familiar with ErrorInstance::finalizeUnconditionally / DeferGCForAWhile semantics should confirm the rooting story and the worldIsRunning() assertion.

Other factors

The PR also changes the expected output of #34104's regression test — the new value matches Node and the crash guard is preserved, but rewriting a just-merged test's assertions is exactly the kind of thing a human should sign off on. The author explicitly flagged this PR as "ready for a maintainer" in the thread.

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