From b38a66dc9a5fc8ae8d76577621bf3d4d7fbbe3e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 18 Jul 2026 12:19:08 +0200 Subject: [PATCH 1/2] =?UTF-8?q?runtime:=20C-ABI=20FFI=20stage=201=20?= =?UTF-8?q?=E2=80=94=20dlopen,=20typed=20calls,=20ptr/CString=20on=20pinne?= =?UTF-8?q?d=20buffers=20(#6562)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bun:ffi-shaped module: dlopen(path, table) -> { symbols, close() } with typed per-symbol call stubs, FFIType (Bun-exact numeric values + aliases), ptr(view[, off]) on non-moving buffer storage, CString, suffix. i64/u64 returns are always BigInt; i64_fast/u64_fast number-within-safe-range; pointers are JS numbers. Stage>=2 exports (toArrayBuffer, JSCallback, CFunction, linkSymbols, viewSource, read, toBuffer, FFIType.function) are declared and throw descriptive stage-1 errors. Call stubs are register-image trampolines (no libffi, no link-line changes): scalar args pack densely per register class through one maximal extern "C" signature; correct on SysV x86-64 + AAPCS64 for <=8 int-class + <=8 float-class args, enforced at dlopen time. f32 args travel as bit images; narrow returns truncate to declared width. Pointer lifetime contract: buffer bytes never move (old-arena, TENURED, movable:false, no growth path), so ptr() is stable for the object's lifetime; views resolve through the view registry to the backing bytes; close() poisons symbols instead of leaving a dangling handle. Documented in bun_ffi/mod.rs. Wiring uses the existing native-module machinery: NATIVE_MODULES/ RUNTIME_ONLY_MODULES + manifest (docs regenerated), NmBucket::BunFfi + js_nm_install_bun_ffi (declared in runtime_decls), callable-export check/arity, constants path for FFIType/suffix (cached object is a registered GC root), module keys. Tests: e2e tier 1 (purpose-built C dylib, every FFIType incl. BigInt boundaries, mixed-register ABI, pinned-buffer round trips both ways), tier 1b (error surfaces), tier 2 (real bun-pty 0.4.10 dylib: spawn sh through the pty, echo round-trip, resize, kill; skips offline) + 9 trampoline/marshalling unit tests in perry-runtime. --- crates/perry-api-manifest/src/entries.rs | 5 + .../perry-api-manifest/src/entries/part_1.rs | 18 + crates/perry-codegen/src/nm_install.rs | 4 + .../src/runtime_decls/objects.rs | 2 + crates/perry-runtime/src/bun_ffi/call.rs | 645 +++++++++++++++++ crates/perry-runtime/src/bun_ffi/dlopen.rs | 658 ++++++++++++++++++ crates/perry-runtime/src/bun_ffi/mod.rs | 169 +++++ crates/perry-runtime/src/bun_ffi/types.rs | 202 ++++++ crates/perry-runtime/src/gc/mod.rs | 2 + crates/perry-runtime/src/lib.rs | 1 + .../native_module/callable_export_check.rs | 20 + .../object/native_module/callable_exports.rs | 6 + .../src/object/native_module/constants.rs | 10 + .../src/object/native_module/module_keys.rs | 17 + .../src/object/native_module_dispatch.rs | 3 +- .../native_module_dispatch/dispatch_a_c.rs | 21 + .../src/object/native_module_registry.rs | 23 +- crates/perry/tests/bun_ffi_stage1.rs | 567 +++++++++++++++ docs/api/perry.d.ts | 29 +- docs/src/api/reference.md | 23 +- 20 files changed, 2417 insertions(+), 8 deletions(-) create mode 100644 crates/perry-runtime/src/bun_ffi/call.rs create mode 100644 crates/perry-runtime/src/bun_ffi/dlopen.rs create mode 100644 crates/perry-runtime/src/bun_ffi/mod.rs create mode 100644 crates/perry-runtime/src/bun_ffi/types.rs create mode 100644 crates/perry/tests/bun_ffi_stage1.rs diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 29a921c0ca..b9c68f402e 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -52,6 +52,9 @@ pub const NATIVE_MODULES: &[&str] = &[ "sqlite", "tursodb", "iroh", + // #6562: Bun FFI (C-ABI). The `bun:` prefix is part of the specifier + // (unlike `node:`, which is stripped) — `import { dlopen } from "bun:ffi"`. + "bun:ffi", "node-cron", "nodemailer", "http", @@ -194,6 +197,8 @@ pub const RUNTIME_ONLY_MODULES: &[&str] = &[ "path/win32", "os", "buffer", + // #6562: bun:ffi is implemented entirely in perry-runtime. + "bun:ffi", "assert", "assert/strict", "test", diff --git a/crates/perry-api-manifest/src/entries/part_1.rs b/crates/perry-api-manifest/src/entries/part_1.rs index a54e5e058e..3880758593 100644 --- a/crates/perry-api-manifest/src/entries/part_1.rs +++ b/crates/perry-api-manifest/src/entries/part_1.rs @@ -203,6 +203,24 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[ method("better-sqlite3", "pluck", true, None), method("better-sqlite3", "columns", true, None), method("better-sqlite3", "transaction", true, None), + // bun:ffi (#6562) — stage-1 surface. `FFIType` and `suffix` are + // constants; symbol-table call stubs live on the object `dlopen` + // returns, so the module surface itself is small. The stage-2/3 + // exports (toArrayBuffer / JSCallback / linkSymbols / CFunction / + // viewSource / read / toBuffer) are declared and throw a descriptive + // ERR_NOT_IMPLEMENTED at runtime. + method("bun:ffi", "dlopen", false, None), + method("bun:ffi", "ptr", false, None), + method("bun:ffi", "CString", false, None), + property("bun:ffi", "FFIType"), + property("bun:ffi", "suffix"), + method("bun:ffi", "toArrayBuffer", false, None), + method("bun:ffi", "toBuffer", false, None), + method("bun:ffi", "JSCallback", false, None), + method("bun:ffi", "CFunction", false, None), + method("bun:ffi", "linkSymbols", false, None), + method("bun:ffi", "viewSource", false, None), + method("bun:ffi", "read", false, None), class("sqlite", "DatabaseSync"), class("sqlite", "Session"), class("sqlite", "SQLTagStore"), diff --git a/crates/perry-codegen/src/nm_install.rs b/crates/perry-codegen/src/nm_install.rs index 8d4fae88b1..348207ed29 100644 --- a/crates/perry-codegen/src/nm_install.rs +++ b/crates/perry-codegen/src/nm_install.rs @@ -13,6 +13,9 @@ pub(crate) fn nm_install_symbol(name: &str) -> Option<&'static str> { "async_hooks" => Some("js_nm_install_async_hooks"), "bigint" => Some("js_nm_install_bigint"), "buffer" | "buffer.Buffer" => Some("js_nm_install_buffer"), + // #6562: bun:ffi keeps its scheme prefix (only `node:` is stripped + // above). + "bun:ffi" => Some("js_nm_install_bun_ffi"), "child_process" => Some("js_nm_install_child_process"), "cluster" => Some("js_nm_install_cluster"), "console" => Some("js_nm_install_console"), @@ -67,6 +70,7 @@ pub(crate) const NM_INSTALL_SYMBOLS: &[&str] = &[ "js_nm_install_async_hooks", "js_nm_install_bigint", "js_nm_install_buffer", + "js_nm_install_bun_ffi", "js_nm_install_child_process", "js_nm_install_cluster", "js_nm_install_console", diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index 7b86f4305a..2a1c3e4770 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -255,6 +255,8 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { module.declare_function("js_nm_install_async_hooks", VOID, &[]); module.declare_function("js_nm_install_bigint", VOID, &[]); module.declare_function("js_nm_install_buffer", VOID, &[]); + // #6562: bun:ffi dispatch bucket. + module.declare_function("js_nm_install_bun_ffi", VOID, &[]); module.declare_function("js_nm_install_child_process", VOID, &[]); module.declare_function("js_nm_install_cluster", VOID, &[]); module.declare_function("js_nm_install_console", VOID, &[]); diff --git a/crates/perry-runtime/src/bun_ffi/call.rs b/crates/perry-runtime/src/bun_ffi/call.rs new file mode 100644 index 0000000000..33c610c938 --- /dev/null +++ b/crates/perry-runtime/src/bun_ffi/call.rs @@ -0,0 +1,645 @@ +//! Typed C-ABI calls: argument marshalling + the register-image trampoline. +//! +//! ## Why not libffi +//! +//! Every stage-1 `FFIType` is a scalar (integer, float, or pointer), so the +//! full generality of libffi (struct classification, closures) buys nothing +//! here, while costing a new native library on every final link (perry's +//! driver links user binaries with `cc`; libffi would have to be added to +//! every platform link line and vendored for cross builds). Instead we +//! exploit how the two supported ABIs assign scalar arguments: +//! +//! - **SysV x86-64**: integer-class args take rdi, rsi, rdx, rcx, r8, r9 in +//! order (then the stack, left to right); float-class args take xmm0–xmm7 +//! in order. The two files are assigned INDEPENDENTLY. +//! - **AAPCS64 (incl. Apple arm64)**: integer-class args take x0–x7; float +//! args take v0–v7. Also independent. +//! +//! So for any callee prototype made of scalars with ≤ 8 integer-class and +//! ≤ 8 float-class parameters, calling through the fixed "maximal" +//! signature +//! +//! ```text +//! extern "C" fn(usize×8, f64×8) -> {u64 | f64 | f32} +//! ``` +//! +//! with the marshalled values packed densely per class produces exactly the +//! register/stack image the callee expects: the callee reads only the +//! registers its own prototype names, and unused slots are ignored. On +//! x86-64 the 7th/8th integer slots become the first two stack slots — the +//! same positions a 7/8-integer-arg callee reads its stack args from. Note +//! this is also why **variadic** callees are NOT supported (Apple arm64 +//! passes variadic args on the stack) — a limitation shared with Bun's +//! documented FFI surface. +//! +//! Two care points: +//! - **f32 args** are passed in the low 32 bits of a vector register. An +//! `f32` value `v` is therefore smuggled as `f64::from_bits(v.to_bits() +//! as u64)` — the callee's `s`/`xmm` read sees the correct f32 bit +//! pattern. (Passing `v as f64` would be wrong: the callee would read +//! the low half of a double.) +//! - **narrow returns** (bool/i8/u8/i16/u16/i32/u32): only the low bits of +//! the return register are specified; we truncate to the declared width +//! before boxing. +//! +//! `dlopen` enforces the ≤ 8 + ≤ 8 limit (and rejects unsupported targets) +//! up front, so a mis-sized signature can never reach `raw_call_*`. + +use super::types::*; +use crate::value::JSValue; + +pub(crate) const MAX_INT_ARGS: usize = 8; +pub(crate) const MAX_FLOAT_ARGS: usize = 8; +/// Total JS-visible parameter cap (drives the per-arity closure thunks). +pub(crate) const MAX_ARGS: usize = 16; + +/// Marshalled register image for one call. +#[derive(Default)] +pub(crate) struct ArgImage { + pub ints: [usize; MAX_INT_ARGS], + pub floats: [f64; MAX_FLOAT_ARGS], + /// NUL-terminated temporaries for `cstring` args passed as JS strings. + /// Kept alive until after the native call returns. + pub temps: Vec>, +} + +/// True when this build can actually issue FFI calls. Kept as a function so +/// `dlopen` can throw one descriptive error on unsupported targets instead +/// of scattering cfg's. +pub(crate) const fn platform_supported() -> bool { + cfg!(all( + unix, + any(target_arch = "x86_64", target_arch = "aarch64") + )) +} + +#[cfg(all(unix, any(target_arch = "x86_64", target_arch = "aarch64")))] +mod raw { + //! The only three transmutes in the module. `#[inline(never)]` keeps the + //! full 16-slot call sequence intact exactly as written — the register + //! image must not be "optimized" against a narrower inferred signature. + #[allow(clippy::type_complexity)] + #[inline(never)] + pub(crate) unsafe fn call_int(f: usize, i: &[usize; 8], d: &[f64; 8]) -> u64 { + let g: unsafe extern "C" fn( + usize, + usize, + usize, + usize, + usize, + usize, + usize, + usize, + f64, + f64, + f64, + f64, + f64, + f64, + f64, + f64, + ) -> u64 = std::mem::transmute(f); + g( + i[0], i[1], i[2], i[3], i[4], i[5], i[6], i[7], d[0], d[1], d[2], d[3], d[4], d[5], + d[6], d[7], + ) + } + + #[allow(clippy::type_complexity)] + #[inline(never)] + pub(crate) unsafe fn call_f64(f: usize, i: &[usize; 8], d: &[f64; 8]) -> f64 { + let g: unsafe extern "C" fn( + usize, + usize, + usize, + usize, + usize, + usize, + usize, + usize, + f64, + f64, + f64, + f64, + f64, + f64, + f64, + f64, + ) -> f64 = std::mem::transmute(f); + g( + i[0], i[1], i[2], i[3], i[4], i[5], i[6], i[7], d[0], d[1], d[2], d[3], d[4], d[5], + d[6], d[7], + ) + } + + #[allow(clippy::type_complexity)] + #[inline(never)] + pub(crate) unsafe fn call_f32(f: usize, i: &[usize; 8], d: &[f64; 8]) -> f32 { + let g: unsafe extern "C" fn( + usize, + usize, + usize, + usize, + usize, + usize, + usize, + usize, + f64, + f64, + f64, + f64, + f64, + f64, + f64, + f64, + ) -> f32 = std::mem::transmute(f); + g( + i[0], i[1], i[2], i[3], i[4], i[5], i[6], i[7], d[0], d[1], d[2], d[3], d[4], d[5], + d[6], d[7], + ) + } +} + +#[cfg(not(all(unix, any(target_arch = "x86_64", target_arch = "aarch64"))))] +mod raw { + // `dlopen` refuses before any symbol closure can exist on these targets; + // these stubs keep the module compiling. + pub(crate) unsafe fn call_int(_f: usize, _i: &[usize; 8], _d: &[f64; 8]) -> u64 { + unreachable!("bun:ffi call on unsupported target") + } + pub(crate) unsafe fn call_f64(_f: usize, _i: &[usize; 8], _d: &[f64; 8]) -> f64 { + unreachable!("bun:ffi call on unsupported target") + } + pub(crate) unsafe fn call_f32(_f: usize, _i: &[usize; 8], _d: &[f64; 8]) -> f32 { + unreachable!("bun:ffi call on unsupported target") + } +} + +// ── JS value → C scalar coercions ─────────────────────────────────────────── + +/// BigInt → low 64 bits, two's complement (i.e. C `(uint64_t)` / `(int64_t)` +/// wrapping semantics — the limbs already store two's complement). +unsafe fn bigint_low_u64(v: JSValue) -> u64 { + let addr = crate::value::js_nanbox_get_bigint(f64::from_bits(v.bits())); + if addr == 0 { + return 0; + } + (*(addr as usize as *const crate::bigint::BigIntHeader)).limbs[0] +} + +/// Numeric coercion for integer-typed args. Numbers use Rust's saturating +/// float→int cast (NaN → 0); BigInts wrap mod 2^64 like C; booleans are +/// 0/1; null/undefined are 0. Objects/strings do NOT go through JS ToNumber +/// here — Bun requires numeric-ish args for integer slots too. +unsafe fn value_to_u64_int(v: f64) -> u64 { + let jv = JSValue::from_bits(v.to_bits()); + if jv.is_int32() { + return jv.as_int32() as i64 as u64; + } + if jv.is_number() { + return jv.as_number() as i64 as u64; + } + if jv.is_bigint() { + return bigint_low_u64(jv); + } + if jv.is_bool() { + return jv.as_bool() as u64; + } + 0 +} + +/// Numeric coercion for float-typed args. +unsafe fn value_to_f64_num(v: f64) -> f64 { + let jv = JSValue::from_bits(v.to_bits()); + if jv.is_int32() { + return jv.as_int32() as f64; + } + if jv.is_number() { + return jv.as_number(); + } + if jv.is_bigint() { + return bigint_low_u64(jv) as i64 as f64; + } + if jv.is_bool() { + return if jv.as_bool() { 1.0 } else { 0.0 }; + } + if jv.is_null() { + return 0.0; + } + f64::NAN +} + +/// Resolve a JS value to `(data_ptr, byte_len)` when it is a +/// buffer-of-bytes object: Buffer, ArrayBuffer, SharedArrayBuffer, +/// DataView (all `BufferHeader`-backed) or a registered TypedArray. +/// +/// Views resolve through `buffer::view::resolve_data_ptr` so the pointer +/// always targets the ultimate backing bytes (#6515) — see the module doc +/// for the resulting read-back caveat on views. +pub(crate) unsafe fn value_buffer_span(v: f64) -> Option<(*mut u8, usize)> { + let jv = JSValue::from_bits(v.to_bits()); + if !jv.is_pointer() { + return None; + } + let addr = crate::value::js_nanbox_get_pointer(f64::from_bits(jv.bits())) as usize; + if addr == 0 { + return None; + } + if crate::buffer::is_registered_buffer(addr) + || crate::buffer::is_any_array_buffer(addr) + || crate::buffer::is_data_view(addr) + || crate::buffer::is_uint8array_buffer(addr) + { + let buf = addr as *const crate::buffer::BufferHeader; + let data = crate::buffer::view::resolve_data_ptr(buf); + return Some((data as *mut u8, (*buf).length as usize)); + } + if crate::typedarray::lookup_typed_array_kind(addr).is_some() { + let ta = + crate::typedarray::clean_ta_ptr(addr as *const crate::typedarray::TypedArrayHeader); + let bytes = crate::typedarray::typed_array_bytes(ta)?; + return Some((bytes.as_ptr() as *mut u8, bytes.len())); + } + None +} + +fn describe_value_for_error(jv: JSValue) -> &'static str { + if jv.is_any_string() { + "a string" + } else if jv.is_bool() { + "a boolean" + } else if jv.is_bigint() { + "a BigInt" + } else if jv.is_undefined() { + "undefined" + } else if jv.is_null() { + "null" + } else { + "the value" + } +} + +/// Pointer-class coercion (`ptr` args). Mirrors Bun: numbers/bigints pass +/// through as addresses, buffer-ish objects hand over their (non-moving) +/// data pointer, null/undefined/0 become NULL, strings are rejected with +/// Bun's exact hint. +unsafe fn value_to_pointer_arg(v: f64) -> usize { + let jv = JSValue::from_bits(v.to_bits()); + if jv.is_undefined() || jv.is_null() { + return 0; + } + if jv.is_bool() { + return jv.as_bool() as usize; + } + if jv.is_int32() { + return jv.as_int32() as i64 as usize; + } + if jv.is_number() { + return jv.as_number() as i64 as usize; + } + if jv.is_bigint() { + return bigint_low_u64(jv) as usize; + } + if let Some((data, _len)) = value_buffer_span(v) { + return data as usize; + } + if jv.is_any_string() { + crate::fs::validate::throw_type_error_with_code( + "To convert a string to a pointer, encode it as a buffer", + "ERR_INVALID_ARG_TYPE", + ); + } + crate::fs::validate::throw_type_error_with_code( + &format!( + "Unable to convert {} to a pointer", + describe_value_for_error(jv) + ), + "ERR_INVALID_ARG_TYPE", + ) +} + +/// `cstring` argument: like `ptr`, but a JS *string* is accepted by making +/// a NUL-terminated UTF-8 copy that lives until the call returns (perry +/// convenience superset — Bun rejects strings; real callers pass Buffers). +unsafe fn value_to_cstring_arg(v: f64, temps: &mut Vec>) -> usize { + let jv = JSValue::from_bits(v.to_bits()); + if jv.is_any_string() { + let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + if let Some(bytes) = crate::string::js_string_key_bytes(jv, &mut sso) { + let mut owned = Vec::with_capacity(bytes.len() + 1); + owned.extend_from_slice(bytes); + owned.push(0); + let ptr = owned.as_ptr() as usize; + temps.push(owned); + return ptr; + } + } + value_to_pointer_arg(v) +} + +/// Marshal `js_args` against the declared `arg_types` into a register +/// image. `js_args` shorter than `arg_types` is padded with undefined +/// (matching JS call semantics); longer is truncated. +/// +/// # Safety +/// `arg_types` must have passed `dlopen` validation (≤ 8 per class, no +/// function/napi/buffer types). +pub(crate) unsafe fn marshal_args(arg_types: &[u8], js_args: &[f64]) -> ArgImage { + let mut image = ArgImage::default(); + let mut ii = 0usize; + let mut fi = 0usize; + for (idx, &ty) in arg_types.iter().enumerate() { + let v = js_args + .get(idx) + .copied() + .unwrap_or(f64::from_bits(crate::value::TAG_UNDEFINED)); + match ty { + T_F64 => { + image.floats[fi] = value_to_f64_num(v); + fi += 1; + } + T_F32 => { + let f = value_to_f64_num(v) as f32; + image.floats[fi] = f64::from_bits(f.to_bits() as u64); + fi += 1; + } + T_BOOL => { + image.ints[ii] = crate::value::js_is_truthy(v) as usize; + ii += 1; + } + T_PTR => { + image.ints[ii] = value_to_pointer_arg(v); + ii += 1; + } + T_CSTRING => { + image.ints[ii] = value_to_cstring_arg(v, &mut image.temps); + ii += 1; + } + // char + all fixed-width integers (incl. usize→u64, the fast + // variants): the callee reads only its declared width. + _ => { + image.ints[ii] = value_to_u64_int(v) as usize; + ii += 1; + } + } + } + image +} + +// ── C scalar → JS value conversions ───────────────────────────────────────── + +const MAX_SAFE: i64 = 9_007_199_254_740_991; // 2^53 - 1 + +fn bool_value(b: bool) -> f64 { + f64::from_bits(if b { + crate::value::TAG_TRUE + } else { + crate::value::TAG_FALSE + }) +} + +fn bigint_value_i64(v: i64) -> f64 { + crate::value::js_nanbox_bigint(crate::bigint::js_bigint_from_i64(v) as i64) +} + +fn bigint_value_u64(v: u64) -> f64 { + crate::value::js_nanbox_bigint(crate::bigint::js_bigint_from_u64(v) as i64) +} + +/// Read a NUL-terminated UTF-8 C string at `addr` into a JS string. +/// (Invalid UTF-8 is replaced lossily — same visible behavior as Bun's +/// `CString`, which decodes via TextDecoder.) +pub(crate) unsafe fn read_cstring_value(addr: usize) -> f64 { + if addr == 0 { + return super::null(); + } + let mut len = 0usize; + let base = addr as *const u8; + while *base.add(len) != 0 { + len += 1; + } + let bytes = std::slice::from_raw_parts(base, len); + match std::str::from_utf8(bytes) { + Ok(s) => super::string_value(s), + Err(_) => super::string_value(&String::from_utf8_lossy(bytes)), + } +} + +/// Issue the native call and convert the result per `ret_type`. +/// +/// # Safety +/// `fn_ptr` must be a callable C function whose true prototype is scalar, +/// non-variadic, and within the marshalled image's class limits. +pub(crate) unsafe fn call_and_convert(fn_ptr: usize, ret_type: u8, image: &ArgImage) -> f64 { + let result = match ret_type { + T_F64 => { + let r = raw::call_f64(fn_ptr, &image.ints, &image.floats); + super::number_value(r) + } + T_F32 => { + let r = raw::call_f32(fn_ptr, &image.ints, &image.floats); + super::number_value(r as f64) + } + _ => { + let r = raw::call_int(fn_ptr, &image.ints, &image.floats); + convert_int_return(ret_type, r) + } + }; + // `image.temps` (cstring temporaries) must outlive the call itself. + std::hint::black_box(&image.temps); + result +} + +fn convert_int_return(ret_type: u8, r: u64) -> f64 { + match ret_type { + T_VOID => super::undefined(), + T_BOOL => bool_value((r as u8) != 0), + T_CHAR | T_I8 => super::number_value((r as u8 as i8) as f64), + T_U8 => super::number_value((r as u8) as f64), + T_I16 => super::number_value((r as u16 as i16) as f64), + T_U16 => super::number_value((r as u16) as f64), + T_I32 => super::number_value((r as u32 as i32) as f64), + T_U32 => super::number_value((r as u32) as f64), + // Bun semantics: i64/u64 (and usize, an alias of u64) ALWAYS return + // BigInt; the `_fast` variants return number while the value is + // within the safe-integer range. + T_I64 => bigint_value_i64(r as i64), + T_U64 => bigint_value_u64(r), + T_I64_FAST => { + let v = r as i64; + if (-MAX_SAFE..=MAX_SAFE).contains(&v) { + super::number_value(v as f64) + } else { + bigint_value_i64(v) + } + } + T_U64_FAST => { + if r <= MAX_SAFE as u64 { + super::number_value(r as f64) + } else { + bigint_value_u64(r) + } + } + T_PTR => { + if r == 0 { + super::null() + } else { + // Bun represents pointers as plain JS numbers. Real user-space + // addresses on the supported targets fit in 52 bits, so the + // f64 conversion is exact. + super::number_value(r as f64) + } + } + T_CSTRING => unsafe { read_cstring_value(r as usize) }, + _ => super::undefined(), + } +} + +// ── tests ─────────────────────────────────────────────────────────────────── + +#[cfg(all(test, unix, any(target_arch = "x86_64", target_arch = "aarch64")))] +mod tests { + use super::*; + + // Callee prototypes deliberately narrower than the 16-slot trampoline — + // exactly the situation at a real dlopen'd symbol. + + extern "C" fn sum8_i32(a: i32, b: i32, c: i32, d: i32, e: i32, f: i32, g: i32, h: i32) -> i64 { + a as i64 + b as i64 + c as i64 + d as i64 + e as i64 + f as i64 + g as i64 + h as i64 + } + + extern "C" fn dsum8(a: f64, b: f64, c: f64, d: f64, e: f64, f: f64, g: f64, h: f64) -> f64 { + a + b + c + d + e + f + g + h + } + + extern "C" fn mixed(a: i32, b: f64, c: i32, d: f64, e: i64, f: f32) -> f64 { + a as f64 + b * 2.0 + c as f64 * 3.0 + d * 4.0 + e as f64 * 5.0 + f as f64 * 6.0 + } + + extern "C" fn f32_half(v: f32) -> f32 { + v * 0.5 + } + + extern "C" fn u64_id(v: u64) -> u64 { + v + } + + extern "C" fn bool_not(v: bool) -> bool { + !v + } + + extern "C" fn i8_neg(v: i8) -> i8 { + -v + } + + fn image_from(ints: &[usize], floats: &[f64]) -> ArgImage { + let mut image = ArgImage::default(); + image.ints[..ints.len()].copy_from_slice(ints); + image.floats[..floats.len()].copy_from_slice(floats); + image + } + + #[test] + fn register_image_reaches_eight_int_args() { + let image = image_from(&[1, 2, 3, 4, 5, 6, 7, 8], &[]); + let r = unsafe { raw::call_int(sum8_i32 as usize, &image.ints, &image.floats) }; + assert_eq!(r as i64, 36); + } + + #[test] + fn register_image_reaches_eight_float_args() { + let image = image_from(&[], &[0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5]); + let r = unsafe { raw::call_f64(dsum8 as usize, &image.ints, &image.floats) }; + assert_eq!(r, 32.0); + } + + #[test] + fn mixed_int_float_assignment_matches_the_abi() { + // callee: (i32 a, f64 b, i32 c, f64 d, i64 e, f32 f) + // ints → [a, c, e], floats → [b, d, f32-image(f)] + let f_img = f64::from_bits((1.5f32).to_bits() as u64); + let image = image_from(&[10, 20, 30], &[2.0, 4.0, f_img]); + let r = unsafe { raw::call_f64(mixed as usize, &image.ints, &image.floats) }; + assert_eq!(r, 10.0 + 4.0 + 60.0 + 16.0 + 150.0 + 9.0); + } + + #[test] + fn f32_return_and_f32_bit_image_arg() { + let image = image_from(&[], &[f64::from_bits((21.0f32).to_bits() as u64)]); + let r = unsafe { raw::call_f32(f32_half as usize, &image.ints, &image.floats) }; + assert_eq!(r, 10.5f32); + } + + #[test] + fn u64_roundtrip_keeps_all_bits() { + let image = image_from(&[u64::MAX as usize], &[]); + let r = unsafe { raw::call_int(u64_id as usize, &image.ints, &image.floats) }; + assert_eq!(r, u64::MAX); + } + + #[test] + fn narrow_returns_truncate_to_declared_width() { + let image = image_from(&[1], &[]); + let r = unsafe { raw::call_int(bool_not as usize, &image.ints, &image.floats) }; + // Only the low byte is specified; the converter masks it. + assert!(!((r as u8) != 0)); + + let image = image_from(&[5], &[]); + let r = unsafe { raw::call_int(i8_neg as usize, &image.ints, &image.floats) }; + assert_eq!(r as u8 as i8, -5); + } + + #[test] + fn int_return_conversion_widths() { + assert_eq!( + convert_int_return(T_I8, 0xFFu64), + super::super::number_value(-1.0) + ); + assert_eq!( + convert_int_return(T_U8, 0x1FFu64), + super::super::number_value(255.0) + ); + assert_eq!( + convert_int_return(T_I32, 0xFFFF_FFFFu64), + super::super::number_value(-1.0) + ); + assert_eq!( + convert_int_return(T_U32, 0xFFFF_FFFFu64), + super::super::number_value(4294967295.0) + ); + // i64_fast within safe range → number + assert_eq!( + convert_int_return(T_I64_FAST, 42u64), + super::super::number_value(42.0) + ); + // ptr NULL → null + assert_eq!( + convert_int_return(T_PTR, 0).to_bits(), + crate::value::TAG_NULL + ); + } + + #[test] + fn marshal_pads_missing_args_with_zero() { + let image = unsafe { marshal_args(&[T_I32, T_I32], &[super::super::number_value(7.0)]) }; + assert_eq!(image.ints[0], 7); + assert_eq!(image.ints[1], 0); + } + + #[test] + fn marshal_saturating_and_bool_coercions() { + unsafe { + let image = marshal_args( + &[T_I32, T_BOOL, T_F32], + &[ + super::super::number_value(-3.9), + f64::from_bits(crate::value::TAG_TRUE), + super::super::number_value(1.5), + ], + ); + assert_eq!(image.ints[0] as u64 as i64, -3); + assert_eq!(image.ints[1], 1); + assert_eq!(image.floats[0].to_bits(), (1.5f32).to_bits() as u64); + } + } +} diff --git a/crates/perry-runtime/src/bun_ffi/dlopen.rs b/crates/perry-runtime/src/bun_ffi/dlopen.rs new file mode 100644 index 0000000000..96a23bcabf --- /dev/null +++ b/crates/perry-runtime/src/bun_ffi/dlopen.rs @@ -0,0 +1,658 @@ +//! `dlopen` / symbol call stubs / `ptr` / `CString`. +//! +//! Library handles and prepared symbols live in process-wide registries +//! (`LIBS` / `SYMS`); a symbol's JS call stub is a runtime closure whose +//! single capture is its `SYMS` index, so the shared per-arity thunks +//! (`sym_thunk_0..=16`) stay signature-compatible with the closure call +//! ABI (`extern "C" fn(*const ClosureHeader, f64 × arity) -> f64`, +//! arity-padded by `closure/dispatch` via `js_register_closure_arity`). +//! +//! `close()` calls `dlclose` and poisons the library's symbols: later +//! calls throw instead of jumping through a dangling handle. This is +//! deliberately stricter than Bun (which leaves use-after-close as UB). + +use super::call::{self, MAX_ARGS, MAX_FLOAT_ARGS, MAX_INT_ARGS}; +use super::types::{self, T_BUFFER, T_FUNCTION, T_NAPI_ENV, T_NAPI_VALUE, T_VOID}; +use crate::closure::ClosureHeader; +use crate::value::JSValue; +use std::sync::Mutex; + +// ── platform dlopen/dlsym/dlclose ─────────────────────────────────────────── +// Mirrors `plugin.rs` (which keeps its helpers private). Windows is absent on +// purpose: `call::platform_supported()` gates every entry point, and stage 1 +// supports unix x86_64/aarch64 only. + +#[cfg(unix)] +unsafe fn open_library(path: &str) -> Result { + let c_path = match std::ffi::CString::new(path) { + Ok(p) => p, + Err(_) => return Err("path contains a NUL byte".to_string()), + }; + // Clear any stale error state, then capture dlerror on failure. + libc::dlerror(); + let h = libc::dlopen(c_path.as_ptr(), libc::RTLD_NOW | libc::RTLD_LOCAL); + if h.is_null() { + let err = libc::dlerror(); + let msg = if err.is_null() { + "unknown dlopen error".to_string() + } else { + std::ffi::CStr::from_ptr(err).to_string_lossy().into_owned() + }; + Err(msg) + } else { + Ok(h as usize) + } +} + +#[cfg(unix)] +unsafe fn find_symbol(handle: usize, name: &str) -> Option { + let c_name = std::ffi::CString::new(name).ok()?; + let sym = libc::dlsym(handle as *mut libc::c_void, c_name.as_ptr()); + if sym.is_null() { + None + } else { + Some(sym as usize) + } +} + +#[cfg(unix)] +unsafe fn close_library(handle: usize) { + libc::dlclose(handle as *mut libc::c_void); +} + +#[cfg(not(unix))] +unsafe fn open_library(_path: &str) -> Result { + Err("bun:ffi is not supported on this platform".to_string()) +} +#[cfg(not(unix))] +unsafe fn find_symbol(_handle: usize, _name: &str) -> Option { + None +} +#[cfg(not(unix))] +unsafe fn close_library(_handle: usize) {} + +// ── registries ────────────────────────────────────────────────────────────── + +struct LibRecord { + /// Raw dlopen handle. Only dereferenced by dlsym/dlclose on the JS + /// thread; stored as usize so the registry is Send. + handle: usize, + path: String, + closed: bool, +} + +/// One prepared symbol: everything a call stub needs, `Copy`-cheap. +#[derive(Clone, Copy)] +pub(crate) struct SymRecord { + fn_ptr: usize, + lib: usize, + ret: u8, + argc: u8, + args: [u8; MAX_ARGS], + /// Leaked once per dlopen'd symbol — used in error messages and as the + /// stable closure display name. + name: &'static str, +} + +static LIBS: Mutex> = Mutex::new(Vec::new()); +static SYMS: Mutex> = Mutex::new(Vec::new()); + +fn lib_is_closed(lib: usize) -> Option { + let libs = LIBS.lock().unwrap(); + let rec = libs.get(lib)?; + if rec.closed { + Some(rec.path.clone()) + } else { + None + } +} + +// ── small JS-value helpers ────────────────────────────────────────────────── + +unsafe fn value_to_owned_string(v: f64) -> Option { + let jv = JSValue::from_bits(v.to_bits()); + if !jv.is_any_string() { + return None; + } + let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let bytes = crate::string::js_string_key_bytes(jv, &mut sso)?; + Some(String::from_utf8_lossy(bytes).into_owned()) +} + +unsafe fn object_ptr_of(v: f64) -> Option<*mut crate::object::ObjectHeader> { + let jv = JSValue::from_bits(v.to_bits()); + if !jv.is_pointer() { + return None; + } + let addr = crate::value::js_nanbox_get_pointer(f64::from_bits(jv.bits())); + if addr == 0 { + return None; + } + Some(addr as usize as *mut crate::object::ObjectHeader) +} + +unsafe fn get_field(obj: *mut crate::object::ObjectHeader, name: &str) -> f64 { + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + f64::from_bits(crate::object::js_object_get_field_by_name(obj, key).bits()) +} + +fn set_field(obj: *mut crate::object::ObjectHeader, name: &str, value: f64) { + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + crate::object::js_object_set_field_by_name(obj, key, value); +} + +// ── the per-arity call-stub thunks ────────────────────────────────────────── + +/// Shared body: resolve the closure's captured `SYMS` index, marshal, call. +unsafe fn invoke_from_closure(closure: *const ClosureHeader, js_args: &[f64]) -> f64 { + let sym_index = crate::closure::js_closure_get_capture_bits(closure, 0) as usize; + let sym = { + let syms = SYMS.lock().unwrap(); + match syms.get(sym_index) { + Some(&s) => s, + None => { + crate::fs::validate::throw_error_with_code( + "bun:ffi: internal error: unknown symbol stub", + "ERR_INVALID_STATE", + ); + } + } + }; + if let Some(path) = lib_is_closed(sym.lib) { + crate::fs::validate::throw_error_with_code( + &format!( + "bun:ffi: symbol \"{}\" was called after close() on \"{path}\"", + sym.name + ), + "ERR_INVALID_STATE", + ); + } + let image = call::marshal_args(&sym.args[..sym.argc as usize], js_args); + call::call_and_convert(sym.fn_ptr, sym.ret, &image) +} + +macro_rules! sym_thunk { + ($name:ident $(, $a:ident)*) => { + extern "C" fn $name(closure: *const ClosureHeader $(, $a: f64)*) -> f64 { + let args = [$($a),*]; + unsafe { invoke_from_closure(closure, &args) } + } + }; +} + +sym_thunk!(sym_thunk_0); +sym_thunk!(sym_thunk_1, a0); +sym_thunk!(sym_thunk_2, a0, a1); +sym_thunk!(sym_thunk_3, a0, a1, a2); +sym_thunk!(sym_thunk_4, a0, a1, a2, a3); +sym_thunk!(sym_thunk_5, a0, a1, a2, a3, a4); +sym_thunk!(sym_thunk_6, a0, a1, a2, a3, a4, a5); +sym_thunk!(sym_thunk_7, a0, a1, a2, a3, a4, a5, a6); +sym_thunk!(sym_thunk_8, a0, a1, a2, a3, a4, a5, a6, a7); +sym_thunk!(sym_thunk_9, a0, a1, a2, a3, a4, a5, a6, a7, a8); +sym_thunk!(sym_thunk_10, a0, a1, a2, a3, a4, a5, a6, a7, a8, a9); +sym_thunk!(sym_thunk_11, a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10); +sym_thunk!( + sym_thunk_12, + a0, + a1, + a2, + a3, + a4, + a5, + a6, + a7, + a8, + a9, + a10, + a11 +); +sym_thunk!( + sym_thunk_13, + a0, + a1, + a2, + a3, + a4, + a5, + a6, + a7, + a8, + a9, + a10, + a11, + a12 +); +sym_thunk!( + sym_thunk_14, + a0, + a1, + a2, + a3, + a4, + a5, + a6, + a7, + a8, + a9, + a10, + a11, + a12, + a13 +); +sym_thunk!( + sym_thunk_15, + a0, + a1, + a2, + a3, + a4, + a5, + a6, + a7, + a8, + a9, + a10, + a11, + a12, + a13, + a14 +); +sym_thunk!( + sym_thunk_16, + a0, + a1, + a2, + a3, + a4, + a5, + a6, + a7, + a8, + a9, + a10, + a11, + a12, + a13, + a14, + a15 +); + +fn sym_thunk_for(arity: usize) -> *const u8 { + match arity { + 0 => sym_thunk_0 as *const u8, + 1 => sym_thunk_1 as *const u8, + 2 => sym_thunk_2 as *const u8, + 3 => sym_thunk_3 as *const u8, + 4 => sym_thunk_4 as *const u8, + 5 => sym_thunk_5 as *const u8, + 6 => sym_thunk_6 as *const u8, + 7 => sym_thunk_7 as *const u8, + 8 => sym_thunk_8 as *const u8, + 9 => sym_thunk_9 as *const u8, + 10 => sym_thunk_10 as *const u8, + 11 => sym_thunk_11 as *const u8, + 12 => sym_thunk_12 as *const u8, + 13 => sym_thunk_13 as *const u8, + 14 => sym_thunk_14 as *const u8, + 15 => sym_thunk_15 as *const u8, + _ => sym_thunk_16 as *const u8, + } +} + +extern "C" fn close_thunk(closure: *const ClosureHeader) -> f64 { + let lib_index = crate::closure::js_closure_get_capture_bits(closure, 0) as usize; + let mut libs = LIBS.lock().unwrap(); + if let Some(rec) = libs.get_mut(lib_index) { + if !rec.closed { + rec.closed = true; + let handle = rec.handle; + drop(libs); + unsafe { close_library(handle) }; + } + } + super::undefined() +} + +/// Allocate a call-stub closure whose capture 0 is a plain (non-pointer) +/// u64 index. The capture is written as raw bits — a small integer never +/// classifies as pointer-bearing in the GC layout, so the closure stays +/// pointer-free. +fn index_closure(func: *const u8, index: usize, arity: u32, name: &str) -> f64 { + crate::closure::js_register_closure_arity(func, arity); + crate::closure::js_register_closure_length(func, arity); + let closure = crate::closure::js_closure_alloc(func, 1); + crate::closure::js_closure_set_capture_bits(closure, 0, index as u64); + crate::object::set_bound_native_closure_name(closure, name); + crate::object::set_builtin_closure_length(closure as usize, arity); + crate::value::js_nanbox_pointer(closure as i64) +} + +// ── dlopen ────────────────────────────────────────────────────────────────── + +fn throw_dlopen_failed(name: &str, detail: &str) -> ! { + crate::fs::validate::throw_error_with_code( + &format!("Failed to open library \"{name}\": {detail}"), + "ERR_DLOPEN_FAILED", + ) +} + +/// Validate one symbol signature at dlopen time so a bad signature can +/// never reach the trampoline. Returns (int_class_count, float_class_count). +fn validate_signature(sym: &str, args: &[u8], ret: u8) -> (usize, usize) { + let reject = |what: &str| -> ! { + crate::fs::validate::throw_type_error_with_code( + &format!("bun:ffi: symbol \"{sym}\": {what}"), + "ERR_INVALID_ARG_TYPE", + ) + }; + let mut ints = 0usize; + let mut floats = 0usize; + for &t in args { + match t { + T_FUNCTION => reject( + "FFIType.function / JSCallback arguments are not yet supported \ + in perry (bun:ffi stage 1, #6562)", + ), + T_NAPI_ENV | T_NAPI_VALUE => reject("napi types are not supported"), + T_BUFFER => reject("FFIType.buffer is not yet supported (use ptr)"), + T_VOID => reject("void is not a valid argument type"), + t if types::is_float_class(t) => floats += 1, + _ => ints += 1, + } + } + match ret { + T_FUNCTION => reject( + "FFIType.function / JSCallback returns are not yet supported \ + in perry (bun:ffi stage 1, #6562)", + ), + T_NAPI_ENV | T_NAPI_VALUE => reject("napi types are not supported"), + T_BUFFER => reject("FFIType.buffer is not yet supported (use ptr / toArrayBuffer)"), + _ => {} + } + if args.len() > MAX_ARGS { + reject(&format!("more than {MAX_ARGS} arguments are not supported")); + } + if ints > MAX_INT_ARGS { + reject(&format!( + "more than {MAX_INT_ARGS} integer/pointer arguments are not supported \ + by perry's stage-1 call stubs" + )); + } + if floats > MAX_FLOAT_ARGS { + reject(&format!( + "more than {MAX_FLOAT_ARGS} float arguments are not supported \ + by perry's stage-1 call stubs" + )); + } + (ints, floats) +} + +/// `dlopen(path, symbolTable)` → `{ symbols: { : fn }, close(): void }`. +pub(crate) unsafe fn dlopen_value(path_arg: f64, table_arg: f64) -> f64 { + if !call::platform_supported() { + crate::fs::validate::throw_error_with_code( + "bun:ffi is not supported on this platform yet (stage 1 targets \ + unix x86_64 / aarch64, #6562)", + "ERR_NOT_IMPLEMENTED", + ); + } + let Some(path) = value_to_owned_string(path_arg) else { + crate::fs::validate::throw_type_error_with_code( + "dlopen(path, symbols) expects a string path", + "ERR_INVALID_ARG_TYPE", + ); + }; + let Some(table) = object_ptr_of(table_arg) else { + crate::fs::validate::throw_type_error_with_code( + "dlopen(path, symbols) expects a symbols object", + "ERR_INVALID_ARG_TYPE", + ); + }; + + let handle = match open_library(&path) { + Ok(h) => h, + Err(msg) => throw_dlopen_failed(&path, &msg), + }; + let lib_index = { + let mut libs = LIBS.lock().unwrap(); + libs.push(LibRecord { + handle, + path: path.clone(), + closed: false, + }); + libs.len() - 1 + }; + + // Walk the symbol table BEFORE building any JS result, so an invalid + // entry throws without leaking half-built objects (the lib handle stays + // registered; dlopen handles are refcounted by the loader and this + // mirrors Bun, which also leaves the library mapped on validation + // throws). + let keys = crate::object::js_object_keys(table); + let key_count = crate::array::js_array_length(keys); + if key_count == 0 { + throw_dlopen_failed(&path, "Expected at least 1 symbol"); + } + + struct Prepared { + name: String, + sym_index: usize, + argc: u32, + } + let mut prepared: Vec = Vec::with_capacity(key_count as usize); + + for i in 0..key_count { + let key_value = crate::array::js_array_get(keys, i); + let name = match value_to_owned_string(f64::from_bits(key_value.bits())) { + Some(n) => n, + None => continue, + }; + let Some(entry) = object_ptr_of(get_field(table, &name)) else { + crate::fs::validate::throw_type_error_with_code( + &format!("bun:ffi: symbol \"{name}\": expected {{ args, returns }}"), + "ERR_INVALID_ARG_TYPE", + ); + }; + + // args: optional array of FFIType values; returns: optional FFIType + // (missing → void), both exactly as Bun accepts them. + let mut args = [0u8; MAX_ARGS]; + let mut argc = 0usize; + let args_value = get_field(entry, "args"); + let args_jv = JSValue::from_bits(args_value.to_bits()); + if !args_jv.is_undefined() && !args_jv.is_null() { + let arr_addr = crate::value::js_nanbox_get_pointer(args_value); + if arr_addr == 0 { + crate::fs::validate::throw_type_error_with_code( + &format!("bun:ffi: symbol \"{name}\": args must be an array"), + "ERR_INVALID_ARG_TYPE", + ); + } + let arr = arr_addr as usize as *const crate::array::ArrayHeader; + let len = crate::array::js_array_length(arr); + if len as usize > MAX_ARGS { + crate::fs::validate::throw_type_error_with_code( + &format!( + "bun:ffi: symbol \"{name}\": more than {MAX_ARGS} arguments \ + are not supported" + ), + "ERR_INVALID_ARG_TYPE", + ); + } + for j in 0..len { + let t = crate::array::js_array_get(arr, j); + args[argc] = types::parse_ffi_type(f64::from_bits(t.bits())); + argc += 1; + } + } + let returns_value = get_field(entry, "returns"); + let returns_jv = JSValue::from_bits(returns_value.to_bits()); + let ret = if returns_jv.is_undefined() || returns_jv.is_null() { + T_VOID + } else { + types::parse_ffi_type(returns_value) + }; + validate_signature(&name, &args[..argc], ret); + + let Some(fn_ptr) = find_symbol(handle, &name) else { + crate::fs::validate::throw_type_error_with_code( + &format!("Symbol \"{name}\" not found in \"{path}\""), + "ERR_INVALID_ARG_TYPE", + ); + }; + + let leaked_name: &'static str = name.clone().leak(); + let sym_index = { + let mut syms = SYMS.lock().unwrap(); + syms.push(SymRecord { + fn_ptr, + lib: lib_index, + ret, + argc: argc as u8, + args, + name: leaked_name, + }); + syms.len() - 1 + }; + prepared.push(Prepared { + name, + sym_index, + argc: argc as u32, + }); + } + + // Build `{ symbols, close }` with every intermediate rooted across the + // remaining allocations. + let scope = crate::gc::RuntimeHandleScope::new(); + let symbols_obj = crate::object::js_object_alloc(0, prepared.len() as u32); + let symbols_handle = scope.root_raw_mut_ptr(symbols_obj); + for p in &prepared { + let value = index_closure(sym_thunk_for(p.argc as usize), p.sym_index, p.argc, &p.name); + let value_handle = scope.root_nanbox_f64(value); + set_field( + symbols_handle.get_raw_mut_ptr::(), + &p.name, + value_handle.get_nanbox_f64(), + ); + } + + let close_value = index_closure(close_thunk as *const u8, lib_index, 0, "close"); + let close_handle = scope.root_nanbox_f64(close_value); + + let result = crate::object::js_object_alloc(0, 2); + let result_handle = scope.root_raw_mut_ptr(result); + let symbols_value = + f64::from_bits(JSValue::object_ptr(symbols_handle.get_raw_mut_ptr::()).bits()); + set_field( + result_handle.get_raw_mut_ptr::(), + "symbols", + symbols_value, + ); + set_field( + result_handle.get_raw_mut_ptr::(), + "close", + close_handle.get_nanbox_f64(), + ); + f64::from_bits(JSValue::object_ptr(result_handle.get_raw_mut_ptr::()).bits()) +} + +// ── ptr / CString ─────────────────────────────────────────────────────────── + +/// `ptr(view[, byteOffset])` → number address of the view's bytes. +/// +/// Lifetime contract (see the module doc in `mod.rs`): the address is the +/// buffer's non-moving inline storage (resolved through the view registry +/// to the ultimate backing); it stays valid while the JS object is alive +/// and un-detached. `ptr()` does not root the object. +pub(crate) unsafe fn ptr_value(view_arg: f64, offset_arg: f64) -> f64 { + let Some((data, len)) = call::value_buffer_span(view_arg) else { + let jv = JSValue::from_bits(view_arg.to_bits()); + if jv.is_any_string() { + crate::fs::validate::throw_type_error_with_code( + "To convert a string to a pointer, encode it as a buffer", + "ERR_INVALID_ARG_TYPE", + ); + } + crate::fs::validate::throw_type_error_with_code( + "ptr(view) expects a TypedArray, Buffer, ArrayBuffer or DataView", + "ERR_INVALID_ARG_TYPE", + ); + }; + let offset_jv = JSValue::from_bits(offset_arg.to_bits()); + let offset = if offset_jv.is_int32() { + offset_jv.as_int32() as i64 + } else if offset_jv.is_number() { + offset_jv.as_number() as i64 + } else { + 0 + }; + if offset < 0 || offset as usize > len { + crate::fs::validate::throw_range_error_named( + &format!("ptr(view, byteOffset): byteOffset {offset} is out of range (0..={len})"), + "ERR_OUT_OF_RANGE", + ); + } + super::number_value(data as usize as f64 + offset as f64) +} + +/// `CString(ptr[, byteOffset[, byteLength]])` → JS string. +/// +/// Stage-1 divergence from Bun (documented): returns a primitive string +/// rather than a `String` subclass carrying `.ptr` — the decoded text is +/// identical. NULL pointers return `null` like Bun's `cstring` return +/// conversion. +pub(crate) unsafe fn cstring_value(ptr_arg: f64, offset_arg: f64, length_arg: f64) -> f64 { + let jv = JSValue::from_bits(ptr_arg.to_bits()); + let base = if jv.is_undefined() || jv.is_null() { + 0usize + } else if jv.is_int32() { + jv.as_int32() as i64 as usize + } else if jv.is_number() { + jv.as_number() as i64 as usize + } else if jv.is_bigint() { + let b = crate::value::js_nanbox_get_bigint(ptr_arg); + if b == 0 { + 0 + } else { + (*(b as usize as *const crate::bigint::BigIntHeader)).limbs[0] as usize + } + } else if let Some((data, _)) = call::value_buffer_span(ptr_arg) { + data as usize + } else { + crate::fs::validate::throw_type_error_with_code( + "CString(ptr) expects a pointer", + "ERR_INVALID_ARG_TYPE", + ); + }; + if base == 0 { + return super::null(); + } + let offset_jv = JSValue::from_bits(offset_arg.to_bits()); + let offset = if offset_jv.is_int32() { + offset_jv.as_int32() as i64 + } else if offset_jv.is_number() { + offset_jv.as_number() as i64 + } else { + 0 + }; + let start = (base as i64 + offset.max(0)) as usize; + let length_jv = JSValue::from_bits(length_arg.to_bits()); + let explicit_len = if length_jv.is_int32() { + Some(length_jv.as_int32() as i64) + } else if length_jv.is_number() { + Some(length_jv.as_number() as i64) + } else { + None + }; + match explicit_len { + Some(n) if n >= 0 => { + let bytes = std::slice::from_raw_parts(start as *const u8, n as usize); + match std::str::from_utf8(bytes) { + Ok(s) => super::string_value(s), + Err(_) => super::string_value(&String::from_utf8_lossy(bytes)), + } + } + _ => call::read_cstring_value(start), + } +} diff --git a/crates/perry-runtime/src/bun_ffi/mod.rs b/crates/perry-runtime/src/bun_ffi/mod.rs new file mode 100644 index 0000000000..31a13cc346 --- /dev/null +++ b/crates/perry-runtime/src/bun_ffi/mod.rs @@ -0,0 +1,169 @@ +//! `bun:ffi` — C-ABI foreign-function interface, stage 1 (#6562). +//! +//! Implements the Bun FFI API shape for perry-compiled programs: +//! +//! - `dlopen(path, symbolTable)` → `{ symbols, close() }` with typed call +//! stubs generated per symbol signature. +//! - `FFIType` — the runtime enum object (numeric values + string aliases +//! mirror Bun's `src/js/bun/ffi.ts` object literal exactly). +//! - `ptr(view[, byteOffset])` — raw native address of a Buffer / +//! TypedArray / ArrayBuffer / DataView's bytes. +//! - `CString(ptr[, byteOffset[, byteLength]])` — read a NUL-terminated +//! (or length-bounded) UTF-8 string from a native pointer. +//! - `suffix` — platform dylib suffix ("dylib" / "so" / "dll"). +//! +//! Stage-1 scope: `toArrayBuffer` (external backing stores), `JSCallback` / +//! `FFIType.function` (native→JS trampolines), `linkSymbols`, `CFunction`, +//! `viewSource` and `read` are declared but throw a clear "not yet +//! supported" error. The dispatch/type plumbing here is shaped so those can +//! be added without reworking stage 1 (see `types::` for the reserved +//! numeric slots and `dlopen::` for the per-symbol signature records). +//! +//! ## Pointer lifetime / pinning contract (the part that must not be wrong) +//! +//! perry's GC relocates nursery objects, but **Buffer / TypedArray / +//! ArrayBuffer / DataView byte storage never moves**: every such object is +//! allocated directly in the non-moving old arena, born `TENURED`, with +//! `movable: false` in `GC_TYPE_INFO_BY_ID` and its bytes stored inline +//! after the header (`buffer/header.rs:468-494`, `typedarray/mod.rs:700-724` +//! — the 2026-07-09 audit made this unconditional precisely because raw +//! data pointers are handed to FFI/tokio). There is also no in-place growth +//! path for buffers (unlike arrays, which reallocate through forwarding +//! stubs — #6228): every buffer-producing operation allocates a fresh +//! header. Consequently: +//! +//! 1. The address returned by `ptr(view)` is stable for the **lifetime of +//! the JS object**. It is invalidated by (a) the object becoming +//! unreachable and being swept (old-arena blocks are recycled — the +//! #6080 ABA class), or (b) `ArrayBuffer.prototype.transfer` / +//! structured-clone detach. The caller must keep a live reference to +//! the buffer for as long as native code holds the pointer — the same +//! contract Bun documents ("keep a reference to the TypedArray while +//! native code uses it"). `ptr()` itself does NOT root the buffer. +//! 2. For **views** (`buffer.subarray`, `new Uint8Array(ab, off, len)`), +//! perry keeps a local byte copy plus a view registry whose backing is +//! the source of truth (#1205/#6515). `ptr()` resolves through +//! `buffer::view::resolve_data_ptr`, so native code always sees the +//! true backing bytes — but a native **write** through such a pointer +//! is not propagated into the view's local copy, so subsequent JS reads +//! through the view's codegen fast path can be stale. Pass base +//! (non-view) Buffers/TypedArrays to native code that writes — which is +//! what real `bun:ffi` consumers (bun-pty, opentui) do. +//! 3. During a synchronous FFI call no GC can run on the calling thread +//! (stage 1 has no native→JS callbacks), and argument marshalling that +//! allocates (temporary NUL-terminated copies of string args) cannot +//! invalidate buffer arguments because buffers are non-moving and are +//! kept alive by the caller's frame. +//! +//! ## Call-stub mechanism +//! +//! Hand-generated register-image thunks rather than libffi (no new native +//! deps, no linker-driver changes): all FFI types are scalars, so on the +//! two supported ABIs (SysV x86-64, AAPCS64 incl. Apple arm64) integer-class +//! args fill the integer register file in order and float-class args fill +//! the vector register file in order, independently. Calling through a +//! 16-slot `extern "C"` signature with the marshalled values packed in +//! class order therefore produces exactly the register (and, on x86-64, +//! stack) image the callee's real prototype expects. See `call.rs` for the +//! per-ABI limits (≤ 8 integer-class + ≤ 8 float-class args) and the f32 +//! bit-image trick. Signatures beyond those limits, and non-unix or +//! non-{x86_64, aarch64} targets, throw a descriptive error at `dlopen` +//! time rather than corrupting registers at call time. + +pub mod call; +pub mod dlopen; +pub mod types; + +use crate::value::JSValue; + +pub(crate) fn undefined() -> f64 { + f64::from_bits(crate::value::TAG_UNDEFINED) +} + +pub(crate) fn null() -> f64 { + f64::from_bits(crate::value::TAG_NULL) +} + +pub(crate) fn string_value(s: &str) -> f64 { + let ptr = crate::string::js_string_from_bytes(s.as_ptr(), s.len() as u32); + f64::from_bits(JSValue::string_ptr(ptr).bits()) +} + +pub(crate) fn number_value(n: f64) -> f64 { + f64::from_bits(JSValue::number(n).bits()) +} + +/// Platform shared-library suffix, matching Bun's `suffix` export +/// (WITHOUT the leading dot, e.g. `"dylib"`). +pub(crate) fn suffix_str() -> &'static str { + #[cfg(target_os = "macos")] + { + "dylib" + } + #[cfg(target_os = "windows")] + { + "dll" + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + "so" + } +} + +/// GC root scanner for the module's cached JS objects (the `FFIType` +/// enum object). Registered from `gc_init` alongside the other runtime +/// side-table scanners. +pub fn scan_bun_ffi_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { + types::scan_ffi_type_cache_mut(visitor); +} + +/// Stage-1 boundary: named exports that exist in `bun:ffi` but are not yet +/// implemented in perry. Kept callable so real-world feature probes fail +/// with an actionable message instead of `undefined is not a function`. +fn throw_stage1_unsupported(what: &str) -> ! { + crate::fs::validate::throw_error_with_code( + &format!( + "bun:ffi: {what} is not supported yet in perry (stage 1, #6562). \ + Available: dlopen, FFIType, ptr, CString, suffix." + ), + "ERR_NOT_IMPLEMENTED", + ) +} + +/// Method dispatch for the `bun:ffi` namespace — the single entry the +/// `nm_dispatch_bun_ffi` bucket routes through. `args` are NaN-boxed +/// JSValues. +/// +/// # Safety +/// `args_ptr` must point at `args_len` valid NaN-boxed f64 slots (or be +/// null when `args_len == 0`), per the NmCtx contract. +pub(crate) unsafe fn dispatch( + method_name: &str, + args_ptr: *const f64, + args_len: usize, +) -> Option { + let arg = |n: usize| -> f64 { + if n < args_len && !args_ptr.is_null() { + *args_ptr.add(n) + } else { + undefined() + } + }; + match method_name { + "dlopen" => Some(dlopen::dlopen_value(arg(0), arg(1))), + "ptr" => Some(dlopen::ptr_value(arg(0), arg(1))), + "CString" => Some(dlopen::cstring_value(arg(0), arg(1), arg(2))), + // Constants are normally served by `get_native_module_constant`, + // but destructured/dynamic reads can land here too. + "FFIType" => Some(types::ffi_type_object_value()), + "suffix" => Some(string_value(suffix_str())), + "toArrayBuffer" => throw_stage1_unsupported("toArrayBuffer (external backing stores)"), + "JSCallback" => throw_stage1_unsupported("JSCallback (native-to-JS callbacks)"), + "CFunction" => throw_stage1_unsupported("CFunction"), + "linkSymbols" => throw_stage1_unsupported("linkSymbols"), + "viewSource" => throw_stage1_unsupported("viewSource"), + "read" => throw_stage1_unsupported("the read namespace"), + "toBuffer" => throw_stage1_unsupported("toBuffer"), + _ => None, + } +} diff --git a/crates/perry-runtime/src/bun_ffi/types.rs b/crates/perry-runtime/src/bun_ffi/types.rs new file mode 100644 index 0000000000..2148bedd9b --- /dev/null +++ b/crates/perry-runtime/src/bun_ffi/types.rs @@ -0,0 +1,202 @@ +//! FFIType — numeric model, string aliases, and the JS enum object. +//! +//! Numeric values mirror Bun's `FFIType` exactly (`packages/bun-types/ +//! ffi.d.ts` / the runtime object literal in `src/js/bun/ffi.ts`): +//! +//! ```text +//! char=0 i8=1 u8=2 i16=3 u16=4 i32=5 u32=6 i64=7 u64=8 +//! f64=9 f32=10 bool=11 ptr=12 void=13 cstring=14 +//! i64_fast=15 u64_fast=16 function=17 napi_env=18 napi_value=19 +//! buffer=20 +//! ``` +//! +//! Stage 1 implements 0–16; `function` (17) parses but `dlopen` rejects it +//! with a "not yet supported" error (JSCallback is stage 3), and the napi / +//! buffer slots (18–20) are rejected as unsupported. The slots stay +//! reserved so later stages only need to lift the rejection. + +use super::{number_value, string_value}; +use crate::value::JSValue; +use std::sync::atomic::{AtomicU64, Ordering}; + +pub const T_CHAR: u8 = 0; +pub const T_I8: u8 = 1; +pub const T_U8: u8 = 2; +pub const T_I16: u8 = 3; +pub const T_U16: u8 = 4; +pub const T_I32: u8 = 5; +pub const T_U32: u8 = 6; +pub const T_I64: u8 = 7; +pub const T_U64: u8 = 8; +pub const T_F64: u8 = 9; +pub const T_F32: u8 = 10; +pub const T_BOOL: u8 = 11; +pub const T_PTR: u8 = 12; +pub const T_VOID: u8 = 13; +pub const T_CSTRING: u8 = 14; +pub const T_I64_FAST: u8 = 15; +pub const T_U64_FAST: u8 = 16; +pub const T_FUNCTION: u8 = 17; +pub const T_NAPI_ENV: u8 = 18; +pub const T_NAPI_VALUE: u8 = 19; +pub const T_BUFFER: u8 = 20; + +/// Bun's runtime `FFIType` object literal, key for key (including the +/// numeric-string self-mapped keys `"0"`–`"17"` — and, like Bun, NOT +/// `"18"`–`"20"`). Order matches `src/js/bun/ffi.ts` for easy diffing. +const FFI_TYPE_ENTRIES: &[(&str, u8)] = &[ + ("0", 0), + ("1", 1), + ("2", 2), + ("3", 3), + ("4", 4), + ("5", 5), + ("6", 6), + ("7", 7), + ("8", 8), + ("9", 9), + ("10", 10), + ("11", 11), + ("12", 12), + ("13", 13), + ("14", 14), + ("15", 15), + ("16", 16), + ("17", 17), + ("bool", T_BOOL), + ("c_int", T_I32), + ("c_uint", T_U32), + ("char", T_CHAR), + ("char*", T_PTR), + ("double", T_F64), + ("f32", T_F32), + ("f64", T_F64), + ("float", T_F32), + ("i16", T_I16), + ("i32", T_I32), + ("i64", T_I64), + ("i8", T_I8), + ("int", T_I32), + ("int16_t", T_I16), + ("int32_t", T_I32), + ("int64_t", T_I64), + ("int8_t", T_I8), + ("isize", T_I64), + ("u16", T_U16), + ("u32", T_U32), + ("u64", T_U64), + ("u8", T_U8), + ("uint16_t", T_U16), + ("uint32_t", T_U32), + ("uint64_t", T_U64), + ("uint8_t", T_U8), + ("usize", T_U64), + ("void*", T_PTR), + ("ptr", T_PTR), + ("pointer", T_PTR), + ("void", T_VOID), + ("cstring", T_CSTRING), + ("i64_fast", T_I64_FAST), + ("u64_fast", T_U64_FAST), + ("function", T_FUNCTION), + ("callback", T_FUNCTION), + ("fn", T_FUNCTION), + ("napi_env", T_NAPI_ENV), + ("napi_value", T_NAPI_VALUE), + ("buffer", T_BUFFER), +]; + +/// Integer-class vs float-class for register assignment (`call.rs`). +pub(crate) fn is_float_class(t: u8) -> bool { + matches!(t, T_F32 | T_F64) +} + +fn alias_to_type(name: &str) -> Option { + // Skip the numeric self-keys — a string alias lookup only matches the + // named entries; numeric values arrive as JS numbers. + FFI_TYPE_ENTRIES + .iter() + .skip(18) + .find(|(k, _)| *k == name) + .map(|&(_, v)| v) +} + +fn throw_unsupported_type(display: &str) -> ! { + let names: Vec<&str> = FFI_TYPE_ENTRIES.iter().skip(18).map(|&(k, _)| k).collect(); + crate::fs::validate::throw_type_error_with_code( + &format!( + "Unsupported type {display}. Must be one of: {}", + names.join(", ") + ), + "ERR_INVALID_ARG_TYPE", + ) +} + +/// Parse one `args`/`returns` entry of a `dlopen` symbol table: a numeric +/// `FFIType` value or a string alias. Throws a Bun-shaped `TypeError` on +/// anything unrecognized. +pub(crate) unsafe fn parse_ffi_type(value: f64) -> u8 { + let jv = JSValue::from_bits(value.to_bits()); + if jv.is_any_string() { + let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + if let Some(bytes) = crate::string::js_string_key_bytes(jv, &mut sso) { + let name = std::str::from_utf8(bytes).unwrap_or(""); + if let Some(t) = alias_to_type(name) { + return t; + } + throw_unsupported_type(name); + } + throw_unsupported_type(""); + } + let n = if jv.is_int32() { + jv.as_int32() as f64 + } else if jv.is_number() { + jv.as_number() + } else { + f64::NAN + }; + if n.is_finite() && n >= 0.0 && n <= T_BUFFER as f64 && n.fract() == 0.0 { + return n as u8; + } + throw_unsupported_type(&format!("{n}")); +} + +// ── The cached FFIType JS object ──────────────────────────────────────── + +/// NaN-boxed pointer to the built-once `FFIType` object. A mutable GC root: +/// scanned (and rewritten, though the object is old-arena and won't move) +/// by `scan_bun_ffi_roots_mut`. +static FFI_TYPE_OBJECT_CACHE: AtomicU64 = AtomicU64::new(0); + +pub(crate) fn scan_ffi_type_cache_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { + visitor.visit_atomic_nanbox_u64_slot( + &FFI_TYPE_OBJECT_CACHE, + Ordering::Relaxed, + Ordering::Relaxed, + ); +} + +/// Build (once) and return the `FFIType` enum object. +pub(crate) fn ffi_type_object_value() -> f64 { + let cached = FFI_TYPE_OBJECT_CACHE.load(Ordering::Relaxed); + if cached != 0 { + return f64::from_bits(cached); + } + let obj = crate::object::js_object_alloc(0, FFI_TYPE_ENTRIES.len() as u32); + for &(name, value) in FFI_TYPE_ENTRIES { + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + crate::object::js_object_set_field_by_name(obj, key, number_value(value as f64)); + } + let value = f64::from_bits(JSValue::object_ptr(obj as *mut u8).bits()); + crate::gc::runtime_store_root_atomic_nanbox_u64( + &FFI_TYPE_OBJECT_CACHE, + value.to_bits(), + Ordering::Relaxed, + ); + value +} + +// Re-exported for the namespace-constants path (`get_native_module_constant`). +pub(crate) fn suffix_value() -> f64 { + string_value(super::suffix_str()) +} diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index b631a68225..f4a480de44 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -407,6 +407,8 @@ pub fn gc_init() { gc_register_mutable_root_scanner(transition_cache_mutable_root_scanner); gc_register_mutable_root_scanner(crate::object::scan_object_cache_roots_mut); gc_register_mutable_root_scanner(crate::object::scan_arguments_object_roots_mut); + // bun:ffi (#6562): the cached FFIType enum object. + gc_register_mutable_root_scanner(crate::bun_ffi::scan_bun_ffi_roots_mut); gc_register_budgeted_mutable_root_scanner_with_source( crate::object::scan_class_side_table_roots_mut, crate::object::scan_class_side_table_roots_mut_step, diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index edfde97e39..8b790ec0ba 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -45,6 +45,7 @@ pub mod bigint; pub mod r#box; pub mod buffer; pub mod builtins; +pub mod bun_ffi; pub mod child_process; pub mod closure; pub mod cluster; diff --git a/crates/perry-runtime/src/object/native_module/callable_export_check.rs b/crates/perry-runtime/src/object/native_module/callable_export_check.rs index 2635dceff1..270cf98596 100644 --- a/crates/perry-runtime/src/object/native_module/callable_export_check.rs +++ b/crates/perry-runtime/src/object/native_module/callable_export_check.rs @@ -33,6 +33,26 @@ pub(crate) fn is_native_module_callable_export(module: &str, prop: &str) -> bool if module == "fs" && matches!(prop, "lchmod" | "lchmodSync") { return crate::fs::lchmod_is_callable_on_this_platform(); } + // bun:ffi (#6562). `FFIType` and `suffix` are constants, not callables; + // the not-yet-supported exports are callable so they throw their + // stage-1 error instead of "undefined is not a function". + if module == "bun:ffi" + && matches!( + prop, + "dlopen" + | "ptr" + | "CString" + | "toArrayBuffer" + | "toBuffer" + | "JSCallback" + | "CFunction" + | "linkSymbols" + | "viewSource" + | "read" + ) + { + return true; + } if matches!(module, "path" | "path.posix" | "path.win32") && matches!( prop, diff --git a/crates/perry-runtime/src/object/native_module/callable_exports.rs b/crates/perry-runtime/src/object/native_module/callable_exports.rs index 6bf86d6b9b..4ea15364fd 100644 --- a/crates/perry-runtime/src/object/native_module/callable_exports.rs +++ b/crates/perry-runtime/src/object/native_module/callable_exports.rs @@ -317,6 +317,12 @@ pub(crate) fn is_cluster_emitter_method(prop: &str) -> bool { fn native_callable_export_arity(module: &str, prop: &str) -> Option { match (module, prop) { + // bun:ffi (#6562). + ("bun:ffi", "dlopen") => Some(2), + ("bun:ffi", "ptr" | "CString" | "JSCallback" | "CFunction" | "linkSymbols") => Some(1), + ("bun:ffi", "toArrayBuffer" | "toBuffer") => Some(3), + ("bun:ffi", "viewSource") => Some(2), + ("bun:ffi", "read") => Some(0), // #3687: node:cluster — module-method `.length` matches Node. ("cluster", "fork" | "disconnect" | "setupPrimary" | "setupMaster" | "Worker") => Some(1), ("cluster", "emit") => Some(1), diff --git a/crates/perry-runtime/src/object/native_module/constants.rs b/crates/perry-runtime/src/object/native_module/constants.rs index 053b787132..5771f71014 100644 --- a/crates/perry-runtime/src/object/native_module/constants.rs +++ b/crates/perry-runtime/src/object/native_module/constants.rs @@ -150,6 +150,16 @@ pub(crate) unsafe fn get_native_module_constant( )); } + // bun:ffi (#6562): `FFIType` is a plain enum object (cached, GC-rooted + // in `bun_ffi::types`); `suffix` is the platform dylib suffix string. + if module_name == "bun:ffi" { + match property { + "FFIType" => return Some(crate::bun_ffi::types::ffi_type_object_value()), + "suffix" => return Some(crate::bun_ffi::types::suffix_value()), + _ => {} + } + } + let o_nofollow: f64 = { #[cfg(target_os = "macos")] { diff --git a/crates/perry-runtime/src/object/native_module/module_keys.rs b/crates/perry-runtime/src/object/native_module/module_keys.rs index eab1ef4260..c34a8420d7 100644 --- a/crates/perry-runtime/src/object/native_module/module_keys.rs +++ b/crates/perry-runtime/src/object/native_module/module_keys.rs @@ -1672,6 +1672,23 @@ pub(crate) fn native_module_enumerable_keys(module_name: &str) -> Option<&'stati b"default", ]), "sqlite.constants" => Some(SQLITE_CONSTANTS_KEYS), + // bun:ffi (#6562) — stage-1 surface plus the declared-but-throwing + // exports (their reads resolve to callables that raise the stage-1 + // error). + "bun:ffi" => Some(&[ + b"dlopen", + b"FFIType", + b"ptr", + b"CString", + b"suffix", + b"toArrayBuffer", + b"toBuffer", + b"JSCallback", + b"CFunction", + b"linkSymbols", + b"viewSource", + b"read", + ]), "sea" => Some(SEA_NAMESPACE_KEYS), "sea.default" => Some(SEA_DEFAULT_KEYS), "domain" => Some(&[b"_stack", b"Domain", b"createDomain", b"create", b"active"]), diff --git a/crates/perry-runtime/src/object/native_module_dispatch.rs b/crates/perry-runtime/src/object/native_module_dispatch.rs index 193b5ba29c..81c2a58ae3 100644 --- a/crates/perry-runtime/src/object/native_module_dispatch.rs +++ b/crates/perry-runtime/src/object/native_module_dispatch.rs @@ -311,7 +311,8 @@ mod dispatch_v_z; pub(crate) use dispatch_a_c::{ nm_dispatch_assert, nm_dispatch_async_hooks, nm_dispatch_bigint, nm_dispatch_buffer, - nm_dispatch_child_process, nm_dispatch_cluster, nm_dispatch_console, nm_dispatch_crypto, + nm_dispatch_bun_ffi, nm_dispatch_child_process, nm_dispatch_cluster, nm_dispatch_console, + nm_dispatch_crypto, }; pub(crate) use dispatch_d_i::{ nm_dispatch_dgram, nm_dispatch_dns, nm_dispatch_domain, nm_dispatch_events, nm_dispatch_fs, diff --git a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs index fb1b36f2db..800ac47cfd 100644 --- a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs +++ b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs @@ -207,6 +207,27 @@ pub(crate) unsafe fn nm_dispatch_bigint(ctx: &NmCtx, module_name: &str, method_n } } +/// bun:ffi (#6562) — dlopen / ptr / CString / FFIType / suffix. Method +/// resolution lives in `crate::bun_ffi::dispatch`; this bucket just forwards +/// the raw NaN-boxed args. +#[allow(unused_variables, unused_mut, unused_unsafe, clippy::all)] +pub(crate) unsafe fn nm_dispatch_bun_ffi(ctx: &NmCtx, module_name: &str, method_name: &str) -> f64 { + let NmCtx { + obj, + args_ptr, + args_len, + assert_skip_prototype, + } = *ctx; + let _ = (obj, assert_skip_prototype); + if module_name != "bun:ffi" { + return f64::from_bits(JSValue::undefined().bits()); + } + match crate::bun_ffi::dispatch(method_name, args_ptr, args_len) { + Some(v) => v, + None => f64::from_bits(JSValue::undefined().bits()), + } +} + #[allow( unused_variables, unused_mut, diff --git a/crates/perry-runtime/src/object/native_module_registry.rs b/crates/perry-runtime/src/object/native_module_registry.rs index de17c1ba83..e0ddc7aa41 100644 --- a/crates/perry-runtime/src/object/native_module_registry.rs +++ b/crates/perry-runtime/src/object/native_module_registry.rs @@ -5,10 +5,10 @@ //! NOTHING here names all buckets together (that would re-pin everything). use super::native_module_dispatch::{ nm_dispatch_assert, nm_dispatch_async_hooks, nm_dispatch_bigint, nm_dispatch_buffer, - nm_dispatch_child_process, nm_dispatch_cluster, nm_dispatch_console, nm_dispatch_crypto, - nm_dispatch_dgram, nm_dispatch_dns, nm_dispatch_domain, nm_dispatch_events, nm_dispatch_fs, - nm_dispatch_http, nm_dispatch_inspector, nm_dispatch_module, nm_dispatch_net, nm_dispatch_os, - nm_dispatch_path, nm_dispatch_perf, nm_dispatch_process, nm_dispatch_punycode, + nm_dispatch_bun_ffi, nm_dispatch_child_process, nm_dispatch_cluster, nm_dispatch_console, + nm_dispatch_crypto, nm_dispatch_dgram, nm_dispatch_dns, nm_dispatch_domain, nm_dispatch_events, + nm_dispatch_fs, nm_dispatch_http, nm_dispatch_inspector, nm_dispatch_module, nm_dispatch_net, + nm_dispatch_os, nm_dispatch_path, nm_dispatch_perf, nm_dispatch_process, nm_dispatch_punycode, nm_dispatch_querystring, nm_dispatch_readline, nm_dispatch_repl, nm_dispatch_sea, nm_dispatch_sqlite, nm_dispatch_stream, nm_dispatch_timers, nm_dispatch_tls, nm_dispatch_tty, nm_dispatch_url, nm_dispatch_util, nm_dispatch_v8, nm_dispatch_vm, nm_dispatch_wasi, @@ -25,6 +25,7 @@ enum NmBucket { AsyncHooks, Bigint, Buffer, + BunFfi, ChildProcess, Cluster, Console, @@ -59,7 +60,7 @@ enum NmBucket { Wasi, Zlib, } -const NM_BUCKET_COUNT: usize = 37; +const NM_BUCKET_COUNT: usize = 38; static NM_DISPATCH_REGISTRY: [AtomicPtr<()>; NM_BUCKET_COUNT] = [const { AtomicPtr::new(std::ptr::null_mut()) }; NM_BUCKET_COUNT]; @@ -72,6 +73,9 @@ fn nm_module_index(name: &str) -> Option { "async_hooks" => Some(NmBucket::AsyncHooks), "bigint" => Some(NmBucket::Bigint), "buffer" | "buffer.Buffer" => Some(NmBucket::Buffer), + // #6562: the `bun:` prefix is part of the name (not stripped like + // `node:`). + "bun:ffi" => Some(NmBucket::BunFfi), "child_process" => Some(NmBucket::ChildProcess), "cluster" => Some(NmBucket::Cluster), "console" => Some(NmBucket::Console), @@ -196,6 +200,14 @@ pub extern "C" fn js_nm_install_buffer() { Ordering::Relaxed, ); } +/// bun:ffi (#6562). +#[no_mangle] +pub extern "C" fn js_nm_install_bun_ffi() { + NM_DISPATCH_REGISTRY[NmBucket::BunFfi as usize].store( + nm_dispatch_bun_ffi as NmDispatchFn as *mut (), + Ordering::Relaxed, + ); +} #[no_mangle] pub extern "C" fn js_nm_install_child_process() { NM_DISPATCH_REGISTRY[NmBucket::ChildProcess as usize].store( @@ -436,6 +448,7 @@ pub extern "C" fn js_nm_install_all() { js_nm_install_async_hooks(); js_nm_install_bigint(); js_nm_install_buffer(); + js_nm_install_bun_ffi(); js_nm_install_child_process(); js_nm_install_cluster(); js_nm_install_console(); diff --git a/crates/perry/tests/bun_ffi_stage1.rs b/crates/perry/tests/bun_ffi_stage1.rs new file mode 100644 index 0000000000..d784d82bce --- /dev/null +++ b/crates/perry/tests/bun_ffi_stage1.rs @@ -0,0 +1,567 @@ +//! bun:ffi stage 1 (#6562) — e2e: compile TS that dlopens real C-ABI +//! dylibs and drive them through the typed call stubs. +//! +//! Two tiers: +//! 1. A purpose-built C dylib (compiled here with the system `cc`, the +//! same toolchain the perry driver links with) exercising every +//! stage-1 FFIType: integer widths/signs, i64/u64 → BigInt, f32/f64, +//! bool, mixed int/float register assignment (the classic ABI trap), +//! 8-int / 8-double register limits, ptr round-trips through pinned +//! Buffers (JS→native reads AND native→JS writes), cstring in both +//! directions, NULL pointers, and the error surfaces (missing symbol, +//! bad library, stage-1 rejections). +//! 2. A bun-pty smoke test against the real third-party +//! `librust_pty` dylib (17-symbol pty FFI table): spawn a shell, +//! write/read round-trip, resize, kill. Runs when the dylib is +//! available (`BUN_PTY_LIB` env or a fresh `npm pack bun-pty@0.4.10`); +//! skips with a note otherwise so offline CI stays green. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run(dir: &Path, entry: &Path, envs: &[(&str, &str)]) -> (bool, String, String) { + let output = dir.join("main_bin"); + let compile = Command::new(perry_bin()) + .current_dir(dir) + .arg("compile") + .arg(entry) + .arg("-o") + .arg(&output) + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + let mut run = Command::new(&output); + for (k, v) in envs { + run.env(k, v); + } + let run = run.output().expect("run compiled binary"); + ( + run.status.success(), + String::from_utf8_lossy(&run.stdout).to_string(), + String::from_utf8_lossy(&run.stderr).to_string(), + ) +} + +/// Compile the tier-1 C fixture into a shared library with the system cc. +fn build_test_dylib(dir: &Path) -> PathBuf { + let c_path = dir.join("ffi_test_lib.c"); + std::fs::write(&c_path, TEST_LIB_C).expect("write C fixture"); + let lib_name = if cfg!(target_os = "macos") { + "libffi_test.dylib" + } else { + "libffi_test.so" + }; + let lib_path = dir.join(lib_name); + let status = Command::new("cc") + .arg("-shared") + .arg("-fPIC") + .arg("-o") + .arg(&lib_path) + .arg(&c_path) + .status() + .expect("run cc (the perry link driver requires it too)"); + assert!(status.success(), "cc failed to build the test dylib"); + lib_path +} + +// ─── tier 1: every FFIType against the purpose-built dylib ────────────────── + +const TEST_LIB_C: &str = r#" +#include +#include +#include +#include + +#define EXPORT __attribute__((visibility("default"))) + +static int g_void_calls = 0; +EXPORT void ffi_void_bump(void) { g_void_calls++; } +EXPORT int32_t ffi_void_calls(void) { return g_void_calls; } + +EXPORT bool ffi_not(bool v) { return !v; } +EXPORT bool ffi_is_forty_two(int32_t v) { return v == 42; } + +EXPORT int8_t ffi_i8_add1(int8_t v) { return (int8_t)(v + 1); } +EXPORT int16_t ffi_i16_add1(int16_t v) { return (int16_t)(v + 1); } +EXPORT int32_t ffi_i32_add1(int32_t v) { return v + 1; } +EXPORT int64_t ffi_i64_add1(int64_t v) { return v + 1; } +EXPORT uint8_t ffi_u8_add1(uint8_t v) { return (uint8_t)(v + 1); } +EXPORT uint16_t ffi_u16_add1(uint16_t v) { return (uint16_t)(v + 1); } +EXPORT uint32_t ffi_u32_add1(uint32_t v) { return v + 1; } +EXPORT uint64_t ffi_u64_add1(uint64_t v) { return v + 1; } +EXPORT size_t ffi_usize_add1(size_t v) { return v + 1; } + +EXPORT int64_t ffi_i64_min(void) { return INT64_MIN; } +EXPORT int64_t ffi_i64_max(void) { return INT64_MAX; } +EXPORT uint64_t ffi_u64_max(void) { return UINT64_MAX; } + +EXPORT float ffi_f32_half(float v) { return v * 0.5f; } +EXPORT double ffi_f64_half(double v) { return v * 0.5; } + +EXPORT double ffi_mixed(int32_t a, double b, int32_t c, double d, + int64_t e, float f) { + return (double)a + b * 2.0 + (double)c * 3.0 + d * 4.0 + + (double)e * 5.0 + (double)f * 6.0; +} +EXPORT int64_t ffi_sum8(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; +} +EXPORT double ffi_dsum8(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; +} + +EXPORT void *ffi_ptr_identity(void *p) { return p; } +EXPORT void *ffi_null_ptr(void) { return NULL; } +EXPORT uint8_t ffi_read_u8(const uint8_t *p, int32_t off) { return p[off]; } +EXPORT void ffi_fill(uint8_t *p, int32_t len, uint8_t value) { + memset(p, value, (size_t)len); +} +EXPORT int64_t ffi_sum_bytes(const uint8_t *p, int32_t len) { + int64_t acc = 0; + for (int32_t i = 0; i < len; i++) acc += p[i]; + return acc; +} + +EXPORT const char *ffi_hello(void) { return "hello from C"; } +EXPORT const char *ffi_empty_string(void) { return ""; } +EXPORT const char *ffi_null_string(void) { return NULL; } +EXPORT int32_t ffi_strlen(const char *s) { return (int32_t)strlen(s); } +static char g_concat_buf[256]; +EXPORT const char *ffi_concat(const char *a, const char *b) { + size_t la = strlen(a); + size_t lb = strlen(b); + if (la + lb + 1 > sizeof(g_concat_buf)) return "overflow"; + memcpy(g_concat_buf, a, la); + memcpy(g_concat_buf + la, b, lb + 1); + return g_concat_buf; +} +EXPORT const char *ffi_utf8(void) { return "caf\xC3\xA9 \xE2\x9C\x93"; } +"#; + +const TIER1_TS: &str = r#" +import { dlopen, FFIType, ptr, CString, suffix } from "bun:ffi"; + +console.log("suffix-ok:", suffix === "dylib" || suffix === "so"); +console.log("ffitype:", FFIType.i32, FFIType.cstring, FFIType.ptr, FFIType.void, FFIType.u64); +console.log("ffitype-aliases:", FFIType.pointer === FFIType.ptr, FFIType["int32_t"] === FFIType.i32, FFIType.usize === FFIType.u64); + +const lib = dlopen(process.env.FFI_TEST_LIB!, { + ffi_void_bump: { args: [], returns: FFIType.void }, + ffi_void_calls: { args: [], returns: FFIType.i32 }, + ffi_not: { args: [FFIType.bool], returns: FFIType.bool }, + ffi_is_forty_two: { args: [FFIType.i32], returns: FFIType.bool }, + ffi_i8_add1: { args: [FFIType.i8], returns: FFIType.i8 }, + ffi_i16_add1: { args: [FFIType.i16], returns: FFIType.i16 }, + ffi_i32_add1: { args: [FFIType.i32], returns: FFIType.i32 }, + ffi_i64_add1: { args: [FFIType.i64], returns: FFIType.i64 }, + ffi_u8_add1: { args: [FFIType.u8], returns: FFIType.u8 }, + ffi_u16_add1: { args: [FFIType.u16], returns: FFIType.u16 }, + ffi_u32_add1: { args: [FFIType.u32], returns: FFIType.u32 }, + ffi_u64_add1: { args: [FFIType.u64], returns: FFIType.u64 }, + ffi_usize_add1: { args: [FFIType.usize], returns: FFIType.usize }, + ffi_i64_min: { args: [], returns: FFIType.i64 }, + ffi_i64_max: { args: [], returns: FFIType.i64 }, + ffi_u64_max: { args: [], returns: FFIType.u64 }, + ffi_f32_half: { args: [FFIType.f32], returns: FFIType.f32 }, + ffi_f64_half: { args: [FFIType.f64], returns: FFIType.f64 }, + ffi_mixed: { + args: [FFIType.i32, FFIType.f64, FFIType.i32, FFIType.f64, FFIType.i64, FFIType.f32], + returns: FFIType.f64, + }, + ffi_sum8: { + args: [FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32], + returns: FFIType.i64, + }, + ffi_dsum8: { + args: [FFIType.f64, FFIType.f64, FFIType.f64, FFIType.f64, FFIType.f64, FFIType.f64, FFIType.f64, FFIType.f64], + returns: FFIType.f64, + }, + ffi_ptr_identity: { args: [FFIType.ptr], returns: FFIType.ptr }, + ffi_null_ptr: { args: [], returns: FFIType.ptr }, + ffi_read_u8: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.u8 }, + ffi_fill: { args: [FFIType.ptr, FFIType.i32, FFIType.u8], returns: FFIType.void }, + ffi_sum_bytes: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i64 }, + ffi_hello: { args: [], returns: FFIType.cstring }, + ffi_empty_string: { args: [], returns: FFIType.cstring }, + ffi_null_string: { args: [], returns: FFIType.cstring }, + ffi_strlen: { args: [FFIType.cstring], returns: FFIType.i32 }, + ffi_concat: { args: [FFIType.cstring, FFIType.cstring], returns: FFIType.cstring }, + ffi_utf8: { args: [], returns: FFIType.ptr }, +}); +const s = lib.symbols; + +// void + side effect +s.ffi_void_bump(); +s.ffi_void_bump(); +console.log("void-calls:", s.ffi_void_calls()); + +// bool +console.log("not-true:", s.ffi_not(true), "not-false:", s.ffi_not(false)); +console.log("is42:", s.ffi_is_forty_two(42), s.ffi_is_forty_two(41)); + +// integer widths, both signs (incl. wrap at the declared width) +console.log("i8:", s.ffi_i8_add1(-2), s.ffi_i8_add1(127)); +console.log("i16:", s.ffi_i16_add1(-2), s.ffi_i16_add1(32767)); +console.log("i32:", s.ffi_i32_add1(-2), s.ffi_i32_add1(2147483647)); +console.log("u8:", s.ffi_u8_add1(254), s.ffi_u8_add1(255)); +console.log("u16:", s.ffi_u16_add1(65534), s.ffi_u16_add1(65535)); +console.log("u32:", s.ffi_u32_add1(4294967294), s.ffi_u32_add1(4294967295)); + +// i64/u64: bigint in AND out (Bun semantics: always bigint for i64/u64) +console.log("i64:", s.ffi_i64_add1(41n), typeof s.ffi_i64_add1(1n)); +console.log("i64-number-arg:", s.ffi_i64_add1(41)); +console.log("i64-min:", s.ffi_i64_min()); +console.log("i64-max:", s.ffi_i64_max()); +console.log("u64-max:", s.ffi_u64_max()); +console.log("u64-wrap:", s.ffi_u64_add1(18446744073709551615n)); +console.log("usize:", s.ffi_usize_add1(7n)); + +// floats +console.log("f32:", s.ffi_f32_half(9), "f64:", s.ffi_f64_half(9)); + +// mixed int/float register assignment + 8-arg register limits +console.log("mixed:", s.ffi_mixed(10, 2.0, 20, 4.0, 30, 1.5)); +console.log("sum8:", s.ffi_sum8(1, 2, 3, 4, 5, 6, 7, 8)); +console.log("dsum8:", s.ffi_dsum8(0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5)); + +// pointers: JS buffer -> native (read) and native -> JS buffer (write) +const buf = new Uint8Array(16); +for (let i = 0; i < 16; i++) buf[i] = i + 1; +const p = ptr(buf); +console.log("ptr-type:", typeof p, p !== 0); +console.log("ptr-identity:", s.ffi_ptr_identity(p) === p); +console.log("ptr-offset:", ptr(buf, 4) === p + 4); +console.log("null-ptr:", s.ffi_null_ptr()); +console.log("read-u8:", s.ffi_read_u8(p, 0), s.ffi_read_u8(p, 15)); +console.log("sum-bytes:", s.ffi_sum_bytes(p, 16)); +s.ffi_fill(p, 16, 9); +console.log("fill-visible:", buf[0], buf[15]); + +// Buffer also works as a pointer arg directly (Bun accepts views for ptr) +const nodeBuf = Buffer.from([1, 2, 3, 4]); +console.log("buffer-arg:", s.ffi_sum_bytes(nodeBuf, 4)); + +// cstring: out (decode), in (encode via NUL-terminated buffer) +console.log("hello:", s.ffi_hello()); +console.log("empty:", JSON.stringify(s.ffi_empty_string())); +console.log("null-cstring:", s.ffi_null_string()); +console.log("strlen:", s.ffi_strlen(Buffer.from("hello world\0"))); +console.log("concat:", s.ffi_concat(Buffer.from("foo\0"), Buffer.from("bar\0"))); + +// CString: read a NUL-terminated string from a raw pointer +const utf8Ptr = s.ffi_utf8(); +console.log("cstring-read:", CString(utf8Ptr)); + +lib.close(); + +// use-after-close throws a descriptive error instead of crashing +let closedError = ""; +try { + s.ffi_i32_add1(1); +} catch (e: any) { + closedError = String(e && e.message); +} +console.log("closed-throws:", closedError.includes("close()")); + +console.log("TIER1-DONE"); +"#; + +#[test] +fn tier1_every_ffi_type_against_test_dylib() { + let dir = tempfile::tempdir().expect("tempdir"); + let lib_path = build_test_dylib(dir.path()); + let entry = dir.path().join("main.ts"); + std::fs::write(&entry, TIER1_TS).expect("write entry"); + + let (ok, stdout, stderr) = compile_and_run( + dir.path(), + &entry, + &[("FFI_TEST_LIB", lib_path.to_str().unwrap())], + ); + assert!(ok, "binary failed\nstdout:\n{stdout}\nstderr:\n{stderr}"); + for needle in [ + "suffix-ok: true", + "ffitype: 5 14 12 13 8", + "ffitype-aliases: true true true", + "void-calls: 2", + "not-true: false not-false: true", + "is42: true false", + "i8: -1 -128", + "i16: -1 -32768", + "i32: -1 -2147483648", + "u8: 255 0", + "u16: 65535 0", + "u32: 4294967295 0", + "i64: 42n bigint", + "i64-number-arg: 42n", + "i64-min: -9223372036854775808n", + "i64-max: 9223372036854775807n", + "u64-max: 18446744073709551615n", + "u64-wrap: 0n", + "usize: 8n", + "f32: 4.5 f64: 4.5", + // 10 + 2*2 + 20*3 + 4*4 + 30*5 + 1.5*6 = 249 + "mixed: 249", + "sum8: 36n", + "dsum8: 32", + "ptr-type: number true", + "ptr-identity: true", + "ptr-offset: true", + "null-ptr: null", + "read-u8: 1 16", + "sum-bytes: 136n", + "fill-visible: 9 9", + "buffer-arg: 10n", + "hello: hello from C", + "empty: \"\"", + "null-cstring: null", + "strlen: 11", + "concat: foobar", + "cstring-read: caf\u{e9} \u{2713}", + "closed-throws: true", + "TIER1-DONE", + ] { + assert!( + stdout.contains(needle), + "expected `{needle}` in output:\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + } +} + +// ─── tier 1b: error surfaces ──────────────────────────────────────────────── + +const ERRORS_TS: &str = r#" +import { dlopen, FFIType, ptr, JSCallback } from "bun:ffi"; + +// bad library path -> ERR_DLOPEN_FAILED with the path in the message +try { + dlopen("/definitely/not/a/library.dylib", { f: { args: [], returns: FFIType.void } }); + console.log("open-missing: no-throw"); +} catch (e: any) { + console.log("open-missing:", String(e.message).includes("/definitely/not/a/library.dylib")); +} + +// missing symbol -> names the symbol and the library +try { + dlopen(process.env.FFI_TEST_LIB!, { not_a_real_symbol: { args: [], returns: FFIType.i32 } }); + console.log("missing-symbol: no-throw"); +} catch (e: any) { + console.log("missing-symbol:", String(e.message).includes('Symbol "not_a_real_symbol" not found')); +} + +// FFIType.function -> clear stage-1 rejection at dlopen time +try { + dlopen(process.env.FFI_TEST_LIB!, { ffi_hello: { args: [FFIType.function], returns: FFIType.void } }); + console.log("function-type: no-throw"); +} catch (e: any) { + console.log("function-type:", String(e.message).includes("not yet supported")); +} + +// JSCallback export exists but throws the stage-1 error when used +try { + JSCallback(() => {}, {}); + console.log("jscallback: no-throw"); +} catch (e: any) { + console.log("jscallback:", String(e.message).includes("not supported yet")); +} + +// strings are not pointers (Bun-compatible hint) +try { + ptr("hello" as any); + console.log("string-ptr: no-throw"); +} catch (e: any) { + console.log("string-ptr:", String(e.message).includes("encode it as a buffer")); +} + +console.log("ERRORS-DONE"); +"#; + +#[test] +fn tier1b_error_surfaces() { + let dir = tempfile::tempdir().expect("tempdir"); + let lib_path = build_test_dylib(dir.path()); + let entry = dir.path().join("main.ts"); + std::fs::write(&entry, ERRORS_TS).expect("write entry"); + + let (ok, stdout, stderr) = compile_and_run( + dir.path(), + &entry, + &[("FFI_TEST_LIB", lib_path.to_str().unwrap())], + ); + assert!(ok, "binary failed\nstdout:\n{stdout}\nstderr:\n{stderr}"); + for needle in [ + "open-missing: true", + "missing-symbol: true", + "function-type: true", + "jscallback: true", + "string-ptr: true", + "ERRORS-DONE", + ] { + assert!( + stdout.contains(needle), + "expected `{needle}` in output:\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + } +} + +// ─── tier 2: bun-pty smoke against the real third-party dylib ─────────────── + +/// Locate (or fetch) bun-pty 0.4.10's prebuilt dylib for this platform. +/// Resolution order: `BUN_PTY_LIB` env → `npm pack bun-pty@0.4.10` into the +/// test tempdir. Returns `None` (→ skip, with a note) when neither works. +fn locate_bun_pty_dylib(dir: &Path) -> Option { + if let Ok(p) = std::env::var("BUN_PTY_LIB") { + let p = PathBuf::from(p); + if p.exists() { + return Some(p); + } + } + let pack = Command::new("npm") + .current_dir(dir) + .args(["pack", "bun-pty@0.4.10", "--silent"]) + .output() + .ok()?; + if !pack.status.success() { + return None; + } + let tgz = dir.join("bun-pty-0.4.10.tgz"); + if !tgz.exists() { + return None; + } + let untar = Command::new("tar") + .current_dir(dir) + .args(["xzf", "bun-pty-0.4.10.tgz"]) + .status() + .ok()?; + if !untar.success() { + return None; + } + let release = dir.join("package/rust-pty/target/release"); + let name = match (std::env::consts::OS, std::env::consts::ARCH) { + ("macos", "aarch64") => "librust_pty_arm64.dylib", + ("macos", _) => "librust_pty.dylib", + ("linux", "aarch64") => "librust_pty_arm64.so", + ("linux", _) => "librust_pty.so", + _ => return None, + }; + let p = release.join(name); + p.exists().then_some(p) +} + +/// Mirrors the FFI usage of bun-pty's `src/terminal.ts` (same symbols, same +/// signatures) without the package's EventEmitter scaffolding: spawn a real +/// shell through the prebuilt Rust dylib, round-trip bytes through the pty, +/// resize, then kill. +const BUN_PTY_TS: &str = r#" +import { dlopen, FFIType, ptr } from "bun:ffi"; + +const lib = dlopen(process.env.BUN_PTY_LIB!, { + bun_pty_spawn: { + args: [FFIType.cstring, FFIType.cstring, FFIType.cstring, FFIType.i32, FFIType.i32], + returns: FFIType.i32, + }, + bun_pty_write: { args: [FFIType.i32, FFIType.pointer, FFIType.i32], returns: FFIType.i32 }, + bun_pty_read: { args: [FFIType.i32, FFIType.pointer, FFIType.i32], returns: FFIType.i32 }, + bun_pty_resize: { args: [FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.i32 }, + bun_pty_kill: { args: [FFIType.i32], returns: FFIType.i32 }, + bun_pty_get_pid: { args: [FFIType.i32], returns: FFIType.i32 }, + bun_pty_get_exit_code: { args: [FFIType.i32], returns: FFIType.i32 }, + bun_pty_close: { args: [FFIType.i32], returns: FFIType.void }, +}); +const s = lib.symbols; + +// Spawn `sh` exactly the way bun-pty's Terminal ctor does (shell-quoted +// cmdline, cwd, NUL-separated env, cols, rows). +const handle = s.bun_pty_spawn( + Buffer.from("'sh'\0", "utf8"), + Buffer.from(process.cwd() + "\0", "utf8"), + Buffer.from("PATH=/usr/bin:/bin\0TERM=xterm\0\0", "utf8"), + 80, + 24, +); +console.log("spawned:", handle >= 0); +const pid = s.bun_pty_get_pid(handle); +console.log("pid-positive:", pid > 0); + +// write/read round-trip: echo a marker through the real pty +const marker = "FFI_PTY_ROUNDTRIP_OK"; +const cmd = Buffer.from("echo " + marker + "\n", "utf8"); +s.bun_pty_write(handle, ptr(cmd), cmd.length); + +const readBuf = Buffer.allocUnsafe(4096); +let collected = ""; +const deadline = Date.now() + 10000; +while (Date.now() < deadline) { + const n = s.bun_pty_read(handle, ptr(readBuf), readBuf.length); + if (n > 0) { + collected += readBuf.subarray(0, n).toString("utf8"); + // the echo output (not just the input echo-back) proves the shell ran + const echoAt = collected.indexOf(marker + "\r"); + const echoAtNl = collected.indexOf(marker + "\n"); + if (echoAt !== -1 || echoAtNl !== -1) break; + } else if (n === -2) { + break; // child exited + } else if (n < 0) { + break; + } else { + // 0 bytes: brief spin-wait (no timers needed for the smoke test) + const until = Date.now() + 8; + while (Date.now() < until) {} + } +} +console.log("roundtrip:", collected.includes(marker)); + +console.log("resize:", s.bun_pty_resize(handle, 120, 40) === 0); + +s.bun_pty_kill(handle); +s.bun_pty_close(handle); +console.log("killed: true"); + +lib.close(); +console.log("PTY-DONE"); +"#; + +#[test] +fn tier2_bun_pty_shell_roundtrip() { + let dir = tempfile::tempdir().expect("tempdir"); + let Some(dylib) = locate_bun_pty_dylib(dir.path()) else { + eprintln!( + "SKIP tier2_bun_pty_shell_roundtrip: bun-pty dylib unavailable \ + (set BUN_PTY_LIB or allow `npm pack bun-pty@0.4.10`)" + ); + return; + }; + let entry = dir.path().join("main.ts"); + std::fs::write(&entry, BUN_PTY_TS).expect("write entry"); + + let (ok, stdout, stderr) = compile_and_run( + dir.path(), + &entry, + &[("BUN_PTY_LIB", dylib.to_str().unwrap())], + ); + assert!(ok, "binary failed\nstdout:\n{stdout}\nstderr:\n{stderr}"); + for needle in [ + "spawned: true", + "pid-positive: true", + "roundtrip: true", + "resize: true", + "killed: true", + "PTY-DONE", + ] { + assert!( + stdout.contains(needle), + "expected `{needle}` in output:\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + } +} diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index 9f3f74f4e5..3668e1b612 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -1,6 +1,6 @@ // Auto-generated from Perry's API manifest (#465). Do not edit by hand. // Source: perry-api-manifest::API_MANIFEST -// Coverage: 1955 entries across 115 modules +// Coverage: 1967 entries across 116 modules type PerryU32 = number & { readonly __perryU32?: never }; type PerryU64 = number & { readonly __perryU64?: never }; @@ -263,6 +263,33 @@ declare module "buffer" { export function transcode(...args: any[]): any; } +declare module "bun:ffi" { + /** stdlib */ + export const FFIType: any; + /** stdlib */ + export const suffix: any; + /** stdlib */ + export function CFunction(...args: any[]): any; + /** stdlib */ + export function CString(...args: any[]): any; + /** stdlib */ + export function JSCallback(...args: any[]): any; + /** stdlib */ + export function dlopen(...args: any[]): any; + /** stdlib */ + export function linkSymbols(...args: any[]): any; + /** stdlib */ + export function ptr(...args: any[]): any; + /** stdlib */ + export function read(...args: any[]): any; + /** stdlib */ + export function toArrayBuffer(...args: any[]): any; + /** stdlib */ + export function toBuffer(...args: any[]): any; + /** stdlib */ + export function viewSource(...args: any[]): any; +} + declare module "cheerio" { /** stdlib */ export function load(p0: string): any; diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index f867cbc66b..dfbd992800 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -2,7 +2,7 @@ This page is auto-generated from Perry's compile-time API manifest (`perry-api-manifest::API_MANIFEST`). It is the source of truth for what `perry compile` accepts; references to symbols not listed here produce `R005 UnimplementedApi` (issue #463). Stubs (#464) are flagged ⚠ — they link cleanly but no-op at runtime on the chosen target. -Total: 2830 entries across 117 modules. +Total: 2842 entries across 118 modules. ## Modules @@ -17,6 +17,7 @@ Total: 2830 entries across 117 modules. - [`better-sqlite3`](#better-sqlite3) - [`bignumber.js`](#bignumber-js) - [`buffer`](#buffer) +- [`bun:ffi`](#bun-ffi) - [`cheerio`](#cheerio) - [`child_process`](#child-process) - [`cluster`](#cluster) @@ -327,6 +328,26 @@ Total: 2830 entries across 117 modules. - `kMaxLength` - `kStringMaxLength` +## `bun:ffi` + +### Methods + +- `CFunction` — module +- `CString` — module +- `JSCallback` — module +- `dlopen` — module +- `linkSymbols` — module +- `ptr` — module +- `read` — module +- `toArrayBuffer` — module +- `toBuffer` — module +- `viewSource` — module + +### Properties + +- `FFIType` +- `suffix` + ## `cheerio` ### Methods From 9de899fd28292b18e70ebe504c1c84afd8095667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 18 Jul 2026 14:46:14 +0200 Subject: [PATCH 2/2] bun:ffi stage 1: address CodeRabbit review (#6580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from the PR review, each fixed: 1. call.rs — replace the fixed 16-arg over-call trampoline (UB per Rust's ABI model, and it wrote surplus x86-64 stack slots into the callee's frame) with EXACT-ARITY shims: dispatch on the marshalled (n_int, n_float) and transmute to a signature with precisely that many usize + f64 params (9x9 macro-generated shims per return class). The zero-deps register-image design is unchanged; libffi noted as the eventual fully-blessed alternative. Wide-register int passing / f32 bit-image / narrow-return truncation documented as residual assumptions. 2. dlopen.rs — make symbol preparation transactional: validate + resolve the whole table into locals first (prepare_symbols, never throws); on any error dlclose the fresh handle and throw at one site, so repeated malformed dlopen calls can't grow LIBS/SYMS/leaked-name state. 3. dlopen.rs — verify the `args` value is genuinely an Array (js_array_is_array) before reading it as an ArrayHeader. 4. dlopen.rs — bound CString reads: keep the source buffer's span and clamp both the explicit-length slice and the NUL scan to its managed storage (raw numeric pointers stay caller-trusted, as in Bun). 5. types.rs — FFIType cache is now thread-local with per-thread rooting (perry's arena/GC is per-thread) instead of a process-global atomic. 6. tests — C fixture uses unsigned internal arithmetic (no signed-overflow UB at INT32_MAX + 1); wraparound expectation preserved. 7. tests — pty round-trip asserts on a shell-EVALUATED marker (echo FFI_$((40+2))_OK -> FFI_42_OK) the terminal echo can't contain, so input echo can't satisfy the check before the shell runs. 8. tests — duplicate the ABI, error-contract, and type-parse checks as cargo-visible perry-runtime unit tests (9 -> 22) so every-PR CI covers them without the compiled-binary e2e target. 9. docs — stage>=2 exports (toArrayBuffer/JSCallback/CFunction/linkSymbols/ viewSource/read/toBuffer) marked `.stub_note` at the manifest source so the generated .d.ts/reference.md say "not yet implemented, throws"; stub_inventory drift-guard updated (+#6562 cluster, +bun:ffi allowlist). 10. docs — anchor() now matches mdbook's normalize_id (drops punctuation instead of turning it into `-`), so the `bun:ffi` TOC link targets `#bunffi` (also fixes slashed/util.types module anchors). Regenerated. Also merged origin/main (bun globals #6560, node-pty #6563, wasm #6579): union-resolved the additive conflicts (bun + bun:ffi + node-pty buckets, NM_BUCKET_COUNT 40). Tests: perry-runtime bun_ffi 22/22; api-manifest incl. stub_inventory 4/4; e2e bun_ffi_stage1 3/3 (tier 1 exact-arity ABI, tier 1b errors, tier 2 real bun-pty shell round-trip); fmt clean; api docs regenerated. # Conflicts: # crates/perry-runtime/src/object/native_module_dispatch.rs # crates/perry-runtime/src/object/native_module_registry.rs # docs/api/perry.d.ts # docs/src/api/reference.md --- crates/perry-api-manifest/src/emit.rs | 19 +- .../perry-api-manifest/src/entries/part_1.rs | 25 +- .../tests/stub_inventory.rs | 7 + crates/perry-runtime/src/bun_ffi/call.rs | 415 +++++++++++++----- crates/perry-runtime/src/bun_ffi/dlopen.rs | 405 +++++++++++------ crates/perry-runtime/src/bun_ffi/types.rs | 150 +++++-- crates/perry/tests/bun_ffi_stage1.rs | 31 +- docs/api/perry.d.ts | 37 +- docs/src/api/reference.md | 122 ++--- 9 files changed, 846 insertions(+), 365 deletions(-) diff --git a/crates/perry-api-manifest/src/emit.rs b/crates/perry-api-manifest/src/emit.rs index 5f840bc7ee..105551b491 100644 --- a/crates/perry-api-manifest/src/emit.rs +++ b/crates/perry-api-manifest/src/emit.rs @@ -461,16 +461,23 @@ fn source_dts_tag(entry: &ApiEntry) -> &'static str { } } -/// Markdown anchor for a heading. mdbook lowercases and replaces -/// non-alphanum with `-`. Matches its slugifier closely enough for -/// the in-page TOC to land. +/// Markdown anchor for a heading, matching mdbook's `normalize_id` +/// (github-slugifier): keep alphanumerics + `_`/`-`, lowercase, turn +/// whitespace into `-`, and DROP every other punctuation character. +/// +/// The distinction matters for module names with punctuation: mdbook +/// slugs `bun:ffi` → `bunffi` and `mysql2/promise` → `mysql2promise` +/// (the `:` / `/` are removed, NOT turned into `-`). The previous +/// "replace every non-alphanumeric with `-`" produced `bun-ffi` / +/// `mysql2-promise`, so the in-page TOC links jumped nowhere. (#6580) fn anchor(s: &str) -> String { s.chars() + .filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-' || c.is_whitespace()) .map(|c| { - if c.is_ascii_alphanumeric() { - c.to_ascii_lowercase() - } else { + if c.is_whitespace() { '-' + } else { + c.to_ascii_lowercase() } }) .collect() diff --git a/crates/perry-api-manifest/src/entries/part_1.rs b/crates/perry-api-manifest/src/entries/part_1.rs index 3880758593..00a0e486d8 100644 --- a/crates/perry-api-manifest/src/entries/part_1.rs +++ b/crates/perry-api-manifest/src/entries/part_1.rs @@ -214,13 +214,24 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[ method("bun:ffi", "CString", false, None), property("bun:ffi", "FFIType"), property("bun:ffi", "suffix"), - method("bun:ffi", "toArrayBuffer", false, None), - method("bun:ffi", "toBuffer", false, None), - method("bun:ffi", "JSCallback", false, None), - method("bun:ffi", "CFunction", false, None), - method("bun:ffi", "linkSymbols", false, None), - method("bun:ffi", "viewSource", false, None), - method("bun:ffi", "read", false, None), + // Stage ≥2 surface: declared so feature-probes get a clear error rather + // than `undefined is not a function`, but NOT implemented yet — each + // throws at runtime. Marked `.stub_note` so the generated `.d.ts` / + // `reference.md` say so instead of reading as usable APIs (#6562). + method("bun:ffi", "toArrayBuffer", false, None) + .stub_note("stage 2 — not yet implemented, throws at runtime (#6562)"), + method("bun:ffi", "toBuffer", false, None) + .stub_note("stage 2 — not yet implemented, throws at runtime (#6562)"), + method("bun:ffi", "JSCallback", false, None) + .stub_note("stage 3 — not yet implemented, throws at runtime (#6562)"), + method("bun:ffi", "CFunction", false, None) + .stub_note("stage 3 — not yet implemented, throws at runtime (#6562)"), + method("bun:ffi", "linkSymbols", false, None) + .stub_note("stage ≥2 — not yet implemented, throws at runtime (#6562)"), + method("bun:ffi", "viewSource", false, None) + .stub_note("stage ≥2 — not yet implemented, throws at runtime (#6562)"), + method("bun:ffi", "read", false, None) + .stub_note("stage ≥2 — not yet implemented, throws at runtime (#6562)"), class("sqlite", "DatabaseSync"), class("sqlite", "Session"), class("sqlite", "SQLTagStore"), diff --git a/crates/perry-api-manifest/tests/stub_inventory.rs b/crates/perry-api-manifest/tests/stub_inventory.rs index 01ef743285..b76766a02f 100644 --- a/crates/perry-api-manifest/tests/stub_inventory.rs +++ b/crates/perry-api-manifest/tests/stub_inventory.rs @@ -93,6 +93,11 @@ fn stub_inventory_matches_known_clusters() { // event-loop refcount), mongodb.findOne (parsed document), // exponential-backoff options (honored, incl. retry predicate). ("#4917", 9), + // #6562 (bun:ffi stage 1) — the stage-≥2 FFI surface is declared so + // feature probes get a clear error, but throws at runtime until the + // later stages land: toArrayBuffer, toBuffer, JSCallback, CFunction, + // linkSymbols, viewSource, read. + ("#6562", 7), ]; let expected_map: BTreeMap = expected.iter().map(|(k, v)| (k.to_string(), *v)).collect(); @@ -119,6 +124,8 @@ fn stubs_only_appear_in_allowlisted_modules() { "exponential-backoff", "inspector", "repl", + // #6562: bun:ffi stage-≥2 exports are declared-but-throwing stubs. + "bun:ffi", ]; for e in iter_entries().filter(|e| e.stub) { assert!( diff --git a/crates/perry-runtime/src/bun_ffi/call.rs b/crates/perry-runtime/src/bun_ffi/call.rs index 33c610c938..46c150009d 100644 --- a/crates/perry-runtime/src/bun_ffi/call.rs +++ b/crates/perry-runtime/src/bun_ffi/call.rs @@ -1,4 +1,5 @@ -//! Typed C-ABI calls: argument marshalling + the register-image trampoline. +//! Typed C-ABI calls: argument marshalling + exact-arity register-image +//! call shims. //! //! ## Why not libffi //! @@ -6,44 +7,59 @@ //! full generality of libffi (struct classification, closures) buys nothing //! here, while costing a new native library on every final link (perry's //! driver links user binaries with `cc`; libffi would have to be added to -//! every platform link line and vendored for cross builds). Instead we -//! exploit how the two supported ABIs assign scalar arguments: +//! every platform link line and vendored for cross builds — including the +//! HarmonyOS/cross targets, where a prebuilt `libffi` is not a given). +//! Instead we exploit how the two supported ABIs assign scalar arguments: //! //! - **SysV x86-64**: integer-class args take rdi, rsi, rdx, rcx, r8, r9 in //! order (then the stack, left to right); float-class args take xmm0–xmm7 -//! in order. The two files are assigned INDEPENDENTLY. +//! in order. The two register files are assigned INDEPENDENTLY. //! - **AAPCS64 (incl. Apple arm64)**: integer-class args take x0–x7; float //! args take v0–v7. Also independent. //! -//! So for any callee prototype made of scalars with ≤ 8 integer-class and -//! ≤ 8 float-class parameters, calling through the fixed "maximal" -//! signature +//! So for a callee prototype made of scalars, packing the marshalled values +//! densely per class and calling through a signature with exactly that many +//! integer-class then float-class parameters reproduces the callee's own +//! register/stack image — integer-class args land in the same integer +//! registers/stack slots and float-class args in the same vector registers, +//! regardless of the callee's original interleaving. //! -//! ```text -//! extern "C" fn(usize×8, f64×8) -> {u64 | f64 | f32} -//! ``` +//! ### Exact arity (no over-calling) //! -//! with the marshalled values packed densely per class produces exactly the -//! register/stack image the callee expects: the callee reads only the -//! registers its own prototype names, and unused slots are ignored. On -//! x86-64 the 7th/8th integer slots become the first two stack slots — the -//! same positions a 7/8-integer-arg callee reads its stack args from. Note -//! this is also why **variadic** callees are NOT supported (Apple arm64 -//! passes variadic args on the stack) — a limitation shared with Bun's -//! documented FFI surface. +//! A previous revision transmuted every symbol to one fixed 16-parameter +//! `fn(usize×8, f64×8)` and relied on the callee ignoring the extra +//! registers/stack. That is an ABI-level truth but NOT blessed by Rust's +//! abstract machine (calling a function pointer whose arity exceeds the real +//! definition's is UB, and the surplus x86-64 stack slots are written into +//! the callee's frame). This revision instead dispatches on the marshalled +//! `(n_int, n_float)` and transmutes to a signature with EXACTLY `n_int` +//! `usize` params followed by `n_float` `f64` params (9 × 9 monomorphic +//! shims per return class, macro-generated below). The callee is therefore +//! never over-called. //! -//! Two care points: -//! - **f32 args** are passed in the low 32 bits of a vector register. An -//! `f32` value `v` is therefore smuggled as `f64::from_bits(v.to_bits() -//! as u64)` — the callee's `s`/`xmm` read sees the correct f32 bit -//! pattern. (Passing `v as f64` would be wrong: the callee would read -//! the low half of a double.) -//! - **narrow returns** (bool/i8/u8/i16/u16/i32/u32): only the low bits of -//! the return register are specified; we truncate to the declared width +//! ### Residual assumptions (documented, not eliminated) +//! +//! - **Wide-register int passing**: narrower integer-class args +//! (bool/i8/i16/i32/char/ptr/cstring) are passed through `usize` (64-bit) +//! slots, zero/sign-extended to 64 bits during marshalling. On both ABIs a +//! narrow integer arg occupies a full integer register and the callee +//! reads the low bits, so this matches the C prototype at the ABI level — +//! but it is a `fn(usize)` ⇄ `fn(int32_t)` type pun that only a native-ABI +//! FFI (or libffi) can make. This is the fundamental FFI assumption; the +//! full-blessing alternative is libffi, deferred (see above). +//! - **f32 args** are passed in the low 32 bits of a vector register, so an +//! `f32` value `v` is smuggled through an `f64` slot as +//! `f64::from_bits(v.to_bits() as u64)`; the callee's `s`/`xmm` read sees +//! the correct single-precision pattern. (`v as f64` would be wrong.) +//! - **Narrow returns** (bool/i8/u8/i16/u16/i32/u32): only the low bits of +//! the return register are specified, so we truncate to the declared width //! before boxing. +//! - **Variadics** are unsupported (Apple arm64 passes variadic args on the +//! stack) — a limitation shared with Bun's documented FFI surface. //! -//! `dlopen` enforces the ≤ 8 + ≤ 8 limit (and rejects unsupported targets) -//! up front, so a mis-sized signature can never reach `raw_call_*`. +//! `dlopen` enforces the ≤ 8-int / ≤ 8-float limit and rejects unsupported +//! targets up front, so an out-of-range `(n_int, n_float)` can never reach a +//! shim (the `_` fallbacks below are unreachable in practice). use super::types::*; use crate::value::JSValue; @@ -58,6 +74,10 @@ pub(crate) const MAX_ARGS: usize = 16; pub(crate) struct ArgImage { pub ints: [usize; MAX_INT_ARGS], pub floats: [f64; MAX_FLOAT_ARGS], + /// Number of populated integer-class / float-class slots — the exact + /// arity the call shim transmutes to. + pub n_int: usize, + pub n_float: usize, /// NUL-terminated temporaries for `cstring` args passed as JS strings. /// Kept alive until after the native call returns. pub temps: Vec>, @@ -75,88 +95,100 @@ pub(crate) const fn platform_supported() -> bool { #[cfg(all(unix, any(target_arch = "x86_64", target_arch = "aarch64")))] mod raw { - //! The only three transmutes in the module. `#[inline(never)]` keeps the - //! full 16-slot call sequence intact exactly as written — the register - //! image must not be "optimized" against a narrower inferred signature. - #[allow(clippy::type_complexity)] + //! Exact-arity call shims. `call_{int,f64,f32}` dispatch on + //! `(n_int, n_float)` and transmute the symbol to a signature with + //! precisely `n_int` `usize` params then `n_float` `f64` params — never + //! more than the callee actually declares. + + // Map an index token to the slot's Rust ABI type (value ignored). + macro_rules! ty_usize { + ($t:tt) => { + usize + }; + } + macro_rules! ty_f64 { + ($t:tt) => { + f64 + }; + } + + /// Transmute `$f` to `extern "C" fn(usize×|ints| , f64×|floats|) -> $ret` + /// and call it with exactly those slots. Trailing commas make the empty + /// list (`fn() -> $ret`) valid. + macro_rules! call_exact { + ($f:expr, $i:ident, $d:ident, $ret:ty, [$($ix:tt)*], [$($fx:tt)*]) => {{ + let g: unsafe extern "C" fn($(ty_usize!($ix),)* $(ty_f64!($fx),)*) -> $ret = + ::core::mem::transmute($f); + g($($i[$ix],)* $($d[$fx],)*) + }}; + } + + /// Inner dispatch over the float-arg count for a fixed int-index list. + macro_rules! inner_floats { + ($f:expr, $i:ident, $d:ident, $ret:ty, [$($ix:tt)*], $nf:expr) => { + match $nf { + 0 => call_exact!($f, $i, $d, $ret, [$($ix)*], []), + 1 => call_exact!($f, $i, $d, $ret, [$($ix)*], [0]), + 2 => call_exact!($f, $i, $d, $ret, [$($ix)*], [0 1]), + 3 => call_exact!($f, $i, $d, $ret, [$($ix)*], [0 1 2]), + 4 => call_exact!($f, $i, $d, $ret, [$($ix)*], [0 1 2 3]), + 5 => call_exact!($f, $i, $d, $ret, [$($ix)*], [0 1 2 3 4]), + 6 => call_exact!($f, $i, $d, $ret, [$($ix)*], [0 1 2 3 4 5]), + 7 => call_exact!($f, $i, $d, $ret, [$($ix)*], [0 1 2 3 4 5 6]), + _ => call_exact!($f, $i, $d, $ret, [$($ix)*], [0 1 2 3 4 5 6 7]), + } + }; + } + + /// Outer dispatch over the int-arg count, then the float count. Expands to + /// 81 exact-arity transmute+call sites for the given return type. + macro_rules! dispatch_exact { + ($f:expr, $i:ident, $d:ident, $ret:ty, $ni:expr, $nf:expr) => { + match $ni { + 0 => inner_floats!($f, $i, $d, $ret, [], $nf), + 1 => inner_floats!($f, $i, $d, $ret, [0], $nf), + 2 => inner_floats!($f, $i, $d, $ret, [0 1], $nf), + 3 => inner_floats!($f, $i, $d, $ret, [0 1 2], $nf), + 4 => inner_floats!($f, $i, $d, $ret, [0 1 2 3], $nf), + 5 => inner_floats!($f, $i, $d, $ret, [0 1 2 3 4], $nf), + 6 => inner_floats!($f, $i, $d, $ret, [0 1 2 3 4 5], $nf), + 7 => inner_floats!($f, $i, $d, $ret, [0 1 2 3 4 5 6], $nf), + _ => inner_floats!($f, $i, $d, $ret, [0 1 2 3 4 5 6 7], $nf), + } + }; + } + #[inline(never)] - pub(crate) unsafe fn call_int(f: usize, i: &[usize; 8], d: &[f64; 8]) -> u64 { - let g: unsafe extern "C" fn( - usize, - usize, - usize, - usize, - usize, - usize, - usize, - usize, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - ) -> u64 = std::mem::transmute(f); - g( - i[0], i[1], i[2], i[3], i[4], i[5], i[6], i[7], d[0], d[1], d[2], d[3], d[4], d[5], - d[6], d[7], - ) - } - - #[allow(clippy::type_complexity)] + pub(crate) unsafe fn call_int( + f: usize, + ni: usize, + i: &[usize; 8], + nf: usize, + d: &[f64; 8], + ) -> u64 { + dispatch_exact!(f, i, d, u64, ni, nf) + } + #[inline(never)] - pub(crate) unsafe fn call_f64(f: usize, i: &[usize; 8], d: &[f64; 8]) -> f64 { - let g: unsafe extern "C" fn( - usize, - usize, - usize, - usize, - usize, - usize, - usize, - usize, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - ) -> f64 = std::mem::transmute(f); - g( - i[0], i[1], i[2], i[3], i[4], i[5], i[6], i[7], d[0], d[1], d[2], d[3], d[4], d[5], - d[6], d[7], - ) - } - - #[allow(clippy::type_complexity)] + pub(crate) unsafe fn call_f64( + f: usize, + ni: usize, + i: &[usize; 8], + nf: usize, + d: &[f64; 8], + ) -> f64 { + dispatch_exact!(f, i, d, f64, ni, nf) + } + #[inline(never)] - pub(crate) unsafe fn call_f32(f: usize, i: &[usize; 8], d: &[f64; 8]) -> f32 { - let g: unsafe extern "C" fn( - usize, - usize, - usize, - usize, - usize, - usize, - usize, - usize, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - ) -> f32 = std::mem::transmute(f); - g( - i[0], i[1], i[2], i[3], i[4], i[5], i[6], i[7], d[0], d[1], d[2], d[3], d[4], d[5], - d[6], d[7], - ) + pub(crate) unsafe fn call_f32( + f: usize, + ni: usize, + i: &[usize; 8], + nf: usize, + d: &[f64; 8], + ) -> f32 { + dispatch_exact!(f, i, d, f32, ni, nf) } } @@ -164,13 +196,31 @@ mod raw { mod raw { // `dlopen` refuses before any symbol closure can exist on these targets; // these stubs keep the module compiling. - pub(crate) unsafe fn call_int(_f: usize, _i: &[usize; 8], _d: &[f64; 8]) -> u64 { + pub(crate) unsafe fn call_int( + _f: usize, + _ni: usize, + _i: &[usize; 8], + _nf: usize, + _d: &[f64; 8], + ) -> u64 { unreachable!("bun:ffi call on unsupported target") } - pub(crate) unsafe fn call_f64(_f: usize, _i: &[usize; 8], _d: &[f64; 8]) -> f64 { + pub(crate) unsafe fn call_f64( + _f: usize, + _ni: usize, + _i: &[usize; 8], + _nf: usize, + _d: &[f64; 8], + ) -> f64 { unreachable!("bun:ffi call on unsupported target") } - pub(crate) unsafe fn call_f32(_f: usize, _i: &[usize; 8], _d: &[f64; 8]) -> f32 { + pub(crate) unsafe fn call_f32( + _f: usize, + _ni: usize, + _i: &[usize; 8], + _nf: usize, + _d: &[f64; 8], + ) -> f32 { unreachable!("bun:ffi call on unsupported target") } } @@ -383,6 +433,8 @@ pub(crate) unsafe fn marshal_args(arg_types: &[u8], js_args: &[f64]) -> ArgImage } } } + image.n_int = ii; + image.n_float = fi; image } @@ -431,17 +483,18 @@ pub(crate) unsafe fn read_cstring_value(addr: usize) -> f64 { /// `fn_ptr` must be a callable C function whose true prototype is scalar, /// non-variadic, and within the marshalled image's class limits. pub(crate) unsafe fn call_and_convert(fn_ptr: usize, ret_type: u8, image: &ArgImage) -> f64 { + let (ni, nf) = (image.n_int, image.n_float); let result = match ret_type { T_F64 => { - let r = raw::call_f64(fn_ptr, &image.ints, &image.floats); + let r = raw::call_f64(fn_ptr, ni, &image.ints, nf, &image.floats); super::number_value(r) } T_F32 => { - let r = raw::call_f32(fn_ptr, &image.ints, &image.floats); + let r = raw::call_f32(fn_ptr, ni, &image.ints, nf, &image.floats); super::number_value(r as f64) } _ => { - let r = raw::call_int(fn_ptr, &image.ints, &image.floats); + let r = raw::call_int(fn_ptr, ni, &image.ints, nf, &image.floats); convert_int_return(ret_type, r) } }; @@ -501,8 +554,9 @@ fn convert_int_return(ret_type: u8, r: u64) -> f64 { mod tests { use super::*; - // Callee prototypes deliberately narrower than the 16-slot trampoline — - // exactly the situation at a real dlopen'd symbol. + // Callee prototypes deliberately narrower than the shim table's max — + // exactly the situation at a real dlopen'd symbol. Each test calls + // through the EXACT arity (n_int, n_float) the callee declares. extern "C" fn sum8_i32(a: i32, b: i32, c: i32, d: i32, e: i32, f: i32, g: i32, h: i32) -> i64 { a as i64 + b as i64 + c as i64 + d as i64 + e as i64 + f as i64 + g as i64 + h as i64 @@ -536,20 +590,38 @@ mod tests { let mut image = ArgImage::default(); image.ints[..ints.len()].copy_from_slice(ints); image.floats[..floats.len()].copy_from_slice(floats); + image.n_int = ints.len(); + image.n_float = floats.len(); image } #[test] fn register_image_reaches_eight_int_args() { let image = image_from(&[1, 2, 3, 4, 5, 6, 7, 8], &[]); - let r = unsafe { raw::call_int(sum8_i32 as usize, &image.ints, &image.floats) }; + let r = unsafe { + raw::call_int( + sum8_i32 as usize, + image.n_int, + &image.ints, + image.n_float, + &image.floats, + ) + }; assert_eq!(r as i64, 36); } #[test] fn register_image_reaches_eight_float_args() { let image = image_from(&[], &[0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5]); - let r = unsafe { raw::call_f64(dsum8 as usize, &image.ints, &image.floats) }; + let r = unsafe { + raw::call_f64( + dsum8 as usize, + image.n_int, + &image.ints, + image.n_float, + &image.floats, + ) + }; assert_eq!(r, 32.0); } @@ -559,36 +631,135 @@ mod tests { // ints → [a, c, e], floats → [b, d, f32-image(f)] let f_img = f64::from_bits((1.5f32).to_bits() as u64); let image = image_from(&[10, 20, 30], &[2.0, 4.0, f_img]); - let r = unsafe { raw::call_f64(mixed as usize, &image.ints, &image.floats) }; + let r = unsafe { + raw::call_f64( + mixed as usize, + image.n_int, + &image.ints, + image.n_float, + &image.floats, + ) + }; assert_eq!(r, 10.0 + 4.0 + 60.0 + 16.0 + 150.0 + 9.0); } #[test] fn f32_return_and_f32_bit_image_arg() { let image = image_from(&[], &[f64::from_bits((21.0f32).to_bits() as u64)]); - let r = unsafe { raw::call_f32(f32_half as usize, &image.ints, &image.floats) }; + let r = unsafe { + raw::call_f32( + f32_half as usize, + image.n_int, + &image.ints, + image.n_float, + &image.floats, + ) + }; assert_eq!(r, 10.5f32); } #[test] fn u64_roundtrip_keeps_all_bits() { let image = image_from(&[u64::MAX as usize], &[]); - let r = unsafe { raw::call_int(u64_id as usize, &image.ints, &image.floats) }; + let r = unsafe { + raw::call_int( + u64_id as usize, + image.n_int, + &image.ints, + image.n_float, + &image.floats, + ) + }; assert_eq!(r, u64::MAX); } #[test] fn narrow_returns_truncate_to_declared_width() { let image = image_from(&[1], &[]); - let r = unsafe { raw::call_int(bool_not as usize, &image.ints, &image.floats) }; + let r = unsafe { + raw::call_int( + bool_not as usize, + image.n_int, + &image.ints, + image.n_float, + &image.floats, + ) + }; // Only the low byte is specified; the converter masks it. assert!(!((r as u8) != 0)); let image = image_from(&[5], &[]); - let r = unsafe { raw::call_int(i8_neg as usize, &image.ints, &image.floats) }; + let r = unsafe { + raw::call_int( + i8_neg as usize, + image.n_int, + &image.ints, + image.n_float, + &image.floats, + ) + }; assert_eq!(r as u8 as i8, -5); } + // Exact-arity dispatch must select the right shim for a callee that uses + // FEWER than the max args — the case the previous over-call trampoline + // got away with only by ABI luck. + extern "C" fn add3(a: i32, b: i32, c: i32) -> i64 { + a as i64 + b as i64 + c as i64 + } + extern "C" fn noargs() -> i32 { + 1234 + } + + #[test] + fn exact_arity_three_ints() { + let image = image_from(&[100, 20, 3], &[]); + let r = unsafe { + raw::call_int( + add3 as usize, + image.n_int, + &image.ints, + image.n_float, + &image.floats, + ) + }; + assert_eq!(r, 123); + } + + #[test] + fn exact_arity_zero_args() { + let image = image_from(&[], &[]); + let r = unsafe { + raw::call_int( + noargs as usize, + image.n_int, + &image.ints, + image.n_float, + &image.floats, + ) + }; + assert_eq!(r as u32, 1234); + } + + #[test] + fn marshal_records_class_counts() { + // (i32, f64, i32, f32, ptr) → 3 int-class, 2 float-class + let image = unsafe { + marshal_args( + &[T_I32, T_F64, T_I32, T_F32, T_PTR], + &[ + super::super::number_value(1.0), + super::super::number_value(2.0), + super::super::number_value(3.0), + super::super::number_value(4.0), + super::super::number_value(0.0), + ], + ) + }; + assert_eq!(image.n_int, 3); + assert_eq!(image.n_float, 2); + } + #[test] fn int_return_conversion_widths() { assert_eq!( diff --git a/crates/perry-runtime/src/bun_ffi/dlopen.rs b/crates/perry-runtime/src/bun_ffi/dlopen.rs index 96a23bcabf..4eaa9b87c7 100644 --- a/crates/perry-runtime/src/bun_ffi/dlopen.rs +++ b/crates/perry-runtime/src/bun_ffi/dlopen.rs @@ -337,111 +337,94 @@ fn throw_dlopen_failed(name: &str, detail: &str) -> ! { ) } -/// Validate one symbol signature at dlopen time so a bad signature can -/// never reach the trampoline. Returns (int_class_count, float_class_count). -fn validate_signature(sym: &str, args: &[u8], ret: u8) -> (usize, usize) { - let reject = |what: &str| -> ! { - crate::fs::validate::throw_type_error_with_code( - &format!("bun:ffi: symbol \"{sym}\": {what}"), - "ERR_INVALID_ARG_TYPE", - ) - }; +/// Validate one symbol signature. Returns `Err(message)` (never throws) so +/// `dlopen` can roll back its transaction before throwing at a single site. +fn validate_signature_checked(sym: &str, args: &[u8], ret: u8) -> Result<(), String> { + let reject = |what: &str| -> String { format!("bun:ffi: symbol \"{sym}\": {what}") }; let mut ints = 0usize; let mut floats = 0usize; for &t in args { match t { - T_FUNCTION => reject( - "FFIType.function / JSCallback arguments are not yet supported \ - in perry (bun:ffi stage 1, #6562)", - ), - T_NAPI_ENV | T_NAPI_VALUE => reject("napi types are not supported"), - T_BUFFER => reject("FFIType.buffer is not yet supported (use ptr)"), - T_VOID => reject("void is not a valid argument type"), + T_FUNCTION => { + return Err(reject( + "FFIType.function / JSCallback arguments are not yet supported \ + in perry (bun:ffi stage 1, #6562)", + )) + } + T_NAPI_ENV | T_NAPI_VALUE => return Err(reject("napi types are not supported")), + T_BUFFER => return Err(reject("FFIType.buffer is not yet supported (use ptr)")), + T_VOID => return Err(reject("void is not a valid argument type")), t if types::is_float_class(t) => floats += 1, _ => ints += 1, } } match ret { - T_FUNCTION => reject( - "FFIType.function / JSCallback returns are not yet supported \ - in perry (bun:ffi stage 1, #6562)", - ), - T_NAPI_ENV | T_NAPI_VALUE => reject("napi types are not supported"), - T_BUFFER => reject("FFIType.buffer is not yet supported (use ptr / toArrayBuffer)"), + T_FUNCTION => { + return Err(reject( + "FFIType.function / JSCallback returns are not yet supported \ + in perry (bun:ffi stage 1, #6562)", + )) + } + T_NAPI_ENV | T_NAPI_VALUE => return Err(reject("napi types are not supported")), + T_BUFFER => { + return Err(reject( + "FFIType.buffer is not yet supported (use ptr / toArrayBuffer)", + )) + } _ => {} } if args.len() > MAX_ARGS { - reject(&format!("more than {MAX_ARGS} arguments are not supported")); + return Err(reject(&format!( + "more than {MAX_ARGS} arguments are not supported" + ))); } if ints > MAX_INT_ARGS { - reject(&format!( + return Err(reject(&format!( "more than {MAX_INT_ARGS} integer/pointer arguments are not supported \ by perry's stage-1 call stubs" - )); + ))); } if floats > MAX_FLOAT_ARGS { - reject(&format!( + return Err(reject(&format!( "more than {MAX_FLOAT_ARGS} float arguments are not supported \ by perry's stage-1 call stubs" - )); + ))); } - (ints, floats) + Ok(()) } -/// `dlopen(path, symbolTable)` → `{ symbols: { : fn }, close(): void }`. -pub(crate) unsafe fn dlopen_value(path_arg: f64, table_arg: f64) -> f64 { - if !call::platform_supported() { - crate::fs::validate::throw_error_with_code( - "bun:ffi is not supported on this platform yet (stage 1 targets \ - unix x86_64 / aarch64, #6562)", - "ERR_NOT_IMPLEMENTED", - ); - } - let Some(path) = value_to_owned_string(path_arg) else { - crate::fs::validate::throw_type_error_with_code( - "dlopen(path, symbols) expects a string path", - "ERR_INVALID_ARG_TYPE", - ); - }; - let Some(table) = object_ptr_of(table_arg) else { - crate::fs::validate::throw_type_error_with_code( - "dlopen(path, symbols) expects a symbols object", - "ERR_INVALID_ARG_TYPE", - ); - }; +/// A fully validated + resolved symbol, held locally until the whole table +/// is known good. Only then are `LibRecord`/`SymRecord` committed. +struct PreparedSym { + name: String, + fn_ptr: usize, + ret: u8, + argc: usize, + args: [u8; MAX_ARGS], +} - let handle = match open_library(&path) { - Ok(h) => h, - Err(msg) => throw_dlopen_failed(&path, &msg), - }; - let lib_index = { - let mut libs = LIBS.lock().unwrap(); - libs.push(LibRecord { - handle, - path: path.clone(), - closed: false, - }); - libs.len() - 1 - }; +/// Walk + validate + resolve every symbol WITHOUT mutating any global +/// registry. Returns `Err((message, code))` on the first problem so the +/// caller can `dlclose` and throw. This makes `dlopen` transactional: +/// repeated malformed calls can't grow `LIBS`/`SYMS`/leaked-name state, +/// because nothing is committed until this returns `Ok`. +unsafe fn prepare_symbols( + handle: usize, + path: &str, + table: *mut crate::object::ObjectHeader, +) -> Result, (String, &'static str)> { + let type_err = |m: String| (m, "ERR_INVALID_ARG_TYPE"); - // Walk the symbol table BEFORE building any JS result, so an invalid - // entry throws without leaking half-built objects (the lib handle stays - // registered; dlopen handles are refcounted by the loader and this - // mirrors Bun, which also leaves the library mapped on validation - // throws). let keys = crate::object::js_object_keys(table); let key_count = crate::array::js_array_length(keys); if key_count == 0 { - throw_dlopen_failed(&path, "Expected at least 1 symbol"); - } - - struct Prepared { - name: String, - sym_index: usize, - argc: u32, + return Err(( + format!("Failed to open library \"{path}\": Expected at least 1 symbol"), + "ERR_DLOPEN_FAILED", + )); } - let mut prepared: Vec = Vec::with_capacity(key_count as usize); + let mut prepared: Vec = Vec::with_capacity(key_count as usize); for i in 0..key_count { let key_value = crate::array::js_array_get(keys, i); let name = match value_to_owned_string(f64::from_bits(key_value.bits())) { @@ -449,10 +432,9 @@ pub(crate) unsafe fn dlopen_value(path_arg: f64, table_arg: f64) -> f64 { None => continue, }; let Some(entry) = object_ptr_of(get_field(table, &name)) else { - crate::fs::validate::throw_type_error_with_code( - &format!("bun:ffi: symbol \"{name}\": expected {{ args, returns }}"), - "ERR_INVALID_ARG_TYPE", - ); + return Err(type_err(format!( + "bun:ffi: symbol \"{name}\": expected {{ args, returns }}" + ))); }; // args: optional array of FFIType values; returns: optional FFIType @@ -462,27 +444,28 @@ pub(crate) unsafe fn dlopen_value(path_arg: f64, table_arg: f64) -> f64 { let args_value = get_field(entry, "args"); let args_jv = JSValue::from_bits(args_value.to_bits()); if !args_jv.is_undefined() && !args_jv.is_null() { - let arr_addr = crate::value::js_nanbox_get_pointer(args_value); - if arr_addr == 0 { - crate::fs::validate::throw_type_error_with_code( - &format!("bun:ffi: symbol \"{name}\": args must be an array"), - "ERR_INVALID_ARG_TYPE", - ); + // #6580(CodeRabbit): verify the value is genuinely an Array before + // reading it as an ArrayHeader — a non-array object/closure would + // otherwise be misinterpreted (arbitrary-memory read). + if !JSValue::from_bits(crate::array::js_array_is_array(args_value).to_bits()).as_bool() + { + return Err(type_err(format!( + "bun:ffi: symbol \"{name}\": args must be an array" + ))); } - let arr = arr_addr as usize as *const crate::array::ArrayHeader; + let arr = crate::value::js_nanbox_get_pointer(args_value) as usize + as *const crate::array::ArrayHeader; let len = crate::array::js_array_length(arr); if len as usize > MAX_ARGS { - crate::fs::validate::throw_type_error_with_code( - &format!( - "bun:ffi: symbol \"{name}\": more than {MAX_ARGS} arguments \ - are not supported" - ), - "ERR_INVALID_ARG_TYPE", - ); + return Err(type_err(format!( + "bun:ffi: symbol \"{name}\": more than {MAX_ARGS} arguments \ + are not supported" + ))); } for j in 0..len { let t = crate::array::js_array_get(arr, j); - args[argc] = types::parse_ffi_type(f64::from_bits(t.bits())); + args[argc] = types::parse_ffi_type_checked(f64::from_bits(t.bits())) + .map_err(|m| type_err(format!("bun:ffi: symbol \"{name}\": {m}")))?; argc += 1; } } @@ -491,43 +474,110 @@ pub(crate) unsafe fn dlopen_value(path_arg: f64, table_arg: f64) -> f64 { let ret = if returns_jv.is_undefined() || returns_jv.is_null() { T_VOID } else { - types::parse_ffi_type(returns_value) + types::parse_ffi_type_checked(returns_value) + .map_err(|m| type_err(format!("bun:ffi: symbol \"{name}\": {m}")))? }; - validate_signature(&name, &args[..argc], ret); + validate_signature_checked(&name, &args[..argc], ret).map_err(type_err)?; let Some(fn_ptr) = find_symbol(handle, &name) else { - crate::fs::validate::throw_type_error_with_code( - &format!("Symbol \"{name}\" not found in \"{path}\""), - "ERR_INVALID_ARG_TYPE", - ); + return Err(type_err(format!( + "Symbol \"{name}\" not found in \"{path}\"" + ))); }; - let leaked_name: &'static str = name.clone().leak(); - let sym_index = { - let mut syms = SYMS.lock().unwrap(); + prepared.push(PreparedSym { + name, + fn_ptr, + ret, + argc, + args, + }); + } + Ok(prepared) +} + +/// `dlopen(path, symbolTable)` → `{ symbols: { : fn }, close(): void }`. +pub(crate) unsafe fn dlopen_value(path_arg: f64, table_arg: f64) -> f64 { + if !call::platform_supported() { + crate::fs::validate::throw_error_with_code( + "bun:ffi is not supported on this platform yet (stage 1 targets \ + unix x86_64 / aarch64, #6562)", + "ERR_NOT_IMPLEMENTED", + ); + } + let Some(path) = value_to_owned_string(path_arg) else { + crate::fs::validate::throw_type_error_with_code( + "dlopen(path, symbols) expects a string path", + "ERR_INVALID_ARG_TYPE", + ); + }; + let Some(table) = object_ptr_of(table_arg) else { + crate::fs::validate::throw_type_error_with_code( + "dlopen(path, symbols) expects a symbols object", + "ERR_INVALID_ARG_TYPE", + ); + }; + + let handle = match open_library(&path) { + Ok(h) => h, + Err(msg) => throw_dlopen_failed(&path, &msg), + }; + + // TRANSACTIONAL: validate + resolve the whole table into locals first. On + // ANY failure, dlclose the freshly-opened handle and throw — nothing was + // committed to LIBS/SYMS, so a repeatedly-malformed dlopen cannot grow + // loader mappings or registry/leak state. + let prepared = match prepare_symbols(handle, &path, table) { + Ok(p) => p, + Err((message, code)) => { + close_library(handle); + crate::fs::validate::throw_error_with_code(&message, code); + } + }; + + // Commit: register the library, then the symbols, then build JS. + let lib_index = { + let mut libs = LIBS.lock().unwrap(); + libs.push(LibRecord { + handle, + path: path.clone(), + closed: false, + }); + libs.len() - 1 + }; + + struct Committed { + name: String, + sym_index: usize, + argc: u32, + } + let mut committed: Vec = Vec::with_capacity(prepared.len()); + { + let mut syms = SYMS.lock().unwrap(); + for p in prepared { + let leaked_name: &'static str = p.name.clone().leak(); syms.push(SymRecord { - fn_ptr, + fn_ptr: p.fn_ptr, lib: lib_index, - ret, - argc: argc as u8, - args, + ret: p.ret, + argc: p.argc as u8, + args: p.args, name: leaked_name, }); - syms.len() - 1 - }; - prepared.push(Prepared { - name, - sym_index, - argc: argc as u32, - }); + committed.push(Committed { + name: p.name, + sym_index: syms.len() - 1, + argc: p.argc as u32, + }); + } } // Build `{ symbols, close }` with every intermediate rooted across the // remaining allocations. let scope = crate::gc::RuntimeHandleScope::new(); - let symbols_obj = crate::object::js_object_alloc(0, prepared.len() as u32); + let symbols_obj = crate::object::js_object_alloc(0, committed.len() as u32); let symbols_handle = scope.root_raw_mut_ptr(symbols_obj); - for p in &prepared { + for p in &committed { let value = index_closure(sym_thunk_for(p.argc as usize), p.sym_index, p.argc, &p.name); let value_handle = scope.root_nanbox_f64(value); set_field( @@ -604,21 +654,28 @@ pub(crate) unsafe fn ptr_value(view_arg: f64, offset_arg: f64) -> f64 { /// conversion. pub(crate) unsafe fn cstring_value(ptr_arg: f64, offset_arg: f64, length_arg: f64) -> f64 { let jv = JSValue::from_bits(ptr_arg.to_bits()); - let base = if jv.is_undefined() || jv.is_null() { - 0usize + // `managed_end`: exclusive upper bound of the SOURCE's managed storage, + // when we know it (a Buffer / TypedArray / ArrayBuffer / DataView). For a + // raw numeric/bigint pointer there is no managed length — like Bun, we + // then trust the caller. When it IS a managed buffer, every read below is + // clamped to `[base, managed_end)` so a bogus offset/length can't scan or + // slice past the buffer's own bytes. + let (base, managed_end): (usize, Option) = if jv.is_undefined() || jv.is_null() { + (0, None) } else if jv.is_int32() { - jv.as_int32() as i64 as usize + (jv.as_int32() as i64 as usize, None) } else if jv.is_number() { - jv.as_number() as i64 as usize + (jv.as_number() as i64 as usize, None) } else if jv.is_bigint() { let b = crate::value::js_nanbox_get_bigint(ptr_arg); - if b == 0 { + let addr = if b == 0 { 0 } else { (*(b as usize as *const crate::bigint::BigIntHeader)).limbs[0] as usize - } - } else if let Some((data, _)) = call::value_buffer_span(ptr_arg) { - data as usize + }; + (addr, None) + } else if let Some((data, len)) = call::value_buffer_span(ptr_arg) { + (data as usize, Some(data as usize + len)) } else { crate::fs::validate::throw_type_error_with_code( "CString(ptr) expects a pointer", @@ -637,6 +694,13 @@ pub(crate) unsafe fn cstring_value(ptr_arg: f64, offset_arg: f64, length_arg: f6 0 }; let start = (base as i64 + offset.max(0)) as usize; + // Clamp the start into the managed storage (a start past the end yields an + // empty read rather than an OOB scan). + if let Some(end) = managed_end { + if start > end { + return super::string_value(""); + } + } let length_jv = JSValue::from_bits(length_arg.to_bits()); let explicit_len = if length_jv.is_int32() { Some(length_jv.as_int32() as i64) @@ -647,12 +711,101 @@ pub(crate) unsafe fn cstring_value(ptr_arg: f64, offset_arg: f64, length_arg: f6 }; match explicit_len { Some(n) if n >= 0 => { - let bytes = std::slice::from_raw_parts(start as *const u8, n as usize); + let mut len = n as usize; + if let Some(end) = managed_end { + len = len.min(end - start); // never slice past the buffer + } + let bytes = std::slice::from_raw_parts(start as *const u8, len); match std::str::from_utf8(bytes) { Ok(s) => super::string_value(s), Err(_) => super::string_value(&String::from_utf8_lossy(bytes)), } } - _ => call::read_cstring_value(start), + // NUL-terminated scan: bounded to the managed storage when known. + _ => match managed_end { + Some(end) => { + let max = end - start; + let base_ptr = start as *const u8; + let mut len = 0usize; + while len < max && *base_ptr.add(len) != 0 { + len += 1; + } + let bytes = std::slice::from_raw_parts(base_ptr, len); + match std::str::from_utf8(bytes) { + Ok(s) => super::string_value(s), + Err(_) => super::string_value(&String::from_utf8_lossy(bytes)), + } + } + None => call::read_cstring_value(start), + }, + } +} + +// ── tests ─────────────────────────────────────────────────────────────────── +// +// Cargo-visible on every PR: the dlopen-time signature-validation ERROR +// CONTRACT (the same rejections the e2e drives through a compiled binary, +// but reachable without `cc` + a dylib). `validate_signature_checked` is a +// pure function over the marshalled type bytes, so no runtime init is needed. + +#[cfg(test)] +mod tests { + // T_BUFFER / T_FUNCTION / T_NAPI_* / T_VOID come in via `use super::*` + // (re-exported from the module-level `use super::types::{...}`); pull the + // remaining constants the tests need directly. + use super::super::types::{T_CSTRING, T_F64, T_I32, T_PTR, T_U64}; + use super::*; + + #[test] + fn accepts_a_valid_scalar_signature() { + // bun-pty's spawn: (cstring, cstring, cstring, i32, i32) -> i32. + let args = [T_CSTRING, T_CSTRING, T_CSTRING, T_I32, T_I32]; + assert!(validate_signature_checked("bun_pty_spawn", &args, T_I32).is_ok()); + // void return is valid. + assert!(validate_signature_checked("f", &[T_PTR, T_I32], T_VOID).is_ok()); + // zero-arg is valid. + assert!(validate_signature_checked("f", &[], T_U64).is_ok()); + } + + #[test] + fn rejects_callback_types_with_a_stage1_message() { + let e = validate_signature_checked("f", &[T_FUNCTION], T_VOID).unwrap_err(); + assert!(e.contains("not yet supported"), "{e}"); + let e = validate_signature_checked("f", &[T_I32], T_FUNCTION).unwrap_err(); + assert!(e.contains("not yet supported"), "{e}"); + } + + #[test] + fn rejects_napi_buffer_and_void_arg() { + assert!(validate_signature_checked("f", &[T_NAPI_ENV], T_VOID).is_err()); + assert!(validate_signature_checked("f", &[T_NAPI_VALUE], T_VOID).is_err()); + assert!(validate_signature_checked("f", &[T_BUFFER], T_VOID).is_err()); + // void is a valid RETURN but never a valid ARGUMENT. + let e = validate_signature_checked("f", &[T_VOID], T_I32).unwrap_err(); + assert!(e.contains("void is not a valid argument"), "{e}"); + } + + #[test] + fn rejects_over_register_class_limits() { + // 9 integer-class args > MAX_INT_ARGS (8). + let nine_ints = [T_I32; 9]; + let e = validate_signature_checked("f", &nine_ints, T_VOID).unwrap_err(); + assert!(e.contains("integer/pointer arguments"), "{e}"); + // 9 float-class args > MAX_FLOAT_ARGS (8). + let nine_floats = [T_F64; 9]; + let e = validate_signature_checked("f", &nine_floats, T_VOID).unwrap_err(); + assert!(e.contains("float arguments"), "{e}"); + // But 8 + 8 mixed is fine. + let mut mixed = [T_I32; 16]; + for m in mixed.iter_mut().take(8) { + *m = T_F64; + } + assert!(validate_signature_checked("f", &mixed, T_VOID).is_ok()); + } + + #[test] + fn error_messages_name_the_symbol() { + let e = validate_signature_checked("my_symbol", &[T_FUNCTION], T_VOID).unwrap_err(); + assert!(e.contains("my_symbol"), "{e}"); } } diff --git a/crates/perry-runtime/src/bun_ffi/types.rs b/crates/perry-runtime/src/bun_ffi/types.rs index 2148bedd9b..c0bb0be9c8 100644 --- a/crates/perry-runtime/src/bun_ffi/types.rs +++ b/crates/perry-runtime/src/bun_ffi/types.rs @@ -17,7 +17,7 @@ use super::{number_value, string_value}; use crate::value::JSValue; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::cell::Cell; pub const T_CHAR: u8 = 0; pub const T_I8: u8 = 1; @@ -121,32 +121,30 @@ fn alias_to_type(name: &str) -> Option { .map(|&(_, v)| v) } -fn throw_unsupported_type(display: &str) -> ! { +fn unsupported_type_message(display: &str) -> String { let names: Vec<&str> = FFI_TYPE_ENTRIES.iter().skip(18).map(|&(k, _)| k).collect(); - crate::fs::validate::throw_type_error_with_code( - &format!( - "Unsupported type {display}. Must be one of: {}", - names.join(", ") - ), - "ERR_INVALID_ARG_TYPE", + format!( + "Unsupported type {display}. Must be one of: {}", + names.join(", ") ) } /// Parse one `args`/`returns` entry of a `dlopen` symbol table: a numeric -/// `FFIType` value or a string alias. Throws a Bun-shaped `TypeError` on -/// anything unrecognized. -pub(crate) unsafe fn parse_ffi_type(value: f64) -> u8 { +/// `FFIType` value or a string alias. Returns `Err(message)` on anything +/// unrecognized so `dlopen` can roll back its transaction and throw at a +/// single site (rather than unwinding mid-registration). +pub(crate) unsafe fn parse_ffi_type_checked(value: f64) -> Result { let jv = JSValue::from_bits(value.to_bits()); if jv.is_any_string() { let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; if let Some(bytes) = crate::string::js_string_key_bytes(jv, &mut sso) { let name = std::str::from_utf8(bytes).unwrap_or(""); if let Some(t) = alias_to_type(name) { - return t; + return Ok(t); } - throw_unsupported_type(name); + return Err(unsupported_type_message(name)); } - throw_unsupported_type(""); + return Err(unsupported_type_message("")); } let n = if jv.is_int32() { jv.as_int32() as f64 @@ -156,29 +154,38 @@ pub(crate) unsafe fn parse_ffi_type(value: f64) -> u8 { f64::NAN }; if n.is_finite() && n >= 0.0 && n <= T_BUFFER as f64 && n.fract() == 0.0 { - return n as u8; + return Ok(n as u8); } - throw_unsupported_type(&format!("{n}")); + Err(unsupported_type_message(&format!("{n}"))) } // ── The cached FFIType JS object ──────────────────────────────────────── -/// NaN-boxed pointer to the built-once `FFIType` object. A mutable GC root: -/// scanned (and rewritten, though the object is old-arena and won't move) -/// by `scan_bun_ffi_roots_mut`. -static FFI_TYPE_OBJECT_CACHE: AtomicU64 = AtomicU64::new(0); +thread_local! { + /// NaN-boxed pointer to this thread's `FFIType` object (0 = not built). + /// Per-thread because perry's arena/GC is per-thread: a JS object is + /// owned by the arena of the thread that allocated it, and each thread + /// runs its own GC. A process-global cache would hand one thread's + /// object to another thread's mutators and expose it to a GC that never + /// roots it. The slot is rooted per-thread by + /// `scan_ffi_type_cache_mut`, which the collector calls on the mutator + /// thread (so `thread_local` resolves to that thread's slot). + static FFI_TYPE_OBJECT_CACHE: Cell = const { Cell::new(0) }; +} pub(crate) fn scan_ffi_type_cache_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { - visitor.visit_atomic_nanbox_u64_slot( - &FFI_TYPE_OBJECT_CACHE, - Ordering::Relaxed, - Ordering::Relaxed, - ); + FFI_TYPE_OBJECT_CACHE.with(|slot| { + let mut value_bits = slot.get(); + if value_bits != 0 { + visitor.visit_nanbox_u64_slot(&mut value_bits); + slot.set(value_bits); + } + }); } -/// Build (once) and return the `FFIType` enum object. +/// Build (once per thread) and return the `FFIType` enum object. pub(crate) fn ffi_type_object_value() -> f64 { - let cached = FFI_TYPE_OBJECT_CACHE.load(Ordering::Relaxed); + let cached = FFI_TYPE_OBJECT_CACHE.with(|slot| slot.get()); if cached != 0 { return f64::from_bits(cached); } @@ -188,11 +195,7 @@ pub(crate) fn ffi_type_object_value() -> f64 { crate::object::js_object_set_field_by_name(obj, key, number_value(value as f64)); } let value = f64::from_bits(JSValue::object_ptr(obj as *mut u8).bits()); - crate::gc::runtime_store_root_atomic_nanbox_u64( - &FFI_TYPE_OBJECT_CACHE, - value.to_bits(), - Ordering::Relaxed, - ); + FFI_TYPE_OBJECT_CACHE.with(|slot| slot.set(value.to_bits())); value } @@ -200,3 +203,86 @@ pub(crate) fn ffi_type_object_value() -> f64 { pub(crate) fn suffix_value() -> f64 { string_value(super::suffix_str()) } + +// ── tests ─────────────────────────────────────────────────────────────────── +// +// Cargo-visible on every PR (no compiled-binary target required): these pin +// the FFIType numeric contract and the numeric type-parse path — the same +// values the e2e asserts against Bun, but reachable by `cargo test`. + +#[cfg(test)] +mod tests { + use super::*; + + fn num(n: f64) -> f64 { + f64::from_bits(JSValue::number(n).bits()) + } + + #[test] + fn ffi_type_numeric_values_match_bun() { + // The exact integers Bun exposes (bun-pty/opentui read FFIType.i32 etc). + assert_eq!(T_CHAR, 0); + assert_eq!(T_I8, 1); + assert_eq!(T_U8, 2); + assert_eq!(T_I16, 3); + assert_eq!(T_U16, 4); + assert_eq!(T_I32, 5); + assert_eq!(T_U32, 6); + assert_eq!(T_I64, 7); + assert_eq!(T_U64, 8); + assert_eq!(T_F64, 9); + assert_eq!(T_F32, 10); + assert_eq!(T_BOOL, 11); + assert_eq!(T_PTR, 12); + assert_eq!(T_VOID, 13); + assert_eq!(T_CSTRING, 14); + assert_eq!(T_I64_FAST, 15); + assert_eq!(T_U64_FAST, 16); + assert_eq!(T_FUNCTION, 17); + } + + #[test] + fn parse_numeric_ffi_type() { + unsafe { + assert_eq!(parse_ffi_type_checked(num(5.0)).unwrap(), T_I32); + assert_eq!(parse_ffi_type_checked(num(14.0)).unwrap(), T_CSTRING); + assert_eq!(parse_ffi_type_checked(num(12.0)).unwrap(), T_PTR); + // function/napi/buffer PARSE fine; rejection is `validate_signature`'s job. + assert_eq!(parse_ffi_type_checked(num(17.0)).unwrap(), T_FUNCTION); + assert_eq!(parse_ffi_type_checked(num(20.0)).unwrap(), T_BUFFER); + } + } + + #[test] + fn parse_rejects_out_of_range_and_non_integer() { + unsafe { + assert!(parse_ffi_type_checked(num(21.0)).is_err()); + assert!(parse_ffi_type_checked(num(-1.0)).is_err()); + assert!(parse_ffi_type_checked(num(5.5)).is_err()); + assert!(parse_ffi_type_checked(num(f64::NAN)).is_err()); + } + } + + #[test] + fn string_aliases_resolve() { + assert_eq!(alias_to_type("i32"), Some(T_I32)); + assert_eq!(alias_to_type("int32_t"), Some(T_I32)); + assert_eq!(alias_to_type("c_int"), Some(T_I32)); + assert_eq!(alias_to_type("pointer"), Some(T_PTR)); + assert_eq!(alias_to_type("ptr"), Some(T_PTR)); + assert_eq!(alias_to_type("void*"), Some(T_PTR)); + assert_eq!(alias_to_type("cstring"), Some(T_CSTRING)); + assert_eq!(alias_to_type("usize"), Some(T_U64)); + assert_eq!(alias_to_type("callback"), Some(T_FUNCTION)); + assert_eq!(alias_to_type("nonsense"), None); + } + + #[test] + fn float_class_is_only_f32_f64() { + assert!(is_float_class(T_F32)); + assert!(is_float_class(T_F64)); + for t in [T_I8, T_I32, T_I64, T_U64, T_PTR, T_BOOL, T_CSTRING] { + assert!(!is_float_class(t)); + } + } +} diff --git a/crates/perry/tests/bun_ffi_stage1.rs b/crates/perry/tests/bun_ffi_stage1.rs index d784d82bce..3758783b82 100644 --- a/crates/perry/tests/bun_ffi_stage1.rs +++ b/crates/perry/tests/bun_ffi_stage1.rs @@ -90,10 +90,15 @@ EXPORT int32_t ffi_void_calls(void) { return g_void_calls; } EXPORT bool ffi_not(bool v) { return !v; } EXPORT bool ffi_is_forty_two(int32_t v) { return v == 42; } -EXPORT int8_t ffi_i8_add1(int8_t v) { return (int8_t)(v + 1); } -EXPORT int16_t ffi_i16_add1(int16_t v) { return (int16_t)(v + 1); } -EXPORT int32_t ffi_i32_add1(int32_t v) { return v + 1; } -EXPORT int64_t ffi_i64_add1(int64_t v) { return v + 1; } +/* Unsigned internal arithmetic so the wraparound at the type max is + * well-defined (signed overflow is C UB — e.g. INT32_MAX + 1). The cast + * back to the signed type is the implementation-defined 2's-complement + * reinterpretation every real target uses, which is exactly the + * wraparound the JS side asserts. */ +EXPORT int8_t ffi_i8_add1(int8_t v) { return (int8_t)((uint8_t)v + 1u); } +EXPORT int16_t ffi_i16_add1(int16_t v) { return (int16_t)((uint16_t)v + 1u); } +EXPORT int32_t ffi_i32_add1(int32_t v) { return (int32_t)((uint32_t)v + 1u); } +EXPORT int64_t ffi_i64_add1(int64_t v) { return (int64_t)((uint64_t)v + 1u); } EXPORT uint8_t ffi_u8_add1(uint8_t v) { return (uint8_t)(v + 1); } EXPORT uint16_t ffi_u16_add1(uint16_t v) { return (uint16_t)(v + 1); } EXPORT uint32_t ffi_u32_add1(uint32_t v) { return v + 1; } @@ -494,9 +499,14 @@ console.log("spawned:", handle >= 0); const pid = s.bun_pty_get_pid(handle); console.log("pid-positive:", pid > 0); -// write/read round-trip: echo a marker through the real pty -const marker = "FFI_PTY_ROUNDTRIP_OK"; -const cmd = Buffer.from("echo " + marker + "\n", "utf8"); +// write/read round-trip: the pty has terminal echo ON, so the INPUT line is +// echoed back verbatim. To prove the SHELL actually ran (not just that our +// keystrokes bounced), send a command the shell must EVALUATE — arithmetic +// expansion — and assert on its *result*. The input echo shows the literal +// `echo FFI_$((40+2))_OK`, which does not contain `FFI_42_OK`; only the +// shell's computed output does. +const expected = "FFI_42_OK"; +const cmd = Buffer.from("echo FFI_$((40+2))_OK\n", "utf8"); s.bun_pty_write(handle, ptr(cmd), cmd.length); const readBuf = Buffer.allocUnsafe(4096); @@ -506,10 +516,7 @@ while (Date.now() < deadline) { const n = s.bun_pty_read(handle, ptr(readBuf), readBuf.length); if (n > 0) { collected += readBuf.subarray(0, n).toString("utf8"); - // the echo output (not just the input echo-back) proves the shell ran - const echoAt = collected.indexOf(marker + "\r"); - const echoAtNl = collected.indexOf(marker + "\n"); - if (echoAt !== -1 || echoAtNl !== -1) break; + if (collected.includes(expected)) break; } else if (n === -2) { break; // child exited } else if (n < 0) { @@ -520,7 +527,7 @@ while (Date.now() < deadline) { while (Date.now() < until) {} } } -console.log("roundtrip:", collected.includes(marker)); +console.log("roundtrip:", collected.includes(expected)); console.log("resize:", s.bun_pty_resize(handle, 120, 40) === 0); diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index 6f3c9fbc1e..d8536022c7 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -1,6 +1,6 @@ // Auto-generated from Perry's API manifest (#465). Do not edit by hand. // Source: perry-api-manifest::API_MANIFEST -// Coverage: 1959 entries across 117 modules +// Coverage: 1980 entries across 119 modules type PerryU32 = number & { readonly __perryU32?: never }; type PerryU64 = number & { readonly __perryU64?: never }; @@ -271,30 +271,51 @@ declare module "buffer" { export function transcode(...args: any[]): any; } +declare module "bun" { + /** stdlib */ + export const stderr: any; + /** stdlib */ + export const stdin: any; + /** stdlib */ + export const stdout: any; + /** stdlib */ + export function file(...args: any[]): any; + /** stdlib */ + export function fileURLToPath(...args: any[]): any; + /** stdlib */ + export function hash(...args: any[]): any; + /** stdlib */ + export function pathToFileURL(...args: any[]): any; + /** stdlib */ + export function stringWidth(...args: any[]): any; + /** stdlib */ + export function write(...args: any[]): any; +} + declare module "bun:ffi" { /** stdlib */ export const FFIType: any; /** stdlib */ export const suffix: any; - /** stdlib */ + /** stdlib @perryStub stage 3 — not yet implemented, throws at runtime (#6562) */ export function CFunction(...args: any[]): any; /** stdlib */ export function CString(...args: any[]): any; - /** stdlib */ + /** stdlib @perryStub stage 3 — not yet implemented, throws at runtime (#6562) */ export function JSCallback(...args: any[]): any; /** stdlib */ export function dlopen(...args: any[]): any; - /** stdlib */ + /** stdlib @perryStub stage ≥2 — not yet implemented, throws at runtime (#6562) */ export function linkSymbols(...args: any[]): any; /** stdlib */ export function ptr(...args: any[]): any; - /** stdlib */ + /** stdlib @perryStub stage ≥2 — not yet implemented, throws at runtime (#6562) */ export function read(...args: any[]): any; - /** stdlib */ + /** stdlib @perryStub stage 2 — not yet implemented, throws at runtime (#6562) */ export function toArrayBuffer(...args: any[]): any; - /** stdlib */ + /** stdlib @perryStub stage 2 — not yet implemented, throws at runtime (#6562) */ export function toBuffer(...args: any[]): any; - /** stdlib */ + /** stdlib @perryStub stage ≥2 — not yet implemented, throws at runtime (#6562) */ export function viewSource(...args: any[]): any; } diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index 29a5695bb8..d325fc9c52 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -2,25 +2,26 @@ This page is auto-generated from Perry's compile-time API manifest (`perry-api-manifest::API_MANIFEST`). It is the source of truth for what `perry compile` accepts; references to symbols not listed here produce `R005 UnimplementedApi` (issue #463). Stubs (#464) are flagged ⚠ — they link cleanly but no-op at runtime on the chosen target. -Total: 2834 entries across 119 modules. +Total: 2855 entries across 121 modules. ## Modules -- [`@lydell/node-pty`](#-lydell-node-pty) -- [`@perryts/pdf`](#-perryts-pdf) -- [`__disposable__`](#--disposable--) +- [`@lydell/node-pty`](#lydellnode-pty) +- [`@perryts/pdf`](#perrytspdf) +- [`__disposable__`](#__disposable__) - [`argon2`](#argon2) - [`assert`](#assert) -- [`assert/strict`](#assert-strict) -- [`async_hooks`](#async-hooks) +- [`assert/strict`](#assertstrict) +- [`async_hooks`](#async_hooks) - [`axios`](#axios) - [`bcrypt`](#bcrypt) - [`better-sqlite3`](#better-sqlite3) -- [`bignumber.js`](#bignumber-js) +- [`bignumber.js`](#bignumberjs) - [`buffer`](#buffer) -- [`bun:ffi`](#bun-ffi) +- [`bun`](#bun) +- [`bun:ffi`](#bunffi) - [`cheerio`](#cheerio) -- [`child_process`](#child-process) +- [`child_process`](#child_process) - [`cluster`](#cluster) - [`commander`](#commander) - [`console`](#console) @@ -29,11 +30,11 @@ Total: 2834 entries across 119 modules. - [`crypto`](#crypto) - [`date-fns`](#date-fns) - [`dayjs`](#dayjs) -- [`decimal.js`](#decimal-js) +- [`decimal.js`](#decimaljs) - [`dgram`](#dgram) -- [`diagnostics_channel`](#diagnostics-channel) +- [`diagnostics_channel`](#diagnostics_channel) - [`dns`](#dns) -- [`dns/promises`](#dns-promises) +- [`dns/promises`](#dnspromises) - [`domain`](#domain) - [`dotenv`](#dotenv) - [`ethers`](#ethers) @@ -42,12 +43,12 @@ Total: 2834 entries across 119 modules. - [`fastify`](#fastify) - [`fetch`](#fetch) - [`fs`](#fs) -- [`fs/promises`](#fs-promises) +- [`fs/promises`](#fspromises) - [`http`](#http) - [`http2`](#http2) - [`https`](#https) - [`inspector`](#inspector) -- [`inspector/promises`](#inspector-promises) +- [`inspector/promises`](#inspectorpromises) - [`ioredis`](#ioredis) - [`iroh`](#iroh) - [`jsonwebtoken`](#jsonwebtoken) @@ -57,7 +58,7 @@ Total: 2834 entries across 119 modules. - [`moment`](#moment) - [`mongodb`](#mongodb) - [`mysql2`](#mysql2) -- [`mysql2/promise`](#mysql2-promise) +- [`mysql2/promise`](#mysql2promise) - [`nanoid`](#nanoid) - [`net`](#net) - [`node-cron`](#node-cron) @@ -66,35 +67,35 @@ Total: 2834 entries across 119 modules. - [`nodemailer`](#nodemailer) - [`os`](#os) - [`path`](#path) -- [`path/posix`](#path-posix) -- [`path/win32`](#path-win32) -- [`perf_hooks`](#perf-hooks) +- [`path/posix`](#pathposix) +- [`path/win32`](#pathwin32) +- [`perf_hooks`](#perf_hooks) - [`perry`](#perry) -- [`perry/ads`](#perry-ads) -- [`perry/audio`](#perry-audio) -- [`perry/background`](#perry-background) -- [`perry/compose`](#perry-compose) -- [`perry/container`](#perry-container) -- [`perry/container-compose`](#perry-container-compose) -- [`perry/gc`](#perry-gc) -- [`perry/i18n`](#perry-i18n) -- [`perry/media`](#perry-media) -- [`perry/plugin`](#perry-plugin) -- [`perry/system`](#perry-system) -- [`perry/thread`](#perry-thread) -- [`perry/tui`](#perry-tui) -- [`perry/ui`](#perry-ui) -- [`perry/updater`](#perry-updater) -- [`perry/widget`](#perry-widget) -- [`perry/workloads`](#perry-workloads) -- [`perry/yoga`](#perry-yoga) +- [`perry/ads`](#perryads) +- [`perry/audio`](#perryaudio) +- [`perry/background`](#perrybackground) +- [`perry/compose`](#perrycompose) +- [`perry/container`](#perrycontainer) +- [`perry/container-compose`](#perrycontainer-compose) +- [`perry/gc`](#perrygc) +- [`perry/i18n`](#perryi18n) +- [`perry/media`](#perrymedia) +- [`perry/plugin`](#perryplugin) +- [`perry/system`](#perrysystem) +- [`perry/thread`](#perrythread) +- [`perry/tui`](#perrytui) +- [`perry/ui`](#perryui) +- [`perry/updater`](#perryupdater) +- [`perry/widget`](#perrywidget) +- [`perry/workloads`](#perryworkloads) +- [`perry/yoga`](#perryyoga) - [`pg`](#pg) - [`process`](#process) - [`punycode`](#punycode) - [`querystring`](#querystring) - [`rate-limiter-flexible`](#rate-limiter-flexible) - [`readline`](#readline) -- [`readline/promises`](#readline-promises) +- [`readline/promises`](#readlinepromises) - [`redis`](#redis) - [`repl`](#repl) - [`sea`](#sea) @@ -102,28 +103,28 @@ Total: 2834 entries across 119 modules. - [`slugify`](#slugify) - [`sqlite`](#sqlite) - [`stream`](#stream) -- [`stream/consumers`](#stream-consumers) -- [`stream/promises`](#stream-promises) -- [`stream/web`](#stream-web) +- [`stream/consumers`](#streamconsumers) +- [`stream/promises`](#streampromises) +- [`stream/web`](#streamweb) - [`streams`](#streams) -- [`string_decoder`](#string-decoder) +- [`string_decoder`](#string_decoder) - [`sys`](#sys) - [`test`](#test) -- [`test/reporters`](#test-reporters) +- [`test/reporters`](#testreporters) - [`timers`](#timers) -- [`timers/promises`](#timers-promises) +- [`timers/promises`](#timerspromises) - [`tls`](#tls) - [`tty`](#tty) - [`tursodb`](#tursodb) - [`url`](#url) - [`util`](#util) -- [`util/types`](#util-types) +- [`util/types`](#utiltypes) - [`uuid`](#uuid) - [`v8`](#v8) - [`validator`](#validator) - [`vm`](#vm) - [`wasi`](#wasi) -- [`worker_threads`](#worker-threads) +- [`worker_threads`](#worker_threads) - [`ws`](#ws) - [`zlib`](#zlib) @@ -340,20 +341,37 @@ Total: 2834 entries across 119 modules. - `kMaxLength` - `kStringMaxLength` +## `bun` + +### Methods + +- `file` — module +- `fileURLToPath` — module +- `hash` — module +- `pathToFileURL` — module +- `stringWidth` — module +- `write` — module + +### Properties + +- `stderr` +- `stdin` +- `stdout` + ## `bun:ffi` ### Methods -- `CFunction` — module +- `CFunction` — module ⚠ **stub** — stage 3 — not yet implemented, throws at runtime (#6562) - `CString` — module -- `JSCallback` — module +- `JSCallback` — module ⚠ **stub** — stage 3 — not yet implemented, throws at runtime (#6562) - `dlopen` — module -- `linkSymbols` — module +- `linkSymbols` — module ⚠ **stub** — stage ≥2 — not yet implemented, throws at runtime (#6562) - `ptr` — module -- `read` — module -- `toArrayBuffer` — module -- `toBuffer` — module -- `viewSource` — module +- `read` — module ⚠ **stub** — stage ≥2 — not yet implemented, throws at runtime (#6562) +- `toArrayBuffer` — module ⚠ **stub** — stage 2 — not yet implemented, throws at runtime (#6562) +- `toBuffer` — module ⚠ **stub** — stage 2 — not yet implemented, throws at runtime (#6562) +- `viewSource` — module ⚠ **stub** — stage ≥2 — not yet implemented, throws at runtime (#6562) ### Properties