Skip to content

fix(expect): propagate exception from custom asymmetric matcher in asymmetricMatch#29199

Open
robobun wants to merge 3 commits intomainfrom
farm/5deb6b2b/asymmetric-match-exception
Open

fix(expect): propagate exception from custom asymmetric matcher in asymmetricMatch#29199
robobun wants to merge 3 commits intomainfrom
farm/5deb6b2b/asymmetric-match-exception

Conversation

@robobun
Copy link
Copy Markdown
Collaborator

@robobun robobun commented Apr 11, 2026

What does this PR do?

Fuzzilli found a debug assertion crash (fingerprint 3c2ad38d8c96b67d) where calling .asymmetricMatch() on a custom asymmetric matcher whose implementation throws triggers:

ASSERTION FAILED: Unexpected exception observed
!exception()
ExceptionScope.h(62) : void JSC::ExceptionScope::releaseAssertNoException()

Root cause

ExpectCustomAsymmetricMatcher.execute() is a callconv(.c) function (shared with the C++ deep-equals path) that handles errors with catch false / catch return false. This swallows the Zig error.JSError but leaves the JSC exception pending on the VM.

asymmetricMatch() then returned JSValue.jsBoolean(matched) — a non-zero value — while a JSC exception was still pending. The host-function wrapper asserts that a non-zero return implies no pending exception, and tripped releaseAssertNoException().

Fix

After calling execute(), check globalThis.hasException() and return error.JSError so the exception is properly propagated to JavaScript instead of returning a stale boolean.

Repro

const { expect } = Bun.jest(import.meta.path);
expect.extend({
  myMatcher() { throw new Error("boom"); },
});
expect.myMatcher().asymmetricMatch({}); // previously: assertion crash in debug builds

How did you verify your code works?

  • Minimal repro above crashes on build/debug-fuzz/bun-debug (before), throws a catchable JS error (after).
  • Added regression test in test/js/bun/test/expect-extend.test.js which crashes the unfixed debug build and passes with the fix.
  • Full expect-extend.test.js suite passes (aside from pre-existing "intensive usage" timeout that also fails on the unfixed build due to debug-build slowness).

…ymmetricMatch

ExpectCustomAsymmetricMatcher.execute() is a callconv(.c) function that
swallows Zig errors via `catch false` while leaving the JSC exception
pending. When asymmetricMatch() called execute() and then returned a
non-zero jsBoolean, the host-fn wrapper tripped releaseAssertNoException()
because a pending exception existed alongside a non-zero return value.

Check for a pending exception after execute() and return error.JSError
so the exception is properly propagated to JavaScript.
@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 11, 2026

Updated 11:01 PM PT - Apr 11th, 2026

@robobun, your commit 2592c43 has 3 failures in Build #45247 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29199

That installs a local version of the PR into your bun-29199 executable, so you can run:

bun-29199 --bun

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 11, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 98592a62-f965-49c7-85df-06172a325c6b

📥 Commits

Reviewing files that changed from the base of the PR and between 0704ed9 and 2592c43.

📒 Files selected for processing (1)
  • src/bun.js/test/expect.zig

Walkthrough

Refactors asymmetric matcher execution to propagate JS exceptions: the core implementation now returns bun.JSError!bool and errors bubble up; a C-callable wrapper preserves the original bool-return API. A test was added to assert synchronous exceptions from asymmetricMatch propagate to the caller.

Changes

Cohort / File(s) Summary
Zig matcher internals
src/bun.js/test/expect.zig
Introduced executeImpl returning bun.JSError!bool and updated flow to try its result so JS exceptions propagate. Added a thin C-callable execute wrapper that catch false to preserve callconv(.c) bool ABI. Final call path to Expect.executeCustomMatcher(...) now returns/propagates bun.JSError instead of swallowing errors.
Test: asymmetric matcher exception
test/js/bun/test/expect-extend.test.js
Added a test that registers a custom matcher which throws synchronously from asymmetricMatch and asserts the thrown exception bubbles up (expects toThrow("boom from matcher")).
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the primary fix: propagating exceptions from custom asymmetric matchers in the asymmetricMatch method, directly matching the changeset's core objective.
Description check ✅ Passed The description comprehensively covers both required template sections: it clearly explains what the PR does (the bug, root cause, and fix) and thoroughly documents verification through a minimal repro, regression test, and test suite results.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Clear pending JS exceptions in ExpectCustomAsymmetricMatcher.execute() #28404 - Both fix pending JSC exception handling in ExpectCustomAsymmetricMatcher in expect.zig; Clear pending JS exceptions in ExpectCustomAsymmetricMatcher.execute() #28404 clears the exception while this PR propagates it

