Skip to content

Error.appendStackTrace: fix abort with unset stackTraceLimit, assertion on materialized errors, and self-append use-after-free - #37370

Open
robobun wants to merge 7 commits into
mainfrom
farm/39f35b50/append-stack-trace-abort
Open

Error.appendStackTrace: fix abort with unset stackTraceLimit, assertion on materialized errors, and self-append use-after-free#37370
robobun wants to merge 7 commits into
mainfrom
farm/39f35b50/append-stack-trace-abort

Conversation

@robobun

@robobun robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Consolidates the four open fixes for Error.appendStackTrace (errorConstructorFuncAppendStackTrace in src/jsc/bindings/FormatStackTraceForJS.cpp) into one PR. This branch started as the stackTraceLimit fix and now also carries the cases from #35100, #34713 and #32098, which are closed in favour of it.

Problem

Three ways to crash Error.appendStackTrace(source, destination), all in the same ten lines:

  • Error.stackTraceLimit set to a non-number (or deleted) and a destination with no frames: ErrorInstance::captureStackTrace() does globalObject->stackTraceLimit().value() on an empty optional. Bun is built without exceptions, so that is a silent abort() (panic(main thread): abort() called on release builds, nothing at all under ASAN). Same class as captureStackTrace: don't abort when Error.stackTraceLimit is non-numeric #29388, which fixed the identical .value() in Error.captureStackTrace. Reported as Error.appendStackTrace: don't abort when stackTraceLimit is unset #35100 and here.
  • Destination whose .stack (or .line / .column / .sourceURL) has already been read: materializing discards the frames and sets m_errorInfoMaterialized, so the !destination->stackTrace() branch captures a fresh trace at the appendStackTrace call site, appends the source's frames to it and empties the source. The destination's .stack string is unaffected, but Bun's native error printer (ZigException.cpp, fromErrorInstance) prefers an error's frames to its .stack, so console.error(destination) / Bun.inspect(destination) then show the call site and the source's frames instead of the destination's own, and source.stack becomes undefined. On debug builds the next GC that finalizes the destination additionally trips ASSERTION FAILED: !m_errorInfoMaterialized in ErrorInstance::computeErrorInfo (ErrorInstance.cpp:368). Reported as fix(error): don't capture stack trace on materialized ErrorInstance in appendStackTrace #34713 and here.
  • source === destination: destination->stackTrace()->appendVector(*source->stackTrace()) appends a vector to itself. appendVector goes through the std::span<const T> overload of append, whose expandCapacity(size_t, U*) does not rebase a pointer into the buffer it just freed, so once the trace has enough frames to reallocate the copy reads freed memory (heap-use-after-free in errorConstructorFuncAppendStackTrace, visible under ASAN with Malloc=1). The clear() that follows then empties the trace and .stack becomes undefined on every build. Reported as Fix use-after-free in Error.appendStackTrace when source is destination #32098.

Fix

  • source === destination returns early, leaving the trace as it was. Appending a trace to itself and then clearing it has no useful meaning, and this is the only way to keep the existing frames.
  • A materialized destination makes the call a no-op: the destination's frames are already gone, so there is nothing to append to, and installing frames at this point only desyncs what the printer shows from what .stack says (and asserts on debug). Leaving the source untouched as well is deliberate; Bun__attachAsyncStackFromPromise (AsyncStackTrace.cpp) bails out of materialized errors for the same reason. (Error.captureStackTrace handles the materialized case differently, by recomputing .stack eagerly; that is the right thing for a fresh capture but not for an append, which has no destination frames left to put in front of the source's.)
  • A destination with no frames gets an empty frame list instead of a capture when stackTraceLimit() is empty. An error constructed in that state has no frames of its own anyway (getStackTrace returns null when the limit is unset), but the source's frames are still appended, so destination.stack shows them. This is why the empty list is used rather than Error.appendStackTrace: don't abort when stackTraceLimit is unset #35100's approach of skipping the append as well.
  • Verified with bun bd test test/js/node/v8/capture-stack-trace.test.js (48 pass). Without the FormatStackTraceForJS.cpp change, four of the five appendStackTrace tests fail on both profiles: on the release build (USE_SYSTEM_BUN=1) the unset-limit test aborts, the self-append test reports zero frames, and the two materialized tests report the printer showing the appendAll call site and the sources losing their traces; on a debug + ASAN build the unset-limit and materialized tests abort and the self-append test gets the ASAN use-after-free. The basic two-error test pins existing behaviour and passes on both.

