bun test: fix a silent stack overflow when printing a deeply nested value

Merged
DY
1mo ago
+61-25

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 deepcanary 7fe13e1b9this PR
arrays and objects, instances of a class, JSX children, JSX props, the data of MessageEvents, objectContainingSIGSEGVRangeError
Map, SetRangeErrorRangeError
the cause of errors, promises, toJSON, arrayContainingthe snapshot does not matchthe 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, toMatchInlineSnapshotwith the check onlywith this
Windows x64, CI's buildpanic: Stack overflow, at 10,000 deep tooleft to CI
Linux x64, debug, ulimit -s 8 MBRangeErrorRangeError
64 MB, 256 MBSIGSEGVRangeError
1 GBnot done after 300 sRangeError

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 x641.4.2, canary 7fe13e1b9
toMatchInlineSnapshot, alternating [v] and { a: v }, 20,000 deepSIGSEGV
toEqual, the same, 8,000 deepprints the diff
toEqual, the same, 10,000 to 15,000 deepSIGSEGV
toEqual, the same, 20,000 deepRangeError from Bun__deepEquals
toEqual, objects of 51 properties (the shape in pretty-format-overflow.test.ts), 4,000 deepprints the diff
the same, 6,000 deepSIGSEGV

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.26 fail: the child is killed by a signal
this PR, debug6 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.ts prints 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 the RangeError. Both print the diff at 450. Release builds print it at 4,000.
  • error snapshots in snapshot.test.ts differs in ANSI codes only.