bun:ffi: throw validation errors from viewSource and JSCallback instead of returning them - #34396
Conversation
… them The native Bun.FFI.viewSource host function returns an Error instance when it rejects its input (non-object descriptor, unknown FFI type, non-object options). The bun:ffi module exported that native function directly, so callers received a TypeError as the return value and had to check Error.isError(result) manually. dlopen, cc and linkSymbols already wrap their native counterparts with 'if (Error.isError(result)) throw result;'. Give viewSource the same wrapper so it throws like its siblings and matches its declared 'string[] | string' return type. The existing regression test only passed because expect().toThrow() currently accepts a returned Error as a throw. Rewritten to assert the error is actually thrown, and extended to cover the unknown-type, non-object-options and callback-descriptor paths plus a valid-input case.
|
Reproduced with 24 tests pass locally (22 fail on an unfixed build). The FFI test file is green on every CI lane across two runs; remaining reds are unrelated pre-existing ASAN failures on the Debian 13 x64-asan lane ( |
|
Updated 2:11 PM PT - Jul 16th, 2026
❌ @robobun, your commit 811e48c has 1 failures in
🧪 To try this PR locally: bunx bun-pr 34396That installs a local version of the PR into your bun-34396 --bun |
WalkthroughChangesFFI viewSource behavior
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 `@test/js/bun/ffi/ffi-viewSource-non-object.test.ts`:
- Around line 25-29: Replace the input loop in the viewSource invalid-value
tests with describe.each() parameterized cases, and apply the same conversion to
the additional test matrices around the referenced cases. Keep each value as a
separately reported case while preserving the existing TypeError assertions and
expected message checks.
🪄 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: 3e5208c5-2c99-4c8b-967b-f6dbdb2a2c31
📒 Files selected for processing (2)
src/js/bun/ffi.tstest/js/bun/ffi/ffi-viewSource-non-object.test.ts
There was a problem hiding this comment.
LGTM — straightforward fix that wraps viewSource with the same Error.isError(result) → throw guard already used by dlopen/cc/linkSymbols.
What was reviewed:
- Confirmed the wrapper matches the sibling pattern in
src/js/bun/ffi.tsand that always passingisCallback(even asundefined) is harmless —FFI::printinffi_body.rstreatsSome(undefined)the same asNoneviato_boolean(). - Checked the zero-arg edge:
eat_requiredno longer sees an empty slot, butprintstill rejects viais_empty_or_undefined_or_null, so it still throws. - Tests use an explicit try/catch helper so they distinguish thrown vs returned regardless of
toThrow()semantics, and cover all four validation paths plus the happy path.
Extended reasoning...
Overview
Two files touched. src/js/bun/ffi.ts renames the direct ffi.viewSource alias to nativeViewSource and adds a 3-line wrapper that throws when the native call returns an Error — byte-for-byte the same pattern as dlopen (line ~444), cc (line ~519), and linkSymbols (line ~531) in the same file. test/js/bun/ffi/ffi-viewSource-non-object.test.ts is expanded from 3 assertions to 5 test cases covering non-object descriptors, unknown FFI types, non-object options, non-object callback descriptors, and the valid-input return shape.
Security risks
None. This is a JS-side wrapper that converts a returned error value into a thrown one; no new native code, no new inputs reach native, no auth/crypto/permissions surface.
Level of scrutiny
Low. The fix is mechanical pattern-matching against three existing siblings in the same file. I traced the one potential behavior delta — the wrapper now always passes two arguments to the native function — through FFIObject.rs::view_source → arguments_old::<2> → next_eat → FfiImpl::print. The native side branches on is_callback.to_boolean(), so Some(undefined) and None both fall through to the non-callback path. The only observable change beyond the intended one is that viewSource() with zero args now throws the invalid_options_arg TypeError instead of the eat_required "Missing argument" error — still a thrown TypeError, and not part of any documented contract.
Other factors
The rewritten test uses an explicit thrown() helper (try/catch) rather than expect().toThrow(), which the PR description notes is deliberate to avoid depending on toThrow()'s current lenient handling of returned errors (being tightened in #34392). Tests assert toBeInstanceOf(TypeError) and check message content, and include a positive case asserting the documented string[] / string return types. No CODEOWNERS entry covers these paths. No outstanding review comments.
Same return-error-as-value pattern as viewSource: the native callback() host function returns a TypeError for a non-object options argument, a non-callable callback or an unknown FFI type, and the JSCallback constructor destructured that error directly so 'new JSCallback(fn, 42)' silently produced a useless instance with ptr === undefined. Guard it the same way as dlopen/cc/linkSymbols/viewSource.
There was a problem hiding this comment.
LGTM — mechanical fix that mirrors the existing Error.isError(result) guard already used by dlopen/cc/linkSymbols in the same file.
What was reviewed:
- Confirmed the native
FFI::printandFFI::callbackreturn validation errors as values viato_invalid_arguments(ffi_body.rs:1268/1272/1345/1421), so the JS-side throw is the correct place for this. - Checked the arity change in the
viewSourcewrapper (now always forwards 2 args):undefinedforisCallbackis falsy inprint, andundefinedforsymbolsstill yields a thrown TypeError viainvalid_options_arg— no behavior regression. - Test error-message assertions match the native strings ("Expected object", "Expected callback function", "JSFunctionCall", "my_callback_function").
- CodeRabbit's
describe.eachnit is stale — commit 6533ebb converted totest.each, which is what the repo guidelines prescribe.
Extended reasoning...
Overview
Two small changes to src/js/bun/ffi.ts:
viewSourceis no longer a direct alias offfi.viewSource; it's a 4-line wrapper that throws when the native result is an Error, matching the identical guard ondlopen,cc, andlinkSymbolsa few lines away.- The
JSCallbackconstructor checksnativeCallback's result before destructuring{ctx, ptr}and throws if it's an Error.
The test file is rewritten with test.each matrices covering non-object descriptors, unknown FFI type names, non-object options, non-callable callbacks, and valid-input positive cases for both viewSource and JSCallback. It uses an explicit thrown() helper so the assertions distinguish thrown vs returned Errors regardless of toThrow() semantics (relevant given #34392).
Security risks
None. This only changes whether an already-constructed TypeError is returned vs thrown from a validation path in bun:ffi. No new inputs are accepted, no parsing changes, no auth/crypto/permission surface.
Level of scrutiny
Low. The pattern is copied byte-for-byte from three sibling functions in the same file. I traced the native side (src/runtime/ffi/ffi_body.rs) to confirm FFI::print and FFI::callback do use to_invalid_arguments (returns an Error as a value, not thrown) on every validation failure, so the wrapper is the right place to convert to a throw. I also checked that the wrapper always forwarding two args doesn't change the non-callback path: arguments_old::<2> yields Some(undefined) for the second arg, to_boolean() is false, and it falls through as before. The zero-arg case now surfaces "Expected an options object with symbol names" instead of "Missing argument", which is an improvement and is what the test asserts.
Other factors
- Test assertions on error messages match the exact native strings (
"Expected object","Expected callback function") and generated-source markers ("JSFunctionCall","my_callback_function") I found inffi_body.rs/host_fns.rs. - The
Error.isErrorcall is not primordial-safe, but it's the exact idiom the three neighboring functions already use — not a new concern introduced here. - CodeRabbit left a stale nit about converting loops to
describe.each; the current revision already usestest.each, which is what CLAUDE.md prescribes for matrices. Nothing outstanding. - No CODEOWNERS entries cover these paths.
- The PR description notes 22/24 test cases fail on an unfixed build, satisfying the "fails for the right reason" bar.
ffi.ts: take main's version (#34396 already added the JSCallback error-throwing this PR originally carried).
What does this PR do?
viewSourcefrombun:ffireturned aTypeErrorinstead of throwing it when the descriptor was invalid:JSCallbackhas the same bug: its constructor destructured the returned error, so validation failures produced a useless instance instead of throwing: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.FFIreturn anErrorinstance as their value when validation fails; thebun:ffimodule is expected to turn that into a throw.dlopen,ccandlinkSymbolsalready do this withif (Error.isError(result)) throw result;, butviewSourcewas exported as a direct alias of the native function andJSCallbackdestructured the native result without checking it.Fix
Wrap
viewSourceand guard theJSCallbackconstructor the same way as the three existing siblings. The declared return type ofviewSourceinpackages/bun-types/ffi.d.tsisstring[] | string, which this now matches.Why this is correct
Matching
dlopen/cc/linkSymbolskeeps thebun:ffimodule internally consistent, and it matches the typed contract. The low-levelBun.FFIobject is left unchanged (it is not part of the typed public surface andBun.FFI.dlopen/Bun.FFI.linkSymbolsalso return errors).How did you verify your code works?
Rewrote
test/js/bun/ffi/ffi-viewSource-non-object.test.tsto assert the error is actually thrown (the old assertions only passed becauseexpect().toThrow()currently accepts a returned Error, which #34392 tightens). Addedtest.eachcases forviewSource(non-object descriptor, unknown type, non-object options, callback descriptor, valid input) andJSCallback(non-object options, non-callable callback, unknown type, valid input).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 underBUN_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::callbackfor 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.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