Skip to content

error printer: render non-Error cause values - #35172

Closed
robobun wants to merge 5 commits into
mainfrom
farm/4990886f/print-non-error-cause
Closed

robobun wants to merge 5 commits into
mainfrom
farm/4990886f/print-non-error-cause

Conversation

@robobun

@robobun robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator

The native error printer silently drops Error.cause when it is not itself an Error instance. util.inspect already shows [cause]: {...} for these, so the context is only lost on the code paths that matter most (console.error and the uncaught-throw report).

Repro

console.error(new Error("req failed", { cause: { code: "ECAUSE", status: 503 } }));
// before: prints "error: req failed" with no mention of the cause object
// node:   prints "[cause]: { code: 'ECAUSE', status: 503 }"

console.error(new Error("L1", { cause: new Error("L2", { cause: "deep" }) }));
// before: prints L1 and L2 but drops the string cause at depth 2

An assigned enumerable e.cause = "x" did print (it flows through the own-property loop), which is why this hid.

Cause

VirtualMachine::print_error_instance_body looks up the non-enumerable cause via get_own but only recurses when cause.js_type() == JSType::ErrorInstance. Every other value (plain object, string, number, DOMException, a Response, etc.) falls through with no fallback render.

Fix

src/jsc/VirtualMachine.rs:

  • When the non-enumerable cause is not an ErrorInstance, render it inline using the same formatter settings as the enumerable own-property dump, so it appears as cause: <value>, in the property block.
  • Track saw_cause for any enumerable property named cause (not just ErrorInstance-typed ones) so an assigned non-Error cause is not rendered twice.
  • Hoisted the existing RestoreFmt drop guard one scope out so both the property loop and the cause fallback can share it.

Output after

error: req failed
 cause: {
  code: "ECAUSE",
  status: 503,
},

      at ...

Tests

New non-Error cause describe block in test/js/bun/util/inspect-error.test.js covering object cause, string cause at depth 2, null/number/undefined cause, no double-print for an enumerable cause, plus spawned-process checks for console.error and the uncaught-throw path. All fail on the unfixed build and pass with the fix.

Also widened the existing normalizeError helper to strip debug-only at require (N:N) frames as well as the older at require (:N:N) form so the two pre-existing minified-file snapshot tests stay green under bun bd.


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

fails on main (without fix)
ASAN without fix: 8 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/util/inspect-error.test.js
bun test v1.4.0 (8dc69958c)

