console: apply depth cap to Map/Set/Array entries and Error cause chains

Merged
RO
2mo ago
+357-32

Problem

  • console.log of a nested Map, Set, Array, MessageEvent, Error cause chain, or AggregateError prints every level. A 1000-deep Map prints 2 MB. Deeper chains throw RangeError: Maximum call stack size exceeded out of console.log. A nested AggregateError chain overflows the native stack.
  • Cause: only print_object (src/jsc/ConsoleObject.rs) compares depth to max_depth. The other container printers never compare it. The cause loop and agg_iter (src/jsc/VirtualMachine.rs) do not track depth.

Fix

  • Each container printer returns [Array ...], [Map ...], [Set ...], [MapIterator ...], or [MessageEvent ...] past the cap, like [Object ...], through one print_depth_exceeded_marker. An empty container still prints [], Map {}, or Set {}.
  • The cause loop and agg_iter bump depth and print [Error ...] past the cap. agg_iter also checks the native stack guard (stack_check). The error property dump narrows max_depth, so it keeps the caller's cap in outer_max_depth for these walks.
  • Bun.inspect.table passes its depth option as the cell start depth, against a fixed max_depth of 5. TablePrinter::set_start_depth now clamps it, so a depth above 5 prints cells like the default.
  • Verified: inspect.test.js (13 new cases, 12 fail on the released bun), bun-inspect-table.test.ts (2 new), and the related console suites.

Background

  • Formatter (ConsoleObject.rs) is the native walker behind console.log and Bun.inspect. max_depth comes from --console-depth, Bun.inspect(x, {depth}), or the default of 2.
  • print_error_instance_body (VirtualMachine.rs) prints an Error, not Formatter. It dumps the error's properties one level deep and walks cause and AggregateError.errors itself.
  • {depth: Infinity} sets max_depth to u16::MAX, and depth saturates there. A depth comparison alone cannot stop that walk.
Notes

Sizes before and after, on this branch:

inputbeforeafternode
100-deep cause chain~20 KB711 B575 B
1000-deep Map~2 MB167 B60 B
1000-deep Set~2 MB152 B40 B
100-deep Array~20 KB129 B20 B
1000-deep MessageEvent~3 MB261 Bn/a
3000 causes, 12000 Maps, 20000 AggregateErrorsRangeError or SIGSEGVtruncatestruncates

Tables. A container nested inside a cell now prints as a marker, like a nested plain object already did: { x: [Object ...] }, [ [Array ...] ], Map(1) { 1: [Map ...] }. Node's console.table prints the same shape ([ [Array] ], Map(1) { 1 => [Map] }). For a depth option above 5, the released bun starts every cell past the cap: a plain object cell prints [Object ...] and the new gates would have done the same to Array, Map, and Set cells. The clamp makes such a depth print cells like the default, object cells included. { depth: 0 } keeps its meaning (console-table.test.ts uses it to mirror console.table). The option is not redefined as a max_depth: #34241 did that and was closed without a merge.

AggregateError at the cap. When the members are one level past the cap, the AggregateError prints like any other error first (name, message, stack), then one [Error ...] per member. Without that, Bun.inspect(agg, { depth: 0 }) printed only the markers. Node prints the same shape: the header, then [errors]: [Array]. The normal output is unchanged: members only, as #39633 left it.

Errors inside Error properties. With err.details = { inner }, err.list = [inner], or an AggregateError inside a property, the cause and the members of the nested error follow the caller's depth: depth: Infinity prints them all, depth: 2 prints [Error ...]. Plain object properties keep the one-level cap.

Cause depth per entry point, measured with a 50-deep chain. console.log: Node prints root + 2 causes, this branch prints root + 2 causes. Uncaught throw: Node prints root + 5 causes, this branch prints root + 8 (the error handler's Formatter::new depth), released bun prints all 50. #39637 changes the error handler's formatter to the console depth. Combined with this PR as written, an uncaught error would print root + 2 causes, and #39637's three-cause test would fail. The cause loop reads the depth cap on purpose so {depth} and --console-depth control it. Whichever PR lands second has to pick: the console depth for causes, or #39637 applying its depth only on the non-Error branch.

test/js/web/console/console-log.expected.txt changes for [[[[Array(1000).fill(4)]]]], which now stops at the fourth level like Node. The removed text (a long array wrapped at indent 10, then ... 900 more items) is asserted again by long arrays get cutoff at a nested indent in console-log.test.ts, through Bun.inspect(x, { depth: 5 }).

stack_check is the formatter's native stack guard. Container printers reach it in print_as_prelude. The error walks do not, so agg_iter calls it directly.

Suites run on the merged branch: inspect.test.js, inspect-error.test.js, reportError.test.ts, console-log.test.ts, console-depth.test.ts, bun-inspect.test.ts, bun-inspect-table.test.ts, console-table.test.ts, build-error.test.ts (244 pass, 1 skip that is also skipped on main).

JSX and Proxy have the same unbounded recursion. #29709 is open with that fix, so it is left out here.

Review history: the AggregateError gate, its stack_check, the print_event gate, the relative max_depth, the state restore before ? in the cause loop, the empty-container order, outer_max_depth, the table clamp, and the AggregateError header at the cap each came from review and each has a test.

The branch merges main as of 2026-09-16. The only textual conflict was in inspect.test.js, where both sides appended a describe block.


no test proof · iteration 3 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/util/inspect.test.js