bun test: fix a silent stack overflow when printing a deeply nested value
What does this PR do?
Fixes a stack overflow in bun test when it prints a deeply nested value. The process ends with SIGSEGV and prints nothing: no panic, no test name, no diff.
let value = 1;
for (let i = 0; i < 20_000; i++) value = i % 2 ? [value] : { a: value };
expect(value).toMatchInlineSnapshot(`"x"`); // exit code 139
toEqual has it too. Bun__deepEquals checks the stack and throws a RangeError, but its frames are smaller than the printer's, so there is a range of depths that compare fine and then overflow in the message.
Cause
console.log's formatter asks StackCheck::is_safe_to_recurse() before it goes into a value. The one in pretty_format.rs, which prints values for matcher messages, diffs and snapshots, has no stack check and no depth limit.
Fix
The same check at the top of print_as, for every value. It throws the RangeError that toEqual throws for a deeper value, and that Jest ends with.
It is not tied to can_have_circular_references(): JSX elements and events are not in that set and print what they hold all the same.
toMatchInlineSnapshot, 100,000 deep | canary 7fe13e1b9 | this PR |
|---|---|---|
arrays and objects, instances of a class, JSX children, JSX props, the data of MessageEvents, objectContaining | SIGSEGV | RangeError |
Map, Set | RangeError | RangeError |
the cause of errors, promises, toJSON, arrayContaining | the snapshot does not match | the same |
A writer as deep as the matchers
print_asymmetric_matcher is generic over the writer, but called back into the formatter with &mut dyn bun_io::Write, which Formatter wrapped in an AsFmt and then in a FmtAdapter to get back to its own kind of writer. So each matcher inside a matcher added two adapters, and every write went through all of them: a recursion as deep as the nesting, inside the write, where nothing checks the stack. The check leaves a fixed reserve, and the chain outgrows it once there is enough stack to nest that far.
amf_print_as is now generic over the writer too, and Formatter passes it on as it is. The mapping of tags it used the bridge for is impl From<FormatTag> for Tag.
objectContaining, 100,000 deep, toMatchInlineSnapshot | with the check only | with this |
|---|---|---|
| Windows x64, CI's build | panic: Stack overflow, at 10,000 deep too | left to CI |
Linux x64, debug, ulimit -s 8 MB | RangeError | RangeError |
| 64 MB, 256 MB | SIGSEGV | RangeError |
| 1 GB | not done after 300 s | RangeError |
console.log goes through the same function with its own formatter, which is not changed: Bun.inspect(value, { depth: Infinity }) of the same value on Windows throws the RangeError.
| release builds, Linux x64 | 1.4.2, canary 7fe13e1b9 |
|---|---|
toMatchInlineSnapshot, alternating [v] and { a: v }, 20,000 deep | SIGSEGV |
toEqual, the same, 8,000 deep | prints the diff |
toEqual, the same, 10,000 to 15,000 deep | SIGSEGV |
toEqual, the same, 20,000 deep | RangeError from Bun__deepEquals |
toEqual, objects of 51 properties (the shape in pretty-format-overflow.test.ts), 4,000 deep | prints the diff |
| the same, 6,000 deep | SIGSEGV |
How did you verify your code works?
Six new tests in bun-test.test.ts, which run bun test on a value 100,000 deep, one for each kind in the first row above.
| new tests | |
|---|---|
| 1.4.2 | 6 fail: the child is killed by a signal |
| this PR, debug | 6 pass |
With this PR, debug, every depth from 500 to 20,000 that I tried ends in the diff or in a RangeError.
expect.test.js, the snapshot tests, pretty-format-*.test.ts and diffexample.test.ts, debug: 490 pass, 2 fail. Both fail on a debug build of main too:
pretty-format-overflow.test.tsprints an object 500 deep, which is more than an unoptimized ASAN build has stack for. On main that build crashes from 500 on (139); with this PR it throws theRangeError. Both print the diff at 450. Release builds print it at 4,000.error snapshotsinsnapshot.test.tsdiffers in ANSI codes only.