Repository navigation
One value formatter for console, Bun.inspect, the error printer and bun:test - #44268
Closed
dylan-conway wants to merge 44 commits into
Closed
dylan-conway wants to merge 44 commits into
dylan-conway wants to merge 44 commits into
Conversation
…one recursion gate
…rty walk, and printers outside bun_jsc the same API as those inside
…eviated or cut short
…embers of a queued AggregateError, and leave the stack check to the formatter's gate
…s of an arguments object whose length is an accessor, and print a non-Error member of an AggregateError at its own depth
… only a primitive string as a format string
… as it reports entries, and adjust the imported tests
…ft in the printers, and bound a run of holes in a diff
…un of more than 8 holes as one line in a diff
…nt into its run of holes, and take the tests for hole runs
Collaborator
|
#45989 overlaps with this PR in
There are two ways to resolve it, for whichever PR lands second:
Which rule do you want for error dumps? I do the matching resolution on #45989 if this PR lands first. #39592 returns node workers to the native console. Without the option read, |
Collaborator
|
#46156 overlaps with this PR in
|
This was referenced Oct 21, 2026
Open
This was referenced Oct 27, 2026
robobun
added a commit
that referenced
this pull request
Oct 27, 2026
…roxy no props - `JSValue::get_own_non_observable` returns `JsResult`. `NodeVMGlobalObject::getOwnPropertySlot` throws a stack overflow in every lookup mode. A React element whose props is a node:vm global left that error pending at the stack limit, and a build with assertions aborted in `assertNoExceptionExceptTermination`. - The lookup calls `getOwnPropertySlotInline`, the dispatch that `JSC__JSValue__getOwn` uses. - `JSValue::through_proxies` stops at a revoked Proxy, and the JSX printers print no props for it. The walk used to read the old target of a revoked Proxy. #44268 has this behaviour. - The test of the non-enumerable `$$typeof` getter asserts the exact output. Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
robobun
added a commit
that referenced
this pull request
Oct 28, 2026
…ve bun:test as on main - The bun:test formatter (`pretty_format.rs`) is as on main again. It writes stored snapshots, and its reference, Jest's pretty-format, reads `$$typeof`, `type` and the props with ordinary gets. - The reads move to `src/jsc/formatter/reader.rs` over one leaf in `FormatterReads.cpp`. `JSValue::get_own_non_observable`, `JSValue::through_proxies` and the binding in `bindings.cpp` are gone. - The leaf takes a field index. `BunBuiltinNames` makes each of the five names on first use, so the `$$typeof` probe of every printed object no longer builds and frees an identifier. - An accessor `children` prints as `children=[Getter]`, like every other accessor prop. - A Proxy `type` is named through its innermost target, so no trap runs. A revoked one prints `NoName`. The reader shape (`react_typeof`, `field`, `through_proxies`) and the `FormatterReads.cpp` home come from #44268. Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
robobun
added a commit
that referenced
this pull request
Oct 28, 2026
…roxy no props - `JSValue::get_own_non_observable` returns `JsResult`. `NodeVMGlobalObject::getOwnPropertySlot` throws a stack overflow in every lookup mode. A React element whose props is a node:vm global left that error pending at the stack limit, and a build with assertions aborted in `assertNoExceptionExceptTermination`. - The lookup calls `getOwnPropertySlotInline`, the dispatch that `JSC__JSValue__getOwn` uses. - `JSValue::through_proxies` stops at a revoked Proxy, and the JSX printers print no props for it. The walk used to read the old target of a revoked Proxy. #44268 has this behaviour. - The test of the non-enumerable `$$typeof` getter asserts the exact output. Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
robobun
added a commit
that referenced
this pull request
Oct 28, 2026
…ve bun:test as on main - The bun:test formatter (`pretty_format.rs`) is as on main again. It writes stored snapshots, and its reference, Jest's pretty-format, reads `$$typeof`, `type` and the props with ordinary gets. - The reads move to `src/jsc/formatter/reader.rs` over one leaf in `FormatterReads.cpp`. `JSValue::get_own_non_observable`, `JSValue::through_proxies` and the binding in `bindings.cpp` are gone. - The leaf takes a field index. `BunBuiltinNames` makes each of the five names on first use, so the `$$typeof` probe of every printed object no longer builds and frees an identifier. - An accessor `children` prints as `children=[Getter]`, like every other accessor prop. - A Proxy `type` is named through its innermost target, so no trap runs. A revoked one prints `NoName`. The reader shape (`react_typeof`, `field`, `through_proxies`) and the `FormatterReads.cpp` home come from #44268. Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
This was referenced Sep 12, 2026
This was referenced Nov 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Bun prints JS values with two formatters.
src/jsc/ConsoleObject.rsservesconsole.*,Bun.inspect, the uncaught-error printer and matcher messages.src/runtime/test_runner/pretty_format.rswas forked from it forbun:testsnapshots and diffs. Both print whatever a program can build, and neither had one place where a value is checked before a printer descends into it:Tag::JSXandTag::Proxyhad no guard in one copy,Tag::Errorremoved itself from the visited set before recursing,Formatter::newhanded out aStackCheckthat always says "safe".length, asize, atoStringor an iterator supplied by the value could throw, hang or change the value mid-print.Each instance has been fixed on its own, in one copy, for one tag. The list at the end has the open PRs that do that. The same shapes keep coming back because nothing stops the next printer, tag or call site from skipping the check.
console.logof a JSX element that is its own child[Circular]in place of the childconsole.logof 100,000 nested Proxiesexpect(v).toEqual(1),va 50,000-deep arrayexpect(v).toEqual(1),v24 levels of{ l: v, r: v }Bun.inspect(e, { depth: 100 }),ea chain of 10 assigned causesBun.inspect(map.entries())(debug build, linux x64)
The repros from the two issues with one, on release builds:
expect(leaf).toBeNull(), happy-dom, 421 nodesexpect(container).toMatchSnapshot(), 20 list items in happy-domFix
One formatter,
bun_jsc::Formatter, with aStyle(ConsoleorJest) that only selects the text.One gate (
src/jsc/formatter/guard.rs).print_ascallsenter, which checks the native stack, the path (cycles) and the shared-reference budget, and returns anEnteredtoken or prints a placeholder. Every printer takes the token, and onlyentercan make one. Printing the same value under another tag goes throughdispatchwith the token, which replaces theremove_before_recursedance.Tag::holds_valueshas no_arm, so a new tag does not compile until someone decides. The error printer'scauseanderrorswalks go through the same gate instead of a private copy of the visited set.Named policies.
Formatter::newis private. A caller states what it prints for:console,table_cell,error_handler,message,matcher_message,diff,snapshot. The policy decides whether a stack overflow throws or stops the print, and what happens past the budget. 108 call sites state one.Shared-reference budget. The visited set now remembers what was printed, not only what is on the path. Bytes printed while repeating an already printed value count against a budget, and past it the value prints as
[Object ...]/[Object]. Output that is linear in the size of the value is never cut. Every top-level value starts with the whole budget, and the "printed" marks are dropped at each garbage collection, because they do not keep their objects alive and a new object can get the address of a dead one.console.*,Bun.inspect,console.table, the error printerutil.inspectstops descendingutil.inspectcounts all output, so it also abbreviates a large value that repeats nothing. Counting repeats cuts only what was already printed in full. Where it matters the two agree: for 40 levels of{ a: o, b: o }atdepth: Infinity, release build: Bun prints 134,224,805 characters in 296 ms, Node v26.7.0 prints 134,402,654 in 483 ms.One reader (
src/jsc/formatter/reader.rs,src/jsc/bindings/FormatterReads.cpp). Own data properties, boxed primitives, RegExp source, native Event fields, array length, present indexes and Map/Set iterator contents come from internal state and run no user code. Listings are bounded by what the value holds or by a fixed budget, not by alengthor asizeit reports: a Map or Set ends after the entry count taken before the first entry printed, a subclass is listed by its own iterator for as many steps as it reports entries (quick-lru extendsMapand keeps its entries elsewhere) and at most 2^20 past what it holds, an array is walked by its present indexes, andconsole.tableandAggregateError.errorsgive any other iterable 1000 and 100 steps. "Hooks that still run" below has what is left.One property walk.
forEachProperty,forEachPropertyNonIndexedandforEachPropertyOrderedare one template overPropertyWalk. The sorted walk writes snapshots, so it keeps three differences: it does not ask the class for its property names (a module namespace and a global proxy list nothing), it lists a non-enumerable__esModule, and an accessor JSC cannot cache isnull.Printers outside
bun_jsc(Response,Request,Blob,S3Client,Archive,BuildArtifact) take the concreteFormatterand the same byte writer as the built-in printers. TheConsoleFormatterandAsymmetricMatcherFormatterbridge traits and their adapters are gone, and the hook propagates a thrown error instead of discarding it.A throw that was lost.
BunString__toErrorInstancereturned an empty value with no exception pending for a message longer than the longest string. The matcher'sthrowwas then a no-op and the failed assertion passed, which is the second half of #37310. It returns JSC's out-of-memory error, like"x".repeat(2 ** 31).Deleted:
pretty_format.rs(2,935 lines; 225 lines that print asymmetric matchers remain asasymmetric_matcher_format.rs, 905 lines of layout are insrc/jsc/formatter/jest.rs), both bridge traits,make_formatter,FormatterTestExt,JSValue::for_each_property*,for_each_with_context,TopExceptionScope::init,StackCheck::update,impl Default for StackCheck(the check that always says "safe"),Unaligned::get.What keeps it fixed
test/internal/source-lints/formatter-reads.test.tsis a ratchet on the reads that can run user code inside the printers.test/js/bun/util/inspect.test.jschecks 62 kinds of hostile value against 24 sinks, one process for each of the seven policies. A new kind of value or a new sink is one line. On main every one of the seven fails: six end in SIGSEGV andconsole.tablein SIGABRT under a 16 GB limit.Text that changes
Snapshots: nothing main could store, with three new errors. The
snapshotpolicy keeps the classification, the reads, the cycle detection and the line-length bookkeeping of the formatter that wrote the stored ones, bugs included:<input type="text"value="foo" />, a trailingResponse {}andBuildArtifact {}, a Proxy as raw JSON. So in a snapshot a getter, a replacedtoStringor the iterator of a subclass still runs, as on main. It gains the stack check, the budget and the bounds. All of it hangs offFormatter::is_stored_snapshot, for a follow-up to remove.Compared with main, release builds of both:
The 58 and 26 of the 27 are a WeakMap and a WeakSet with an own numeric
size, where main throwsType error. The other one prints uninitialized memory (SlowBuffer). Main crashes on 12 more of the cyclic graphs, this branch on none. Six tests insnapshot.test.tspin what differs from a diff. Five pass on main, four of them with expected text that main's binary wrote. The sixth has the bounds, which hang there.New errors, for values main could store:
Diffs, which are not stored:
[class ProxyObject], and a revoked or cyclic one replaced the assertion error with aTypeErrorfromJSON.stringify. A revoked one prints<Revoked Proxy>.argumentsprints asArguments [, as in Jest. A React 19 element prints as JSX. An iterator prints asMap Iterator {},Array Iterator {},Iterator Helper {},RegExp String Iterator {}; they were{}.ResponseandBuildArtifactprint once.N x empty items,line.[Circular]stands for a React element, an event, aResponseor an asymmetric matcher that reaches itself. It printed the value once more first.Console:
Symbol.toStringTagprints. A non-enumerable__esModuleis hidden and an enumerable one prints, own or inherited.sorted: trueis as on main.type,key,props,childrenand$$typeofare own data properties or they are not there, so an inherited one or a getter does not count. An accessor among the props prints[Getter].<x {...{ children }} a="1" b="2" />printsa="1" b="2", it printeda="1"b="2". A long run of holes inchildrenis one line.MessageEventorErrorEventprint their internal state, whatevertoString,source,dataormessagea subclass or an own property puts in front. AnEventthat only has the type"message"or"error"prints as the object it is; it printed asMessageEvent { data: undefined }.Function.prototypeprints[Function]. It printed[Circular].console.log(new String("a"), 1)prints[String: "a"] 1, as it already did for the last argument.new Number(-0)prints[Number: -0].[inspect.custom]ortoJSONreturns and that holds the object it was called on says[Circular]there, as in Node. It nested until the depth limit.Map(2)is what it holds, whatever an ownsizesays, and for a subclass whosesizeis not a number, which is not converted to one. A Map or Set without an iterator, such as one whose prototype was removed, lists what it holds; it printedMap {}. A subclass whose iterator yields more than itssizeends with... more items. A WeakMap subclass with asizeprintsWeakMap {}, it threw.inspect.custom, a Buffer as text).console.table:Object.keysin Node: a hole is not a row, and a named property is one. A replaced iterator on an array is not run.... more rows. A Map or Set subclass gets as many as itssizegetter says, so that getter runs, and what it throws propagates.Errors:
[Circular]under the key that closes the cycle.causethat was assigned is queued like one from the constructor. It was printed in place and queued, which doubled the renders at every level.causeprints itself, then its cause chain, then its members. The members were dropped.errorsarray holds: a hole prints nothing (it printedundefined) and a replaced iterator does not run. Another iterable gets 100 steps, then... more errors.[Object ...].Hooks that still run
Same as on main, in console and in diffs. A throw from one still replaces the matcher's message.
argumentsindex,toJSONof aDate,URLSearchParams,FormDataorHeaders, and[inspect.custom], which are asked for,Symbol.iterator,nextandsizeof a Map or Set subclass, or of one with an ownSymbol.iterator,message,stack,causeanderrorsgetters of an Error in the error printer, which swallows what they throw,getPrototypeOfof a Proxy,JSON.stringifyruns for the values that go to it (a generator,%j), with no budget.Decisions worth a second look
util.inspectcaps the whole output at 2^27, and Cap expect() failure message rendering so huge values cannot swallow the assertion error #37311 caps a matcher message at 1 MiB. Both also cut a large value that repeats nothing. The blow-up in expect(<happy-dom Element>).toBeNull() takes ~40s+ on a large tree and then SILENTLY PASSES (throw is lost) #37310 is shared references, which the budget fixes.errorsfor an iterable that is not an array, a typed array, a Map or a Set. They come from console: do not hang on an iterator that never ends #42264.nextdoes not run and printing one does not use it up.console.grouplevels included.console.logof one nested deeper than the stack throws a RangeError. It crashed.Cost
Release builds of the merge base and of this branch, linux x64. User-mode instructions and cycles for the whole process (
perf stat), best of 3. The machine was shared and loaded, so wall time was not usable. "A/A" is a second copy of the base binary against the first.Bun.inspect, tree of 335,923 objects, depth InfinityBun.inspect, Map of 200,000 entriesBun.inspect, Set of 200,000 stringsBun.inspect, small object, 200,000 callsBun.inspect, a number, 600,000 callsBun.inspect, array of 100 strings, 30,000 callsBun.inspect, array of 100 numbers, 100,000 callsconsole.log("hello world", i), 300,000 callsBun.inspect, Error with a cause, 5,000 callsBun.inspect, JSX tree of 19,531 elementsBun.inspect, object with 200,000 object valuesBun.inspect, object with 800,000 object valuesBun.inspect.table, 1,000,000 numbersBun.inspect.table, 100,000 rows of { id, name }Bun.inspect.table, 1,000 numbers, 100 callsbun:test, five diff and message workloadsInstructions are steady (A/A within 1.3%). Cycles are not (A/A up to 11%), so only the large differences in that column mean anything.
A value that holds nothing (a number, a string,
console.log("hello world", i)) costs 2% to 3.5% more instructions.Past about 200,000 containers in one print, fewer instructions take 13% to 17% more cycles: the visited set holds every container printed instead of the current path, and no longer fits in cache. Storing each value's index on a path stack, so that leaving a value does not touch the set again, did not change that (+16.8% against +19.8% at 800,000), so it is not in here.
The JSX tree takes 18% more cycles. JSX had no cycle check, and now inserts into the visited set.
The visited set grows with the number of containers printed instead of the nesting depth. Peak RSS: 224 MB to 256 MB for the tree of 335,923 objects, 341 MB to 361 MB for 800,000 object values.
A
causechain prints 1,406 levels before the stack check stops it (main: 1,450).inspect-error-leak.test.js, release: +4 MB on main, +5 MB here, limit 10 MB.The binary is 28,672 bytes smaller.
test/js/bun/util/inspect.test.js: 11.3 s and 25.6 CPU-seconds on a debug build (main: 5.5 s and 6.4, with 57 fewer tests), 2.6 s on release (main: 0.1 s). The matrix is most of the difference, and no test in it takes 5 s on a debug build.Verification
test/hunks alone and ran them against this branch. That found about ten gaps, all fixed here. The tests are included unless another test covers the same thing; where an expectation changed, the reason is in a comment next to it.error snapshotsinsnapshot.test.tsfails on both when colors are off.BUN_JSC_validateExceptionChecks=1.Leave, the guard that undoesenter, holds raw pointers. A model of it passes Miri with Tree Borrows. The same model with the pointers taken from a&mutparameter, as they first were, does not.Not measured: the stack-sensitive tests on a release build with ASAN, which only CI has.
Closes
GitHub closes the issues on merge. It links the pull requests but does not close them.
Issues:
Fixes #37310
Fixes #34178
#34178 has no repro. Its stack was symbolicated in the thread to the
bun:testformatter under a failingtoEqual, expanding shared references without bound, which is what the budget stops.Pull requests whose own tests pass here unmodified:
Fixes #30373
Fixes #34179
Fixes #34884
Fixes #36404
Fixes #37270
Fixes #37402
Fixes #38586
Fixes #39045
Fixes #39054
Fixes #41119
Fixes #43963
Fixes #44174
Pull requests whose tests pass with an expectation changed, because the value now prints where it printed nothing, or because a hook no longer runs:
Fixes #29709
Fixes #36921
Fixes #42247
Fixes #42264
Pull requests whose code is gone, or whose fix is already on main:
Fixes #37331
Fixes #37334
Fixes #40534
Fixes #40919
Fixes #44139
The issue it fixes is fixed here another way (see decision 1):
Fixes #37311
Not closed
get_lengthfor every caller; the formatter no longer depends on it), error printer: print an uncaught non-Error value at the console depth #39637, console: print nested String wrapper objects as [String: ...] #38171.