Skip to content

bun:test: check exception after isArray() in expect.any(Array), toMatchObject, toHaveProperty - #34753

Open
robobun wants to merge 1 commit into
mainfrom
farm/5e5335ed/expect-isarray-exception-check
Open

bun:test: check exception after isArray() in expect.any(Array), toMatchObject, toHaveProperty#34753
robobun wants to merge 1 commit into
mainfrom
farm/5e5335ed/expect-isarray-exception-check

Conversation

@robobun

@robobun robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Repro

Under BUN_JSC_validateExceptionChecks=1 on a debug build:

const { expect } = require('bun:test');
expect(new Proxy({}, {})).toEqual(expect.any(Array));
// or:
expect(new Proxy([], {})).toMatchObject([]);
// or:
expect({ a: 1 }).toHaveProperty(new Proxy(new Set(['a']), {}));
ERROR: Unchecked JS exception:
    This scope can throw a JS exception: isArraySlowInline @ JavaScriptCore/runtime/ArrayConstructor.cpp:128
    But the exception was unchecked as of this scope: hasInstance @ JavaScriptCore/runtime/JSObject.cpp:2678
ASSERTION FAILED: exception check validation failed

Cause

JSC::isArray() declares a throw scope on its Proxy path (isArraySlowInline, for the revoked-Proxy case). Three call sites in src/jsc/bindings/bindings.cpp used it inside an if condition and then either entered another throw-scope-declaring call, or returned with the scope's m_needExceptionCheck still set:

  • matchAsymmetricMatcherAndGetFlags (AsymmetricMatcherConstructorType::Array): isArray() then falls through to constructorObject->hasInstance().
  • Bun__deepMatch: isArray(globalObject, objValue) && isArray(globalObject, subsetValue) chains two throw-scope calls, then getPropertyNames().
  • JSC__JSValue__getIfPropertyExistsFromPath (the toHaveProperty path lookup): when isArray() returns false for a Proxy over an iterable non-array (Set, Map, generator), the body is skipped and the function returns {} with the outer ThrowScope still unsatisfied.

The Bun__deepEquals sites (v1Array/v2Array) already had the check.

Fix

Hoist each isArray() result out of the condition and RETURN_IF_EXCEPTION immediately after it. Same pattern as #34747 for the NodeVM.cpp instances.

Release behavior is effectively unchanged for expect.any(Array) and toHaveProperty (the isArray revocation error already reached the caller via a later check). For toMatchObject with a revoked-Proxy receiver, the thrown TypeError message changes from the generic revocation message to Array.isArray cannot be called on a Proxy that has been revoked, matching the operation that actually failed.

Verification

New subprocess test in test/js/bun/test/expect.test.js spawns with BUN_JSC_validateExceptionChecks=1 and runs every affected matcher shape with transparent and revoked Proxies. On builds without exception-scope verification the option is a no-op and the child exits 0 either way.

# fail-before (src/ stashed, bun bd):
(fail) expect.any(Array)/toMatchObject/toHaveProperty check the isArray() exception for Proxy values
  exitCode: 134, signalCode: SIGABRT
  ERROR: Unchecked JS exception: isArraySlowInline ... hasInstance

# pass-after (bun bd):
(pass) expect.any(Array)/toMatchObject/toHaveProperty check the isArray() exception for Proxy values

expect.test.js (408 pass), jest-extended.test.js (57 pass), test/js/node/assert/deep-equal.test.ts (260 pass) all pass.

Related

#32948 applies the same Bun__deepMatch split as part of a larger Proxy-transparency change and composes with this one; it does not touch the expect.any(Array) or toHaveProperty sites.


[stamp-90s] gate passed · iteration 1 · 2 files touched

fails on main (without fix)
ASAN without fix: 1 failed, 10 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/test/expect.test.js
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (4b23b02ae)

