Skip to content

bun:ffi: use the engine-native FFI when available - #35246

Merged
Jarred-Sumner merged 34 commits into
mainfrom
jarred/jsc-ffi
Jul 29, 2026
Merged

bun:ffi: use the engine-native FFI when available#35246
Jarred-Sumner merged 34 commits into
mainfrom
jarred/jsc-ffi

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Reimplements bun:ffi on top of a new engine-native FFI in JavaScriptCore (oven-sh/WebKit#319). For dlopen(), linkSymbols(), CFunction(), and JSCallback, the engine generates the marshalling itself, promotes hot calls to a direct native call from JIT'd code, and owns callback lifetime. TinyCC is used only as cc()'s C compiler.

Performance

Engine-native vs TinyCC-based bun:ffi (macOS arm64, release):

operation TinyCC engine-native
noop call 2.13 ns 0.70 ns 3.0×
new CString(ptr) (46-char string) 92.5 ns 24.1 ns 3.8×

Against Deno on the same native library:

operation Bun Deno
noop call 0.69 ns 1.51 ns 2.2×
hash (ptr + u32) 39 ns 36 ns parity — dominated by the C function's own work
C string return 22 ns (returns: "cstring") 34 ns 1.5×

String arguments, same string ("550e8400-…", 36 chars):

how the string is passed ns/call
raw pointer (ptr(buf)) 16
TypedArray 17
JS string (encoded into the call arena) 27
result of CString(ptr) 27

Passing a JS string re-encodes it on every call; for a hot loop over the same string, pass a pointer or TypedArray.

opentui

Three of opentui's own benchmark suites (packages/core), engine-native vs TinyCC-based bun:ffi, same machine. opentui binds its Zig core -- including its native Yoga port (222 symbols) -- through one bun:ffi dlopen, so its Yoga layout passes are FFI-call-dense.

render-traversal (Yoga reads + scrollbox culling) -- geomean 1.30x, scaling with call count as fixed per-scenario cost amortizes:

scenario speedup
yoga_layout_reads_1000 2.08x
yoga_layout_reads_10000 2.02x
yoga_layout_reads_100 1.38x
scrollbox_culling_scaling_10000 1.43x
scrollbox_culling_scaling_5000 1.32x
scrollbar_stack / layout_only_opencode_wrappers 1.00x

layout-benchmark (16 dirty-and-relayout scenarios through the Yoga FFI) -- geomean 1.19x, all scenarios faster (1.04x-1.38x); OpenCode-shaped full-render passes 1.30x-1.38x, pure calculate_only layout 1.07x-1.19x.

native-span-feed (default suite, a memcpy-bound span stream) -- geomean +4.9% throughput, worst scenario -1.7%, best +22.9% on commit_4k write; gains concentrate at high call rates and vanish at 32 MB spans.

opentui is unaffected by the CString change (it uses neither CString nor cstring returns).

Behavior changes

  • cstring returns are strings. A symbol declared returns: "cstring" yields a JS string primitive (typeof "string", === works) decoded from the callee's char*; a NULL return is null. There is no wrapper object and no address is exposed. Callback parameters typed cstring arrive as strings the same way.
  • CString is a constructor that returns a string. new CString(ptr, byteOffset?, byteLength?) and CString(ptr, ...) transcode the bytes at ptr and return a plain string; CString has no accessor surface.
  • buffer_length — new argument type: pass the same TypedArray/DataView you passed for a buffer argument and the callee receives that view's byte length as a uint64_t, read off the same object at call time so pointer and length always agree. Argument-only; not available inside cc().
  • .ptr / .native on FFI functions are real read-only own properties.
  • N-API types are cc()-only. napi_env / napi_value in dlopen, linkSymbols, CFunction, or JSCallback throw a TypeError. Inside cc(), a napi_env parameter is filled in by the compiled trampoline and its JS argument is a consumed-but-ignored placeholder.
  • cc() performs C-side conversions in its TinyCC-compiled trampoline (integer arguments wrap), and bundles N-API headers under <bun-cc>/node/ so #include <node/node_api.h> resolves without a -I flag.
  • Thread-safe callbacks can be invoked from any thread and are delivered on the JS thread with arguments converted there (64-bit integers and large pointers arrive as exact BigInts). close() refuses new foreign-thread calls but every already-queued invocation is still delivered.
  • The JIT is required. With the JIT disabled, dlopen() and friends throw a TypeError.

What is removed

  • The TinyCC compile path for dlopen/linkSymbols/CFunction/JSCallback symbols, viewSource of callbacks (there is no generated C to show), and the per-symbol wrapper objects — every symbol is the engine function itself.
  • CString's object surface (see above) and toArrayBuffer-backed arrayBuffer on it.

Testing

  • test/js/bun/ffi/ builds its C fixture with the host compiler at test time, so the suite runs on every CI platform: 203 tests, 0 fail. Includes an ABI conformance suite whose fixture returns position-weighted combinations of its arguments, so any calling-convention error changes the observable result — verified to detect a deliberately mis-declared signature.
  • Source lints, the napi FFI file, and the ffi bench all green; opentui audited as unaffected by the CString change (it uses neither CString nor cstring returns).

@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator
Updated 6:05 AM PT - Jul 29th, 2026

@Jarred-Sumner, your commit 787c740 is building: #85168

@github-actions

Copy link
Copy Markdown
Contributor

Found 4 issues this PR may fix:

  1. bun:ffi leaks memory #20845 - Per-symbol setup memory drops from ~70KB to <1KB by eliminating TinyCC trampoline JIT, directly addressing the ~70MB leak after 10M FFI calls
  2. Typed-array reads after a bun:ffi call return stale values once the calling function is JIT-compiled (DFG load elimination across native calls) #32054 - JSC-native CallFFI DFG nodes properly model FFI calls as potentially clobbering typed-array memory, fixing stale reads after JIT compilation
  3. bun:ffi mis-lays sub-8-byte stack arguments on macOS arm64 (8-byte slots vs Apple natural-size packing) — callee reads shifted garbage #33672 - Switching to JSC's native FFI delegates argument layout to the engine, which correctly follows Apple's arm64 natural-size packing instead of TinyCC's incorrect 8-byte slot layout
  4. bun:ffi silently fails #12237 - Eliminating TinyCC trampoline generation for standard dlopen symbols avoids whatever silent failure mode caused process exit with no error

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

Fixes #20845
Fixes #32054
Fixes #33672
Fixes #12237

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. ffi: u32 and i64_fast returns of 2 ** 31 arrive in JS as -2147483648 #33340 - Fixes the same FFIType.u32 misbehavior #7007 u32 >= 2^31 sign-flip bug, using a different approach (correcting MAX_INT32 in FFI.h vs. switching to engine-native FFI)
  2. bun:ffi: unify integer argument coercion on modular wrap #35180 - Replaces the same per-type integer argument coercion in ffi.ts with modular wrap, which this PR supersedes by moving coercion to the JSC engine entirely

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Changes

JavaScriptCore FFI integration

Layer / File(s) Summary
JSC FFI bridge
src/jsc/bindings/JSCFFIBridge.cpp
Adds signature validation and C-exported creation and close functions for JSC-native FFI functions and callbacks.
Runtime path selection
src/bun_core/env_var.rs, src/runtime/ffi/ffi_body.rs
Adds feature-flag gating and routes eligible callbacks, opened symbols, and linked symbols through JSC-native FFI while retaining TinyCC for other cases.
Callback cleanup wiring
src/runtime/ffi/ffi_body.rs, src/runtime/ffi/FFIObject.rs
Exposes closeJSCCallback and forwards callback teardown to the JSC bridge.
JavaScript callback and symbol handling
src/js/bun/ffi.ts, test/js/bun/ffi/cc.test.ts
Tracks JSC callback handles, centralizes symbol wrapping for dlopen() and linkSymbols(), and updates numeric argument coercion expectations.

WebKit dependency update

Layer / File(s) Summary
WebKit version selection
scripts/build/deps/webkit.ts
Updates the WebKit autobuild-preview version identifier used by the build scripts.

Possibly related PRs

  • oven-sh/bun#34575: Changes JSFFIFunction constructor wiring used by the JSC-native FFI path.

Suggested reviewers: robobun

🚥 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 accurately summarizes the main change: routing bun:ffi through engine-native FFI when available.
Description check ✅ Passed The description covers the change and verification, though it does not use the exact template headings.

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

@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 `@src/jsc/bindings/JSCFFIBridge.cpp`:
- Around line 37-72: Update the `.ptr` assignment in `Bun__CreateJSCFFIFunction`
to numerically convert `reinterpret_cast<uintptr_t>(target)` to a JavaScript
number instead of bit-casting the integer bits as a `double`. Preserve the
existing read-only property and ensure consumers of `function->ptr` receive the
original target address value.

In `@src/runtime/ffi/ffi_body.rs`:
- Around line 128-190: Extract the repeated ABI argument-type tag conversion and
empty-slice-to-null pointer setup from create_jsc_ffi_function and FFI::callback
into a shared helper, preferably associated with Function. Update both JSC call
sites to reuse that helper while preserving the existing u8 tags, argument
count, and null pointer behavior for empty argument lists.
🪄 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: 9febf64e-6dae-4ef2-8709-da5b35c6b55e

📥 Commits

Reviewing files that changed from the base of the PR and between feee846 and 3be2bfc.

📒 Files selected for processing (5)
  • src/bun_core/env_var.rs
  • src/js/bun/ffi.ts
  • src/jsc/bindings/JSCFFIBridge.cpp
  • src/runtime/ffi/FFIObject.rs
  • src/runtime/ffi/ffi_body.rs

Comment thread src/jsc/bindings/JSCFFIBridge.cpp
Comment thread src/runtime/ffi/ffi_body.rs
Comment thread src/runtime/ffi/ffi_body.rs Outdated
Comment thread src/js/bun/ffi.ts Outdated
Comment thread src/runtime/ffi/ffi_body.rs Outdated

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/runtime/ffi/ffi_body.rs (1)

1390-1402: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Root the newly created callback before storing it.

cb is held as a raw JSValue across get_own() and create_object_2(), both of which may allocate. If GC runs before result.put(..., cb), the callback cell can be collected and the stored jsc handle becomes invalid.

Proposed fix
             if cb.is_empty() {
                 ...
             }
+            let _cb_keep = jsc::EnsureStillAlive(cb);
             let ptr_value = cb
                 .get_own(global_this, &bun_core::String::borrow_utf8(b"ptr"))?

As per coding guidelines, every JavaScript value held beyond the current call must be rooted across potentially allocating operations.

🤖 Prompt for 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.

In `@src/runtime/ffi/ffi_body.rs` around lines 1390 - 1402, Root cb before calling
get_own or create_object_2, and keep that root alive through result.put so the
stored jsc value remains valid across allocations. Update the callback
construction flow around cb and create_object_2 without changing the resulting {
ptr, ctx, jsc } shape.

Source: Coding guidelines

♻️ Duplicate comments (1)
src/jsc/bindings/JSCFFIBridge.cpp (1)

69-69: ⚠️ Potential issue | 🔴 Critical

The .ptr conversion is still incorrect.

std::bit_cast<double> reinterprets the address bits as IEEE-754 bits; it does not numerically convert the pointer address. This is the same unresolved issue from the previous review and still corrupts .ptr.

🤖 Prompt for 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.

In `@src/jsc/bindings/JSCFFIBridge.cpp` at line 69, Update the `.ptr` property
assignment in the JSC FFI bridge to numerically convert the target pointer
address to the JavaScript number representation, rather than bit-casting the
integer bits into a double. Preserve the existing read-only property attributes
and pointer value source.
🤖 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/js/bun/ffi.ts`:
- Line 506: The native-symbol marker checks in the dlopen wrapping logic and
linkSymbols() must require own-property membership. Update both the jscSymbols
check near line 506 and the corresponding check near line 585 in
src/js/bun/ffi.ts to use an own-property test while preserving the existing
true-value requirement.

In `@src/runtime/ffi/ffi_body.rs`:
- Around line 1658-1660: Root the native-symbol map variables before any
allocation or garbage-collection points: keep jsc_symbols rooted for the full
open() flow at src/runtime/ffi/ffi_body.rs lines 1658-1660 and for the full
link_symbols() flow at lines 1804-1805. Apply the same rooting pattern at both
sites so each map remains valid throughout its respective operation.

---

Outside diff comments:
In `@src/runtime/ffi/ffi_body.rs`:
- Around line 1390-1402: Root cb before calling get_own or create_object_2, and
keep that root alive through result.put so the stored jsc value remains valid
across allocations. Update the callback construction flow around cb and
create_object_2 without changing the resulting { ptr, ctx, jsc } shape.

---

Duplicate comments:
In `@src/jsc/bindings/JSCFFIBridge.cpp`:
- Line 69: Update the `.ptr` property assignment in the JSC FFI bridge to
numerically convert the target pointer address to the JavaScript number
representation, rather than bit-casting the integer bits into a double. Preserve
the existing read-only property attributes and pointer value source.
🪄 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: 010a21ad-a5dd-4273-b7c0-c8334b67c8f8

📥 Commits

Reviewing files that changed from the base of the PR and between 3be2bfc and 7b9696b.

📒 Files selected for processing (4)
  • src/js/bun/ffi.ts
  • src/jsc/bindings/JSCFFIBridge.cpp
  • src/runtime/ffi/ffi_body.rs
  • test/js/bun/ffi/cc.test.ts

Comment thread src/js/bun/ffi.ts Outdated
Comment thread src/runtime/ffi/ffi_body.rs Outdated
Comment thread src/js/bun/ffi.ts Outdated
Comment thread test/js/bun/ffi/cc.test.ts Outdated
Comment thread src/jsc/bindings/JSCFFIBridge.cpp Outdated
Comment thread src/runtime/ffi/ffi_body.rs Outdated

@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: 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 `@scripts/build/deps/webkit.ts`:
- Line 13: Update the WEBKIT_VERSION constant to the merged WebKit commit SHA
for PR `#319`, replacing the temporary autobuild-preview-pr-319-ca52299c value
while preserving the existing version-pin usage.
🪄 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: bcfc1ed3-fcc3-4a12-9d14-6db11b2ea84c

📥 Commits

Reviewing files that changed from the base of the PR and between 7e6b25d and a55f1ef.

📒 Files selected for processing (1)
  • scripts/build/deps/webkit.ts

Comment thread scripts/build/deps/webkit.ts Outdated

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

♻️ Duplicate comments (1)
src/runtime/ffi/ffi_body.rs (1)

1658-1661: 🩺 Stability & Availability | 🔴 Critical

Root both jsc_symbols maps for their complete lifetimes.

Both maps remain unprotected across JSC allocations and later property writes. Add a protected()/EnsureStillAlive() guard that remains in scope through the final js_object.put(...).

  • src/runtime/ffi/ffi_body.rs#L1658-L1661: root jsc_symbols throughout FFI::open.
  • src/runtime/ffi/ffi_body.rs#L1804-L1806: root jsc_symbols throughout FFI::link_symbols.

As per coding guidelines, JavaScript values held across allocation/GC points must be rooted and covered by GC-stress tests.

#!/bin/bash
rg -n -C4 'jsc_symbols|EnsureStillAlive|protected\(' src/runtime/ffi/ffi_body.rs
🤖 Prompt for 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.

In `@src/runtime/ffi/ffi_body.rs` around lines 1658 - 1661, Root each jsc_symbols
value with a protected()/EnsureStillAlive() guard immediately after creation,
keeping the guard in scope through the final js_object.put(...) in FFI::open at
src/runtime/ffi/ffi_body.rs#L1658-L1661 and FFI::link_symbols at
src/runtime/ffi/ffi_body.rs#L1804-L1806; ensure both maps remain protected
across all JSC allocations and property writes.

Source: Coding guidelines

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

Duplicate comments:
In `@src/runtime/ffi/ffi_body.rs`:
- Around line 1658-1661: Root each jsc_symbols value with a
protected()/EnsureStillAlive() guard immediately after creation, keeping the
guard in scope through the final js_object.put(...) in FFI::open at
src/runtime/ffi/ffi_body.rs#L1658-L1661 and FFI::link_symbols at
src/runtime/ffi/ffi_body.rs#L1804-L1806; ensure both maps remain protected
across all JSC allocations and property writes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1dc5d487-da79-422d-b80d-ad2bbc3b6d2a

📥 Commits

Reviewing files that changed from the base of the PR and between a55f1ef and 36960f0.

📒 Files selected for processing (2)
  • src/jsc/bindings/JSCFFIBridge.cpp
  • src/runtime/ffi/ffi_body.rs

Comment thread src/runtime/ffi/ffi_body.rs Outdated
Comment thread src/runtime/ffi/ffi_body.rs Outdated
Comment thread src/js/bun/ffi.ts Outdated
Comment thread src/js/bun/ffi.ts Outdated
@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Windows arm64 validation

Binary 1.4.0-canary.1+d4edfa832 (build 79426 artifact) on Windows 11 Pro 10.0.26100, Cobalt 100 (16 cores, ARM64, neon/sve).

(1) bun test test/js/bun/ffi/ — engine FFI (default)

CI state (no native dll): 51 pass / 22 skip / 1 todo / 0 fail, exit 0. Same as other platforms.

With ffi-test.c compiled to a dll (so the run ffi describe actually runs, which it never does in win-arm64 CI): on a completed run, 143 pass / 21 skip / 1 todo / 2 fail. All run ffi > FFI runner[ (fast int)] cases pass, including full-range uint32_t identity and every callback/threadsafe-callback type.

The 2 "fail" entries are expect() calls at ffi.test.js:563 (inside threadsafe callback) firing after their own test has already passed and landing on whatever test is current. They reproduce identically on main canary 50bb3bd8e (see below), and a standalone threadsafe-callback probe with proper draining passes 12/12 on both paths. Not an engine-FFI marshalling bug.

Intermittently (~60%) the suite segfaults instead of completing; root cause is a pre-existing toBuffer GC crash (see "pre-existing" below), unrelated to this PR.

(2) BUN_FEATURE_FLAG_DISABLE_JSC_FFI=1 — kill switch

CI state (no native dll): 50 pass / 22 skip / 1 todo / 1 fail. The 1 fail is cc.test.ts double <-> JSValue conversions > f64 arguments reach C with NaN, -0.0, and BigInt sign intact: this PR changed the expectation at cc.test.ts:921 to string: ["threw", "TypeError"], but under the kill switch CFunction goes through TinyCC which still returns 2.5 instead of throwing. If the kill switch is meant to keep CI green, that assertion needs to branch on the flag (not arm64-specific; would fail everywhere with the flag set).

With the dll: on a completed run, 140 pass / 5 fail. The extra 3 fails vs the engine path are pre-existing TinyCC bugs on this platform: identity_uint32_t(2147483648) returns -2147483648 (sign-extended) in both ffiRunner passes, plus the cc.test.ts assertion above. Same intermittent toBuffer segfault.

(3) AAPCS64 stress pass

Custom dll covering everything called out (12×i32 stack-spill, weighted 10×i32 ordering check, 12×f64, 10×f32, interleaved i32/f64, 10×i32+10×f64 spilling both register files, i8/u8/i16/u16 sub-word sign/zero extension incl. stack-spilled i8, i64/u64 BigInt near-max, 10×u64 spill, f32 vs f64 returns, stack-spilled ptr, JSCallback invoked from C for i32/i64/f32/f64, JSCallback with 10 i32 args, JSCallback with interleaved i32/f64):

path result runs
engine FFI 26/26 pass 10/10 clean
DISABLE_JSC_FFI=1 26/26 pass clean

No crashes, no wrong values.

(4) Perf sanity (50M iters, best-of-3)

bench engine FFI TinyCC (flag=1)
noop 1.20 ns 3.88 ns
hash(64B djb2) 55.3 ns 72.5 ns
new CString(ffi_string()) 283.9 ns 280.6 ns

noop is 3.2× faster than TinyCC (vs 1.25× on the macOS CI build). CString is dominated by allocation so it's a wash.

Pre-existing win-arm64 bugs surfaced (reproduce on main 50bb3bd8e, both with and without the flag)

  1. toBuffer(ptr, 0, n) + Bun.gc(true) segfaults ~50% on Windows arm64. Minimal repro:

    import { dlopen, toBuffer } from "bun:ffi";
    const { symbols } = dlopen(dll, { f: { returns: "ptr", args: [] } });
    toBuffer(symbols.f(), 0, 4);
    Bun.gc(true);   // segfault at a low address, ~50% of runs

    PR jsc-ffi 17/30, PR flag=1 19/30, main 16/30. This is why ffi.test.js crashes once the dll is present: run ffi > primitives calls toBuffer then Bun.gc(true). toArrayBuffer is fine. Not visible in CI because the dll is never built there.

  2. TinyCC uint32_t return sign-extends ≥2³¹ on Windows arm64: identity_uint32_t(2147483648)-2147483648. Engine FFI returns the correct 2147483648, so this PR fixes it for the default path.

  3. Threadsafe JSCallback late-fire with wrong arg1: the threadsafe callback > fn(T) T tests use await 1 which isn't sufficient; the callback sometimes fires after the test exits with a garbage value (e.g. {ptr:…, returns:"ptr"}). Same on main. Threadsafe path is unchanged by this PR.

Summary

Nothing fails only on the engine-FFI path on Windows arm64. Every red I found reproduces on main and/or under the kill switch. The one kill-switch-specific fail is the cc.test.ts:921 expectation this PR changed. Core marshalling (all primitive types, BigInt, f32/f64, stack-spilled args both register files, sub-word ext, CFunction, non-threadsafe JSCallback) is correct, and noop overhead drops from 3.88ns to 1.20ns.

Comment thread src/js/bun/ffi.ts Outdated
Comment thread src/js/bun/ffi.ts Outdated
@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Windows x64 validation report

Binary: 1.4.0-canary.1+d4edfa832 (build 79426 artifact), Windows Server 2019 x64, Xeon Platinum 8573C (no AVX).

(1) bun test test/js/bun/ffi/ as CI runs it (no compiled test DLL)

Path pass skip todo fail
JSC-FFI (default) 52 21 1 0
BUN_FEATURE_FLAG_DISABLE_JSC_FFI=1 51 21 1 1

The kill-switch works but exposes one pre-existing TinyCC failure that JSC-FFI fixes: cc.test.ts > double <-> JSValue conversions > f64 arguments reach C with NaN, -0.0, and BigInt sign intact.

(2) With ffi-test.c compiled (runs the skipped run ffi suite)

The big run ffi describe block is skipped on Windows CI because no /tmp/bun-ffi-test.dll exists. Compiling it and re-running uncovers three pre-existing bugs on main (verified against 50bb3bd8e) that also reproduce on this branch; none are introduced by this PR. I'll file them separately. With a one-line workaround for the toBuffer crash, 10/10 runs on JSC-FFI: 100 pass / 4 skip / 2 fail, and 10/10 on TinyCC: 98 pass / 4 skip / 4 fail (JSC-FFI additionally fixes the two TinyCC uint32_t sign-extension failures).

(3) Win64 ABI stress (>4 args, mixed by position, stack spill, sub-word, i64/u64, f32/f64, JSCallback from C)

Wrote a 28-symbol stress DLL covering 38 distinct call shapes.

Path result
JSC-FFI 38/38 pass, 50/50 runs
TinyCC 38/38 pass, 50/50 runs

All register and stack argument positions are marshalled correctly in LLInt/Baseline/DFG.

🚨 One real JSC-FFI regression: ptr / integer args are garbage under FTL

The bench/ffi hash benchmark segfaults on this branch only, once FTL tiers up (~180k iterations). It does not reproduce on TinyCC or main, and BUN_JSC_useFTLJIT=0 fixes it, so this is the FTL direct-call lowering from oven-sh/WebKit#319.

Affected: ptr, i64, u64, i32, u32, i16, u16, i8, u8 arguments (native side receives garbage in RCX; f64/f32/bool are fine). Happens at every argument position 1-5 tested (registers and stack). The test suite didn't catch it because nothing in test/js/bun/ffi/ runs a single call site enough times to reach FTL.

Minimal repro (jsc-ffi-ftl-repro.c + .js, drop in anywhere):

// clang -shared -O2 -o jsc-ffi-ftl-repro.dll jsc-ffi-ftl-repro.c
#include <stdint.h>
#ifdef _WIN32
#define X __declspec(dllexport)
#else
#define X __attribute__((visibility("default")))
#endif
X uint64_t ptr_as_u64(const void* p) { return (uint64_t)(uintptr_t)p; }
X int32_t echo_i32(int32_t v) { return v; }
import { dlopen, ptr, suffix } from "bun:ffi";
const { symbols: { ptr_as_u64, echo_i32 } } = dlopen(
  import.meta.dir + "/jsc-ffi-ftl-repro." + suffix,
  {
    ptr_as_u64: { args: ["ptr"], returns: "u64" },
    echo_i32: { args: ["i32"], returns: "i32" },
  },
);
const buf = new Uint8Array(8);
const p = ptr(buf);
const want = BigInt(p);
let bad = -1, got;
for (let i = 0; i < 5_000_000; i++) {
  const r = ptr_as_u64.native(p);
  if (r !== want && bad < 0) { bad = i; got = r; }
}
console.log(bad < 0 ? "ptr OK" : `ptr FAIL iter ${bad}: got 0x${got.toString(16)} want 0x${want.toString(16)}`);
// same shape for i32:
bad = -1;
for (let i = 0; i < 5_000_000; i++) {
  const r = echo_i32.native(-12345);
  if (r !== -12345 && bad < 0) { bad = i; got = r; }
}
console.log(bad < 0 ? "i32 OK" : `i32 FAIL iter ${bad}: got ${got}`);

Output on this branch, Windows x64:

ptr FAIL iter 179955: got 0xd2912b838917 want 0x2538221c130
i32 FAIL iter 114943: got 91255809

Output with BUN_JSC_useFTLJIT=0 or BUN_FEATURE_FLAG_DISABLE_JSC_FFI=1 or on main:

ptr OK
i32 OK

(4) Perf (bench/ffi equivalent, same DLL)

bench JSC-FFI (FTL on) JSC-FFI (FTL off) TinyCC
noop 1.73 ns 3.46 ns 6.66 ns
hash segfault (FTL bug above) 44.93 ns 56.48 ns
c string segfault 192.03 ns 193.70 ns

So ~3.85x faster noop under FTL, ~1.9x at DFG, once the FTL arg-marshalling bug is fixed.

Answer to follow-up (1): the threadsafe i64/u64 callback failures

These are pre-existing on main and cross-platform (repro on Linux x64 canary too); not a JSC-FFI regression. The threadsafe callback stores the int64_t/uint64_t argument's BigInt without a GC root, so if the JSCallback goes out of scope and Bun.gc(true) runs before the deferred task drains, the callback receives a freed-and-reused heap cell (often {value:"CFunction1"} or {}). Signature: new JSCallback(fn, { args: ["int64_t"], threadsafe: true }) invoked through new CFunction({ ptr: cb.ptr, returns: "void", args: ["int64_t"] })(-64n). Filing as a separate issue.

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Windows x64 re-verification on a0e603a49 (WebKit autobuild-preview-pr-319-6d6505bf)

FTL fix confirmed. All four checks green.

(1) FTL argument-marshalling repro

With FTL on, 3/3 runs:

ptr OK
i32 OK

Exhaustive sweep (each probe 5M iterations, monomorphic call site): ptr at positions 1/2/3/5(stack), i64/u64/i32/u32 (both Int32- and Double-encoded)/i16/u16/i8/u8/bool/f64/f32, plus deref_ptr(ptr,u32) which segfaulted before: 18/18 OK. The per-process isolated probes that read 6/8 FAIL on d4edfa832 now read 8/8 OK.

(2) Win64 ABI stress (38 shapes)

Path result
JSC-FFI 38/38 pass, 50/50 runs
TinyCC 38/38 pass, 50/50 runs

(3) bench/ffi equivalent

No segfault. FTL on, same-binary A/B:

bench JSC-FFI TinyCC ratio
noop 3.35 ns 6.60 ns 1.97x
hash 45.11 ns 56.57 ns 1.25x
c string 194.96 ns 193.65 ns ~1.0x

noop matches the thunk-path expectation (Windows keeps the Win64 invoke thunk in FTL now rather than the direct B3 CCallValue).

(4) bun test test/js/bun/ffi/

Path pass skip todo fail runs
JSC-FFI 52 21 1 0 3/3
BUN_FEATURE_FLAG_DISABLE_JSC_FFI=1 51 21 1 1 3/3

The one TinyCC-only failure is the pre-existing cc.test.ts > double <-> JSValue conversions > f64 arguments reach C with NaN, -0.0, and BigInt sign intact, unchanged from d4edfa832 and present on main.

Comment thread src/runtime/ffi/ffi_body.rs Outdated
Comment thread test/harness.ts Outdated
Comment thread src/jsc/bindings/JSCFFIBridge.cpp Outdated
Comment thread test/js/bun/ffi/ffi.test.js
Comment thread src/runtime/ffi/ffi_body.rs
Comment thread src/runtime/ffi/ffi_body.rs Outdated
Comment thread src/runtime/ffi/ffi_body.rs Outdated
Comment thread src/jsc/bindings/JSCFFIBridge.cpp Outdated
Comment thread src/runtime/ffi/ffi_body.rs Outdated
Comment thread src/runtime/ffi/ffi_body.rs Outdated
Comment thread docs/runtime/ffi.mdx
@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Windows validation: WebKit preview autobuild-preview-pr-319-feea5e10

Built jarred/jsc-ffi @ 2f7fe3e71 (debug) on both Windows targets and ran the full test/js/bun/ffi/ suite plus a 38-shape Win64 ABI stress DLL. Everything is green.

Target OS test/js/bun/ffi/ ABI stress DLL
x64 Windows Server 2019 (10.0.17763) ✅ 163 pass / 20 skip / 5 todo / 0 fail · 395,607 expects · 22.88s ✅ 33/33
arm64 Windows 11 Pro (10.0.26100) ✅ 162 pass / 21 skip / 5 todo / 0 fail · 395,606 expects · 29.96s ✅ 33/33

Suite notes

ABI stress DLL (regenerated; MSVC cl /LD /O2, 38 exported shapes, 33 tests / 40 expects):

  • Widths: i8/i16/i32/i64/u8/u16/u32/u64/f32/f64/bool/ptr identity
  • Win64 positional int/float mixing: s11 i,d,i,d (RCX,XMM1,R8,XMM3), s12 d,i,d,i (XMM0,RDX,XMM2,R9), s13 f,f,i,i, s14 i,i,f,f
  • Register→stack spill: 5/8/12/16-arg int, double, u64, and alternating mixes (s20 idididid, s21 fifififi, s30 ddddiiii, s31 iiiidddd, s32 width mix, s38 16×f64)
  • Order sensitivity: s23 12-arg weighted mix (i32,f64,f32,i64,u32,f64,i32,f32,i64,f64,u32,f32)
  • Returns: u64 high-bit (0x8000000000000001), i64 near-min, u32≥2³¹, void+ptr side-effect
  • JSCallback thunks: (i32)→i32, (f64,f64)→f64, (i32,f64,i64,f32)→i64, 8×i32→i64 (reg+stack)
  • 20k-iteration hot loop on s17 to push through JIT tiers

All 33 pass on both architectures. On arm64 the same shapes exercise AAPCS64 (X0-X7 / V0-V7, separate int/float register files) rather than the Win64 positional rule, so the mixed-position cases passing there confirms the invoke thunk handles both conventions.

ABI stress source (abi_stress.c + abi_stress.test.mjs)
// Win64 ABI stress shapes for bun:ffi engine-native marshalling.
// Built with: cl /LD /O2 abi_stress.c /Fe:abi_stress.dll
#include <stdint.h>
#include <stdbool.h>
#define X __declspec(dllexport)
X int8_t   s01_i8 (int8_t  a){ return a; }
X int16_t  s02_i16(int16_t a){ return a; }
X int32_t  s03_i32(int32_t a){ return a; }
X int64_t  s04_i64(int64_t a){ return a; }
X uint64_t s05_u64(uint64_t a){ return a; }
X float   s06_f32(float  a){ return a; }
X double  s07_f64(double a){ return a; }
X bool    s08_bool(bool a){ return a; }
X int64_t s09_iiii(int32_t a,int32_t b,int32_t c,int32_t d){ return (int64_t)a+b+c+d; }
X double s10_dddd(double a,double b,double c,double d){ return a+b+c+d; }
X double s11_idid(int32_t a,double b,int32_t c,double d){ return a+b+c+d; }
X double s12_didi(double a,int32_t b,double c,int32_t d){ return a+b+c+d; }
X float s13_ffii(float a,float b,int32_t c,int32_t d){ return a+b+(float)c+(float)d; }
X float s14_iiff(int32_t a,int32_t b,float c,float d){ return (float)a+(float)b+c+d; }
X int64_t s15_iiiii(int32_t a,int32_t b,int32_t c,int32_t d,int32_t e){ return (int64_t)a+b+c+d+e; }
X double s16_ddddd(double a,double b,double c,double d,double e){ return a+b+c+d+e; }
X int64_t s17_i8x(int32_t a,int32_t b,int32_t c,int32_t d,int32_t e,int32_t f,int32_t g,int32_t h){ return (int64_t)a+b+c+d+e+f+g+h; }
X double s18_d8x(double a,double b,double c,double d,double e,double f,double g,double h){ return a+b+c+d+e+f+g+h; }
X uint64_t s19_u64_8x(uint64_t a,uint64_t b,uint64_t c,uint64_t d,uint64_t e,uint64_t f,uint64_t g,uint64_t h){ return a+b+c+d+e+f+g+h; }
X double s20_idididid(int32_t a,double b,int32_t c,double d,int32_t e,double f,int32_t g,double h){ return a+b+c+d+e+f+g+h; }
X double s21_fifififi(float a,int64_t b,float c,int64_t d,float e,int64_t f,float g,int64_t h){ return (double)a+(double)b+(double)c+(double)d+(double)e+(double)f+(double)g+(double)h; }
X int64_t s22_i12(int32_t a,int32_t b,int32_t c,int32_t d,int32_t e,int32_t f,int32_t g,int32_t h,int32_t i,int32_t j,int32_t k,int32_t l){ return (int64_t)a+b+c+d+e+f+g+h+i+j+k+l; }
X double s23_mix12(int32_t a,double b,float c,int64_t d,uint32_t e,double f,int32_t g,float h,int64_t i,double j,uint32_t k,float l){ return a*1.0+b*2.0+c*3.0+(double)d*4.0+e*5.0+f*6.0+g*7.0+h*8.0+(double)i*9.0+j*10.0+k*11.0+l*12.0; }
X void* s24_ptr_id(void* p){ return p; }
X uint64_t s25_ptr_sum(const uint8_t* p, uint64_t n){ uint64_t s=0; for(uint64_t i=0;i<n;i++) s+=p[i]; return s; }
X uint64_t s26_u64_hibit(void){ return 0x8000000000000001ULL; }
X int64_t s27_i64_min1(void){ return -9223372036854775807LL; }
X uint32_t s28_u32_id(uint32_t a){ return a; }
X float s29_f32_mad(float a,float b){ return a*3.0f+b; }
X double s30_ddddiiii(double a,double b,double c,double d,int32_t e,int32_t f,int32_t g,int32_t h){ return a+b+c+d+e+f+g+h; }
X double s31_iiiidddd(int32_t a,int32_t b,int32_t c,int32_t d,double e,double f,double g,double h){ return a+b+c+d+e+f+g+h; }
X int64_t s32_widths(uint8_t a,uint16_t b,uint32_t c,uint64_t d,int8_t e,int16_t f,int32_t g,int64_t h){ return (int64_t)a+(int64_t)b+(int64_t)c+(int64_t)d+(int64_t)e+(int64_t)f+(int64_t)g+h; }
typedef int32_t (*cb_i_i)(int32_t);
X int32_t s33_cb_i_i(cb_i_i cb,int32_t a){ return cb(a); }
typedef double (*cb_d_dd)(double,double);
X double s34_cb_d_dd(cb_d_dd cb,double a,double b){ return cb(a,b); }
typedef int64_t (*cb_mix)(int32_t,double,int64_t,float);
X int64_t s35_cb_mix(cb_mix cb){ return cb(7,2.5,1000000000000LL,1.5f); }
typedef int64_t (*cb_i8x)(int32_t,int32_t,int32_t,int32_t,int32_t,int32_t,int32_t,int32_t);
X int64_t s36_cb_i8x(cb_i8x cb){ return cb(1,2,3,4,5,6,7,8); }
X void s37_void_store(int32_t* p,int32_t v){ *p=v; }
X double s38_d16(double a,double b,double c,double d,double e,double f,double g,double h,double i,double j,double k,double l,double m,double n,double o,double p){ return a+b+c+d+e+f+g+h+i+j+k+l+m+n+o+p; }

Test driver: dlopen the DLL with all 38 symbols, assert each shape with boundary values, four JSCallback round-trips, and a 20k hot loop. Full .mjs available on request.

No regressions vs the previous Windows run; the hand-written invoke thunk path behaves identically with TinyCC fully removed from dlopen/linkSymbols/CFunction/JSCallback.

Comment thread src/runtime/ffi/ffi_body.rs Outdated
Comment thread test/js/bun/ffi/ffi.test.js Outdated
Comment thread docs/runtime/ffi.mdx
Comment thread src/js/bun/ffi.ts Outdated
Comment thread src/runtime/ffi/ffi_body.rs
Comment thread test/js/bun/ffi/ffi.test.js
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.

2 participants