🤖 Generated with Claude Code

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 11, 2026

Re: duplicate detection pointing at #28404 — related but not a duplicate.

Both stem from ExpectCustomAsymmetricMatcher.execute() swallowing Zig errors while leaving JSC exceptions pending, but they address different call sites with opposite semantics:

Propagation matches the existing behavior of the C++ toEqual path (which already surfaces these exceptions via its ThrowScope):

expect.extend({ bad() { throw new Error("boom"); } });
expect({a: 1}).toEqual({a: expect.bad()});  // already throws "boom" on main
expect.bad().asymmetricMatch({});           // crashed on main; now throws "boom"

I also verified #28404's repro (not.toEqual with a matcher returning undefined) no longer crashes on current main — the exception propagates cleanly through the C++ path. So #28404's clearException approach would actually regress that by hiding "Unexpected return from matcher function" errors that currently surface.

The two PRs are incompatible: if #28404 merges, this PR's test would fail because the exception gets cleared before asymmetricMatch can see it.

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — clean one-line fix for the pending-exception / stale-boolean assertion crash, consistent with existing hasException patterns in the file.

Extended reasoning...

Overview

The PR modifies two files: a one-line fix in src/bun.js/test/expect.zig and a new regression test in test/js/bun/test/expect-extend.test.js. Scope is narrow and self-contained.

Root cause & fix

ExpectCustomAsymmetricMatcher.execute() uses catch false / catch return false which silently swallows error.JSError but leaves the JSC exception pending on the VM. asymmetricMatch() then returned a non-zero JSValue while an exception was still live, violating the JSC host-function contract and triggering releaseAssertNoException(). The fix adds the missing if (globalThis.hasException()) return error.JSError; check immediately after execute(), which is the standard pattern already used in at least two other places in this file (lines 343 and 1575).

Security risks

None. This is test-framework infrastructure; no auth, crypto, or permission code is touched.

Level of scrutiny

Low. The change is a single line that follows an already-established pattern, addresses a confirmed crash, and is accompanied by a focused regression test. The logic is easy to verify locally against the codebase.

Other factors

No prior reviews on this PR. No outstanding reviewer comments. Bug hunting system found no additional issues. The regression test is well-written and directly exercises the fixed code path.

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — minimal one-line fix with a regression test, correctly propagates the pending JSC exception from instead of returning a boolean while the exception is still set.

Extended reasoning...

Overview

Two files changed: one line added to src/bun.js/test/expect.zig and a regression test added to test/js/bun/test/expect-extend.test.js. The fix targets the ExpectCustomAsymmetricMatcher.asymmetricMatch function.

Root Cause and Fix

execute() is a callconv(.c) function that uses catch false to swallow Zig errors, but this leaves any pending JSC exception on the VM. asymmetricMatch() previously returned JSValue.jsBoolean(matched) unconditionally — a non-null value — while a pending exception violated the JSC host-function wrapper's releaseAssertNoException() invariant. The fix adds a single globalThis.hasException() check before returning, matching the same pattern already used on line 1575 of the same file.

Security Risks

None. This is a bug fix in the test framework's expect API, not security-sensitive code.

Level of Scrutiny

Low. The change is one line of Zig plus a test case, following an established pattern in the codebase. The PR description clearly explains root cause, fix, and verification. CodeRabbit found no actionable comments.

Other Factors

The PR author also explains why this PR is not a duplicate of #28404: that PR proposes silently clearing the exception, while this PR propagates it — which is the correct behavior that matches the existing C++ toEqual path. The regression test directly validates the fix.

Split execute() into a Zig-native executeImpl() returning bun.JSError!bool.
The callconv(.c) execute() wrapper preserves existing behavior for the C++
matchAsymmetricMatcher caller, while asymmetricMatch() now calls executeImpl
directly with try so exceptions propagate via the error union instead of
needing an explicit hasException() check.
Comment on lines +1703 to +1723
const matcher_name = try matcher_fn.getName(globalThis);

// retrieve the asymmetric matcher args
// if null, it means the function has not yet been called to capture the args, which is a misuse of the matcher
const captured_args: JSValue = js.capturedArgsGetCached(thisValue) orelse {
globalThis.throw("expect.{f} misused, it needs to be instantiated by calling it with 0 or more arguments", .{matcher_name}) catch {};
return false;
return globalThis.throw("expect.{f} misused, it needs to be instantiated by calling it with 0 or more arguments", .{matcher_name});
};
captured_args.ensureStillAlive();

// prepare the args array as `[received, ...captured_args]`
const args_count = captured_args.getLength(globalThis) catch return false;
const args_count = try captured_args.getLength(globalThis);
var allocator = std.heap.stackFallback(8 * @sizeOf(JSValue), globalThis.allocator());
var matcher_args = std.array_list.Managed(JSValue).initCapacity(allocator.get(), args_count + 1) catch {
globalThis.throwOutOfMemory() catch {};
return false;
return globalThis.throwOutOfMemory();
};
matcher_args.appendAssumeCapacity(received);
for (0..args_count) |i| {
matcher_args.appendAssumeCapacity(captured_args.getIndex(globalThis, @truncate(i)) catch return false);
matcher_args.appendAssumeCapacity(try captured_args.getIndex(globalThis, @truncate(i)));
}

return Expect.executeCustomMatcher(globalThis, matcher_name, matcher_fn, matcher_args.items, this.flags, true) catch false;
return Expect.executeCustomMatcher(globalThis, matcher_name, matcher_fn, matcher_args.items, this.flags, true);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 This PR introduces executeImpl without fixing two pre-existing memory leaks from the old execute(): (1) matcher_name (a ref-counted bun.String from getName()) is never deref'd, leaking a WTFStringImpl reference on every matcher invocation; (2) matcher_args ArrayList is never deinit'd — when args_count+1 > 8 the stackFallback spills to heap and that memory is never freed. Both leaks existed before this PR but the refactor is a good opportunity to fix them: add defer matcher_name.deref() after line 1703, and defer matcher_args.deinit() after the initCapacity call.

Extended reasoning...

matcher_name leak (bug_001): matcher_fn.getName(globalThis) calls JSC__JSValue__getName which internally calls Bun::toStringRef() (see BunString.cpp lines 270/278/286). toStringRef explicitly calls wtfString.impl()->ref() to increment the WTFStringImpl refcount, transferring ownership to the caller. The returned bun.String is stored in matcher_name at line 1703 (executeImpl) and line 1146 (applyCustomMatcher), but neither site has a corresponding defer matcher_name.deref(). The existing codebase demonstrates the correct pattern in multiple places: ScopeFunctions.zig lines 316-317 uses var description_name = try description.getName(globalThis); defer description_name.deref();, and lines 1906-1907 of the same file show const matcher_name = try arguments[0].toBunString(globalThis); defer matcher_name.deref();. Both toBunString and getName have the same ownership semantics via toStringRef.

matcher_args leak (bug_002): In executeImpl lines 1714-1720, a std.array_list.Managed(JSValue) is created using a stackFallback allocator with an 8-element (64-byte) stack buffer. When args_count+1 > 8 (i.e., 8 or more captured matcher args), initCapacity falls back to globalThis.allocator() for heap allocation. globalThis.allocator() returns bunVM().allocator, a general-purpose VM allocator — not an arena — so the memory must be explicitly freed. Neither the success path (line 1723 passes items to executeCustomMatcher but the list is never freed) nor any early-return error path calls matcher_args.deinit().

Pre-existing nature: Both bugs existed in the old execute() function before this PR. The PR refactors the logic into executeImpl() without fixing either leak. The same missing deinit pattern also appears at lines 1165-1170 (the symmetric matcher path). Since these are pre-existing, the PR does not worsen them significantly — the new asymmetricMatch call path was previously calling execute() anyway.

Concrete proof — matcher_name: For any named custom matcher (e.g., expect.myMatcher()), each call to executeImpl calls getName() which calls toStringRef which bumps the WTFStringImpl refcount from N to N+1. On function exit, that reference is never released. After 1000 calls, the refcount is N+1000 and the string's backing memory cannot be freed.

Concrete proof — matcher_args: Suppose a matcher is registered as expect.extend({ myMatcher: function(a,b,c,d,e,f,g,h) { ... } }) with 8 captured args. Then args_count = 8, args_count+1 = 9 > 8, so initCapacity(9) spills to heap. matcher_args.deinit() is never called, so 9 * sizeof(JSValue) bytes are leaked per call.

Fix: After line 1703: add defer matcher_name.deref();. After the initCapacity call: add defer matcher_args.deinit();. This mirrors the patterns used elsewhere in the codebase.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are both pre-existing leaks (as noted) that also exist in applyCustomMatcher at lines 1146 and 1165-1170 which this PR doesn't touch. Fixing them only in executeImpl but not in the sibling applyCustomMatcher would be inconsistent.

Keeping this PR scoped to the assertion crash fix; the leaks are worth addressing but belong in a separate change that covers both call sites.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant