Skip to content

Guard print_errorlike_object against unbounded AggregateError recursion#34892

Open
robobun wants to merge 3 commits into
mainfrom
farm/d2dfe157/fix-aggregate-error-recursion
Open

Guard print_errorlike_object against unbounded AggregateError recursion#34892
robobun wants to merge 3 commits into
mainfrom
farm/d2dfe157/fix-aggregate-error-recursion

Conversation

@robobun

@robobun robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Printing a deeply nested AggregateError SIGSEGVs every error-print path: console.log, Bun.inspect, an uncaught throw, and an unhandled Promise.reject. Node prints the chain and exits 1.

Repro

let e = new Error('leaf');
for (let i = 0; i < 5000; i++) e = new AggregateError([e], 'agg' + i);
console.log(e); // Segmentation fault (exit 139), same for Bun.inspect(e) / throw e / Promise.reject(e)

3/3 on 1.4.0-canary and ASAN main; ~1000 levels prints fine, ~2000 crashes. The equivalent cause chain at the same depth throws a clean RangeError from Bun.inspect.

Cause

The AggregateError branch of VirtualMachine::print_errorlike_object iterates .errors via JSValue::for_each -> agg_iter -> print_errorlike_object with no stack check. The recursion stays inside that branch (each inner error is also an AggregateError) and never reaches the guarded print_error_instance_js path until the leaf, so the native stack overflows first.

Separately, VirtualMachine::print_exception and the jsc_hooks::print_exception entry point both construct a fresh Formatter with the default StackCheck (cached_stack_end == 0, always reports safe), so the existing cause-chain guard in print_error_instance_js never fires on the uncaught-throw / unhandled-reject paths either. Throwing a 5000-deep cause chain also SIGSEGVs today.

Fix

  • src/jsc/VirtualMachine.rs: hoist the formatter's is_safe_to_recurse() check to the top of print_errorlike_object, matching the existing pattern in print_error_instance_js. When the limit is hit the formatter throws RangeError (for Bun.inspect / console.log, which opt in via can_throw_stack_overflow) or truncates silently (uncaught printer), same as the cause chain does today.
  • src/jsc/VirtualMachine.rs, src/runtime/jsc_hooks.rs: seat formatter.stack_check = StackCheck::init() at the two print_exception entry points so the guard actually fires on the throw/reject paths.

Verification

New tests in test/js/node/util/bun-inspect.test.ts alongside the existing 16K-deep Error/object stack-overflow tests: Bun.inspect of a 16K-deep AggregateError throws RangeError: Maximum call stack size exceeded., and spawned subprocesses covering console.log / throw / Promise.reject of a 16K-deep AggregateError plus throw of a 16K-deep cause chain all exit 1 with no signal.

On the unfixed build the in-process test SIGSEGVs the runner and all four subprocess variants exit 139; with the fix all 17 tests in the file pass.

Related: #34884 seats StackCheck inside Formatter::new() for the deep non-Error (array/Proxy) throw path; that change does not on its own cover the AggregateError loop here, which never reaches a checked function. Whichever lands second has a trivial rebase of the two seating lines. Also distinct from #31988 (tampered .errors property).


[stamp-90s] gate passed · iteration 0 · 3 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/util/bun-inspect.test.ts
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 (383f3bc90)

test/js/node/util/bun-inspect.test.ts:
(pass) Bun.inspect > reports error instead of [native code] [217.29ms]
(pass) Bun.inspect > supports colors: false [5.01ms]
(pass) Bun.inspect > supports colors: true [3.37ms]
(pass) Bun.inspect > supports colors: false, via 2nd arg [1.90ms]
(pass) Bun.inspect > supports colors: true, via 2nd arg [2.05ms]
(pass) Bun.inspect > supports compact [5.10ms]
(pass) Bun.inspect > depth < 0 throws [4.98ms]
(pass) Bun.inspect > depth = Infinity works for Error [104.92ms]
(pass) Bun.inspect > depth = Infinity works for Object [94.32ms]
(pass) Bun.inspect > stack overflow is thrown when it should be for objects [64.47ms]
(pass) Bun.inspect > stack overflow is thrown when it should be for Er
... (truncated)

