Skip to content

Guard AggregateError .errors printing against self-reference - #35820

Closed
robobun wants to merge 5 commits into
mainfrom
farm/f240f4dd/aggregate-error-cycle-guard
Closed

Guard AggregateError .errors printing against self-reference#35820
robobun wants to merge 5 commits into
mainfrom
farm/f240f4dd/aggregate-error-cycle-guard

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

A self-referential AggregateError (its own .errors array contains itself) SIGSEGVs every error-printing path: console.log, Bun.inspect, uncaught throw, and unhandled Promise.reject. Node prints [errors]: [ [Circular *1] ].

Repro

const ae = new AggregateError([], "circ");
ae.errors.push(ae);
console.log(ae); // Segmentation fault (exit 139), same for Bun.inspect / throw / reject

3/3 on 1.4.0-canary and ASAN main; all four entries crash. No depth needed, a single self-reference is enough.

Cause

The is_aggregate_error branch of VirtualMachine::print_errorlike_object iterates .errors via for_each -> agg_iter -> print_errorlike_object with neither a cycle guard nor a stack check. A cycle (or a sufficiently deep non-cyclic chain) recurses until the native stack overflows. The cause chain already has both protections, in print_error_instance_body / print_error_instance_js.

Fix

src/jsc/VirtualMachine.rs: inside the is_aggregate_error branch, add the formatter's visited-map cycle guard (prints [Circular] on re-entry, removed after iteration so sibling duplicates still print in full) and a stack_check.is_safe_to_recurse() guard. Same pattern as the existing cause-chain guard.

src/jsc/VirtualMachine.rs and src/runtime/jsc_hooks.rs: seat formatter.stack_check = StackCheck::init() at the two print_exception entry points so the stack guard actually fires on uncaught throw / unhandled rejection (the default StackCheck has cached_stack_end == 0 and never trips).

Verification

New tests in test/js/node/util/bun-inspect.test.ts alongside the existing deep-Error/object stack-overflow tests:

  • Bun.inspect of a self-referential AggregateError returns a string containing [Circular]
  • Bun.inspect of a 16K-deep AggregateError chain throws RangeError: Maximum call stack size exceeded.
  • spawned subprocesses for console.log / Bun.inspect / throw / Promise.reject of a self-referential AggregateError all print [Circular] with signalCode === null

On the unfixed build the first two segfault the test runner (exit 139) and all four subprocess variants exit 139.

Related

The bare [Circular] output (no aggregate header) matches current Bun behaviour for AggregateError printing; #35174 addresses the header separately.


[review] 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
bun test v1.4.0 (d489dc3fd)

test/js/node/util/bun-inspect.test.ts:
(pass) Bun.inspect > reports error instead of [native code] [294.17ms]
(pass) Bun.inspect > supports colors: false [5.99ms]
(pass) Bun.inspect > supports colors: true [3.42ms]
(pass) Bun.inspect > supports colors: false, via 2nd arg [2.02ms]
(pass) Bun.inspect > supports colors: true, via 2nd arg [2.06ms]
(pass) Bun.inspect > supports compact [5.03ms]
(pass) Bun.inspect > depth < 0 throws [5.07ms]
(pass) Bun.inspect > depth = Infinity works for Error [112.53ms]
(pass) Bun.inspect > depth = Infinity works for Object [98.09ms]
(pass) Bun.inspect > stack overflow is thrown when it should be for objects [66.50ms]
(pass) Bun.inspect > stack overflow is thrown when it should be for Error [814.43ms]
============================================================
Bun Canary v1.4.0-canary.1 (1498d7b77) Linux x64
Linux Kernel v6.17.0 | glibc v2.41
CPU: sse42 popcnt avx avx2 avx512
Args: "bun" "scripts/build.ts" "--profile=debug" "--quiet" "test
... (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] [4.75ms]
(pass) Bun.inspect > supports colors: false [0.24ms]
(pass) Bun.inspect > supports colors: true [0.07ms]
(pass) Bun.inspect > supports colors: false, via 2nd arg [0.02ms]
(pass) Bun.inspect > supports colors: true, via 2nd arg [0.03ms]
(pass) Bun.inspect > supports compact [0.07ms]
(pass) Bun.inspect > depth < 0 throws [0.07ms]
(pass) Bun.inspect > depth = Infinity works for Error [3.00ms]
(pass) Bun.inspect > depth = Infinity works for Object [1.37ms]
(pass) Bun.inspect > stack overflow is thrown when it should be for objects [60.97ms]
(pass) Bun.inspect > stack overflow is thrown when it should be for Error [230.18ms]
/bin/bash: line 1: 64810 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
bun test v1.4.0 (d489dc3fd)

