Skip to content

Add missing exception checks in expect matchers, mock functions and error construction - #40068

Merged
Jarred-Sumner merged 6 commits into
mainfrom
claude/exception-checks-expect-mock
Aug 30, 2026
Merged

Jarred-Sumner merged 6 commits into
mainfrom
claude/exception-checks-expect-mock

Conversation

@dylan-conway

Copy link
Copy Markdown
Member

What does this PR do?

Second batch from an audit of native code that performs another JSC operation (or returns) while a JS exception may be pending. This one covers bun:test matchers/mocks and error construction; every site is reachable from user JS.

  • Asymmetric matchers (bindings.cpp): check after isArray in expect.any(Array); after each getIndex in expect.arrayContaining (a throwing index getter previously reached Bun__deepEquals with the exception pending); after toString/RegExp::match in expect.stringMatching; after toInt32 in expect.closeTo (its two numbers are validated at construction, so they are read with asNumber). Bun__deepMatch and toHaveProperty(path) check after isArray (revoked Proxy) before using the result.
  • mockResolvedValue / mockResolvedValueOnce: JSPromise::resolvedPromise reads the value's constructor and can throw; that was stored as a null implementation. Now propagates.
  • mockRestore / jest.restoreAllMocks: putting the original back (overrideExportValue on a module namespace, putDirectIndex) can throw; clearSpy gets a scope and its callers propagate.
  • createInvalidThisError → throwInvalidThisError(scope, …): describing the receiver reads constructor.name. The old function handed the pending exception to createError, whose catch scope then adopted the thrown value as the ERR_INVALID_THIS error. It now propagates the getter's exception; all callers (Mock, BunRequest, MIMEType/MIMEParams accessors, node:sqlite) are switched.
  • $ERR_OUT_OF_RANGE: check after building the message, same reason.
  • Test-runner diff output: DiffFormatter formatted both values with JestPrettyFormat (runs getters / Proxy traps) inside Display::fmt and dropped the result; it now formats in a fallible DiffFormatter::new(...)? and fmt only prints. Snapshot matchers propagate the pretty-format error instead of throwing "Failed to pretty format value" over it.

How did you verify your code works?

New test for mockResolvedValue (fails on release). mock-fn, expect, mock-module, node-sqlite suites pass under BUN_JSC_validateExceptionChecks=1 on a debug build. The matcher cases (arrayContaining with a throwing index getter, revoked-Proxy receivers) already surfaced the right error in release; the missing checks there are what the validator flags.

…rror construction

- matchAsymmetricMatcher: check after isArray (expect.any(Array)), after both
  getIndex calls in expect.arrayContaining, after toString/RegExp::match in
  expect.stringMatching, after toInt32 in expect.closeTo (and read the two
  already-validated numbers with asNumber).
- Bun__deepMatch / getIfPropertyExistsFromPath: check after isArray before
  using its result (revoked Proxy).
