Skip to content

Error.appendStackTrace: don't abort when stackTraceLimit is unset - #35100

Closed
robobun wants to merge 3 commits into
mainfrom
farm/e2e25f41/fix-appendstacktrace-nullopt-limit
Closed

Error.appendStackTrace: don't abort when stackTraceLimit is unset#35100
robobun wants to merge 3 commits into
mainfrom
farm/e2e25f41/fix-appendstacktrace-nullopt-limit

Conversation

@robobun

@robobun robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Found by Fuzzilli (fingerprint cb5f2d970ad4162c).

Assigning a non-number to Error.stackTraceLimit (or deleting it) clears JSGlobalObject::m_stackTraceLimit to std::nullopt, and errors created after that point carry no native stack trace. Error.appendStackTrace then took the "destination has no trace" path and called ErrorInstance::captureStackTrace, which does globalObject->stackTraceLimit().value() unconditionally; with no value that's a bad_optional_access that aborts the process (JSC is built without exceptions).

Error.stackTraceLimit = undefined;
const a = new Error();
const b = new Error();
Error.appendStackTrace(a, b); // SIGABRT

Fix: only call captureStackTrace when stackTraceLimit() has a value, and only append when the destination actually has a vector (so skipping the capture does not become a null deref when the source still has frames).

Related open PRs #32098 and #34713 touch the same function for the self-append and already-materialized cases respectively; this one is orthogonal (still crashes with distinct source/destination and neither materialized).


[stamp-90s] gate passed · iteration 2 · 2 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 (c9787deb8)

test/js/node/v8/capture-stack-trace.test.js:
(pass) Regular .stack [13.29ms]
(pass) throw inside Error.prepareStackTrace doesnt crash [6.34ms]
(pass) capture stack trace [6.57ms]
(pass) capture stack trace with message [7.05ms]
(pass) capture stack trace with constructor [5.06ms]
(pass) capture stack trace limit [22.39ms]
(pass) prepare stack trace [10.99ms]
(pass) capture stack trace second argument [16.69ms]
(pass) capture stack trace edge cases [10.51ms]
(pass) Error.captureStackTrace installs .stack as non-enumerable [20.00ms]
(pass) prepare stack trace call sites [11.54ms]
(pass) sanity check [11.88ms]
(pass) CallFrame isEval works as expected [7.73ms]
(pass) CallFrame isTopLevel returns false for Function constructor [7.34ms]
(pass) CallFrame.p.getThisgetFunction: strict/sloppy mode interaction [8.29ms]
(pass) CallFrame.p.isConstructor [3.42ms]
(pass) CallFrame.p.isNative [2.95ms]
(pass) return non-strings from Error.prepareStackTrace [3.70ms]
(pass) CallF
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (7b64c8917)