Tests

All in test/js/node/v8/capture-stack-trace.test.js, next to the existing non-numeric stackTraceLimit test. Everything that can abort runs in a child process.

Background

  • ErrorInstance keeps a native Vector<StackFrame> until something reads .stack (or line/column/sourceURL); at that point the frames are formatted into properties and dropped, and m_errorInfoMaterialized records that this happened. GC's finalizeUnconditionally also formats and drops frames early when one of them points at code that is about to be collected, which is the path that asserts if frames exist after materialization.
  • JSGlobalObject::stackTraceLimit() is a std::optional<unsigned>; assigning a non-number to Error.stackTraceLimit or deleting it stores nullopt, and errors created while it is unset get no frames at all.
  • Bun prints errors (console.error, Bun.inspect, uncaught errors) through ZigException.cpp, which uses the error's native frames when it still has any and only falls back to parsing the .stack string otherwise; that is why frames installed after materialization are visible even though .stack never changes.
  • Error.appendStackTrace is a Bun-specific, Private-visibility helper installed on the Error constructor (ZigGlobalObject.cpp); it is reachable from user code, which is how the fuzzer hit all three cases.

CI

Latest head is c7a06b3 (comments condensed, no code change since bf028d9). Build #94462: 176 of 179 jobs passed, including every build lane and the x64 ASAN test lanes. The three that did not pass never started: the two darwin 14 aarch64 - test-bun jobs are still waiting for an agent (same as on the two previous builds) and one windows 2019 x64 - test-bun shard failed to get an agent created; the other shards of that lane passed. The yellow entries (child_process ipc handle, fs read stream, napi, bun-patch, cpu-prof, watch mode, cluster) passed on retry or alone and none of them touch Error.appendStackTrace.

Nothing in this change is platform specific, so I am not retriggering for those lanes. Locally, starting from a build directory that predates the main merges, the tree builds cleanly both with and without the FormatStackTraceForJS.cpp change; without it 4 of the 5 appendStackTrace tests fail, with it the file passes 48/48.


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

fails on main (without fix)
ASAN without fix: 4 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 (96470ad2e)

test/js/node/v8/capture-stack-trace.test.js:
(pass) Regular .stack [15.85ms]
(pass) throw inside Error.prepareStackTrace doesnt crash [7.43ms]
(pass) capture stack trace [10.09ms]
(pass) capture stack trace with message [11.24ms]
(pass) capture stack trace with constructor [7.14ms]
(pass) capture stack trace limit [29.16ms]
(pass) prepare stack trace [15.72ms]
(pass) capture stack trace second argument [20.89ms]
(pass) capture stack trace edge cases [14.14ms]
(pass) Error.captureStackTrace installs .stack as non-enumerable [26.12ms]
(pass) prepare stack trace call sites [12.87ms]
(pass) sanity check [15.59ms]
(pass) CallFrame isEval works as expected [9.57ms]
(pass) CallFrame isTopLevel returns false for Function constructor [10.84ms]
(pass) CallFrame.p.getThisgetFunction: strict/sloppy mode interaction [11.41ms]
(pass) CallFrame.p.isConstructor [6.40ms]
(pass) CallFrame.p.isNative [4.32ms]
(pass) return non-strings from Error.prepareStackTrace [4.78ms]
(pass) C
... (truncated)

release without fix: 2 FAILED
bun test v1.4.0-canary.1 (da3851e57)