release without fix: BUILD FAILED (no junit output)
bun test v1.4.0-canary.1 (1498d7b77)

test/js/node/util/bun-inspect.test.ts:
(pass) Bun.inspect > reports error instead of [native code] [3.89ms]
(pass) Bun.inspect > supports colors: false [0.18ms]
(pass) Bun.inspect > supports colors: true [0.05ms]
(pass) Bun.inspect > supports colors: false, via 2nd arg [0.02ms]
(pass) Bun.inspect > supports colors: true, via 2nd arg [0.02ms]
(pass) Bun.inspect > supports compact [0.06ms]
(pass) Bun.inspect > depth < 0 throws [0.06ms]
(pass) Bun.inspect > depth = Infinity works for Error [2.32ms]
(pass) Bun.inspect > depth = Infinity works for Object [1.28ms]
(pass) Bun.inspect > stack overflow is thrown when it should be for objects [54.76ms]
(pass) Bun.inspect > stack overflow is thrown when it should be for Error [217.77ms]
/bin/bash: line 1: 49718 Segmentation fault      (core dumped) USE_SYSTEM_BUN=1 bun test --reporter=junit --reporter-outfile=/tmp/mechgate.xml 'test/js/node/util/bun-inspect.test.ts' 2>&1
__F:-1:S:0
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/util/bun-inspect.test.ts
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 (383f3bc90)