- mockResolvedValue[Once]: check after JSPromise::resolvedPromise (reads the
  value's `constructor`) instead of storing a null implementation.
- mockRestore / jest.restoreAllMocks: clearSpy's put-back can throw
  (module-namespace export, indexed slot); give it a scope and propagate.
- createInvalidThisError → throwInvalidThisError(scope, …): describing the
  receiver reads `constructor.name`; check after it instead of handing a
  pending exception to createError (whose catch scope then adopted the thrown
  value as the error). All callers (Mock, BunRequest, MIMEType/MIMEParams,
  node:sqlite) switched.
- $ERR_OUT_OF_RANGE: check after building the message before createError.
- test runner DiffFormatter: format both values in a fallible constructor
  (JestPrettyFormat runs getters/Proxy traps) rather than inside Display::fmt
  with the result dropped; snapshot matchers propagate the pretty-format error
  instead of throwing a second one over it.
@coderabbitai

coderabbitai Bot commented Aug 22, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your current included review allowance is based on your included PR review attempts over the past 7 days.

Next review available in: 40 minutes

Limit details: You’ve used the included review currently available. Your 61 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

You’re in a promotional period — use the checkbox below to run this review for free:

  • Run review for free

On-demand reviews are free for the next 29 days. After that, they cost $0.25 per reviewed file.

How can I continue?

Run this review now using the option above, or comment @coderabbitai review --use-credits.

You can also wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b42e9dd6-c258-43bb-8377-3e1ff34b2e21

📥 Commits

Reviewing files that changed from the base of the PR and between 4627502 and e8ac715.

📒 Files selected for processing (25)
  • src/jsc/bindings/BunAnalyzeTranspiledModule.cpp
  • src/jsc/bindings/ErrorCode.cpp
  • src/jsc/bindings/ErrorCode.h
  • src/jsc/bindings/JSBunRequest.cpp
  • src/jsc/bindings/JSMockFunction.cpp
  • src/jsc/bindings/bindings.cpp
  • src/jsc/bindings/sqlite/NodeSqlite.cpp
  • src/jsc/bindings/webcore/JSMIMEParams.cpp
  • src/jsc/bindings/webcore/JSMIMEType.cpp
  • src/runtime/test_runner/diff_format.rs
  • src/runtime/test_runner/expect.rs
  • src/runtime/test_runner/expect/toBe.rs
  • src/runtime/test_runner/expect/toEqual.rs
  • src/runtime/test_runner/expect/toHaveBeenCalledWith.rs
  • src/runtime/test_runner/expect/toHaveBeenLastCalledWith.rs
  • src/runtime/test_runner/expect/toHaveBeenNthCalledWith.rs
  • src/runtime/test_runner/expect/toHaveLastReturnedWith.rs
  • src/runtime/test_runner/expect/toHaveNthReturnedWith.rs
  • src/runtime/test_runner/expect/toHaveProperty.rs
  • src/runtime/test_runner/expect/toHaveReturnedWith.rs
  • src/runtime/test_runner/expect/toMatchObject.rs
  • src/runtime/test_runner/expect/toStrictEqual.rs
  • src/runtime/test_runner/mod.rs
  • src/runtime/test_runner/snapshot.rs
  • test/js/bun/test/mock-fn.test.js

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

@robobun

robobun commented Aug 22, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 12:13 PM PT - Aug 22nd, 2026

✅ @dylan-conway, your commit e8ac715313feeb678323a73588ec78dc46916f55 passed in Build #103673! 🎉


🧪   To try this PR locally:

bunx bun-pr 40068

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

bun-40068 --bun

@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.

Beyond the inline findings, I also checked: the otherProp.asNumber() change in expect.closeTo is guarded by if (!otherProp.isNumber()) at bindings.cpp:560 (the comment about construction-time validation only covers expectedValue, not otherProp); the removed JSMock__resetSpies extern symbol has no remaining callers; and the surviving createInvalidThisError callers in JSDOMExceptionHandling.cpp / JSDOMPromiseDeferred.cpp all use the string-message overload, not the removed JSValue one.

Extended reasoning...

This PR touches 24 files across native JSC bindings and the Rust test runner, refactoring exception-check discipline and the createInvalidThisError → throwInvalidThisError API. Two nits were surfaced as inline comments. I verified a few additional concerns that looked risky in the diff but turned out safe: the toNumber → asNumber change on otherProp (guarded by an isNumber() check just above, not by construction as the comment might suggest for that particular value), the removed extern C symbol has no remaining Rust/C++ callers, and the remaining createInvalidThisError call sites use the still-declared string overload. Given the scope (native exception handling across many bindings, a public-shape refactor of DiffFormatter, and the restoreAllMocks ordering change flagged inline), this warrants a human look.

Comment thread test/js/bun/test/mock-fn.test.js Outdated
Comment thread src/jsc/bindings/JSMockFunction.cpp
Comment thread src/runtime/test_runner/diff_format.rs Outdated
received_ptr: *const core::ffi::c_char,
received_len: usize,
global_this: &JSGlobalObject,
_global_this: &JSGlobalObject,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

delete?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Deleted in 1cded53 (Rust extern and the C++ caller).

@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.

I re-reviewed after eabeb4b and didn't find any bugs — both earlier nits are addressed (the activeSpies set is now cleared only after the restore loop completes, and the new mockResolvedValue test is guarded by if (isBun)). Given this spans 24 files of JSC exception-check plumbing plus the DiffFormatter eager-format refactor, a human pass is still worthwhile.

What was reviewed:

  • expect.closeTo's switch to otherProp.asNumber() — guarded by the !otherProp.isNumber() early-return above it.
  • The removed extern "C" JSMock__resetSpies — no remaining callers in Rust or C++.
  • clearSpy now nulls spyTarget/spyIdentifier before the fallible put, so a retry after a mid-loop throw no-ops on already-cleared spies.
  • Every DiffFormatter call site: received/expected argument order matches the old struct-literal field assignments.
Extended reasoning...

Overview

Second batch of an exception-check audit: adds RETURN_IF_EXCEPTION after fallible JSC operations in bun:test matchers, mock functions, and error construction. The C++ side (bindings.cpp, JSMockFunction.cpp, ErrorCode.cpp, NodeSqlite.cpp, JSMIMEType/Params.cpp, JSBunRequest.cpp) inserts checks after isArray, getIndex, toString, match, toInt32, resolvedPromise, overrideExportValue, and putDirectIndex, and reworks createInvalidThisError into throwInvalidThisError(scope, …) so a throwing constructor.name getter propagates rather than being adopted as the ERR_INVALID_THIS body. The Rust side refactors DiffFormatter so JestPrettyFormat (which runs user getters/Proxy traps) executes in a fallible new() constructor instead of inside Display::fmt, and updates 12 matcher call sites plus snapshot.rs/expect.rs accordingly.

Since the last review

My earlier run flagged two nits, both fixed in eabeb4b and confirmed in the current diff: (1) restoreAllMocks now clears activeSpies after the loop, so a mid-loop throw leaves un-restored spies tracked for a retry; and (2) the new mockResolvedValue throw-propagation test is wrapped in if (isBun) per the file-header convention. Both threads are marked resolved.

Security risks

None. This is exception-propagation hygiene in test-runner and error-message code paths; no auth, crypto, network, or filesystem surface changes.

Level of scrutiny

High. REVIEW.md flags exception checks after JS-entering calls as the most-blocked native-code category, and this PR touches ~20 such sites plus a control-flow refactor (DiffFormatter) and an API signature change (createInvalidThisError → throwInvalidThisError) fanned out across 6 files. Individual edits are mechanical, but the aggregate is large enough that it doesn't fit the "simple/mechanical/obvious" auto-approve bar.

Other factors

I spot-checked the non-obvious edits: otherProp.asNumber() in expect.closeTo is safe because !otherProp.isNumber() returns FAIL a few lines earlier; expectedValue.asNumber() relies on construction-time validation as the comment states. The removed extern "C" JSMock__resetSpies has no remaining callers (grep across the tree). clearSpy now snapshots target/identifier/attributes into locals and nulls the fields before the potentially-throwing put — so a throw leaves the spy already detached, and a subsequent restoreAllMocks retry hits the if (!target) return no-op. The DiffFormatter::new argument order (received, expected) matches every previous struct-literal at the converted call sites. The PR description says the affected suites pass under BUN_JSC_validateExceptionChecks=1.

robobun added a commit that referenced this pull request Aug 25, 2026
Findings of scripts/jsc-exception-lint over src/jsc/bindings and
src/jsc/modules: a RETURN_IF_EXCEPTION after each call that can enter
JS (get, toString, toWTFString, getIndex, putDirectIndex, JSString
value and view, JSC::call, JSON parsing, iterator steps) where the code
went on to another JSC operation or returned from its ThrowScope
without one, RELEASE_AND_RETURN for tail calls that can throw,
asNumber()/asInt32()/JSC::toUInt32(asNumber()) after a type check in
place of the throwing coercions, and one nested DECLARE_THROW_SCOPE
removed from Bun.deepEquals. No behavior changes beyond stopping at the
first pending exception.

Not touched: napi.cpp, v8/, JSMockFunction.cpp, ErrorCode.cpp,
JSMIMEParams.cpp, JSMIMEType.cpp and the asymmetric matcher, deepMatch,
getIfPropertyExistsFromPath and dlopen functions, which #40068 and
dylan-conway pushed a commit that referenced this pull request Aug 25, 2026
…40410)

