error printer: print the properties and cause of an Error thrown through a JSC exception - #37524
error printer: print the properties and cause of an Error thrown through a JSC exception#37524robobun wants to merge 1 commit into
Conversation
…ugh a JSC exception A synchronously failing test body and an uncaught exception thrown from a callback are printed from the JSC::Exception cell that unwound the stack. toZigException reads the throw-site stack through the cell, but print_error_instance_body also received the cell, so it never saw the Error's own properties, its code, or its cause chain. Hand the body the wrapped Error when the exception holds one; the stack still comes from the cell. Rename Exception::value to as_js_value since it returns the cell itself.
|
Warning Review limit reached
Next review available in: 1 minute Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
Comment |
|
Status: reproduced and fixed, waiting on CI. Reproduced on the current release build (Linux, and Windows at the same commit as this branch) with a test body that synchronously throws an Error carrying Cause: both paths print from the Fix: This is the second half of the |
There was a problem hiding this comment.
LGTM — small, well-guarded fix that unwraps the JSC::Exception cell before handing it to the error-body printer.
What was reviewed:
- Verified
JSValue::to_errorunwrapsJSC::Exceptioncells (bindings.cpp:5013) and thatjs_type()is safe on non-cell values (returnsCellType), sothrow 42through an exception cell falls through to the original path. - Confirmed the
ErrorInstanceguard preserves existing behavior forResolveMessage/BuildMessageand plain-object throws — only genuineErrorInstancevalues are unwrapped. - Checked the
value()→as_js_value()rename is complete (no remainingException::value()callers insrc/).
Extended reasoning...
Overview
The PR fixes error printing when an Error reaches the printer wrapped in a JSC::Exception cell (synchronous test throws, uncaught callback exceptions). Previously print_error_instance_body received the cell, whose js_type() is not ErrorInstance, so the block that prints code, own properties, and the cause chain was skipped. The fix is a 4-line unwrap in print_error_instance_js that calls to_error() and passes the wrapped ErrorInstance to the body while still passing the cell to remap_zig_exception (so the throw-site stack is preserved). A method rename (Exception::value → as_js_value) clarifies that it returns the cell, not the wrapped value.
Security risks
None. This is output formatting only — no parsing of untrusted input, no allocation changes, no refcount/GC changes.
Level of scrutiny
Medium-low. The change is 4 substantive lines plus a rename and doc comments. I verified the semantics of JSC__JSValue__toError_ in bindings.cpp (returns the value itself for ErrorInstanceType, exception->value() for JSC::Exception cells, empty otherwise) and that js_type() guards non-cells. The ErrorInstance match-guard means non-Error thrown values (primitives, ResolveMessage, plain objects) fall through unchanged to the existing path — the third test pins this. The change is strictly additive: it prints more detail than before, matching what the async/rejection path already prints.
Other factors
- Three spawned tests added to the existing
stack.test.ts(correct location per repo conventions). Tests 1 and 2 exercise both entry points (bun testsync throw,setTimeoutcallback) and would fail without the fix. Test 3 guards theResolveMessagenon-unwrap case. - Tests follow harness conventions:
tempDir,bunEnv,normalizeBunSnapshot,test.concurrent, drain-then-assert-exit-code,await usingfor spawned processes. - The rename covers both call sites (verified via grep); the repo checkout is at the PR HEAD so any missed site would fail
cargo check. - The PR description is unusually thorough and cross-references three related open PRs whose fixes will now also apply to synchronous throws.
What
When a test body throws synchronously, or a callback throws an uncaught exception, the printed error is missing everything except its name, message, and stack: own properties, the
codeline, and thecausechain are all dropped. The same error thrown from an async test (or rejected) prints all of them.Before (
bun test my.test.js, source preview lines elided; the same code inside asetTimeoutcallback underbun -eprints the same way):After, which is what an
asyncversion of the same test already printed:This is what was behind the handoff that led here: a
usingresource whose dispose threw during a failing test printed onlySuppressedError:with no trace of either error. See "Related" below.Cause
Both entry points (
BunTest::on_uncaught_exceptionaftertry_take_exception, andreport_uncaught_exception) handrun_error_handlertheJSC::Exceptioncell rather than the thrown value. That is deliberate:JSC__Exception__asJSValuereturns the cell, andtoZigExceptionlooks through it to take the stack of the throw site. Butprint_error_instance_jsalso passed the cell on toprint_error_instance_body, whereis_error_instancechecksjs_type() == ErrorInstance. AnExceptioncell is not anErrorInstance, so the whole block that prints thecode, the own properties, and the non-enumerablecause(and anything other PRs add to that block) was skipped. Rejections and top-level throws pass the Error itself, which is why only the synchronous and callback cases were affected.This is not a regression; the Zig version had the same shape.
Fix
print_error_instance_jskeeps passing the cell toremap_zig_exception, so the stack, source preview, and divot are unchanged, and passes the Error the exception wraps (JSValue::to_error, the existing helper for exactly this unwrap) toprint_error_instance_body. Values that are not anErrorInstanceare passed through as before. Unwrapping everything would turn the currentfor a synchronous
require()of a missing module into that plus a seconderror: Cannot find module ...block from the non-Error fallback, sinceBuildMessage/ResolveMessageare only intercepted before the body when they arrive bare. How non-Error values thrown through an exception should render (plain objects, internal messages,AggregateErrormembers) is a rendering decision that this PR leaves as it is today; the third test pins theResolveMessagecase.Exception::value()is renamed toas_js_value(): it returns the cell, not the value, and the old name is how this read as if the body was already getting the Error. Two call sites.Related
.error/.suppressedprinting forSuppressedErrorto the same body block. It fixesconsole.error(se)and a top-levelusingthrow, but on its own a failing test body with a throwing dispose still prints a bareSuppressedError:, because that error arrives through the exception cell. With both changes the test printsSuppressedError:, thenerror: dispose failed, then theexpect(received).toBe(expected)block (verified by building the two together). The same applies to the other open changes to that block (error printer: render non-Error cause values #35172 non-Error causes, error printer: print the AggregateError header, label [cause]/[errors] blocks, and guard the .errors walk #36602AggregateErrormembers): with this change they also take effect for synchronous test failures and uncaught callback exceptions.ErrorInstancecheck inprint_error_instance_jsshould become itsis_error_like()so function-style Error subclasses get the same treatment.Verification
test/js/bun/test/stack.test.ts(the file holding the existing own-property printing tests) gains three spawned cases: thebun testfailure above, the same error thrown fromsetTimeoutunderbun -e, and the synchronous missing-modulerequire()whose output must stay single. The first two fail on the current release build on Linux and Windows and pass with the fix on both; the third passes on both builds (it only guards theErrorInstancecheck) and leaves the stack frames and divot out of its snapshot because debug builds add an internalrequireframe there. Runningtest/js/bun/testandtest/cli/testagainst this build showed no other output changes; the few failures there were unrelated to the diff (a full disk while they ran, plus two snapshot tests whose mismatches were a[4.08s]timing suffix and missing ANSI codes).