test/js/node/util/bun-inspect.test.ts:
(pass) Bun.inspect > reports error instead of [native code] [237.73ms]
(pass) Bun.inspect > supports colors: false [5.71ms]
(pass) Bun.inspect > supports colors: true [3.63ms]
(pass) Bun.inspect > supports colors: false, via 2nd arg [2.93ms]
(pass) Bun.inspect > supports colors: true, via 2nd arg [2.15ms]
(pass) Bun.inspect > supports compact [5.21ms]
(pass) Bun.inspect > depth < 0 throws [5.73ms]
(pass) Bun.inspect > depth = Infinity works for Error [108.17ms]
(pass) Bun.inspect > depth = Infinity works for Object [96.97ms]
(pass) Bun.inspect > stack overflow is thrown when it should be for objects [67.24ms]
(pass) Bun.inspect > stack overflow is thrown when it should be for Error [813.04ms]
(pass) Bun.inspect > self-referential AggregateError prints [Circular] [3.88ms]
(pass) Bun.inspect > deeply nested AggregateError throws a stack overflow instead of crashing [1050.06ms]
(pass) Bun.inspect > self-referential AggregateError via 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     d489dc3fd4
  features     baseline

22 deps, 108 codegen, 1171 objects in 1572ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1234] gen ErrorCode+*.h
[2/1234] gen bindgenv2
[3/1234] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[4/1234] fetch tinycc
[tinycc] up to date
[5/1234] fetch picohttpparser
[picohttpparser] up to date
[6/1234] fetch zlib
[zlib] up to date
[7/1234] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[8/1234] gen .bind.ts → GeneratedBindings.cpp
[9/1234] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[10/1234] subst deps/zlib/zlib.h
[11/1234] gen ProcessBindingHTTPParser.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingHTTPParser.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingHTTPParser.cpp
[12/1234] subst deps
... (truncated)
diff hotspot
src/jsc/VirtualMachine.rs             | 37 ++++++++++++++++++++++++
 src/runtime/jsc_hooks.rs              |  1 +
 test/js/node/util/bun-inspect.test.ts | 53 +++++++++++++++++++++++++++++++++++
 3 files changed, 91 insertions(+)

gate history · 1 passed · 0 rejected · iteration 0

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

… nesting

A self-referential AggregateError (ae.errors contains ae) segfaulted every
error-printing path (console.log, Bun.inspect, uncaught throw, unhandled
rejection): the is_aggregate_error branch of print_errorlike_object iterates
.errors via for_each -> agg_iter -> print_errorlike_object with neither a
cycle guard nor a stack check, so a depth-1 cycle recurses until the native
stack overflows.

Add the same two protections the cause chain already has: the formatter's
visited map (prints [Circular] on re-entry) and the stack_check guard (for
deep non-cyclic nesting). Also seat StackCheck::init() in the two
print_exception entry points so the guard actually fires on uncaught
throw / unhandled rejection.
@coderabbitai

coderabbitai Bot commented Jul 25, 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: 18 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: 7f121013-2f84-404f-80f9-69615c14dfa1

📥 Commits

Reviewing files that changed from the base of the PR and between 916492f and 3db4c25.

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

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

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:05 PM PT - Jul 25th, 2026

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


🧪   To try this PR locally:

bunx bun-pr 35820

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

bun-35820 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Guard print_errorlike_object against unbounded AggregateError recursion #34892 - Also guards print_errorlike_object against unbounded AggregateError recursion by adding StackCheck to the same entry points; Guard AggregateError .errors printing against self-reference #35820 is a superset that additionally adds cycle detection via the visited-map