test/js/node/v8/capture-stack-trace.test.js:
(pass) Regular .stack [0.46ms]
(pass) throw inside Error.prepareStackTrace doesnt crash [0.10ms]
(pass) capture stack trace [0.06ms]
(pass) capture stack trace with message [0.07ms]
(pass) capture stack trace with constructor [0.06ms]
(pass) capture stack trace limit [0.21ms]
(pass) prepare stack trace [0.14ms]
(pass) capture stack trace second argument [0.17ms]
(pass) capture stack trace edge cases [0.10ms]
(pass) Error.captureStackTrace installs .stack as non-enumerable [0.25ms]
(pass) prepare stack trace call sites [0.13ms]
(pass) sanity check [0.11ms]
(pass) CallFrame isEval works as expected [0.10ms]
(pass) CallFrame isTopLevel returns false for Function constructor [0.12ms]
(pass) CallFrame.p.getThisgetFunction: strict/sloppy mode interaction [0.09ms]
(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.22ms]
(pass) Error.prepareStackTrace inside a node:vm works [5.31ms]
(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 (c9787deb8)

test/js/node/v8/capture-stack-trace.test.js:
(pass) Regular .stack [13.86ms]
(pass) throw inside Error.prepareStackTrace doesnt crash [6.42ms]
(pass) capture stack trace [6.68ms]
(pass) capture stack trace with message [7.65ms]
(pass) capture stack trace with constructor [5.51ms]
(pass) capture stack trace limit [22.20ms]
(pass) prepare stack trace [10.69ms]
(pass) capture stack trace second argument [17.66ms]
(pass) capture stack trace edge cases [11.75ms]
(pass) Error.captureStackTrace installs .stack as non-enumerable [21.58ms]
(pass) prepare stack trace call sites [12.28ms]
(pass) sanity check [13.09ms]
(pass) CallFrame isEval works as expected [7.42ms]
(pass) CallFrame isTopLevel returns false for Function constructor [7.20ms]
(pass) CallFrame.p.getThisgetFunction: strict/sloppy mode interaction [8.21ms]
(pass) CallFrame.p.isConstructor [3.48ms]
(pass) CallFrame.p.isNative [2.88ms]
(pass) return non-strings from Error.prepareStackTrace [3.29ms]
(pass) CallF
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 674ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] cxx obj/unified/UnifiedSource-src_jsc_bindings-1.cpp.o
[2/6] gen cpp.rs (cppbind)
[2/6] 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    Blocking�[0m waiting for file lock on build directory
�[1m�[92m    Finished�[0m `release` profile [optimized + debuginfo] target(s) in 2m 37s
[3/6] link bun-profile
[4/6] bun-profile --revision
1.4.0-canary.1+c9787deb8
[6/6] strip bun
[build] done
bun test v1.4.0-canary.1 (c9787deb8)

test/js/node/v8/capture-stack-trace.test.js:
(pass) Regular .stack [2.10ms]
(pass) throw inside Error.prepareStackTrace doesnt crash [0.14ms]
(pass) capture stack trace [0.09ms]
(pass) capture stack trace with message [0.10ms]
(pass) capture stack trace with constructor [0.10ms]
(pass) capture stack trace limit [0.35ms]
(pass) prepare stack trace [0.16ms]
(pass) capture stack trace second argument [0.27ms]
(pass) capture stack trace edge cases [0.16ms]
... (truncated)
diff hotspot
src/jsc/bindings/FormatStackTraceForJS.cpp  |  4 ++--
 test/js/node/v8/capture-stack-trace.test.js | 25 +++++++++++++++++++++++++
 2 files changed, 27 insertions(+), 2 deletions(-)

gate history · 3 passed · 0 rejected · iteration 2

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

ErrorInstance::captureStackTrace calls globalObject->stackTraceLimit().value()
unconditionally, which aborts when the optional is empty. That state is reachable
from JS by assigning a non-number to Error.stackTraceLimit (or deleting it),
after which errors carry no native stack trace. Error.appendStackTrace then
falls into the captureStackTrace path for the destination and the process dies
with SIGABRT.

Guard the capture on stackTraceLimit() having a value, and guard the append on
the destination actually having a vector so skipping the capture does not turn
into a null deref.
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Error.appendStackTrace now conditionally captures and merges stack traces based on the configured stack trace limit and available source/destination stacks, with regression coverage for undefined and deleted limits.

Changes

Error stack trace handling

Layer / File(s) Summary
Conditional capture and append validation
src/jsc/bindings/FormatStackTraceForJS.cpp, test/js/node/v8/capture-stack-trace.test.js
Destination stack capture requires a configured stack trace limit, merging requires both stacks, and tests verify safe behavior for undefined, deleted, and numeric limits.

Possibly related PRs

  • oven-sh/bun#34263: Both changes adjust stack trace capture behavior based on stackTraceLimit.
  • oven-sh/bun#34713: Both changes modify destination stack capture and merging in errorConstructorFuncAppendStackTrace.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately summarizes the main fix to Error.appendStackTrace.
Description check ✅ Passed The description covers the fix and verification evidence, matching the template's required summary and testing info.

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

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:54 AM PT - Jul 22nd, 2026

@robobun, your commit c9787de has 4 failures in Build #77769 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35100

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

bun-35100 --bun

Comment thread test/js/node/v8/capture-stack-trace.test.js
Create the second source error while the limit is set so it carries frames,
then unset the limit before creating the destination. That reaches the
appendVector call with a null destination vector, which is what the second
guard protects against.
@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Added coverage for the second guard in 7b64c89: the c/d case now creates the source while stackTraceLimit is set (so it has frames) and the destination after deleting it, which reaches appendVector with a null destination vector. Verified the test segfaults on a build with only the first guard applied and aborts with SIGABRT on an unpatched release build.

@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 — the follow-up commit addresses the earlier feedback: the c/d case now creates the source with a real limit and the destination after deleting it, so both added guards are exercised.

What was reviewed:

  • Traced both guards against the updated fixture — scenario 1 hits the stackTraceLimit() guard, scenario 2 hits the destination->stackTrace() guard.
  • Checked for sibling ErrorInstance::captureStackTrace callers in src/ that could hit the same .value() abort — this is the only one.
  • Test shape: subprocess with concurrent pipe drain, asserts signalCode: null and exact stdout, matches file conventions.
Extended reasoning...

Overview

Two-line change to errorConstructorFuncAppendStackTrace in src/jsc/bindings/FormatStackTraceForJS.cpp plus a subprocess regression test in test/js/node/v8/capture-stack-trace.test.js. The fix guards destination->captureStackTrace(...) on globalObject->stackTraceLimit() having a value (preventing the bad_optional_access abort inside ErrorInstance::captureStackTrace), and guards the subsequent appendVector on the destination having a stack-trace vector (preventing a null deref when the capture was skipped but the source still has frames).

Security risks

None. Error.appendStackTrace is a Bun-specific API on the Error constructor; the change strictly narrows two conditionals to avoid crashing on already-reachable user input. No new data flow, no allocation, no user-callback surface.

Level of scrutiny

Low-to-medium. This is a targeted fuzzer-found crash fix in a small, self-contained host function. The behavioral change is that appending becomes a no-op when stackTraceLimit is unset (instead of aborting the process), which is strictly an improvement — errors created under an unset limit have no native trace anyway, so there is nothing meaningful to append.

Other factors

  • I previously flagged that the original test never reached the second guard's right operand. Commit 7b64c89 restructured the second scenario so c is created with stackTraceLimit = 10 (has frames) and d after delete Error.stackTraceLimit (no frames, capture skipped), which the author verified segfaults on a build with only the first guard applied. Both load-bearing clauses are now covered.
  • Grepped src/ for other ErrorInstance::captureStackTrace call sites and unguarded stackTraceLimit().value() — this is the only in-tree caller, so no sibling sites need the same guard.
  • The test follows the file's existing subprocess pattern (Bun.spawn with -e, bunEnv, concurrent Promise.all pipe drain, combined-object assertion including signalCode: null, exit code asserted last).
  • The gate evidence in the PR description shows the test failing on unpatched debug/release builds and passing with the fix.

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

CI failures across builds #77665 and #77769 are unrelated to this change:

  • test-net-connect-memleak.js, test-gc-http-client-connaborted.js: GC-timing tests for net/http socket collection; both also red on main build #77601. Pass 15-20/20 locally with this build.
  • worker_threads.test.ts (x64-asan): !exception() scope assertion. Error.appendStackTrace is not called anywhere in src/js/ or the worker_threads module, and this diff only adds two pure-getter guard conditions with no new exception paths.
  • bun-install-proxy.test.ts: subprocess SIGTERM at the 20s timeout inside bun install.

The added regression test (capture-stack-trace.test.js) passed on every lane in both runs.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Consolidated into #37370, which fixes the same stackTraceLimit().value() abort and now also carries the Error.stackTraceLimit = undefined case from this PR's test. It keeps the source's frames when the limit is unset (the destination gets an empty frame list instead of skipping the append), and also covers the materialized-destination and self-append crashes in the same function. Closing.

@robobun robobun closed this Aug 13, 2026
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