test/js/node/v8/capture-stack-trace.test.js:
(pass) Regular .stack [0.19ms]
(pass) throw inside Error.prepareStackTrace doesnt crash [0.12ms]
(pass) capture stack trace [0.07ms]
(pass) capture stack trace with message [0.07ms]
(pass) capture stack trace with constructor [0.09ms]
(pass) capture stack trace limit [0.29ms]
(pass) prepare stack trace [0.12ms]
(pass) capture stack trace second argument [0.20ms]
(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.16ms]
(pass) sanity check [0.12ms]
(pass) CallFrame isEval works as expected [0.10ms]
(pass) CallFrame isTopLevel returns false for Function constructor [0.13ms]
(pass) CallFrame.p.getThisgetFunction: strict/sloppy mode interaction [0.12ms]
(pass) CallFrame.p.isConstructor [0.04ms]
(pass) CallFrame.p.isNative [0.03ms]
(pass) return non-strings from Error.prepareStackTrace [0.04ms]
(pass) CallFrame.p.toString [0.03ms]
(pass) err.stack should invoke prepareStackTrace [0.25ms]
(pass) Error.prepareStackTrace inside a node:vm works [2.45ms]
(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 (96470ad2e)

test/js/node/v8/capture-stack-trace.test.js:
(pass) Regular .stack [18.88ms]
(pass) throw inside Error.prepareStackTrace doesnt crash [9.31ms]
(pass) capture stack trace [8.91ms]
(pass) capture stack trace with message [8.74ms]
(pass) capture stack trace with constructor [6.10ms]
(pass) capture stack trace limit [23.44ms]
(pass) prepare stack trace [13.41ms]
(pass) capture stack trace second argument [18.68ms]
(pass) capture stack trace edge cases [12.79ms]
(pass) Error.captureStackTrace installs .stack as non-enumerable [23.39ms]
(pass) prepare stack trace call sites [12.61ms]
(pass) sanity check [13.97ms]
(pass) CallFrame isEval works as expected [8.08ms]
(pass) CallFrame isTopLevel returns false for Function constructor [8.85ms]
(pass) CallFrame.p.getThisgetFunction: strict/sloppy mode interaction [9.58ms]
(pass) CallFrame.p.isConstructor [4.62ms]
(pass) CallFrame.p.isNative [3.39ms]
(pass) return non-strings from Error.prepareStackTrace [3.74ms]
(pass) CallF
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 924ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/131] 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/131] gen cpp.rs (cppbind)
[3/131] gen JS modules (bundle-modules)
Preprocess modules (12488ms)
Bundle modules (164ms)
Postprocesss modules (984ms)
Bundle Functions (2465ms)
Generate Code (35ms)

[16.16s] Bundled "src/js" for production
  2626 kb
  197 internal modules
  13 native modules
  91 internal functions across 17 files