🤖 Generated with Claude Code

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Correct, this is a superset of #34892. #34892 adds only the stack_check guard, which turns the depth-1 self-reference case from a segfault into a RangeError: Maximum call stack size exceeded after ~thousands of frames. This PR additionally adds the visited-map cycle guard so the self-reference is caught on the first re-entry and prints [Circular] (matching the cause-chain behaviour and Node). Either PR can land; whichever lands second is a trivial rebase of the overlapping StackCheck::init() seating lines.

Comment thread test/js/node/util/bun-inspect.test.ts
These exercise the StackCheck::init() seating at the two print_exception
entry points; the self-referential subprocess tests stop at depth 1 via
the cycle guard and never reach the stack check.
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/VirtualMachine.rs
On the uncaught throw / unhandled rejection paths can_throw_stack_overflow
is false, so when the stack guard trips no JS exception is thrown and
for_each proceeds to the next .errors sibling. With fan-out > 1 this
re-descends per sibling and turns O(D) into O(2^D). Match the
ConsoleObject for_each callbacks and bail out once failed is set. The
deep-nesting subprocess tests now use [e, e] to cover this.

@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 — both earlier review points are addressed (deep-chain throw/reject tests in 13c492a, formatter.failed short-circuit + fan-out=2 coverage in a3ac642).

Re-checked on this revision:

  • The new guard block mirrors the existing cause-chain guard at VirtualMachine.rs:6134-6164 (same pool-node init, get_or_put/found_existing, post-iteration remove).
  • All four print_errorlike_object entry points now have stack_check seated: the two new sites here plus ConsoleObject.rs:727/1439/1502/5974 for the print_errorprint_errorlike_object path.
  • agg_iter short-circuits on formatter.failed before recursing, matching the ConsoleObject for_each callbacks; the fan-out=2 subprocess test pins this.
Extended reasoning...

Overview

Crash fix in the error-printing path: a self-referential (or deeply nested) AggregateError previously SIGSEGV'd print_errorlike_object via unbounded native recursion through agg_iter. The change adds the same visited-map cycle guard and StackCheck guard that the cause-chain already has (~200 lines below in the same file), seats StackCheck::init() at the two print_exception entry points so the guard is live on the uncaught-throw / unhandled-rejection path, and short-circuits agg_iter on formatter.failed. Tests cover Bun.inspect, console.log, throw, and Promise.reject for both the self-referential and 16K-deep fan-out=2 shapes.

Prior review follow-up

I reviewed this PR twice before. The first pass flagged that the two new StackCheck::init() seatings had no test coverage (self-referential subprocess tests are caught by the cycle guard at depth 1; the deep-nesting test used Bun.inspect which seats stack_check independently). Fixed in 13c492a with a second it.concurrent.each for throw/Promise.reject over a deep non-cyclic chain. The second pass flagged that without a formatter.failed short-circuit in agg_iter, a fan-out ≥2 chain on the throw path re-descends per sibling for 2^D work — a hang. Fixed in a3ac642 with the short-circuit and by switching the deep-nesting test to new AggregateError([e, e]) so it hangs without the fix. Both threads are resolved.

Security risks

None. This is diagnostic-output code; the change turns a native stack overflow (SIGSEGV) into either a [Circular] marker or a controlled RangeError/silent truncation. No new user-controlled parsing, no allocation driven by untrusted sizes beyond what already existed.

Level of scrutiny

Moderate. print_errorlike_object runs on every uncaught exception, but the added block is a straight copy of the pattern already used for the cause chain in the same file (identical pool-node lazy-init, identical get_or_put/remove bracketing, identical is_safe_to_recursefailed = true → optional throw_stack_overflow). I verified the fourth caller at ConsoleObject.rs:3997 passes an already-seated formatter (all its entry points seat stack_check at 727/1439/1502/5974), and that its temporary map.remove/scopeguard-reinsert dance composes correctly with the new get_or_put/remove here. The map_node lifecycle is unchanged — released via Formatter's Drop, same as before.

Other factors

Mechgate confirms every new test SIGSEGVs the runner on the unfixed build (release and ASAN) and passes with the fix. Every load-bearing clause — cycle guard, stack check, both StackCheck::init() seatings, and the agg_iter short-circuit — now has a test that fails without it. The comment-cop nit was addressed (comment trimmed to one line). Overlap with #34892 is acknowledged in the description as a superset with a trivial rebase either way.

