bun:ffi: use the engine-native FFI when available - #35246
Conversation
|
Updated 6:05 AM PT - Jul 29th, 2026
@Jarred-Sumner, your commit 787c740 is building: |
|
Found 4 issues this PR may fix:
🤖 Generated with Claude Code |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughChangesJavaScriptCore FFI integration
WebKit dependency update
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (5)
src/bun_core/env_var.rssrc/js/bun/ffi.tssrc/jsc/bindings/JSCFFIBridge.cppsrc/runtime/ffi/FFIObject.rssrc/runtime/ffi/ffi_body.rs
There was a problem hiding this comment.
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 winRoot the newly created callback before storing it.
cbis held as a rawJSValueacrossget_own()andcreate_object_2(), both of which may allocate. If GC runs beforeresult.put(..., cb), the callback cell can be collected and the storedjschandle 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 | 🔴 CriticalThe
.ptrconversion 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
📒 Files selected for processing (4)
src/js/bun/ffi.tssrc/jsc/bindings/JSCFFIBridge.cppsrc/runtime/ffi/ffi_body.rstest/js/bun/ffi/cc.test.ts
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 `@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
📒 Files selected for processing (1)
scripts/build/deps/webkit.ts
There was a problem hiding this comment.
♻️ Duplicate comments (1)
src/runtime/ffi/ffi_body.rs (1)
1658-1661: 🩺 Stability & Availability | 🔴 CriticalRoot both
jsc_symbolsmaps 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 finaljs_object.put(...).
src/runtime/ffi/ffi_body.rs#L1658-L1661: rootjsc_symbolsthroughoutFFI::open.src/runtime/ffi/ffi_body.rs#L1804-L1806: rootjsc_symbolsthroughoutFFI::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
📒 Files selected for processing (2)
src/jsc/bindings/JSCFFIBridge.cppsrc/runtime/ffi/ffi_body.rs
2fd2ecf to
70bceba
Compare
c0fedf7 to
d4edfa8
Compare
Windows arm64 validationBinary (1)
|
| 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)
-
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.jscrashes once the dll is present:run ffi > primitivescallstoBufferthenBun.gc(true).toArrayBufferis fine. Not visible in CI because the dll is never built there. -
TinyCC
uint32_treturn sign-extends ≥2³¹ on Windows arm64:identity_uint32_t(2147483648)→-2147483648. Engine FFI returns the correct2147483648, so this PR fixes it for the default path. -
Threadsafe
JSCallbacklate-fire with wrongarg1: thethreadsafe callback > fn(T) Ttests useawait 1which 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.
Windows x64 validation reportBinary: (1)
|
| 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.
Windows x64 re-verification on
|
| 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.
Windows validation: WebKit preview
|
| 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
- arm64's one extra skip is
long double varargs inside cc()-compiled C > va_arg over long double(arch-gated). - The 5 todos on both are
can run a .c fileplus the fourthreadsafe callback > fn({int64_t,uint64_t})cases tracked in bun:ffi toBuffer() on a dlopen-returned pointer segfaults during GC on Windows x64 #35405 / bun:ffi threadsafe JSCallback with int64_t/uint64_t arg receives freed BigInt after GC #35406, as expected. - The
engine-native FFI (single implementation)describe block (11 tests) passes on both, including the u32 sign-flip (FFIType.u32 misbehavior #7007), BigInt pointer round-trip (ffi: 64 bits pointers can be > 2^52 #28068 / Bun FFI Bug: BigInt to ptr conversion fails on Linux #22751), JSCallback-as-engine-cell, and polymorphic hot-loop tiering checks. JSCallback tolerates worker.terminate() arriving inside the callbackpasses on both (1.28s x64 / 1.77s arm64).dlopen accepts non-ASCII library paths on Windowspasses on both.
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,s32width mix,s3816×f64) - Order sensitivity:
s2312-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 JSCallbackthunks:(i32)→i32,(f64,f64)→f64,(i32,f64,i64,f32)→i64, 8×i32→i64 (reg+stack)- 20k-iteration hot loop on
s17to 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.
Reimplements
bun:ffion top of a new engine-native FFI in JavaScriptCore (oven-sh/WebKit#319). Fordlopen(),linkSymbols(),CFunction(), andJSCallback, 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 ascc()'s C compiler.Performance
Engine-native vs TinyCC-based
bun:ffi(macOS arm64, release):new CString(ptr)(46-char string)Against Deno on the same native library:
ptr+u32)returns: "cstring")String arguments, same string ("550e8400-…", 36 chars):
ptr(buf))CString(ptr)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-basedbun:ffi, same machine. opentui binds its Zig core -- including its native Yoga port (222 symbols) -- through onebun:ffidlopen, 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:yoga_layout_reads_1000yoga_layout_reads_10000yoga_layout_reads_100scrollbox_culling_scaling_10000scrollbox_culling_scaling_5000scrollbar_stack/layout_only_opencode_wrapperslayout-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, purecalculate_onlylayout 1.07x-1.19x.native-span-feed(defaultsuite, a memcpy-bound span stream) -- geomean +4.9% throughput, worst scenario -1.7%, best +22.9% oncommit_4kwrite; gains concentrate at high call rates and vanish at 32 MB spans.opentui is unaffected by the
CStringchange (it uses neitherCStringnorcstringreturns).Behavior changes
cstringreturns are strings. A symbol declaredreturns: "cstring"yields a JS string primitive (typeof "string",===works) decoded from the callee'schar*; aNULLreturn isnull. There is no wrapper object and no address is exposed. Callback parameters typedcstringarrive as strings the same way.CStringis a constructor that returns a string.new CString(ptr, byteOffset?, byteLength?)andCString(ptr, ...)transcode the bytes atptrand return a plain string;CStringhas no accessor surface.buffer_length— new argument type: pass the same TypedArray/DataView you passed for abufferargument and the callee receives that view's byte length as auint64_t, read off the same object at call time so pointer and length always agree. Argument-only; not available insidecc()..ptr/.nativeon FFI functions are real read-only own properties.cc()-only.napi_env/napi_valueindlopen,linkSymbols,CFunction, orJSCallbackthrow aTypeError. Insidecc(), anapi_envparameter 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-Iflag.close()refuses new foreign-thread calls but every already-queued invocation is still delivered.dlopen()and friends throw aTypeError.What is removed
dlopen/linkSymbols/CFunction/JSCallbacksymbols,viewSourceof 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) andtoArrayBuffer-backedarrayBufferon 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.CStringchange (it uses neitherCStringnorcstringreturns).