test/js/bun/util/inspect-error.test.js:
(pass) error.cause [6.12ms]
(pass) Error [3.03ms]
(pass) BuildMessage [10.96ms]
(pass) Error inside minified file (no color)  [80.87ms]
(pass) Error inside minified file (color)  [47.03ms]
(pass) Inserted originalLine and originalColumn do not appear in node:util.inspect [278.98ms]
(pass) observable properties > sourceURL is observable [5.88ms]
(pass) observable properties > line is observable [2.21ms]
(pass) observable properties > column is observable [1.93ms]
(pass) error.stack throwing an error doesn't lead to a crash [4.16ms]
199 |   const strMark = ["STR", "CAUSE", "MARK"].join("_");
200 | 
201 |   test("Bun.inspect prints an object cause", () => {
202 |     const out = Bun.inspect(new Error("req failed", { cause: { code: objMark, status: 503 } }));
203 |     expect(out).toContain("cause:");
204 |     expect(out).toContain(objMark);
                      ^
error: expect(received).toContain(expected)

Expected to contain: "O
... (truncated)

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

test/js/bun/util/inspect-error.test.js:
(pass) error.cause [0.19ms]
(pass) Error [0.08ms]
(pass) BuildMessage [0.28ms]
(pass) Error inside minified file (no color)  [2.36ms]
(pass) Error inside minified file (color)  [0.31ms]
(pass) Inserted originalLine and originalColumn do not appear in node:util.inspect [4.75ms]
(pass) observable properties > sourceURL is observable [0.13ms]
(pass) observable properties > line is observable [0.05ms]
(pass) observable properties > column is observable [0.03ms]
(pass) error.stack throwing an error doesn't lead to a crash [0.06ms]
(pass) non-Error cause > Bun.inspect prints an object cause [0.10ms]
(pass) non-Error cause > Bun.inspect prints a string cause at depth 2 [0.08ms]
(pass) non-Error cause > Bun.inspect prints a null cause [0.05ms]
(pass) non-Error cause > Bun.inspect prints a number cause [0.03ms]
(pass) non-Error cause > Bun.inspect prints a undefined cause [0.02ms]
(pass) non-Error cause > enumerable non-Error cause is printed once [0.06ms]
(pass) non-Error cause > enumerable Error cause at depth 2 gets a full render [0.12ms]
(pass) non-Error cause > console.error and uncaught throw
... (truncated)
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/bun/util/inspect-error.test.js
bun test v1.4.0 (8dc69958c)

test/js/bun/util/inspect-error.test.js:
(pass) error.cause [5.55ms]
(pass) Error [3.16ms]
(pass) BuildMessage [11.06ms]
(pass) Error inside minified file (no color)  [85.91ms]
(pass) Error inside minified file (color)  [64.45ms]
(pass) Inserted originalLine and originalColumn do not appear in node:util.inspect [309.26ms]
(pass) observable properties > sourceURL is observable [7.83ms]
(pass) observable properties > line is observable [2.23ms]
(pass) observable properties > column is observable [1.83ms]
(pass) error.stack throwing an error doesn't lead to a crash [4.94ms]
(pass) non-Error cause > Bun.inspect prints an object cause [4.85ms]
(pass) non-Error cause > Bun.inspect prints a string cause at depth 2 [7.42ms]
(pass) non-Error cause > Bun.inspect prints a null cause [4.18ms]
(pass) non-Error cause > Bun.inspect prints a number cause [1.97ms]
(pass) non-Error cause > Bun.inspect prints a undefined cause [1.80ms]
(pass) non-Error cause > enumerable non-Error cause i
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 673ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 237 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_output v
... (truncated)
diff hotspot
src/jsc/VirtualMachine.rs              | 144 +++++++++++++++++++--------------
 test/js/bun/util/inspect-error.test.js | 121 ++++++++++++++++++++++-----
 2 files changed, 183 insertions(+), 82 deletions(-)

gate history · 3 passed · 0 rejected · iteration 1

evidence per changed file
file                                    reads  edits  tests
src/jsc/VirtualMachine.rs                   9      6      0
test/js/bun/util/inspect-error.test.js      7      9      0

root cause · written by the author bot

The native error printer only followed an error's cause when the value was a JSC ErrorInstance, so plain objects, strings, numbers, and other non-Error causes were silently omitted from both console.error output and the uncaught error report. The fix removes that type gate and adds a fallback that renders non-Error cause values inline using the inspector, while still recursing into Error-typed causes so chained errors print fully at any depth. This brings the printer's behavior in line with util.inspect and Node's [cause]: ... output.

print_error_instance_body only followed Error.cause when the cause was
itself a JSC ErrorInstance; a plain object, string, number, or other
value set via new Error(msg, { cause }) was silently dropped from both
console.error and the uncaught-throw report. util.inspect already
handled this correctly, so the context was only lost at the point it
mattered most.

When the non-enumerable cause is not an ErrorInstance, format it inline
using the same style as enumerable own properties. Also mark cause as
seen regardless of its type so an enumerable non-Error cause is not
printed twice.
@coderabbitai

coderabbitai Bot commented Jul 22, 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: 1 minute

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: 4a8a0b7c-bbe3-49bf-b150-56b57be6f4d4

📥 Commits

Reviewing files that changed from the base of the PR and between 47597ab and 8dc6995.

📒 Files selected for processing (2)
  • src/jsc/VirtualMachine.rs
  • test/js/bun/util/inspect-error.test.js

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

@robobun

robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:10 PM PT - Jul 22nd, 2026

❌ @robobun, your commit 8dc6995 has 2 failures in Build #78100 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35172

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

bun-35172 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Unsupported proper logging of AggregateError, Error.cause, modified/accessed Error.stack #1352 - This issue reports that Error.cause doesn't get printed at all in logged errors; this PR adds a fallback render path for non-Error cause values (objects, strings, numbers, etc.) in the native error printer, directly addressing that aspect of the bug.

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

Fixes #1352

🤖 Generated with Claude Code

The formatter collapsed a blank line, shifting the inline-snapshot
line references in the minified-file tests. Split the normalizeError
comment over two lines to keep the original offsets.
Comment thread src/jsc/VirtualMachine.rs Outdated
Both the enumerable-property loop and the non-Error cause fallback now
expand the same macro body, so the formatter settings and exception
policy can't drift between them.

@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 two inline notes are nits (a niche depth-≥2 enumerable-Error-cause edge case where pre-PR output was already wrong, and a test-file require placement); neither blocks. The earlier dedup feedback was addressed in fc83a03 with the write_error_field! macro.

Extended reasoning...

Overview

Adds a fallback render path in VirtualMachine::print_error_instance_body so a non-ErrorInstance cause (object, string, number, null, undefined) is printed inline in the property block instead of being silently dropped. The enumerable-property render body was extracted into a local write_error_field! macro (addressing my earlier dedup comment), saw_cause is now set for any enumerable cause to avoid double-printing, and the trailing-newline check moved after the cause fallback so it fires when the cause is the only rendered field. Test coverage in inspect-error.test.js spans Bun.inspect, console.error, and the uncaught-throw path via spawned subprocesses.

Security risks

None. This is diagnostic output formatting; no new user-controlled paths into allocation, syscalls, or exception handling beyond what the existing property loop already exercised. The macro reuses the same clear_exception / early-return policy the pre-PR code had.

Level of scrutiny

Moderate — VirtualMachine.rs is core, but the touched region is the error printer, whose blast radius is stderr formatting rather than runtime semantics. The refactor is largely mechanical (hoist struct + macro-ize existing block + add one else branch). I traced the RestoreFmt guard scoping through the macro expansion and confirmed is_first_property / formatter borrow lifetimes stay correct after the reshuffle.

Other factors

  • Fails-on-main / passes-with-fix evidence provided for both ASAN-debug and release builds.
  • The two remaining findings are explicitly nits: the saw_cause hoist changes a depth-≥2 enumerable-Error-cause path from "double-printed with full stack" to "single truncated inline render" — the old output was itself a bug, so this is a lateral move on a niche path. The require("harness") placement is a style guideline deviation that appears deliberate to preserve line-number-sensitive snapshots above it.
  • Prior review thread (dedup) is resolved and marked as such.

Comment thread src/jsc/VirtualMachine.rs
Comment thread test/js/bun/util/inspect-error.test.js Outdated
…import

- An enumerable Error-typed cause now always pushes to errors_to_append
  for a full recursive render, independent of prev_had_errors, so a
  depth>=2 assigned cause keeps its stack trace instead of falling
  through to the truncated inline path.
- Moved bunEnv/bunExe to a module-scope import and regenerated the
  line-number-sensitive inline snapshots.

@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 — all three prior review notes (macro dedup, depth≥2 enumerable Error cause, top-level harness import) are addressed cleanly.

What was reviewed:

  • write_error_field! macro: verified the RestoreFmt guard, formatter-state capture/restore, and exception-handling policy match the pre-PR property-loop body it replaces.
  • Enumerable cause handling: an Error-typed enumerable cause now unconditionally pushes to errors_to_append (independent of prev_had_errors), so depth-1 behavior is unchanged and depth-2 gets a single full render instead of the old truncated+double-print.
  • !is_first_property newline reorder: moved after the cause fallback so an inline-rendered non-Error cause still gets its trailing blank line; the code block ordering is preserved.
  • normalizeError regex: anchored to \(:?\d+:\d+\)$ so it only strips sourceless debug frames — path-bearing frames like ([dir]/file.js:95:7) don't match.
Extended reasoning...

Overview

Output-only fix in VirtualMachine::print_error_instance_body (src/jsc/VirtualMachine.rs): when a non-enumerable Error.cause is not itself an ErrorInstance, render it inline in the property block instead of dropping it. The change extracts the pre-existing enumerable-property render body into a local write_error_field! macro (per my first-round feedback), reuses it for the cause fallback, and fixes the depth≥2 enumerable Error-typed cause case (per my second-round feedback) by pushing it to errors_to_append unconditionally rather than only when !prev_had_errors. Test file gains a non-Error cause describe block plus a widened normalizeError helper for debug-only frames.

Security risks

None. This is the error-instance pretty-printer — output formatting only. No new user-input parsing, no allocation sizing on external data, no FFI surface change.

Level of scrutiny

Medium-low. VirtualMachine.rs is a core file, but the touched region is the property-dump section of the error printer, and the change is behavior-additive (previously-dropped values now render). The macro is a mechanical extraction of the pre-existing block; I diffed it line-by-line against the original and the only substantive changes are the parameterized label/label-len/value and the RestoreFmt field-initializer reorder (state is now captured inline in the struct literal rather than via four prev_* locals — semantically identical). The is_first_property newline moving after the cause fallback is intentional and correct: a non-Error cause rendered inline needs the same trailing blank line the enumerable-property block produces.

Other factors

This is my third pass. Both prior rounds of nits were addressed in follow-up commits (fc83a03, 8dc6995) with the exact fixes I suggested, and the author added the depth-2 test I described. The evidence block shows the new tests fail on the unfixed debug build (7 failures) and pass on both debug+ASAN and release with the fix. Subprocess tests drain pipes concurrently, use test.concurrent, and assert content before exit code. The runtime-constructed marker strings are a reasonable workaround for the source-preview lines Bun.inspect prepends. The regenerated inline snapshots are a mechanical consequence of adding the line-2 import.

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

CI on build #78100 is green for this diff. test/js/bun/util/inspect-error.test.js passes on every lane.

The two remaining red tests are pre-existing GC-timing Node compat failures also present on main:

  • test/js/node/test/parallel/test-net-connect-memleak.js (ubuntu 25.04 x64, debian 13 x64)
  • test/js/node/test/parallel/test-gc-http-client-connaborted.js (ubuntu 25.04 x64, stuck at 7/8 collected)

Neither touches the error printer or console output. Ready for review.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-22, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

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