[3/130] 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_paths v0.0.0 (/workspace/bun/src/paths)
^[[1m^[[92m   Compiling^[[0m bun_sys v0.0.0 (/workspace/bun/src/sys)
^[[1m^[[92m   Compiling^[[0m bun_url v0.0.0 (/workspace/bun/src/url)
^[[1m^[[92m   Compiling^[[0m bun_http_types v0.0.0 (/workspace/bun/src/http_types)
^[[1m^[[92m   Compiling^[[0m bun_perf v0.0.0 (/workspace/bun/src/perf)
^[[1m^[[92m   Comp
... (truncated)
diff hotspot
src/jsc/bindings/FormatStackTraceForJS.cpp  |  17 +++-
 test/js/node/v8/capture-stack-trace.test.js | 151 +++++++++++++++++++++++++++-
 2 files changed, 166 insertions(+), 2 deletions(-)

gate history · 2 passed · 0 rejected · iteration 2

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

root cause · written by the author bot

Error.appendStackTrace assumed a happy path that user code can violate: appending an error's frames to itself passed appendVector a span into the same buffer being grown, so reallocation left a dangling pointer and a use after free; appending onto an error whose stack had already been materialized tripped a debug assertion when new frames were installed; and when Error.stackTraceLimit was not a number the empty stackTraceLimit() optional was unwrapped unconditionally and aborted. The fix adds three guards in the binding: it returns early when source and destination are the same obje…

… a number

ErrorInstance::captureStackTrace() calls .value() on the global's
stackTraceLimit optional, which is empty once Error.stackTraceLimit has
been set to a non-number or deleted. With -fno-exceptions that is a
plain abort(). Give the destination an empty frame list in that state
instead of capturing.

Also return early when the destination's error info has already been
materialized: its frames were discarded and are never read again, and
installing new ones trips ASSERT(!m_errorInfoMaterialized) in
computeErrorInfo during GC.
@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: 1 minute

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: 8689cca2-4528-4c8c-8902-fe026e84bc58

📥 Commits

Reviewing files that changed from the base of the PR and between b7a0431 and c7a06b3.

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

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:42 AM PT - Aug 13th, 2026

@robobun, your commit c7a06b3 has 1 failures in Build #94462 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 37370

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

bun-37370 --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.

LGTM — two narrow guards in errorConstructorFuncAppendStackTrace that mirror the same handling already in errorConstructorFuncCaptureStackTrace.

What was reviewed:

  • setStackFrames(vm, {}) produces a non-null empty vector (makeUnique<Vector<StackFrame>>), so the following destination->stackTrace()->appendVector(...) is safe and the source's frames still land — the first test's appended: true assertion pins this.
  • The materialized-destination early return is not a release behavior change: once .stack is a data property the installed frames were never read anyway; this only removes the debug assertion and wasted work.
  • Tests follow harness conventions (subprocess with bunEnv, concurrent stdout/stderr/exited drain, combined-object assertion, test.concurrent).
Extended reasoning...

Overview

Two guards added to errorConstructorFuncAppendStackTrace in src/jsc/bindings/FormatStackTraceForJS.cpp:

  1. Early-return when destination->hasMaterializedErrorInfo() — once .stack is materialized the frames are discarded and m_errorInfoMaterialized is set; re-installing frames only trips ASSERT(!m_errorInfoMaterialized) in computeErrorInfo during finalizeUnconditionally. errorConstructorFuncCaptureStackTrace already special-cases this exact state a few lines below.
  2. Guard captureStackTrace() on globalObject->stackTraceLimit() being non-empty — ErrorInstance::captureStackTrace unwraps the optional with .value(), which is abort() under -fno-exceptions when Error.stackTraceLimit is non-numeric or deleted. The fallback setStackFrames(vm, {}) unconditionally does makeUnique<Vector<StackFrame>>, so stackTrace() is non-null afterward and the subsequent appendVector on the source's frames still runs.

Two subprocess regression tests are added next to the existing non-numeric stackTraceLimit test in test/js/node/v8/capture-stack-trace.test.js.

Security risks

None. No untrusted input parsing, no allocation-size arithmetic, no new ownership. Both guards are pure short-circuits on existing state.

Level of scrutiny

Medium — this is JSC bindings C++, but the change is two conditionals that each mirror an existing pattern in the sibling function in the same file (hasMaterializedErrorInfo() branch, stackTraceLimit().value_or(...)). No new GC roots, no pointers held across JS re-entry, no exception-scope changes.

Other factors

  • The PR description is precise about the mechanism (bad_optional_accessabort() under -fno-exceptions; finalizeUnconditionally tripping the materialized assertion) and honest that the second test only fails on debug builds.
  • The no-op-when-materialized choice was checked for release-build behavior regression: after materialization .stack is already a data property, so the frames the old code installed were dead — no observable change.
  • Tests use test.concurrent, drain pipes concurrently, assert the combined {stdout, stderr, exitCode} object, and the first test asserts the positive contract (appended: true) rather than just "didn't crash".
  • No CODEOWNERS entry covers these paths.

…and unset-limit cases

Appending an error's trace to itself made Vector::appendVector copy out of
the buffer it had just reallocated and then clear() wiped the trace. Adds
tests for the self-append, for destinations materialized through
.sourceURL, and for Error.stackTraceLimit = undefined.
Comment on lines +671 to +673
// Appending a trace to itself would make appendVector copy out of the buffer
// it just reallocated (the span overload does not rebase the source
// pointer), and the clear() below would then wipe the trace.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +678 to +680
// Once .stack is materialized the frames are discarded and never read again;
// installing new ones only trips ASSERT(!m_errorInfoMaterialized) in
// computeErrorInfo when GC finalizes the error.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +686 to +687
// ErrorInstance::captureStackTrace() unwraps stackTraceLimit(), which is
// empty once Error.stackTraceLimit has been set to a non-number or deleted.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@robobun robobun changed the title Error.appendStackTrace: don't abort when Error.stackTraceLimit is not a number Error.appendStackTrace: fix abort with unset stackTraceLimit, assertion on materialized errors, and self-append use-after-free Aug 13, 2026

@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. The three guards look correct and mirror the existing hasMaterializedErrorInfo() handling in errorConstructorFuncCaptureStackTrace a few lines below. Because it touches GC-sensitive JSC bindings and comment-cop has flagged the three new multi-line comments (they read as invariant explanations rather than workaround justifications, but that's a maintainer call), a human look would still be worthwhile.

What was reviewed:

  • stackTraceLimit() guard — confirmed ErrorInstance::captureStackTrace unconditionally calls .value() on the optional; setStackFrames(vm, {}) gives a non-null vector so the append below still runs.
  • hasMaterializedErrorInfo() early-return — same pattern as captureStackTrace; frames installed post-materialization are only ever seen by finalizeUnconditionally.
  • source == destination — verified appendVector's span overload doesn't rebase after grow, and clear() would then wipe the trace; no-op is the right semantics.
  • Tests — each abort case runs in a spawned child, drains stdout/stderr/exited concurrently, and asserts a positive observable (frame count / appended: true) rather than just "didn't crash".
Extended reasoning...

Overview

Adds three early-return guards to errorConstructorFuncAppendStackTrace in src/jsc/bindings/FormatStackTraceForJS.cpp (~20 lines) and five tests in test/js/node/v8/capture-stack-trace.test.js. The guards prevent: (1) abort() from unwrapping an empty stackTraceLimit() optional, (2) a debug assertion in computeErrorInfo when frames are installed on an already-materialized error, and (3) a use-after-realloc + self-clear when source and destination are the same ErrorInstance.

Security risks

None. Error.appendStackTrace is a Bun-specific API operating on already-validated ErrorInstance objects (both arguments are dynamicDowncast-checked before the new code runs). No untrusted lengths, no allocation sizing, no string parsing.

Level of scrutiny

High — this is native JSC binding code with GC interactions (setStackFrames, materialization state, finalizeUnconditionally). The change is small and each guard mirrors an existing pattern in the same file, but the failure modes it addresses (silent SIGABRT, GC-time assertion) are exactly the kind where a subtle mistake surfaces far from the change site.

Other factors

  • Correctness: All three guards check state that's already validated by the type check above them; none can enter user JS or throw, so no new exception-scope concerns. setStackFrames(vm, {}) is used identically in errorInstanceLazyStackCustomGetter and errorConstructorFuncCaptureStackTrace.
  • Tests: Thorough — a happy-path test plus four subprocess tests covering each guard, including the .sourceURL materialization path and an ASAN-specific Malloc=1 config for the self-append case. All use test.concurrent, drain pipes with Promise.all, and assert combined {stdout, stderr, exitCode} objects.
  • Outstanding feedback: comment-cop (github-actions bot) flagged all three new multi-line comments with the "paragraph-long comment justifying a workaround" rule. The comments explain why each state is unsafe to proceed with (appendVector span rebasing, m_errorInfoMaterialized assertion, empty optional unwrap) — these are the fix, not workarounds, and the neighboring captureStackTrace has an equivalent 4-line comment for the same materialization case. Whether to trim them is a maintainer style call I'm not making unilaterally.

Deferring rather than approving because JSC/GC-sensitive native code plus unaddressed automated style feedback warrants a human sign-off.

@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 — three straightforward guards that turn demonstrated crashes into safe no-ops, each mirrored by a subprocess test that fails on main.

What was reviewed:

  • The source == destination and hasMaterializedErrorInfo() early returns — both convert a crash into a no-op; the materialized check mirrors the existing one in errorConstructorFuncCaptureStackTrace a few lines down.
  • The stackTraceLimit() fallback: setStackFrames(vm, {}) leaves stackTrace() non-null so the subsequent appendVector still lands the source frames — the non-numeric-limit test's appended: true assertion pins that.
  • The comment-cop bot flags look stale: the current guard comments are single-line (addressed in 96470ad2).
Extended reasoning...

Overview

Three additive guards in errorConstructorFuncAppendStackTrace (src/jsc/bindings/FormatStackTraceForJS.cpp), each fixing a distinct user-reachable crash in Error.appendStackTrace: self-append UAF via appendVector on a reallocated buffer, a debug assertion when frames are installed on an already-materialized ErrorInstance, and an abort() from unwrapping an empty stackTraceLimit() optional. Five new tests in test/js/node/v8/capture-stack-trace.test.js cover a positive baseline plus each failure mode in a spawned child.

Security risks

None. Error.appendStackTrace is a Bun-private helper on the Error constructor; the guards add early returns and a fallback branch with no new allocations, pointer lifetimes, or GC-visited state. Each change strictly narrows the reachable state space relative to the prior code.

Level of scrutiny

C++ JSC bindings normally warrant elevated scrutiny, but this diff is ~15 additive lines of guard logic inside one function, following the exact pattern already used by errorConstructorFuncCaptureStackTrace for the materialized case. No existing behaviour is removed; every path that previously crashed now returns early or takes a safe fallback. The setStackFrames(vm, {}) fallback is exercised end-to-end by the non-numeric-limit test asserting the source frames still land in the destination, so stackTrace() is demonstrably non-null afterward.

Other factors

The mechgate evidence shows the new tests fail on unfixed debug+ASAN and release builds and pass with the fix, satisfying the fails-for-the-right-reason requirement. Tests follow harness conventions: test.concurrent subprocess spawns, concurrent stdout/stderr/exited drain, combined {stdout, stderr, exitCode} assertions, bunEnv spread, and an isASAN-gated Malloc=1 env for the UAF repro. The three comment-cop bot comments flagging long comments are at outdated positions (line 0) and the head commit 96470ad2 ("shorten the guard comments") already reduced each to a single line, matching the repo's one-line comment rule. No human review comments are outstanding.

…alized no-op

The native error printer prefers an error's frames over its .stack string,
so appending onto a materialized destination used to make it print the
appendStackTrace call site and empty the source on every build, not only
trip the assertion on debug builds. The materialized tests now check the
printed frames and that the source keeps its trace, the basic test pins the
frame order and that the source is consumed, and the guard's comment says
what it is actually protecting.
Comment on lines +676 to +679
// Once .stack is materialized the frames are gone. Installing new ones would make the native error
// printer (which prefers frames over .stack) show this call site plus the source's frames, and trip
// computeErrorInfo's !m_errorInfoMaterialized assertion when GC finalizes the error. Leave both
// errors alone instead, as Bun__attachAsyncStackFromPromise does.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed bf028d9 after a second look at the materialized case. The "frames are never read again" rationale was not right: ZigException.cpp prints an error from its native frames whenever it has any, so on release builds the old code made console.error(destination) / Bun.inspect(destination) show the appendStackTrace call site plus the source's frames, and emptied the source. The no-op is still the right contract (there are no destination frames left to append to, and Bun__attachAsyncStackFromPromise bails out of materialized errors the same way), so the code is unchanged apart from the comment. The two materialized tests now assert that release-visible side (printed frames, sources keeping their traces), which also makes them fail on an unfixed release build instead of only on the ASAN lane, and the basic test pins frame order and the source being consumed. Description updated to match; 48/48 locally.

@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 — three targeted early-return guards fixing three documented crashes in Error.appendStackTrace, each following an existing pattern in the same file.

What was reviewed:

  • source == destination guard: pointer equality on the two dynamicDowncast results correctly detects self-append; avoids the appendVector UAF and the subsequent clear().
  • hasMaterializedErrorInfo() guard: matches Bun__attachAsyncStackFromPromise and the captureStackTrace handling ~120 lines below; no-op is the only coherent semantics here since materialized frames can't be appended to.
  • stackTraceLimit() fallback: setStackFrames(vm, {}) is already used elsewhere in this file and leaves stackTrace() non-null so the following appendVector still runs — verified by the test asserting appended: true.
  • Tests cover happy path plus all three crash modes, subprocess-isolated with ASAN Malloc=1 for the UAF case; fails-without-fix / passes-with-fix confirmed on both debug+ASAN and release.

The comment-cop flag on the 4-line comment at L676-679 looks like a heuristic false positive — it explains a non-obvious GC-finalizer assertion and is the same length/style as the existing comment for the identical check in errorConstructorFuncCaptureStackTrace.

Extended reasoning...

Overview

Adds three early-return guards to errorConstructorFuncAppendStackTrace in src/jsc/bindings/FormatStackTraceForJS.cpp (~17 lines) and five tests in test/js/node/v8/capture-stack-trace.test.js (~190 lines). Each guard prevents a distinct user-reachable crash: self-append use-after-free in WTF::Vector::appendVector, a debug assertion in ErrorInstance::computeErrorInfo when frames are installed on an already-materialized error, and an abort() from unwrapping an empty std::optional when Error.stackTraceLimit is non-numeric.

Security risks

None. Error.appendStackTrace is a Bun-private helper on the Error constructor that manipulates in-process stack-frame vectors. The change strictly narrows the reachable code paths (three new early returns) and adds no new allocation, no new GC roots, and no new user-observable state. The self-append guard closes a heap use-after-free that was previously reachable from user JS.

Level of scrutiny

This is JSC bindings C++, which is memory-safety territory, but the change is three defensive early returns that each mirror an existing pattern in the same file: hasMaterializedErrorInfo() is already checked in errorConstructorFuncCaptureStackTrace and Bun__attachAsyncStackFromPromise; setStackFrames(vm, {}) is already used in errorInstanceLazyStackCustomGetter; and the stackTraceLimit() optional check mirrors the .value_or() fix from #29388. No new state, no changed ownership, no reordered lifecycle. I traced that setStackFrames(vm, {}) leaves stackTrace() non-null (the non-numeric-limit test's appended: true assertion would fail or the child would crash on the appendVector deref otherwise, and it passes on debug+ASAN).

Other factors

The tests are thorough: a happy-path baseline, the three crash reproductions each subprocess-isolated (with Malloc=1 + ASAN options for the UAF), and a GC-stress variant for the materialized case via .sourceURL. The PR body and the robobun evidence block show fails-without-fix (4 on ASAN, 2 on release) and passes-with-fix on both build profiles. CI on the prior head had 177/177 jobs passing with only darwin-aarch64 lanes unscheduled. The outstanding comment-cop flag on line 679 is a style-linter heuristic, not a correctness concern — the flagged comment documents a GC-finalizer hazard and matches the existing comment on the same check in captureStackTrace. The behavioral choice to no-op on a materialized destination (rather than eagerly recompute like captureStackTrace does) is the only coherent option for an append operation, since the destination's frames no longer exist to append onto.

@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 — three additive early-return guards in errorConstructorFuncAppendStackTrace, each following an existing pattern in the same file or AsyncStackTrace.cpp, with thorough child-process tests verified to fail without the fix on both release and ASAN.

What was reviewed:

  • Self-append guard is a pointer-equality check on the ErrorInstance* from both dynamicDowncast calls; prevents the appendVector self-span UAF and the trailing clear().
  • Materialized-destination no-op matches Bun__attachAsyncStackFromPromise (AsyncStackTrace.cpp:163); leaving the source untouched is intentional and asserted by the tests.
  • stackTraceLimit() fallback installs an empty frame vector via setStackFrames(vm, {}) so the source's frames still append — the appended: true assertion covers this.
  • Comment-cop feedback addressed in c7a06b3 (comments are now single-line).
Extended reasoning...

Overview

Adds three guards to errorConstructorFuncAppendStackTrace in src/jsc/bindings/FormatStackTraceForJS.cpp (17 lines) and five tests in test/js/node/v8/capture-stack-trace.test.js. Each guard prevents a distinct crash: source == destination avoids a self-appendVector heap-use-after-free; hasMaterializedErrorInfo() avoids the !m_errorInfoMaterialized debug assertion and the release-build printer/.stack desync; and checking globalObject->stackTraceLimit() before captureStackTrace() avoids .value() on an empty optional (abort). All three are early returns or a fallback before the existing dangerous operation, so nothing on the pre-existing happy path changes.

Security risks

None. Error.appendStackTrace is a Private-visibility Bun-internal helper on the Error constructor. The change removes three user-reachable crashes (one a heap-use-after-free) and introduces no new allocation, no new JS re-entry, and no new exception paths. The guards are strictly additive and cannot introduce new UAF or leaks.

Level of scrutiny

Medium-high because this is JSC C++ bindings, but the change is small and mechanical: each guard mirrors an existing pattern already in the file (hasMaterializedErrorInfo() at line 797 in captureStackTrace, stackTraceLimit().value_or(...) at line 787) or in AsyncStackTrace.cpp:163. setStackFrames(vm, {}) is used elsewhere in the same file. No new throw scopes are needed since the added paths either return early or call non-throwing helpers that the surrounding code already calls unchecked.

Other factors

The tests follow harness conventions well: child-process isolation for the abort/ASAN cases, test.concurrent, {...bunEnv, ...} spread, concurrent pipe draining via Promise.all, combined {stdout, stderr, exitCode} assertions, and Malloc=1 + detect_leaks=0 for the UAF repro under ASAN. The PR description's evidence block shows all five tests fail on unfixed release/ASAN builds and pass with the fix. The comment-cop bot's four "paragraph-long comment" flags were addressed in c7a06b3 — the current diff has single-line comments. The one semantic choice (materialized destination is a full no-op that leaves the source intact) is well-reasoned in the description, consistent with the sibling in AsyncStackTrace.cpp, and pinned by two tests.

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