test/js/node/util/bun-inspect.test.ts:
(pass) Bun.inspect > reports error instead of [native code] [213.97ms]
(pass) Bun.inspect > supports colors: false [5.32ms]
(pass) Bun.inspect > supports colors: true [3.40ms]
(pass) Bun.inspect > supports colors: false, via 2nd arg [1.90ms]
(pass) Bun.inspect > supports colors: true, via 2nd arg [2.01ms]
(pass) Bun.inspect > supports compact [5.06ms]
(pass) Bun.inspect > depth < 0 throws [4.92ms]
(pass) Bun.inspect > depth = Infinity works for Error [106.48ms]
(pass) Bun.inspect > depth = Infinity works for Object [95.65ms]
(pass) Bun.inspect > stack overflow is thrown when it should be for objects [65.19ms]
(pass) Bun.inspect > stack overflow is thrown when it should be for Er
... (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     383f3bc904
  features     (none)

22 deps, 106 codegen, 1169 objects in 829ms

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

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

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

Checked 129 installs across 147 packages (no changes) [13.00ms]
[6/1232] fetch zlib
[zlib] up to date
[7/1232] fetch tinycc
[tinycc] up to date
[8/1232] fetch picohttpparser
[pic
... (truncated)
diff hotspot
src/jsc/VirtualMachine.rs             | 12 ++++++++++++
 src/runtime/jsc_hooks.rs              |  1 +
 test/js/node/util/bun-inspect.test.ts | 29 +++++++++++++++++++++++++++++
 3 files changed, 42 insertions(+)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                   reads  edits  tests
src/jsc/VirtualMachine.rs                  5      2      0
src/runtime/jsc_hooks.rs                   1      1      0
test/js/node/util/bun-inspect.test.ts      2      4      0

Printing a deeply nested AggregateError (e.g. 2k levels of
new AggregateError([inner])) SIGSEGVs every error-print path:
console.log, Bun.inspect, uncaught throw, and unhandled rejection.

The AggregateError branch of print_errorlike_object iterates .errors
via JSValue::for_each -> agg_iter -> print_errorlike_object with no
stack check, so the recursion runs until the native stack overflows.
The Error cause chain is guarded one level deeper in
print_error_instance_js, but that guard is only reached once the
AggregateError loop bottoms out.

Additionally, VirtualMachine::print_exception and the jsc_hooks
print_exception entry point construct a Formatter with the default
(unseated) StackCheck, so the existing cause-chain guard never fired
on the uncaught-throw / unhandled-reject paths either.

Fix: hoist the formatter's is_safe_to_recurse() check to the top of
print_errorlike_object (matching the pattern in
print_error_instance_js), and seat formatter.stack_check at the two
print_exception entry points.
@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:18 AM PT - Jul 21st, 2026

@robobun, your commit 383f3bc90498396489248b00e5d2b021f45c6e5f passed in Build #76714! 🎉


🧪   To try this PR locally:

bunx bun-pr 34892

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

bun-34892 --bun

@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced: exit 139 on stock 1.4.0-canary for all four print paths; Bun.inspect of the equivalent cause chain throws RangeError cleanly (the control). Fix adds the missing stack check in print_errorlike_object and seats stack_check at the two print_exception entry points. bun bd test test/js/node/util/bun-inspect.test.ts passes 17/17; the system bun SIGSEGVs the runner at the new AggregateError test.

383f3bc flattens the subprocess matrix to it.concurrent.each and asserts a built marker on stderr so an unrelated exit-1 cannot satisfy the test.

Re the auto-suggested issues: #1352, #21528 and #32343 are all about how AggregateError / cause output is formatted, not about the segfault on deep nesting; this PR does not change formatting, so none of them are closed here.

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. Unsupported proper logging of AggregateError, Error.cause, modified/accessed Error.stack #1352 - PR adds recursion guards to AggregateError and Error.cause printing paths, directly addressing the reported lack of proper AggregateError/cause logging
  2. print AggregateError more distinctly #21528 - PR fixes the AggregateError .errors traversal in print_errorlike_object, which is the code path responsible for swallowing AggregateError display
  3. Bun prints multi-level Error.cause chain as separate errors instead of preserving Node-style nested stack trace #32343 - PR adds stack-depth checks to the cause-chain traversal, fixing the deeply nested Error.cause printing behavior

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #1352
Fixes #21528
Fixes #32343

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The change initializes stack tracking during exception formatting, prevents unsafe recursive traversal of AggregateError.errors, and adds direct and subprocess tests covering deeply nested errors across printing and throwing paths.

AggregateError stack safety

Layer / File(s) Summary
Formatter stack initialization
src/jsc/VirtualMachine.rs, src/runtime/jsc_hooks.rs
Exception formatting initializes formatter.stack_check before printing errorlike objects.
AggregateError recursion guard
src/jsc/VirtualMachine.rs
Unsafe recursion marks formatting as failed, optionally throws stack overflow, and stops AggregateError traversal.
Stack-safety validation
test/js/node/util/bun-inspect.test.ts
Tests cover deeply nested AggregateError inspection and subprocess printing, throwing, rejecting, and cause handling.

Possibly related PRs

  • oven-sh/bun#34497: Both modify AggregateError rendering in VirtualMachine::print_errorlike_object.
  • oven-sh/bun#34885: Both add StackCheck-based recursion protection to formatting paths.

Suggested reviewers: alii, cirospaciari

🚥 Pre-merge checks | ✅ 2 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR fixes AggregateError recursion, but linked issue #39 is about Node.js build output and unrelated blockers. Either retarget the PR to issue #39's build-output requirements or unlink #39 and add the correct issue for this recursion fix.
Out of Scope Changes check ⚠️ Warning All code and test changes address AggregateError stack safety, which is outside the linked Node.js build-output issue. Remove the unrelated linked issue or split this recursion fix into a PR linked to the appropriate error-printing issue.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main fix: guarding AggregateError recursion in print_errorlike_object.
Description check ✅ Passed The description covers the bug, root cause, fix, and verification, so it is mostly complete despite using different headings than the template.

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/node/util/bun-inspect.test.ts`:
- Around line 100-101: Replace the sequential describe.each matrix around
“printing a deeply nested error via %s” with a flat test.concurrent.each matrix,
preserving the four ["log", "throw", "reject", "cause"] cases and the existing
test body so all subprocess cases run concurrently.
- Around line 117-119: Update the test around the Promise.all calls to retain
the formatter’s stdout and stderr instead of discarding them, then assert the
expected stable per-case error or truncation marker for each case in addition to
proc.signalCode and exitCode. Keep the existing termination assertions while
ensuring an unrelated exit code 1 cannot satisfy the test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5d07f236-5aef-4490-ae9e-1688eee44408

📥 Commits

Reviewing files that changed from the base of the PR and between 5b98630 and a5e3c07.

📒 Files selected for processing (3)
  • src/jsc/VirtualMachine.rs
  • src/runtime/jsc_hooks.rs
  • test/js/node/util/bun-inspect.test.ts

Comment thread test/js/node/util/bun-inspect.test.ts Outdated
Comment thread test/js/node/util/bun-inspect.test.ts Outdated
Switch to it.concurrent.each (flatter, same concurrency) and write a
'built' marker to stderr after constructing the 16K-deep chain so the
test asserts the child actually reached the print/throw rather than
passing on an unrelated exit-1.

@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 — focused crash fix that mirrors the existing print_error_instance_js stack guard.

What was reviewed:

  • Verified the new guard in print_errorlike_object is byte-identical in shape to the existing one at VirtualMachine.rs:5625-5634 (failed = true + conditional throw_stack_overflow), and that a default StackCheck (cached_stack_end == 0) makes is_safe_to_recurse() vacuously true, so callers that don't seat it are unaffected.
  • Confirmed all four print_errorlike_object call sites now reach it with a seated stack_check: the two patched print_exception entry points, the recursive agg_iter, and ConsoleObject.rs:4036 (whose formatter is seated by format2/Bun.inspect at ConsoleObject.rs:727/1439/1502/6018).
  • Subprocess tests: stdout: "ignore" avoids pipe backpressure on the console.log case, stderr is drained, and the built\n marker + signalCode === null guard against unrelated exit-1.
Extended reasoning...

Overview

Three-file change fixing a native stack overflow (SIGSEGV) when printing deeply nested AggregateError chains. src/jsc/VirtualMachine.rs gains an is_safe_to_recurse() guard at the top of print_errorlike_object (7 lines) plus a one-line StackCheck::init() seating in VirtualMachine::print_exception. src/runtime/jsc_hooks.rs gets the same one-line seating in its print_exception else-branch. test/js/node/util/bun-inspect.test.ts adds one in-process Bun.inspect test alongside the existing 16K-deep object/Error tests, plus a 4-case it.concurrent.each subprocess matrix (log/throw/reject on AggregateError + throw on cause-chain).

Security risks

None. This is the error-printing path; the change converts a user-triggerable SIGSEGV into a clean RangeError or silent truncation, which is strictly a DoS-hardening improvement. No new user input is parsed and no allocations are added.

Level of scrutiny

Low-to-medium. The native change is tiny and copies an established pattern verbatim from the same file (print_error_instance_js at line ~5625). The StackCheck::init() seating matches the existing idiom used four times in ConsoleObject.rs. The PR description notes #34884 will move the seating into Formatter::new() itself; whichever lands second has a trivial two-line rebase. The default-StackCheck semantics (cached_stack_end == 0 → guard never trips) mean the new check is inert for any call path that doesn't seat it, so there's no risk of regressing shallow error printing.

Other factors

The whole-class check holds: I grepped all four callers of print_errorlike_object and each now reaches it with a seated stack_check. The many other Formatter::new() sites (test_runner/expect/validators) don't route through print_errorlike_object and are out of scope here (covered by #34884). Test coverage is solid: the in-process test asserts the exact RangeError message via inline snapshot (matching the two neighboring tests), and the subprocess matrix asserts a post-construction built\n marker on stderr before checking signalCode === null and exitCode === 1, so an unrelated early failure can't satisfy it. Both CodeRabbit review comments were addressed in 383f3bc and marked resolved. No outstanding human review comments.

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