@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 — both earlier findings (missing throw/reject deep-chain coverage, and the fan-out≥2 hang from not short-circuiting on formatter.failed) are addressed in 13c492a and a3ac642.

What was reviewed:

  • New guard block mirrors print_as_prelude (ConsoleObject.rs:3345-3383) and the cause-chain guard (VirtualMachine.rs:6139-6164) — same lazy map_node init, same .expect("unreachable") on get_or_put, same post-iteration map.remove.
  • agg_iter's formatter.failed short-circuit makes unwind O(D) on the throw/reject path where no JS exception stops for_each; verified the fan-out=2 subprocess tests now cover this.
  • StackCheck::init() seatings at both print_exception entry points are covered by the new deep-chain throw/Promise.reject subprocess rows.
Extended reasoning...

Overview

Fixes a SIGSEGV when printing a self-referential or deeply-nested AggregateError on any of the four error-printing paths (console.log, Bun.inspect, uncaught throw, unhandled Promise.reject). Touches src/jsc/VirtualMachine.rs (+37), src/runtime/jsc_hooks.rs (+1), and test/js/node/util/bun-inspect.test.ts (+55). The fix adds a stack-check + visited-map cycle guard to the is_aggregate_error branch of print_errorlike_object, seats StackCheck::init() at the two print_exception entry points, and short-circuits the agg_iter for_each callback on formatter.failed.

Security risks

None material. This is defensive hardening of an error-printing path against pathological user-constructed inputs. The only user-reachable effect is that a value which previously crashed the process now prints [Circular] or truncates. No new allocation sizes are derived from untrusted lengths; the visited-map node comes from the existing formatter pool via the same code path already used by print_as_prelude and the cause-chain guard.

Level of scrutiny

Medium. It's native Rust in the JSC error-printing hot path, but the change is a near-verbatim copy of two existing in-file guard patterns (ConsoleObject.rs:3345-3383 and VirtualMachine.rs:6139-6164). I compared the new block line-by-line against both: same lazy map_node acquisition, same get_or_put/found_existing cycle check, same [Circular] output via pretty_fmt!, same post-recursion map.remove(&value) so sibling duplicates still print in full. The formatter.failed short-circuit in agg_iter matches the ConsoleObject for_each callbacks (2907/3003/3191). NonNull is already in scope (used at 6140).

Other factors

This PR has already been through two review iterations from me. The first (missing deep-chain coverage for the throw/reject StackCheck::init() seatings) was fixed in 13c492a. The second (2^D hang on fan-out≥2 because agg_iter didn't check formatter.failed) was confirmed by the author and fixed in a3ac642, with the deep-chain subprocess tests upgraded to fan-out=2 so they hang without the short-circuit. All threads are resolved. The mechgate evidence in the description shows the tests SIGSEGV on unfixed builds (both ASAN debug and release) and pass with the fix. The subprocess tests use it.concurrent.each, drain both pipes via Promise.all, and assert signalCode === null per the hang-guard convention. The bug-hunting system found nothing this run.

The known overlap with #34892 (stack-check-only subset) and #35174 (header ordering) is acknowledged in the description as trivial rebases whichever lands second.

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI is red on infrastructure, not this diff. Build #81680: the Rust side compiled cleanly on every lane that got an agent (linux-aarch64, linux-aarch64-musl, linux-x64-android, freebsd-x64 all show Finished release profile), then each timed out waiting on its sibling build-cpp job which never left scheduled. Build #81794 (retrigger): only the pipeline-upload step ran; 26 jobs expired without an agent and 169 downstream never started. Same pattern on main (#81770, #81444 both red).

The diff itself is green: all 20 tests in test/js/node/util/bun-inspect.test.ts pass on a local debug+ASAN build, cargo clippy / Format / Source lints / Lint JavaScript all pass on GitHub Actions, and the gate confirmed fail-before/pass-after. Ready for review once CI agents recover.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #36602, which consolidates the open AggregateError printing PRs. ae.errors.push(ae) now prints the header once followed by '[errors]: [Circular]' on every sink, with tests for the self and mutual cycle cases.

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

2 participants