Skip to content

fix(core): bound error graph conversion - #36070

Merged
nathanwhit merged 3 commits into
denoland:mainfrom
nathanwhit:fix/error-graph-conversion
Jul 16, 2026
Merged

nathanwhit merged 3 commits into
denoland:mainfrom
nathanwhit:fix/error-graph-conversion

Conversation

@nathanwhit

Copy link
Copy Markdown
Member

Summary

  • preserve cycle tracking while converting nested error causes and aggregate members
  • cap error conversion by both path depth and a shared node budget
  • contain failed aggregate property reads and bound recursive terminal formatting
  • add regressions for cycles, deep chains, shared graphs, and throwing getters

Bug

Deno converts JavaScript exceptions into a native JsError tree before formatting them. Aggregate members previously restarted conversion with a fresh cycle set, so a self-reference could recurse indefinitely. Deep cause chains also had no native recursion bound, and compact graphs containing the same child on multiple branches could cause conversion work to grow much faster than the input graph.

The converter now retains path-local cycle state, limits each path to 32 levels, and shares a 256-node budget across the complete conversion. It emits a single truncation marker when either limit is reached. Reads of AggregateError.errors and its elements are contained so a throwing getter produces an unavailable-details marker instead of disrupting conversion. The terminal formatter and its cause-chain prepasses have their own depth bound for manually constructed JsError values.

Validation

  • ./tools/format.js
  • cargo fmt --check --package deno_core --package deno_runtime
  • 6 focused deno_core error-conversion tests
  • cargo test -p deno_runtime test_format_js_error_truncates_deep_cause_chain --lib
  • cargo check -p deno_core -p deno_runtime
  • cargo build --bin deno
  • behavior smokes for a cyclic aggregate, a throwing errors getter, and a 100-level cause chain

@nathanwhit
nathanwhit marked this pull request as ready for review July 15, 2026 21:38
Cycle termination re-emits the repeated error one final time with its
cause and aggregate members dropped, instead of substituting a
"[Circular]" placeholder node.

The terminal formatter derives its "<ref *n>" / "[Circular *n]" labels
from that repeat: find_recursive_cause matches a cause against its
earlier occurrence by value, which is why is_same_error deliberately
ignores cause. A placeholder matches nothing, so the labels disappeared
and error_cause_recursive{,_tail,_aggregate} failed.

Aggregate members now honor the same rule via the path-local seen set,
which is what bounds the self-referential aggregate that previously
recursed until the stack overflowed. The depth cap, node budget, and
getter containment are unchanged.

@bartlomieju bartlomieju left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice fix. The self-referencing AggregateError overflow is well diagnosed: aggregate members restarted conversion with a fresh cycle set, and now cycle state is path-local and threaded into both cause and aggregate recursion. Confirmed the aggregate path was the only entry restarting state, and the layered bounds (32-deep + 256-node budget in conversion, independent 64-deep cap in the formatter) plus TryCatch containment of throwing getters all look correct. Test coverage is thorough (cycles, crossed cycles, deep chains, shared-graph fan-out, throwing getters).

Only non-blocking note: legitimate cause chains deeper than 32 now render [Error details truncated]. Real chains are almost always shallow so this is fine, just a heads-up that it's a user-visible change for pathological cases.

@nathanwhit
nathanwhit merged commit 6f570cf into denoland:main Jul 16, 2026
136 checks passed
bartlomieju pushed a commit that referenced this pull request Jul 23, 2026
## Summary

- preserve cycle tracking while converting nested error causes and
aggregate members
- cap error conversion by both path depth and a shared node budget
- contain failed aggregate property reads and bound recursive terminal
formatting
- add regressions for cycles, deep chains, shared graphs, and throwing
getters

## Bug

Deno converts JavaScript exceptions into a native `JsError` tree before
formatting them. Aggregate members previously restarted conversion with
a fresh cycle set, so a self-reference could recurse indefinitely. Deep
cause chains also had no native recursion bound, and compact graphs
containing the same child on multiple branches could cause conversion
work to grow much faster than the input graph.

The converter now retains path-local cycle state, limits each path to 32
levels, and shares a 256-node budget across the complete conversion. It
emits a single truncation marker when either limit is reached. Reads of
`AggregateError.errors` and its elements are contained so a throwing
getter produces an unavailable-details marker instead of disrupting
conversion. The terminal formatter and its cause-chain prepasses have
their own depth bound for manually constructed `JsError` values.

## Validation

- `./tools/format.js`
- `cargo fmt --check --package deno_core --package deno_runtime`
- 6 focused `deno_core` error-conversion tests
- `cargo test -p deno_runtime
test_format_js_error_truncates_deep_cause_chain --lib`
- `cargo check -p deno_core -p deno_runtime`
- `cargo build --bin deno`
- behavior smokes for a cyclic aggregate, a throwing `errors` getter,
and a 100-level cause chain
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants