Skip to content

error printer: fix infinite recursion when an error reaches itself through cause and errors - #37270

Open
robobun wants to merge 5 commits into
mainfrom
farm/6002b032/error-printer-cyclic-cause-errors
Open

error printer: fix infinite recursion when an error reaches itself through cause and errors#37270
robobun wants to merge 5 commits into
mainfrom
farm/6002b032/error-printer-cyclic-cause-errors

Conversation

@robobun

@robobun robobun commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

A plain Error that is its own cause and also sits in its own errors array crashes the process when the error printer renders it. Found sweeping hostile uncaught-error shapes delivered to worker.on('error').

Repro

const e = new Error('cyc'); e.cause = e; e.errors = [e]; throw e;
  • Main thread: prints error: cyc / cause: ... / errors: [ ... alternating, then silent SIGSEGV (exit 139) on 1.4.0-canary and main.
  • Thrown inside a node:worker_threads Worker: debug/ASAN builds abort the whole process with ASSERTION FAILED: Unexpected exception observed ... Error Exception: Maximum call stack size exceeded ... assertNoExceptionExceptTermination - ExceptionScope.h(63). Release survives, but the parent's 'error' event receives the printer's multi-line rendered dump as err.message instead of "cyc".
  • console.log(e) on the same shape kills the script (exit 1) instead of continuing.
  • Controls: e.cause = e alone and e.errors = [e, e] alone were already handled.

Node prints <ref *1> Error: cyc ... cause: [Circular *1], errors: [ [Circular *1] ] and exits 1.

Cause

Formatter::print_error (the Tag::Error arm) removed the value from the formatter's visited set before re-entering printErrorlikeObject, reasoning that print_as had already done the circular check. But the error printer re-enters the formatter for the error's own properties: cause rendered inline and the errors array. Alternating between those two paths re-entered print_error each cycle, so the value was never in the set when it mattered and the recursion was unbounded. The cause-chain guard in print_error_instance_body keys on the same set, so it was bypassed the same way.

The worker abort is a second contract bug surfaced by the same shape: print_error_instance_body returned Ok with the thrown stack-overflow RangeError still pending when side effects are disallowed, so the worker's error render (format2) reported success and the pending exception tripped the next ExceptionScope assert; the pending exception also made the error serialization fail, which is why the parent saw the rendered dump as message.

Fix

  • Keep the value in the visited set across the print_error re-entry; self-references through cause/errors now print [Circular] like every other cycle.
  • Restore the error contract at the formatter boundary: Formatter::print_error converts an exception left pending by print_errorlike_object (which returns unit) into Err for its ?-chaining callers instead of reporting success. The exception itself stays pending and is never cleared on this path, so Bun.inspect still re-throws a genuine stack overflow from a deep non-cyclic chain (pinned by bun-inspect.test.ts), while the worker render can no longer return success with an exception pending.

With the cycle bounded, the worker render no longer overflows, nothing is left pending, and the parent receives name/message intact (matches Node).

Related open PRs cover adjacent shapes in the same printer: self-referencing AggregateError (#35820, #35825) and depth-capping deep cause chains (#35288).

Verification

Tests added to test/regression/issue/circular-error-stack.test.ts (the file from the original cycle-guard fix, #22863): uncaught throw, console.log, and the worker delivery. All three fail on the unfixed build (SIGSEGV / exit 1 / ASAN abort with mangled message) and pass with the fix; the existing six tests in the neighboring error-printing suites (inspect.test.js, reportError.test.ts, worker_threads suite, console suites) stay green.


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

fails on main (without fix)
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/regression/issue/circular-error-stack.test.ts
bun test v1.4.0 (796adb283)

test/regression/issue/circular-error-stack.test.ts:
(pass) error with circular stack reference should not cause infinite recursion [312.56ms]
(pass) error with nested circular references should not cause infinite recursion [285.33ms]
(pass) error with circular reference in cause chain [282.38ms]
92 |   });
93 | 
94 |   const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
95 | 
96 |   expect(stderr).toContain("error: cyc");
97 |   expect(stderr).toContain("[Circular]");
                      ^
error: expect(received).toContain(expected)

Expected to contain: "[Circular]"
Received: "1 | const e = new Error('cyc'); e.cause = e; e.errors = [e]; throw e;\n                  ^\nerror: cyc\n errors: [\n  1 | const e = new Error('cyc'); e.cause = e; e.errors = [e]; throw e;\n                  ^\nerror: cyc\n  cause: 1 | const e = new Error('cyc'); e.cause = e; e.errors = [e]; throw e;\n                  ^\nerror: cyc
... (truncated)

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

test/regression/issue/circular-error-stack.test.ts:
(pass) error with circular stack reference should not cause infinite recursion [8.17ms]
(pass) error with nested circular references should not cause infinite recursion [6.92ms]
(pass) error with circular reference in cause chain [6.44ms]
(pass) uncaught error that is its own cause and its own errors entry [5.31ms]
(pass) console.log of error that is its own cause and its own errors entry [5.23ms]
(pass) worker uncaught cyclic error reaches the parent error event intact [45.62ms]

 6 pass
 0 fail
 22 expect() calls
Ran 6 tests across 1 file. [216.00ms]
__F:0: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/regression/issue/circular-error-stack.test.ts
bun test v1.4.0 (796adb283)

test/regression/issue/circular-error-stack.test.ts:
(pass) error with circular stack reference should not cause infinite recursion [317.96ms]
(pass) error with nested circular references should not cause infinite recursion [286.62ms]
(pass) error with circular reference in cause chain [285.04ms]
(pass) uncaught error that is its own cause and its own errors entry [285.32ms]
(pass) console.log of error that is its own cause and its own errors entry [272.53ms]
(pass) worker uncaught cyclic error reaches the parent error event intact [2530.63ms]

 6 pass
 0 fail
 22 expect() calls
Ran 6 tests across 1 file. [5.79s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 675ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 93 exports (host=3, lazy=10, generic=80, rust=0); 239 extern-C blocks audited
[1/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   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_brotli v
... (truncated)
diff hotspot
src/jsc/ConsoleObject.rs                           | 24 ++-------
 test/regression/issue/circular-error-stack.test.ts | 61 ++++++++++++++++++++++
 2 files changed, 66 insertions(+), 19 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                                reads  edits  tests
src/jsc/ConsoleObject.rs                                8      6      0
test/regression/issue/circular-error-stack.test.ts      2      3      0

A plain Error that is its own cause and also sits in its own errors array
crashed the process when the uncaught-exception printer rendered it
(silent SIGSEGV on the main thread; thrown inside a Worker, debug/ASAN
builds aborted on assertNoExceptionExceptTermination and release builds
delivered the rendered dump as the parent error event's message).

The formatter's Tag::Error handler removed the value from the visited set
before re-entering printErrorlikeObject, on the theory that print_as had
already done the circular check. But the error printer re-enters the
formatter for the error's own properties (cause rendered inline, the
errors array), so alternating between the two paths never found the value
in the set and recursed until the stack ran out. Keeping the value in the
set makes those re-entries print [Circular] like every other cycle.

Also restore the printer's error contract: print_error_instance_body
returned Ok with a thrown exception still pending when side effects were
disallowed, and print_errorlike_object can swallow exceptions outright.
Propagate instead, so console.log and the worker error render don't leak
a pending exception into the next ExceptionScope, and the worker's error
serialization no longer fails (the parent now receives the real message).

Verified: the repro exits 1 with [Circular] markers on the main thread,
console.log continues afterwards, and a Worker delivers name/message
intact with no debug assert.
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The formatter now retains visited errors during rendering and propagates pending JavaScript exceptions. Regression tests cover cyclic errors in uncaught output, console.log, and worker-thread error events.

Changes

Circular error formatting

Layer / File(s) Summary
Preserve circular tracking during error rendering
src/jsc/ConsoleObject.rs
print_error keeps the value in the visited map during formatting and returns JsError::Thrown when a pending exception exists.
Validate cyclic error output and worker handling
test/regression/issue/circular-error-stack.test.ts
Tests verify circular markers, exit codes, continued execution, and worker error messages for self-referential cause and errors values.

Possibly related PRs

  • oven-sh/bun#36912: Both changes update Formatter error propagation in src/jsc/ConsoleObject.rs.
  • oven-sh/bun#35039: Both changes cover nested or circular error rendering.

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 clearly identifies the error printer and the infinite-recursion fix, which is the primary change.
Description check ✅ Passed The description explains the problem, cause, fix, affected behavior, and verification results, although it does not use the template headings.

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

@github-actions github-actions Bot added the claude label Aug 9, 2026
@robobun

robobun commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.

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

Returning Err from the no-side-effects bail routed genuine stack-overflow
exceptions into print_error_from_maybe_private_data's clear_exception, so
Bun.inspect of a deep (non-cyclic) Error chain with depth Infinity no
longer threw the RangeError its callers pin (bun-inspect.test.ts).

Leave the body returning Ok with the exception pending, as before; the
formatter boundary (Formatter::print_error) still converts it to
Err(Thrown) without clearing, which is what keeps the worker error render
from reporting success with an exception pending.
Comment thread src/jsc/ConsoleObject.rs Outdated
Comment thread src/jsc/ConsoleObject.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/ConsoleObject.rs Outdated
Comment thread src/jsc/ConsoleObject.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/ConsoleObject.rs
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Guard AggregateError .errors printing against self-reference #35820 - Adds the same visited-map [Circular] guard around the error printer's .errors recursion and seats StackCheck::init() on both uncaught-print paths, which bounds the same self-referencing-error crash.
  2. console: guard AggregateError .errors recursion (cycle, depth, tampered property) #35825 - Near-identical to Guard AggregateError .errors printing against self-reference #35820 (visited-map [Circular] guard plus StackCheck seating on both uncaught-print entry points), targeting the same unbounded error-printer recursion.
  3. Guard print_errorlike_object against unbounded AggregateError recursion #34892 - Seats StackCheck on the same two uncaught-print entry points and bails early in print_errorlike_object, stopping the same unbounded recursion/SIGSEGV.
  4. error printer: clear the pending exception when formatting a non-Error uncaught value throws #36921 - Fixes the identical pending-exception-leak contract in print_error_instance_body (returning success with an exception still pending), the same change as this PR's second half.
  5. Seat StackCheck in both Formatter::new() constructors so deep non-Error values don't SIGSEGV the printer #34884 - Changes Formatter::new() to StackCheck::init(), seating the check for the uncaught error printer so the existing recursion guard fires instead of crashing on the same repro.

🤖 Generated with Claude Code

@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/regression/issue/circular-error-stack.test.ts`:
- Around line 86-88: In test/regression/issue/circular-error-stack.test.ts,
replace the rationale comment at lines 86-88 with the confirmed GitHub issue URL
comment, and remove the duplicate rationale block at lines 123-127; retain only
the single issue-URL comment required for regression tests.
- Around line 90-97: Drain both configured subprocess pipes concurrently in both
test cases: at test/regression/issue/circular-error-stack.test.ts lines 90-97,
include stdout alongside stderr and proc.exited in the Promise.all call; at
lines 105-116, include stderr alongside stdout and proc.exited. No other changes
are needed.
🪄 Autofix

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: 88ddf1fa-210d-4104-a2e0-20f71eb8947d

📥 Commits

Reviewing files that changed from the base of the PR and between d2f48c6 and a63cab1.

📒 Files selected for processing (2)
  • src/jsc/ConsoleObject.rs
  • test/regression/issue/circular-error-stack.test.ts

Comment thread test/regression/issue/circular-error-stack.test.ts Outdated
Comment thread test/regression/issue/circular-error-stack.test.ts Outdated
@robobun

robobun commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author

Checked all five; same printer, different bugs, and this PR was deliberately narrowed to not overlap them.

All five can land independently of this one.

Comment thread test/regression/issue/circular-error-stack.test.ts Outdated

@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 fix removes the visited-map removal/re-insertion dance in print_error and adds the same has_exception() propagation already used in print_as_prelude.

What was reviewed:

  • Traced the visited-map lifecycle: print_as_prelude inserts, VisitedRemove guard removes on exit, and keeping the value in the map during print_errorlike_object correctly bounds both the inline-property re-entry (errors: [e]print_as) and the cause-chain loop at VirtualMachine.rs:6438.
  • Confirmed the top-level error still prints (the map is only consulted for child re-entries, not the initial render).
  • Tests drain both pipes, use test.concurrent, and the author verified fail-without-fix on both ASAN and release.
Extended reasoning...

Overview

The PR fixes a SIGSEGV in the error printer when an Error is both its own cause and an entry in its own errors array. The change is a net -14 lines in src/jsc/ConsoleObject.rs plus three regression tests.

The production change has two parts:

  1. Delete the 18-line block in Formatter::print_error that temporarily removed the value from the visited map (via raw pointer + scopeguard::defer! with an unsafe re-insertion) before calling print_errorlike_object. The value now stays in the map, so re-entrant formatting of cause/errors hits the existing [Circular] guard in print_as_prelude and the cause-chain loop in print_error_instance_body.
  2. Add a 3-line has_exception() check after print_errorlike_object returns, converting a pending exception into Err(JsError::Thrown) — the identical pattern already used at ConsoleObject.rs:3299 in print_as_prelude.

Security risks

None. This is error-formatting output logic; no auth, crypto, filesystem, or network surface. The change removes an unsafe block rather than adding one.

Level of scrutiny

Medium. The error printer is subtle and this file has several other open PRs targeting adjacent shapes (#35820, #35825, #34892, #34884, #36921), but the author analyzed each and this change is deliberately scoped not to overlap — it fixes only the Formatter::print_error visited-map handling, not the AggregateError branch or StackCheck seating those PRs cover. The removed code was demonstrably wrong (caused a crash), and the replacement is strictly simpler. I traced the map lifecycle through print_asprint_as_prelude (insert) → print_errorprint_errorlike_object → property iteration → re-entrant print_as (finds existing → [Circular]) → cause-chain loop at VirtualMachine.rs:6427-6454 (finds existing → [Circular]) → VisitedRemove guard (remove on exit). The top-level error still renders because the map is only consulted for children.

Other factors

  • All prior review feedback (my pipe-draining note, CodeRabbit's identical finding, and the comment-cop bot's comment-length flags) was addressed in cf12347/a63cab1/796adb2; every thread is resolved.
  • The gate evidence in the description shows all three new tests fail on both ASAN-debug and release without the fix (SIGSEGV / mangled worker message) and pass with it.
  • The removed code included a raw-pointer capture inside scopeguard::defer! with an unsafe re-insertion — deleting it is a clear safety improvement.
  • The added has_exception() check does not clear the exception, so Bun.inspect's stack-overflow rethrow (pinned by bun-inspect.test.ts) is preserved.

@robobun

robobun commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: 195 of 196 jobs passed on 796adb2. The one red lane (darwin 14 x64 test-bun) is test/cli/test/parallel.test.ts, which the CI helper marks as pre-existing (same failure on main) and which this diff does not touch; it has been reported for main-break triage. The remaining entries in the annotation passed alone or on retry. The previously red bun-inspect.test.ts stack-overflow test passes on this revision.

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