bun:test: stop treating a returned Error as thrown in toThrow - #34392
bun:test: stop treating a returned Error as thrown in toThrow#34392robobun wants to merge 4 commits into
Conversation
Expect.get_value_as_to_throw folded a synchronous throw and a normal
return into the same variable, then called to_error() on the result.
When the function body was `() => new TypeError(...)` the return value
is an ErrorInstance, to_error() returned Some, and toThrow() passed even
though nothing was thrown. Jest and Vitest both fail here with
"Received function did not throw".
Track whether the call actually threw (or a rejection was captured) and
report "did not throw" for every non-Promise normal return, regardless
of the return value's type. The Promise-returning path (Bun's
expect(async () => { throw e }).toThrow() extension) and the
.resolves/.rejects path are unchanged.
|
Updated 1:32 PM PT - Jul 16th, 2026
❌ @robobun, your commit 4b6222b has 2 failures in
🧪 To try this PR locally: bunx bun-pr 34392That installs a local version of the PR into your bun-34392 --bun |
WalkthroughChanges
toThrow error distinction
Possibly related PRs
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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 `@src/runtime/test_runner/expect.rs`:
- Around line 845-849: In the rejection-capture flow around
handle_rejected_promises(), move restoration of
vm.unhandled_pending_rejection_to_capture until after rejected promises are
drained and captured_rejection is computed. Keep the capture slot pointed at
return_value while handle_rejected_promises() runs, then restore
prev_unhandled_pending_rejection_to_capture.
🪄 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: a450ef5c-9a28-4a59-a659-b8a597dea1fb
📒 Files selected for processing (2)
src/runtime/test_runner/expect.rstest/js/bun/test/expect.test.js
The test relied on toThrow() accepting a returned Error; assert on the returned value instead so the crash regression it covers is still caught.
There was a problem hiding this comment.
LGTM — the earlier comment-length nit was addressed in 4b6222b.
Checked that take_exception returns the JSC::Exception wrapper, so throw 42 still yields Some from to_error() via the Exception cell-type branch and did_throw stays true for non-Error throws. Traced both callers of get_value_as_to_throw (toThrow.rs and the two snapshot matchers via fn_to_err_string_or_undefined) — both treat None as "did not throw" and match the new test's failure-message regex. Confirmed the capture-pointer/drain ordering CodeRabbit flagged is unchanged from main and the returned-rejected-Promise case flows through the as_any_promise() branch instead. The FFI test rewrite still guards the original crash regression (asserts on the returned TypeError).
Extended reasoning...
Overview
Changes Expect::get_value_as_to_throw in src/runtime/test_runner/expect.rs (~10 lines) to record whether the received function actually threw (threw_sync) or captured an unhandled rejection (captured_rejection), and returns (None, ...) when neither happened and the return value is not a Promise. Adds a comprehensive test to test/js/bun/test/expect.test.js covering every toThrow argument shape, both snapshot matchers, .not, Error subclasses, plain {message} objects, throwing non-Error values, and the Bun-only async-function extension. Rewrites test/js/bun/ffi/ffi-viewSource-non-object.test.ts, the one existing test that only passed because of the bug being fixed.
Security risks
None. This is test-runner matcher logic; no I/O, auth, parsing of untrusted input, or FFI surface changes.
Level of scrutiny
Medium. toThrow is one of the most-used matchers, so a semantic change here touches a lot of surface area. But the change is (a) small and easy to trace, (b) a correction toward Jest 30 / Vitest 4 semantics (author verified against both), (c) only affects the pathological case where a function returns an Error rather than throwing — which no correct test should rely on. I traced the control flow: take_exception returns the JSC::Exception cell (bindings.cpp:6010), so to_error() on it hits the inherits<JSC::Exception> branch (bindings.cpp:4668) and returns Some(exception->value()) — meaning throw <non-Error> still produces did_throw = true. Returned Promises exit early via as_any_promise() before reaching the new guard. The captured_rejection check preserves the pre-existing capture-slot path unchanged.
Other factors
- The new test is thorough and was cross-checked against Jest 30.4.1 and Vitest 4.1.10 per the PR description; full
expect.test.jsreported green underBUN_JSC_validateExceptionChecks=1. - My earlier inline nit about the 6-line comment in the FFI test was addressed in 4b6222b — the comment is now 2 lines and drops the PR-history rationale.
- CodeRabbit's ordering concern was correctly rebutted (the restore-before-drain sequence is unchanged from main; returned rejected Promises use the
as_any_promise()path, not the capture slot) and withdrawn. - The FFI test rewrite still exercises the original crash repro (calling
viewSourcewith non-object descriptors) and asserts on the returnedTypeError, so the regression guard is preserved.
|
CI status on 4b6222b (build #74033): the remaining failures are all unrelated to this diff.
The |
…ad of returning them (#34396) ## What does this PR do? `viewSource` from `bun:ffi` returned a `TypeError` instead of throwing it when the descriptor was invalid: ```js import { viewSource } from "bun:ffi"; const result = viewSource({ myFunc: 42 }); result instanceof TypeError; // true, returned not thrown result.message; // 'Expected an object for key "myFunc"' ``` `JSCallback` has the same bug: its constructor destructured the returned error, so validation failures produced a useless instance instead of throwing: ```js import { JSCallback } from "bun:ffi"; const cb = new JSCallback(() => {}, 42); // options must be an object cb.ptr; // undefined, no error surfaced ``` Both happen for every validation failure the native implementation reports: non-object descriptors, unknown FFI type names, a non-object options argument, and (for `JSCallback`) a non-callable callback. ### Cause The native host functions on `Bun.FFI` return an `Error` instance as their value when validation fails; the `bun:ffi` module is expected to turn that into a throw. `dlopen`, `cc` and `linkSymbols` already do this with `if (Error.isError(result)) throw result;`, but `viewSource` was exported as a direct alias of the native function and `JSCallback` destructured the native result without checking it. ### Fix Wrap `viewSource` and guard the `JSCallback` constructor the same way as the three existing siblings. The declared return type of `viewSource` in `packages/bun-types/ffi.d.ts` is `string[] | string`, which this now matches. ### Why this is correct Matching `dlopen`/`cc`/`linkSymbols` keeps the `bun:ffi` module internally consistent, and it matches the typed contract. The low-level `Bun.FFI` object is left unchanged (it is not part of the typed public surface and `Bun.FFI.dlopen`/`Bun.FFI.linkSymbols` also return errors). ### How did you verify your code works? Rewrote `test/js/bun/ffi/ffi-viewSource-non-object.test.ts` to assert the error is actually thrown (the old assertions only passed because `expect().toThrow()` currently accepts a returned Error, which #34392 tightens). Added `test.each` cases for `viewSource` (non-object descriptor, unknown type, non-object options, callback descriptor, valid input) and `JSCallback` (non-object options, non-callable callback, unknown type, valid input). ``` $ bun bd test test/js/bun/ffi/ffi-viewSource-non-object.test.ts 24 pass 0 fail ``` 22 of the 24 cases fail on an unfixed build (the two valid-input cases pass as expected). The full `test/js/bun/ffi/` suite is green and clean under `BUN_JSC_validateExceptionChecks=1`. ### Related #34392 changes `toThrow()` to stop accepting a returned Error and rewrites this test to assert on the returned value as a stopgap. This PR is the actual fix; whichever lands second will need the test file reconciled. #33200 touches `FFI::print`/`FFI::callback` for a different bug (propagating exceptions from descriptor getters) and still returns the validation error as a value, so the wrappers here cover both before and after that change. <!-- robobun:evidence:begin --> --- **no test proof** · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/ffi/ffi-viewSource-non-object.test.ts <!-- robobun:evidence:end -->
What does this PR do?
expect(fn).toThrow()passed whenfnreturned an Error instead of throwing it.Jest 30 and Vitest 4 both fail the first two with "Received function did not throw" and pass the third. Bun passed all three
toThrow()forms (no-arg, string, regexp, constructor, Error instance, asymmetric matcher) and failed.not.toThrow(), so a test written to assert "this API throws rather than returning an Error" could not fail for exactly the bug class it was written to catch.This surfaced while writing regression tests for #33522 (the DH constructor used to
returna created error instead of throwing; atoThrow-based test passed on both the fixed and the unfixed binary) and affects the tests in #31708 for the same reason.Cause
Expect::get_value_as_to_throwinsrc/runtime/test_runner/expect.rsstores the call result in one variable regardless of whether the call threw or returned normally, then runs.to_error()on it.JSValue::to_error()yieldsSomefor anyErrorInstance, so a returned Error was indistinguishable from a thrown one.Fix
Record whether the call threw synchronously (and whether an unhandled rejection was captured during it). When neither happened and the return value is not a Promise, report "did not throw" regardless of the return value's type. The Promise-returning path (
expect(async () => { throw e }).toThrow(), a deliberate Bun extension used by ~30 existing tests) and the.resolves/.rejectspath are untouched.toThrowErrorMatchingSnapshotandtoThrowErrorMatchingInlineSnapshotshare the same helper and are fixed by the same change.Why this is correct
Jest's
toThrowsetsthrownonly inside thecatchblock of itstry { received() } catch (e) { ... }; the return value is never consulted. Vitest'stoThrow(chai-based) does the same. The new test was run against Jest 30.4.1 and Vitest 4.1.10 and passes on both.How did you verify your code works?
Added
toThrow does not treat a returned Error as throwntotest/js/bun/test/expect.test.jscovering every expected-value shape (no-arg, string, regexp, constructor, Error instance,expect.any,expect.objectContaining), the two snapshot matchers,.not.toThrow(), Error subclasses, a plain{message}object, throwing non-Error values, and the Bun-only async extension. The non-Bun assertions were run green against Jest 30.4.1 and Vitest 4.1.10.Fails on unfixed bun (
USE_SYSTEM_BUN=1), passes with this change. The fullexpect.test.jsis 407 pass / 10 todo / 0 fail, clean underBUN_JSC_validateExceptionChecks=1. Grepped the test suite for arrow functions that return an Error into.toThrow(); none exist, so no existing tests rely on the old behavior.Related
#32952 fixes the
.rejects/.resolvesside of the same helper (the non-function branch). This PR fixes the synchronous-function branch; the two are complementary.test/js/bun/ffi/ffi-viewSource-non-object.test.tswas written as.toThrow("Expected an object")butBun.FFI.viewSourceactually returns the TypeError rather than throwing it, so that assertion only passed because of this bug. Rewrote it to assert on the returned value so the crash regression it covers is still caught; makingviewSourcethrow is tracked separately.no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/ffi/ffi-viewSource-non-object.test.ts