### Problem
- Native code must check for a pending JS exception before the next
JS-observable call and before it returns from a `ThrowScope`. A missing
check asserts on debug builds (`ERROR: Unchecked JS exception` from
`VM::verifyExceptionCheckNeedIsSatisfied`). On release builds the code
runs on a dummy result, a later unrelated check misattributes the error,
or a second throw overwrites it.
- Coverage was dynamic only (`BUN_JSC_validateExceptionChecks=1` on the
ASAN lane), so a path no test executes was never checked.

### Fix
- `scripts/jsc-exception-lint`: a clang LibTooling checker that models
the validator's state machine over the CFG of every function in
`src/**/*.cpp`. Callees are classified from visible bodies, then from
summary passes over the JavaScriptCore sources and Bun's bindings, then
from the `JSGlobalObject*` / `ThrowScope&` convention. `run.ts` drives
it; `rust-externs.ts` cross-checks hand-declared Rust externs.
- Fixes its findings: `RETURN_IF_EXCEPTION` after the throwing call,
`RELEASE_AND_RETURN` for tail calls, `asNumber()`/`asInt32()` after a
type check instead of the throwing coercion, one nested
`DECLARE_THROW_SCOPE` removed. Rust externs whose C++ body throws go
through the scope helpers and return `JsResult`. No termination special
cases, no `clearException`.
- Not touched: the files and functions #40068 and #40249 cover (napi,
v8, JSMockFunction, ErrorCode, MIME, asymmetric matchers). The JSC-side
sites are in oven-sh/WebKit#514; their skip-list entries stay until that
bump.
- Verified: `test/js/bun/jsc/exception-checks.test.ts` (new; each
snippet aborted the validator before), the affected suites under the
validator (sqlite, process, ffi, headers, streams, workers, vm, buffer,
crypto), and `bun run rust:check` for linux, windows and macOS.

