bun:test: stop building a matcher diff once pretty-printing a value throws - #37505
bun:test: stop building a matcher diff once pretty-printing a value throws#37505robobun wants to merge 1 commit into
Conversation
…hrows DiffFormatter ignored the result of pretty-printing received and expected. When printing the first value threw, the exception stayed pending while the second value was printed, which trips the pending-exception assertions in debug builds. Propagate the failure as fmt::Error instead, so the matcher error is built from what was written so far, like the other matchers. matcherHint rendered the diff with format!, which panics when a Display impl fails; write it into the buffer and return the pending exception instead.
|
Warning Review limit reached
Next review available in: 17 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
Comment |
|
CodeRabbit's automatic review got rate limited here rather than skipped on purpose; it should pick the PR up again on its own or on the next push. Nothing in the diff has changed since it was opened, CI is running. |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
LGTM — targeted fix that stops DiffFormatter from continuing into JSC after JestPrettyFormat throws.
What was reviewed:
- Traced every
DiffFormattercaller: the string-only sites (snapshots,zig__renderDiff) never reach the changed path; the JSValue sites all go throughthrow!→create_error_instance, which already clears the pending exception onfmt::Error(JSGlobalObject.rs:744-748). matcherHint'sformat!→writeln!swap avoids the panic the new?would introduce; thehas_exception()→Thrown/OutOfMemorybranch matches the pattern used elsewhere in this file.- Tests follow harness conventions (concurrent subprocess spawn, both pipes drained, combined-object assertion) and cover both the deterministic getter case and the fuzzer's stack-overflow shape.
Extended reasoning...
Overview
Three files: diff_format.rs replaces two let _ = ...; // TODO: with .map_err(|_| fmt::Error)? so the Display impl bails out when pretty-printing throws instead of continuing with a pending JSC exception; expect.rs swaps format! for writeln! in ExpectMatcherUtils::matcherHint so the newly-fallible Display doesn't panic; and a new test file exercises toStrictEqual/toEqual/matcherHint with a throwing $$typeof getter plus the fuzzer's stack-overflow-at-matcher-call shape.
Security risks
None. This is the test runner's failure-message rendering path — no untrusted input parsing, no auth/crypto/filesystem, and the only external effect is which error object escapes to the user's catch.
Level of scrutiny
Low-to-medium. The Rust change is ~6 lines and mechanical (propagate an error that was explicitly TODO'd). The one non-obvious risk — that other DiffFormatter consumers might now panic on the new fmt::Error — was checked: the snapshot/C++-bridge callers use received_string/expected_string and return before the changed code, and every JSValue caller (toEqual, toStrictEqual, toMatchObject, toHaveProperty, toHaveBeen*CalledWith, toHave*ReturnedWith, toBe string branch) routes through throw_rendered → JSGlobalObject::throw → create_error_instance, whose write!().is_err() branch already clears the exception and returns the partial buffer. matcherHint was the only format! caller and is fixed in this PR.
Other factors
The PR description is unusually thorough (root cause, why release builds accidentally worked, why the fuzzer repro was flaky, full list of related test files re-run). Tests use test.concurrent, drain stdout/stderr/exited together, and assert exact strings — the first test's assertions distinguish the fixed behavior (matcher header with empty body) from the unfixed release behavior ("getter threw"), so it fails on both build types without the fix as claimed. No CODEOWNERS entry covers src/runtime/test_runner/. No prior reviewer comments to address.
|
Closing this one. The duplicate finder is right: the two For whoever lands this, how the open PRs differ:
Two things from this PR that may be useful there: the same assertion keeps coming out of fuzzing (it shows up whenever the matcher fails at the bottom of the JS stack, because the getter the pretty-printer calls for const { expect } = Bun.jest();
const o = { a: 1 };
Object.defineProperty(o, "$$typeof", { get() { throw new Error("boom"); } });
expect(o).toStrictEqual({ a: 2 }); // debug build: assertNoExceptionExceptTermination in JSC__JSValue__getOwn |
Problem
Fuzzing hit this assertion in a debug build while a failing
toStrictEqualwas building its error message:The fuzzer's program recursed until the JS stack ran out and then called
Bun.jest().expect(x).toStrictEqual(new Uint8ClampedArray())at the bottom. It is flaky in that form because pretty-printing the fuzzer'sxonly calls back into JS under leftover state from earlier programs. Any value whose pretty-printing throws reproduces it deterministically, for example a throwing$$typeofgetter (the printer reads$$typeofto detect React elements):Cause
DiffFormatter::fmt(src/runtime/test_runner/diff_format.rs) pretty-printsreceivedandexpectedinto two buffers and ignored both results (let _ = ...; // TODO:, carried over from the Zig version). When printingreceivedthrows, the exception is still pending whileexpectedis printed, and the first JSC call made for it (JSC__JSValue__getOwnfor the$$typeofcheck,ASSERT_NO_PENDING_EXCEPTION) fires the assertion. In release builds it only worked by accident: the second print fails early on the stale exception andthrow_valuerefuses to replace a pending exception, so the getter's error escaped instead of the matcher's.Fix
The two
?indiff_format.rsare the fix. A failed print now returnsfmt::ErrorfromDisplay, the same thing theZigFormatteradapter used by the other matchers does, and nothing touches JSC afterwards.create_error_instancealready handles that case: it clears the exception and throws the matcher error built from what was written so far, sotoEqual/toStrictEqualand the other diff-based matchers now behave liketoBedoes when printing a value throws (expect(received).toStrictEqual(expected)with an empty body, instead of the getter's error).ExpectMatcherUtils.matcherHintrendered the sameDiffFormatterwithformat!, which would now panic when theDisplayimpl fails. It writes into the buffer instead and returns the pending exception (orOutOfMemorywhen the printer failed without one), which keeps its current behavior of surfacing the error thrown while printing; previously it returned a string with the exception still pending, which is the same assertion in debug builds.utils.stringify/printReceived/printExpectedhave a related panic that is already fixed separately in #36912, so they are not touched here.Verification
test/js/bun/test/expect-diff-format-throw.test.ts:toStrictEqualandtoEqualwith a throwingreceived,toStrictEqualwith a throwingexpected, andmatcherHint. On the unfixed build the first two cases abort the debug build with the assertion above, and on release 1.4.0 all four cases report"getter threw"instead of the matcher message, so it fails on both build types without the fix.Also ran
expect.test.js,expect-extend.test.js,jest-extended.test.js,mock-fn.test.js, theexpect-*-crashtests,expect-label,test-test.test.tsand the snapshot tests against the debug build.pretty-format-overflow.test.tssegfaults its child process on a local debug+ASAN build before and after this change (the jest pretty-printer has no stack check; reported separately), which is unrelated to this fix.