error printer: fix infinite recursion when an error reaches itself through cause and errors - #37270
error printer: fix infinite recursion when an error reaches itself through cause and errors#37270robobun wants to merge 5 commits into
Conversation
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.
WalkthroughThe formatter now retains visited errors during rendering and propagates pending JavaScript exceptions. Regression tests cover cyclic errors in uncaught output, ChangesCircular error formatting
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
@coderabbitai review |
|
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.
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
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
📒 Files selected for processing (2)
src/jsc/ConsoleObject.rstest/regression/issue/circular-error-stack.test.ts
|
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. |
There was a problem hiding this comment.
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_preludeinserts,VisitedRemoveguard removes on exit, and keeping the value in the map duringprint_errorlike_objectcorrectly bounds both the inline-property re-entry (errors: [e]→print_as) and the cause-chain loop atVirtualMachine.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:
- Delete the 18-line block in
Formatter::print_errorthat temporarily removed the value from the visited map (via raw pointer +scopeguard::defer!with anunsafere-insertion) before callingprint_errorlike_object. The value now stays in the map, so re-entrant formatting ofcause/errorshits the existing[Circular]guard inprint_as_preludeand the cause-chain loop inprint_error_instance_body. - Add a 3-line
has_exception()check afterprint_errorlike_objectreturns, converting a pending exception intoErr(JsError::Thrown)— the identical pattern already used atConsoleObject.rs:3299inprint_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_as → print_as_prelude (insert) → print_error → print_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 anunsafere-insertion — deleting it is a clear safety improvement. - The added
has_exception()check does not clear the exception, soBun.inspect's stack-overflow rethrow (pinned bybun-inspect.test.ts) is preserved.
|
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. |
A plain
Errorthat is its owncauseand also sits in its ownerrorsarray crashes the process when the error printer renders it. Found sweeping hostile uncaught-error shapes delivered toworker.on('error').Repro
error: cyc / cause: ... / errors: [ ...alternating, then silent SIGSEGV (exit 139) on 1.4.0-canary and main.node:worker_threadsWorker: debug/ASAN builds abort the whole process withASSERTION 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 aserr.messageinstead of"cyc".console.log(e)on the same shape kills the script (exit 1) instead of continuing.e.cause = ealone ande.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(theTag::Errorarm) removed the value from the formatter's visited set before re-enteringprintErrorlikeObject, reasoning thatprint_ashad already done the circular check. But the error printer re-enters the formatter for the error's own properties:causerendered inline and theerrorsarray. Alternating between those two paths re-enteredprint_erroreach cycle, so the value was never in the set when it mattered and the recursion was unbounded. The cause-chain guard inprint_error_instance_bodykeys 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_bodyreturnedOkwith the thrown stack-overflowRangeErrorstill pending when side effects are disallowed, so the worker's error render (format2) reported success and the pending exception tripped the nextExceptionScopeassert; the pending exception also made the error serialization fail, which is why the parent saw the rendered dump asmessage.Fix
print_errorre-entry; self-references throughcause/errorsnow print[Circular]like every other cycle.Formatter::print_errorconverts an exception left pending byprint_errorlike_object(which returns unit) intoErrfor its?-chaining callers instead of reporting success. The exception itself stays pending and is never cleared on this path, soBun.inspectstill re-throws a genuine stack overflow from a deep non-cyclic chain (pinned bybun-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/messageintact (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)
passes on PR (with fix)
diff hotspot
gate history · 2 passed · 0 rejected · iteration 1
evidence per changed file