### Background
- The validator: every `ThrowScope` destructor sets
`VM::m_needExceptionCheck`. The next `ThrowScope` constructor or
non-released destructor asserts if it is still set. Only `exception()`
(what `RETURN_IF_EXCEPTION` expands to), `clearException()` and
`assertNoException` clear it. The tool reports the states in which those
asserts fire, plus a call made after the function already threw.
- A summary is a callee's exit state set: clean, check pending, thrown,
or conditional thrower (the caller tests the return value; reported only
with `--kind maybe-thrown-call`). Each pass resolves one more level of
cross-file calls, so JSC gets three passes (cached per WebKit version).
- Rust-implemented `extern "C"` functions run under their own scope and
signal a throw with a sentinel, so the C++ side sees them as conditional
throwers.

<details><summary>Notes</summary>

Numbers. First pass over `src/jsc/bindings` with only the signature
convention: 2194 findings. With JSC and Bun summaries, the
TopExceptionScope model, template-aware carrier detection, and the
Rust-extern rule: 667 findings in 103 files (480 pending-call, 164
unchecked-exit, 13 nested scope, 10 call-after-throw). 603 were in scope
for this PR after excluding the files above. After the fixes: 113 in 21
files, of which 72 are in the excluded files and the rest were reviewed
as false positives (generated `JSSink` and `ZigGeneratedClasses` code,
`JSSetIterator::next` in `Keys` mode, `JSFunction::name`,
`getCalculatedDisplayName`, `rejectWithCaughtException` right after a
throw, global object construction). Those need entries in `nothrow.txt`
or the summary pass over `build/*/codegen` the driver now does.