test/js/bun/test/expect.test.js:
(pass) expect() > () [615.76ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true [2.91ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true [0.62ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true [0.38ms]
(pass) expect() > toBe() > expect(-0).toBe(-0) == true [0.38ms]
(pass) expect() > toBe() > expect(1).toBe(1) == true [0.37ms]
(pass) expect() > toBe() > expect(1).toBe(1) == true [0.37ms]
(pass) expect() > toBe() > expect(NaN).toBe(NaN) == true [0.40ms]
(pass) expect() > toBe() > expect(Infinity).toBe(Infinity) == true [0.37ms]
(pass) expect() > toBe() > expect({}).toBe({}) == true [0.41ms]
(pass) expect() > toBe() > expect(Symbol(a)).toBe(Symbol(a)) == true [0.36ms]
(pass) ex
... (truncated)

release without fix: 10 skipped
bun test v1.4.0-canary.1 (4b23b02ae)

test/js/bun/test/expect.test.js:
(pass) expect() > () [0.70ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true [0.03ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true
(pass) expect() > toBe() > expect(0).toBe(0) == true
(pass) expect() > toBe() > expect(-0).toBe(-0) == true
(pass) expect() > toBe() > expect(1).toBe(1) == true
(pass) expect() > toBe() > expect(1).toBe(1) == true
(pass) expect() > toBe() > expect(NaN).toBe(NaN) == true
(pass) expect() > toBe() > expect(Infinity).toBe(Infinity) == true
(pass) expect() > toBe() > expect({}).toBe({}) == true
(pass) expect() > toBe() > expect(Symbol(a)).toBe(Symbol(a)) == true
(pass) expect() > toBe() > expect(0).toBe(false) == false [0.02ms]
(pass) expect() > toBe() > expect(0).toBe("") == false
(pass) expect() > toBe() > expect(0).toBe(-0) == false
(pass) expect() > toBe() > expect(0).toBe(-0) == false
(pass) expect() > toBe() > expect(1).toBe(2) == false
(pass) expect() > toBe() > expect(1).toBe(true) == false
(pass) expect() > toBe() > expect(1).toBe("1") == false
(pass) expect() > toBe() > expect(Infinity).toBe(-Infinity) == false
(pass) expect() > toBe() > expect("foo
... (truncated)
passes on PR (with fix)
ASAN with fix: 10 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/test/expect.test.js
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (4b23b02ae)

test/js/bun/test/expect.test.js:
(pass) expect() > () [586.95ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true [2.83ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true [0.57ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true [0.45ms]
(pass) expect() > toBe() > expect(-0).toBe(-0) == true [0.37ms]
(pass) expect() > toBe() > expect(1).toBe(1) == true [0.44ms]
(pass) expect() > toBe() > expect(1).toBe(1) == true [0.37ms]
(pass) expect() > toBe() > expect(NaN).toBe(NaN) == true [0.44ms]
(pass) expect() > toBe() > expect(Infinity).toBe(Infinity) == true [0.37ms]
(pass) expect() > toBe() > expect({}).toBe({}) == true [0.43ms]
(pass) expect() > toBe() > expect(Symbol(a)).toBe(Symbol(a)) == true [0.36ms]
(pass) ex
... (truncated)

release with fix: 10 skipped
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 734ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] cxx obj/src/jsc/bindings/bindings.cpp.o
[2/6] gen cpp.rs (cppbind)
[2/5] link bun-profile
[3/5] bun-profile --revision
1.4.0-canary.1+4b23b02ae
[5/5] strip bun
[build] done
bun test v1.4.0-canary.1 (4b23b02ae)

test/js/bun/test/expect.test.js:
(pass) expect() > () [0.70ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true [0.03ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true
(pass) expect() > toBe() > expect(0).toBe(0) == true
(pass) expect() > toBe() > expect(-0).toBe(-0) == true
(pass) expect() > toBe() > expect(1).toBe(1) == true
(pass) expect() > toBe() > expect(1).toBe(1) == true
(pass) expect() > toBe() > expect(NaN).toBe(NaN) == true
(pass) expect() > toBe() > expect(Infinity).toBe(Infinity) == true
(pass) expect() > toBe() > exp
... (truncated)
diff hotspot
src/jsc/bindings/bindings.cpp   | 15 ++++++++++++---
 test/js/bun/test/expect.test.js | 32 ++++++++++++++++++++++++++++++++
 2 files changed, 44 insertions(+), 3 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                             reads  edits  tests
src/jsc/bindings/bindings.cpp        6      3      0
test/js/bun/test/expect.test.js      2      2      0

@robobun

robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:21 PM PT - Jul 19th, 2026

@robobun, your commit 4b23b02 has 1 failures in Build #75937 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34753

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

bun-34753 --bun

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Matcher exception safety

Layer / File(s) Summary
Cached array checks and exception validation
src/jsc/bindings/bindings.cpp
Array checks in asymmetric matching, deep matching, and property-path lookup are cached in local booleans with explicit exception checks before branching.
Proxy regression coverage
test/js/bun/test/expect.test.js
Adds subprocess coverage for matcher calls involving Proxy and revoked Proxy values with exception-check validation enabled.

Possibly related PRs

  • oven-sh/bun#34647: Updates related asymmetric matcher array handling and matcher test coverage.

Suggested reviewers: jarred-sumner, sosukesuzuki

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly names the affected bun:test matchers and the isArray exception fix.
Description check ✅ Passed The description covers the bug, cause, fix, and verification steps, matching the template's intent.

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

@robobun robobun changed the title bun:test: check exception after isArray() in expect.any(Array) and toMatchObject bun:test: check exception after isArray() in expect.any(Array), toMatchObject, toHaveProperty Jul 20, 2026

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/test/expect.test.js`:
- Around line 681-683: Replace the explanatory regression-test comment near
expect.any(Array), toMatchObject, and toHaveProperty with only the relevant
issue URL, preserving the test behavior and removing all other comment text.
- Around line 684-711: Update the child script in the Proxy matcher test so
successful matcher calls remain uncaught, while expected failures are explicitly
asserted with their error messages. Add revoked-Proxy coverage for the
toMatchObject and toHaveProperty paths in addition to the existing toEqual case,
and ensure the assertions validate the expected outcomes rather than swallowing
all exceptions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f66c3c7e-4ee5-4087-b432-c6219444fe95

📥 Commits

Reviewing files that changed from the base of the PR and between 0a17ce6 and a8a0538.

📒 Files selected for processing (2)
  • src/jsc/bindings/bindings.cpp
  • test/js/bun/test/expect.test.js

Comment thread test/js/bun/test/expect.test.js
Comment thread test/js/bun/test/expect.test.js
…chObject, toHaveProperty

JSC::isArray() declares a throw scope on its Proxy path (isArraySlowInline).
Three call sites in bindings.cpp used it inside an if condition and then
either entered another throw-scope-declaring call or returned with the
outer ThrowScope still unsatisfied, which aborts under the JSC
exception-scope validator:

  matchAsymmetricMatcherAndGetFlags: isArray then hasInstance
  Bun__deepMatch: two chained isArray calls then getPropertyNames
  JSC__JSValue__getIfPropertyExistsFromPath: isArray false then return

Hoist each isArray() result out of the condition and RETURN_IF_EXCEPTION
immediately after.
@robobun
robobun force-pushed the farm/5e5335ed/expect-isarray-exception-check branch from a8a0538 to 4b23b02 Compare July 20, 2026 02:19

@claude claude Bot left a comment

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.

LGTM — mechanical RETURN_IF_EXCEPTION insertion after three JSC::isArray() call sites, matching the pattern already used at the Bun__deepEquals sites in the same file.

What was reviewed

  • Confirmed each site has a ThrowScope in scope (throwScope param for the first two, local scope for the third) and the sentinel return values match sibling error paths.
  • Bun__deepMatch now evaluates both isArray calls unconditionally instead of short-circuiting; verified this can't introduce a new throw because subsetObj->getPropertyNames() already runs (and throws for a revoked Proxy) before that point.
  • Test drains stdout/stderr/exited concurrently, spreads bunEnv, and asserts signalCode: null so a validator abort can't be masked by the inner try/catch.
  • Both CodeRabbit findings were responded to by the author and withdrawn.
Extended reasoning...

Overview

Three call sites in src/jsc/bindings/bindings.cpp used JSC::isArray() inline in an if condition without checking for an exception afterward. isArray() declares a throw scope on its Proxy path (isArraySlowInline), so under BUN_JSC_validateExceptionChecks=1 the next throw-scope-declaring call (or scope destruction) aborts. The fix hoists each result into a local and inserts RETURN_IF_EXCEPTION immediately after — the exact same shape already used for the v1Array/v2Array sites in Bun__deepEquals in this file, and the same fix applied to NodeVM.cpp in #34747. A subprocess test in expect.test.js runs each affected matcher shape under the validator flag and asserts clean exit.

Security risks

None. This is test-runner matcher code (expect.any, toMatchObject, toHaveProperty) with no auth, crypto, network, or filesystem exposure. The change adds exception checks; it removes nothing.

Level of scrutiny

Low-to-moderate. The transformation is purely mechanical and pattern-matched against neighboring code. I verified: (1) matchAsymmetricMatcherAndGetFlags receives ThrowScope& throwScope by reference and already returns AsymmetricMatcherResult::FAIL on other error paths; (2) Bun__deepMatch receives ThrowScope& throwScope and returns false on exception at the adjacent getPropertyNames check; (3) JSC__JSValue__getIfPropertyExistsFromPath declares a local ThrowScope scope and returns {} on exception throughout. The one behavioral delta — Bun__deepMatch no longer short-circuits the second isArray — is safe because subsetObj->getPropertyNames at line 1645 would already have thrown for any subsetValue that could make the second isArray throw.

Other factors

The robobun evidence gate shows fail-before/pass-after on both the ASAN debug build and a release build, and the author reports expect.test.js (408 pass), jest-extended.test.js, and deep-equal.test.ts all passing. CodeRabbit raised two findings on the test (comment style and try/catch swallowing outcomes); the author explained the test's scope is the validator abort — not matcher semantics, which are under active change in #32948/#34649 — and both were withdrawn. No prior reviews from me on this PR.

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. toStrictEqual and toMatchObject unexpected assertion error #9103 - toStrictEqual and toMatchObject fail with Proxy values (e.g. Vue's toValue); this PR fixes exception scope handling in Bun__deepMatch which is the code path used by these matchers when operating on Proxy objects

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #9103

🤖 Generated with Claude Code

@robobun

robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Build 75937: expect.test.js passes on every lane. The only red is test/js/node/test/parallel/test-net-connect-memleak.js on alpine x64-baseline, which also fails on main; the rest are flaky tests that passed on retry. Gate stamp confirms fail-before/pass-after on both ASAN and release. Ready for review.

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