What the tool does not see: a throwing call whose result is passed
straight into another call and then `RELEASE_AND_RETURN` (the
JSMockFunction pattern #40068 fixes) is legal for the validator and not
reported. That needs value tracking. Exceptions observed only through a
return value (`if (!result) return {}` after a helper that throws into
the caller's scope) are reported as `maybe-thrown-call` and hidden by
default.

Running it: `bun scripts/jsc-exception-lint/run.ts` needs a configured
debug build (`build/debug/compile_commands.json`) and the LLVM 21
development package (`libclang-cpp`, headers; CI's `llvm.sh 21 all`
installs them). The first run parses the JSC sources three times (about
30 minutes, cached in `build/debug/jsc-exception-lint/`); later runs
take about 15 minutes. A CI step on the linux debug lane after the C++
build is the natural next step; this PR does not add it.

How the fixes were made: the findings were split by file and fixed in
parallel under one written rule set (`RETURN_IF_EXCEPTION` only, no
termination special cases, report false positives instead of editing),
then the tree was rebuilt, re-analyzed, and a second pass handled the
remainder. I reverted two of the resulting hunks by hand (a scope added
to `rsisDetachNativeTransform`, a no-op branch in
`JSCTaskScheduler.cpp`) because they rested on a stale classification of
callees that cannot throw.

Dynamic runs: `test/js/bun/util/BunObject.test.ts` and
`test/js/bun/jsonl/jsonl-parse.test.ts` still abort under the validator
on the JSC-side sites (oven-sh/WebKit#514).
`test/js/node/test/parallel/test-repl-inspect-defaults.js` is the JSONP
`doGet` case in the same PR. Tests that failed in my container (worker
message flood, ffi FTL warm-up, node:util parseArgs stress, stdin
fixtures, IPv6 fetch, root-permission checks) fail identically on an
unmodified main build there.
</details>

<!-- robobun:evidence:begin -->

---

**[review]** gate passed · iteration 0 · 115 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/jsc/exception-checks.test.ts
bun test v1.4.1 (4448a2e)

test/js/bun/jsc/exception-checks.test.ts:
(pass) process.exitCode assigned a rope string [272.39ms]
(pass) process.kill with an unknown rope signal name [268.19ms]
(pass) process.umask with a rope string [340.13ms]
42 |     // them in the comparison so a failure names the call site.
43 |     const unchecked = stderr
44 |       .split("\n")
45 |       .map(line => line.trim())
46 |       .filter(line => line.startsWith("This scope can throw") || line.startsWith("But the exception was unchecked"));
47 |     expect({ stdout: stdout.trim(), unchecked, exitCode }).toEqual({ stdout: expected, unchecked: [], exitCode: 0 });
                                                                ^
error: expect(received).toEqual(expected)

  {
-   "exitCode": 0,
-   "stdout": "TypeError: Expected 2 values to compare",
-   "unchecked": [],
+   "exitCode": 134,
+   "stdout": "",
+   "unchecked": [
+     "This scope can throw a JS exception: functionBunDeepEquals @ ../../src/jsc/bindin
... (truncated)

release without fix: all passed
bun test v1.4.1-canary.1 (a95369a)

test/js/bun/jsc/exception-checks.test.ts:
(pass) process.umask with a rope string [4.80ms]
(pass) Bun.deepEquals with one argument [5.74ms]
(pass) process.exitCode assigned a rope string [4.57ms]
(pass) process.kill with an unknown rope signal name [4.33ms]

 4 pass
 0 fail
 4 expect() calls
Ran 4 tests across 1 file. [91.00ms]
__F:0:S:0
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/jsc/exception-checks.test.ts
bun test v1.4.1 (4448a2e)

test/js/bun/jsc/exception-checks.test.ts:
(pass) Bun.deepEquals with one argument [313.84ms]
(pass) process.umask with a rope string [277.49ms]
(pass) process.exitCode assigned a rope string [276.65ms]
(pass) process.kill with an unknown rope signal name [272.38ms]

 4 pass
 0 fail
 4 expect() calls
Ran 4 tests across 1 file. [2.32s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 628ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/60] gen ProcessBindingHTTPParser.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingHTTPParser.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingHTTPParser.cpp
[2/60] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[3/60] gen cpp.rs (cppbind)
[4/60] gen BunProcess.lut.h
Generating /workspace/bun/build/release/codegen/BunProcess.lut.h from /workspace/bun/src/jsc/bindings/BunProcess.cpp
[5/60] gen generated_host_exports.rs
generated_host_exports.rs: 120 exports (host=5, lazy=10, generic=105, rust=0); 242 extern-C blocks audited
[6/60] gen BunObject.lut.h
Generating /workspace/bun/build/release/codegen/BunObject.lut.h from /workspace/bun/src/jsc/bindings/BunObject.cpp
[7/60] gen JS modules (bundle-modules)
Preprocess modules (7492ms)
Bundle modules (48ms)
Postprocesss modules (96ms)
Bundle Functions (491ms)
Generate Code (32ms)

[8.17s] Bundled "src/js" for production
  2594 kb
  197 internal modules
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
scripts/jsc-exception-lint/README.md               |   83 ++
 scripts/jsc-exception-lint/jsc-exception-lint.cpp  | 1184 ++++++++++++++++++++
 scripts/jsc-exception-lint/nothrow.txt             |  112 ++
 scripts/jsc-exception-lint/run.ts                  |  450 ++++++++
 scripts/jsc-exception-lint/rust-externs.ts         |  142 +++
 src/http_jsc/headers_jsc.rs                        |    2 -
 src/jsc/ConsoleObject.rs                           |    4 +-
 src/jsc/FetchHeaders.rs                            |   18 +-
 src/jsc/JSGlobalObject.rs                          |   34 +-
 src/jsc/JSObject.rs                                |   18 +-
 src/jsc/JSUint8Array.rs                            |   23 +-
 src/jsc/JSValue.rs                                 |   43 +-
 src/jsc/VirtualMachine.rs                          |    7 +-
 src/jsc/array_buffer.rs                            |   16 +-
 src/jsc/bindings/BunDebugger.cpp                   |    7 +-
 src/jsc/bindings/BunInjectedScriptHost.cpp         |   34 +-
 src/jsc/bindings/BunObject.cpp                     |   13 +-
 src/jsc/bindings/BunPlugin.cpp                     |   20 +-
 src/jsc/bindings/BunProcess.cpp                    |   72 +-
 src/jsc/bindings/BunProcessReportObjectWindows.cpp |    1 +
 src/jsc/bindings/BunString.cpp                     |    2 +
 src/jsc/bindings/CallSite.cpp                      |    5 +
 src/jsc/bindings/CallSitePrototype.cpp             |    1 +
 src/jsc/bindings/ConsoleObject.cpp                 |    7 +-
 src/jsc/bindings/ErrorStackTrace.cpp               |   39 +-
 src/jsc/bindings/FormatStackTraceForJS.cpp         |    9 +-
 src/jsc/bindings/HTMLEntryPoint.cpp                |    2 +-
 src/jsc/bindings/ImportMetaObject.cpp              |    1 -
 src/jsc/bindings/InspectorLifecycleAgent.cpp       |    1 +
 src/jsc/bindings/InternalModuleRegistry.cpp        |    1 +
 src/jsc/bindings/JSBuffer.cpp                      |   12 +-
 src/jsc/bindings/JSCTestingHelpers.cpp       
... (truncated)
```

</details>

**gate history** · 2 passed · 0 rejected · iteration 0

<details><summary>evidence per changed file</summary>

```
file                                               reads  edits  tests
scripts/jsc-exception-lint/README.md                   0      1      0
scripts/jsc-exception-lint/jsc-exception-lint.cpp      2      4      0
scripts/jsc-exception-lint/nothrow.txt                 0      1      0
scripts/jsc-exception-lint/run.ts                      0      1      0
scripts/jsc-exception-lint/rust-externs.ts             0      1      0
src/http_jsc/headers_jsc.rs                            0      0      0
src/jsc/ConsoleObject.rs                               0      0      0
src/jsc/FetchHeaders.rs                                0      0      0
src/jsc/JSGlobalObject.rs                              0      0      0
src/jsc/JSObject.rs                                    0      0      0
src/jsc/JSUint8Array.rs                                0      0      0
src/jsc/JSValue.rs                                     0      0      0
src/jsc/VirtualMachine.rs                              0      0      0
src/jsc/array_buffer.rs                                0      0      0
src/jsc/bindings/BunDebugger.cpp                       0      0      0
src/jsc/bindings/BunInjectedScriptHost.cpp             0      0      0
(+ 99 more files)
```

</details>

<!-- robobun:evidence:end -->

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
@robobun

robobun commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Four open PRs fixed the same two lines in DiffFormatter with one test each: #40883, #40390, #40555 and #39565. I ran each of their test files against main with this branch merged. None aborts. The only difference from their expectations is that this PR propagates the error from the user code, where those PRs made the matcher throw its own error. I closed the four in favor of this PR.

Their coverage is in #40919, a test-only PR that targets this branch (one new file, test/js/bun/test/expect-diff-format-throw.test.ts). Merge it into this branch if you want the diff path covered here, or let it retarget to main after this lands. Without this branch, all 10 of its tests abort a debug build with the assertNoExceptionExceptTermination assertion.

@Jarred-Sumner
Jarred-Sumner merged commit 3ee9801 into main Aug 30, 2026
10 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/exception-checks-expect-mock branch August 30, 2026 07:15
Jarred-Sumner pushed a commit that referenced this pull request Aug 30, 2026
…pect matchers (#40981)

### Problem
- #40068 (3ee9801) added exception checks after `JSC::isArray()` at
three call sites in `src/jsc/bindings/bindings.cpp`: `expect.any(Array)`
(`matchAsymmetricMatcherAndGetFlags`), `toMatchObject`
(`Bun__deepMatch`), and `toHaveProperty` with an array path
(`JSC__JSValue__getIfPropertyExistsFromPath`).
- That PR added a test only for `mockResolvedValue`. Nothing in the tree
runs these three matchers with a Proxy under
`BUN_JSC_validateExceptionChecks=1`. A future edit can drop one of the
checks and no test fails.
- Without a check, a debug build aborts with `ERROR: Unchecked JS
exception: This scope can throw a JS exception: isArraySlowInline @
JavaScriptCore/runtime/ArrayConstructor.cpp ... ASSERTION FAILED:
exception check validation failed`.

### Fix
- Test only. It carries the test from #34753 into
`test/js/bun/test/expect.test.js`. #34753 fixed the same three sites and
is closed as superseded by #40068.
- The test spawns a child with `BUN_JSC_validateExceptionChecks=1`. The
child runs each matcher with transparent and revoked Proxy values and
prints `ok`. The test asserts `stdout`, `exitCode` 0, and no signal. On
a release build the option is a no-op and the child exits 0.
- Verified: `bun bd test test/js/bun/test/expect.test.js -t isArray`
passes on main. With the three checks removed from `bindings.cpp` and
rebuilt, the same test fails with `exitCode: 134, signalCode: SIGABRT`
and the abort message above. The full file passes (416 pass, 2 todo).

### Background
- `JSC::isArray()` follows the `Array.isArray` spec. For a Proxy it
walks to the target and throws a `TypeError` if the Proxy is revoked. So
it declares a throw scope, and the caller must check for an exception
before the next JSC call.
- `BUN_JSC_validateExceptionChecks=1` makes a debug JSC assert when a
throw scope is left unchecked. It is the tool that finds these sites.
Release builds ignore it.
- The test lives inside the `if (isBun)` block and reads `harness` with
`require` inside the test body. This file also runs under Jest and
Vitest, and the existing `test("()")` uses the same pattern for that
reason.

<details><summary>Notes</summary>

Fail-before run on main with the three `RETURN_IF_EXCEPTION` lines after
`isArray()` removed from `bindings.cpp`:

```
error: expect(received).toMatchObject(expected)
+   "exitCode": 134,
+   "signalCode": "SIGABRT",
+   "stderr":
+ "ERROR: Unchecked JS exception:
+     This scope can throw a JS exception: isArraySlowInline @ vendor/WebKit/Source/JavaScriptCore/runtime/ArrayConstructor.cpp:130
+     But the exception was unchecked as of this scope: hasInstance @ vendor/WebKit/Source/JavaScriptCore/runtime/JSObject.cpp:2686
+ ASSERTION FAILED: exception check validation failed
```

Pass-after on main as is: `1 pass, 417 filtered out`.

The three repro snippets from #34753 also run without the abort on main
under `BUN_JSC_validateExceptionChecks=1 BUN_JSC_dumpSimulatedThrows=1`:

```js
expect(new Proxy({}, {})).toEqual(expect.any(Array));
expect(new Proxy([], {})).toMatchObject([]);
expect({ a: 1 }).toHaveProperty(new Proxy(new Set(["a"]), {}));
```

Each throws the normal matcher failure and the process exits 0.

</details>

<!-- robobun:evidence:begin -->

---

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

<details><summary>passes on PR (with fix)</summary>

```console
Test-only change.

Debug/ASAN (expected pass):
$ bun bd test 'test/js/bun/test/expect.test.js'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/js/bun/test/expect.test.js
bun test v1.4.1 (d578a8c)

test/js/bun/test/expect.test.js:
(pass) expect() > () [302.05ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true [1.56ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true [0.44ms]
(pass) expect() > toBe() > expect(0).toBe(0) == true [0.26ms]
(pass) expect() > toBe() > expect(-0).toBe(-0) == true [0.23ms]
(pass) expect() > toBe() > expect(1).toBe(1) == true [0.23ms]
(pass) expect() > toBe() > expect(1).toBe(1) == true [0.24ms]
(pass) expect() > toBe() > expect(NaN).toBe(NaN) == true [0.23ms]
(pass) expect() > toBe() > expect(Infinity).toBe(Infinity) == true [0.23ms]
(pass) expect() > toBe() > expect({}).toBe({}) == true [0.23ms]
(pass) expect() > toBe() > expect(Symbol(a)).toBe(Symbol(a)) == true [0.27ms]
(pass) expect() > toBe() > expect(0).toBe(false) == false [2.35ms]
(pass) expect() > toBe() > expect(0).toBe("") == false [0.50ms]
(pass) expect() > toBe() > expect(0).toBe(-0) == false [0.30ms]
(pass) expect() > toBe() > expect(0).toBe(-0) == false [0.29ms]
(pass) expect() > toBe() > expect(1).toBe(2) == false [0.32ms]
(pass) expect() > toBe() > expect(1).toBe(true) == false [0.30ms]
(pass) expect() > toBe() > expect(1).toBe("1") == false [0.45ms]
(pass) expect() > toBe() > expect(Infinity).toBe(-Infinity) == false [0.31ms]
(pass) expect() > toBe() > expect("foo").toBe("Foo") == false [0.30ms]
(pass) expect() > toBe() > expect("foo").toBe("bar") == false [0.29ms]
(pass) expect() > toBe() > expect("").toBe(" ") == false [0.33ms]
(pass) expect() > toBe() > expect("").toBe(" ") == false [0.29ms]
(pass) expect() > toBe() > expect("").toBe(true) == false [0.29ms]
(pass) expect() > toBe() > expect({}).toBe({}) == false [0.31ms]
(pass) expect() > toBe() > expect(Set {}).toBe(Set {}) == false [0.29ms]
(pass) expect() > toBe() > expect([Function: a]).toBe([Function: a]) == false [0.31ms]
(pass) expect() > toBe() > e
... (truncated)
Exit: 0
```

</details>

<details><summary>diff hotspot</summary>

```
test/js/bun/test/expect.test.js | 33 +++++++++++++++++++++++++++++++++
 1 file changed, 33 insertions(+)
```

</details>

**gate history** · 2 passed · 0 rejected · iteration 2

<details><summary>evidence per changed file</summary>

```
file                             reads  edits  tests
test/js/bun/test/expect.test.js      1      1      0
```

</details>

<!-- robobun:evidence:end -->
robobun added a commit that referenced this pull request Sep 2, 2026
…b5cc2, baseline refresh

The JSC cell boilerplate (create, createStructure, finishCreation,
createPrototype, createConstructor, getConstructor, prototype,
prototypeForStructure, getDOMStructure, getDOMPrototype,
getDOMConstructor, initializeProperties, subspaceFor) with a VM& first
parameter installs properties and structures and does not run
JavaScript. The fallback classification treats it as non-throwing, so
the committed summaries no longer carry those rows: bun.tsv goes from
8440 rows to 6477, webkit.tsv from 3250 to 2937. A function of that
shape whose body does throw keeps its row. The fallback rules are one
function now, used by the classification and by the export's
"conventional" column.

Both summaries are regenerated for the WebKit bump on main, and their
headers say what a row is.

The baseline goes from 81 entries to 58: #40068 fixed its entries, the
refreshed summaries cleared stale NodeVM rows, and one new finding in
NodeVMSourceTextModule::create is listed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants