From e378e5ef2f7ca0122816ef0c18376b460ee0ac9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 24 Aug 2026 08:13:37 +0200 Subject: [PATCH] fix(runtime): complete C-ABI FFI layer (#6562) --- changelog.d/8704-complete-c-abi-ffi.md | 11 + crates/perry-api-manifest/src/entries.rs | 2 + .../perry-api-manifest/src/entries/part_1.rs | 29 +- .../tests/stub_inventory.rs | 6 - crates/perry-codegen/src/codegen/entry.rs | 4 + crates/perry-codegen/src/expr/fs_await.rs | 4 + crates/perry-codegen/src/nm_install.rs | 2 +- .../src/runtime_decls/strings_part2.rs | 1 + .../native_module/imported_module_dispatch.rs | 7 + crates/perry-hir/src/lower/expr_new.rs | 7 +- crates/perry-hir/src/lower/expr_new/member.rs | 4 +- .../module_decl/native_default_import.rs | 1 + crates/perry-runtime/src/bun_ffi/call.rs | 689 ++++++++++------- crates/perry-runtime/src/bun_ffi/callback.rs | 392 ++++++++-- crates/perry-runtime/src/bun_ffi/dlopen.rs | 707 ++++++++++++++---- crates/perry-runtime/src/bun_ffi/memory.rs | 50 ++ crates/perry-runtime/src/bun_ffi/mod.rs | 55 +- crates/perry-runtime/src/bun_ffi/read.rs | 201 +++++ crates/perry-runtime/src/event_pump.rs | 3 + .../perry-runtime/src/object/native_module.rs | 8 +- .../callable_export_arity_table.rs | 15 +- .../native_module/callable_export_check.rs | 9 +- .../native_module/callable_export_table.rs | 11 +- .../src/object/native_module/constants.rs | 4 + .../src/object/native_module/module_keys.rs | 8 + .../src/object/native_module_dispatch.rs | 3 + .../native_module_dispatch/dispatch_a_c.rs | 4 +- .../src/object/native_module_registry.rs | 2 +- crates/perry-runtime/src/process.rs | 7 +- .../perry-runtime/src/promise/microtasks.rs | 2 + crates/perry/tests/bun_ffi_stage1.rs | 124 ++- scripts/raw_handle_debt_baseline.txt | 2 +- scripts/raw_handle_debt_files.txt | 1 - 33 files changed, 1849 insertions(+), 526 deletions(-) create mode 100644 changelog.d/8704-complete-c-abi-ffi.md create mode 100644 crates/perry-runtime/src/bun_ffi/read.rs diff --git a/changelog.d/8704-complete-c-abi-ffi.md b/changelog.d/8704-complete-c-abi-ffi.md new file mode 100644 index 0000000000..9640014c88 --- /dev/null +++ b/changelog.d/8704-complete-c-abi-ffi.md @@ -0,0 +1,11 @@ +--- +category: Runtime +title: Complete Bun and Node C-ABI FFI support +--- + +Perry now supports typed scalar C-ABI calls through `bun:ffi`, including stack +arguments, pinned pointers, zero-copy native memory views, scalar reads, and +rooted same-thread or threadsafe callbacks. A Node 26-compatible `node:ffi` +adapter lets OpenTUI/Yoga and other native wrappers load their upstream shared +libraries without source changes, and the real `bun-pty` shell roundtrip is +covered end to end. diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 1e9c66a9ae..c099a601eb 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -58,6 +58,7 @@ pub const NATIVE_MODULES: &[&str] = &[ // #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", + "ffi", // node:ffi (the node: prefix is normalized away) "bun:sqlite", // Bun facade over Perry's native SQLite engine "node-cron", // cron-style scheduler (npm node-cron; aliases `cron`) "nodemailer", // SMTP email sending @@ -243,6 +244,7 @@ pub const RUNTIME_ONLY_MODULES: &[&str] = &[ "buffer", // #6562: bun:ffi is implemented entirely in perry-runtime. "bun:ffi", + "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 e1f40d8790..757c28e806 100644 --- a/crates/perry-api-manifest/src/entries/part_1.rs +++ b/crates/perry-api-manifest/src/entries/part_1.rs @@ -206,9 +206,8 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[ // bun:ffi (#6562). `FFIType` and `suffix` are // constants; symbol-table call stubs live on the object `dlopen` // returns, so the module surface itself is small. The later-stage - // exports (linkSymbols / CFunction / viewSource / read) are - // declared and throw a descriptive - // ERR_NOT_IMPLEMENTED at runtime. + // exports (linkSymbols / CFunction / viewSource / read) share the same + // scalar ABI and pinned-memory implementation. method("bun:ffi", "dlopen", false, None), method("bun:ffi", "ptr", false, None), method("bun:ffi", "CString", false, None), @@ -217,18 +216,18 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[ method("bun:ffi", "toArrayBuffer", false, None), method("bun:ffi", "toBuffer", false, None), method("bun:ffi", "JSCallback", false, None), - // Remaining 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", "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)"), + method("bun:ffi", "CFunction", false, None), + method("bun:ffi", "linkSymbols", false, None), + method("bun:ffi", "viewSource", false, None), + property("bun:ffi", "read"), + // node:ffi compatibility surface (Node 26), consumed by OpenTUI's Node + // adapter. Callback registration lives on the library returned by dlopen. + method("ffi", "dlopen", false, None), + method("ffi", "getRawPointer", false, None), + method("ffi", "toArrayBuffer", false, None), + method("ffi", "toBuffer", false, None), + method("ffi", "toString", false, None), + property("ffi", "suffix"), // bun:sqlite (#8510) shares node:sqlite's rusqlite handles while keeping // Bun's public constructor and statement vocabulary. class("bun:sqlite", "Database"), diff --git a/crates/perry-api-manifest/tests/stub_inventory.rs b/crates/perry-api-manifest/tests/stub_inventory.rs index 6f40fb62e7..01ef743285 100644 --- a/crates/perry-api-manifest/tests/stub_inventory.rs +++ b/crates/perry-api-manifest/tests/stub_inventory.rs @@ -93,10 +93,6 @@ 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) — the remaining FFI surface is declared so - // feature probes get a clear error, but throws at runtime until the - // later stages land: CFunction, linkSymbols, viewSource, read. - ("#6562", 4), ]; let expected_map: BTreeMap = expected.iter().map(|(k, v)| (k.to_string(), *v)).collect(); @@ -123,8 +119,6 @@ fn stubs_only_appear_in_allowlisted_modules() { "exponential-backoff", "inspector", "repl", - // #6562: bun:ffi later-stage exports are declared-but-throwing stubs. - "bun:ffi", ]; for e in iter_entries().filter(|e| e.stub) { assert!( diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 120fca6108..17778dc1b8 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -1166,6 +1166,9 @@ pub(super) fn compile_module_entry( "0".to_string() }; let has_stdlib = ctx.block().call(I32, "js_stdlib_has_active_handles", &[]); + let has_ffi_callbacks = + ctx.block() + .call(I32, "js_bun_ffi_has_active_threadsafe_callbacks", &[]); // #591: TASK_QUEUE may carry a pending `.then` continuation // that was queued by `js_run_stdlib_pump`'s resolution path // in the SAME body iteration that already drained the inflight @@ -1175,6 +1178,7 @@ pub(super) fn compile_module_entry( let has_microtasks = ctx.block().call(I32, "js_microtasks_pending", &[]); let any1 = ctx.block().or(I32, &has_timers, &has_callbacks); let any2 = ctx.block().or(I32, &has_intervals, &has_stdlib); + let any2 = ctx.block().or(I32, &any2, &has_ffi_callbacks); let any3 = ctx.block().or(I32, &any1, &any2); let any4 = ctx.block().or(I32, &any3, &has_cron); let any = ctx.block().or(I32, &any4, &has_microtasks); diff --git a/crates/perry-codegen/src/expr/fs_await.rs b/crates/perry-codegen/src/expr/fs_await.rs index 9ffc923f95..72da86e5d0 100644 --- a/crates/perry-codegen/src/expr/fs_await.rs +++ b/crates/perry-codegen/src/expr/fs_await.rs @@ -222,9 +222,13 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let has_callbacks = ctx.block().call(I32, "js_callback_timer_has_pending", &[]); let has_intervals = ctx.block().call(I32, "js_interval_timer_has_pending", &[]); let has_stdlib = ctx.block().call(I32, "js_stdlib_has_active_handles", &[]); + let has_ffi_callbacks = + ctx.block() + .call(I32, "js_bun_ffi_has_active_threadsafe_callbacks", &[]); let has_microtasks = ctx.block().call(I32, "js_microtasks_pending", &[]); let any1 = ctx.block().or(I32, &has_timers, &has_callbacks); let any2 = ctx.block().or(I32, &has_intervals, &has_stdlib); + let any2 = ctx.block().or(I32, &any2, &has_ffi_callbacks); let any3 = ctx.block().or(I32, &any1, &any2); let any = ctx.block().or(I32, &any3, &has_microtasks); let no_refed_work = ctx.block().icmp_eq(I32, &any, "0"); diff --git a/crates/perry-codegen/src/nm_install.rs b/crates/perry-codegen/src/nm_install.rs index 282baa071b..a5f6e1ac5c 100644 --- a/crates/perry-codegen/src/nm_install.rs +++ b/crates/perry-codegen/src/nm_install.rs @@ -16,7 +16,7 @@ pub(crate) fn nm_install_symbol(name: &str) -> Option<&'static str> { "bun" => Some("js_nm_install_bun"), // #6562: bun:ffi keeps its scheme prefix (only `node:` is stripped // above). - "bun:ffi" => Some("js_nm_install_bun_ffi"), + "bun:ffi" | "ffi" | "ffi.default" => 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"), diff --git a/crates/perry-codegen/src/runtime_decls/strings_part2.rs b/crates/perry-codegen/src/runtime_decls/strings_part2.rs index bb85f2a274..406d442753 100644 --- a/crates/perry-codegen/src/runtime_decls/strings_part2.rs +++ b/crates/perry-codegen/src/runtime_decls/strings_part2.rs @@ -760,6 +760,7 @@ pub(crate) fn declare_phase_b_strings_part2(module: &mut LlModule) { // Stdlib has-active-handles — returns 1 if WS servers, pending // HTTP events, etc. need the loop to keep running. module.declare_function("js_stdlib_has_active_handles", I32, &[]); + module.declare_function("js_bun_ffi_has_active_threadsafe_callbacks", I32, &[]); // #591: returns 1 iff perry-runtime's per-thread microtask // TASK_QUEUE has a pending entry. The codegen-emitted event-loop // header check ORs this in so the loop doesn't exit between the diff --git a/crates/perry-hir/src/lower/expr_call/native_module/imported_module_dispatch.rs b/crates/perry-hir/src/lower/expr_call/native_module/imported_module_dispatch.rs index 3d5dfee7d5..1b3f7f9fe9 100644 --- a/crates/perry-hir/src/lower/expr_call/native_module/imported_module_dispatch.rs +++ b/crates/perry-hir/src/lower/expr_call/native_module/imported_module_dispatch.rs @@ -105,6 +105,13 @@ pub(super) fn try_imported_module_dispatch( if module_name == "worker_threads" && method_name == "workerData" { return Ok(Err(args)); } + // `read` is a data namespace (`read.u32(pointer)`), not a + // class/module prefix. A named import must call the reader + // closure stored on that runtime object instead of being + // reinterpreted as a nonexistent `bun:ffi.u32` export. + if module_name == "bun:ffi" && imported_method == Some("read") { + return Ok(Err(args)); + } if module_name.strip_prefix("node:").unwrap_or(module_name) == "vm" && imported_method.is_none() && method_name == "Module" diff --git a/crates/perry-hir/src/lower/expr_new.rs b/crates/perry-hir/src/lower/expr_new.rs index 0983973c4d..3bb744b362 100644 --- a/crates/perry-hir/src/lower/expr_new.rs +++ b/crates/perry-hir/src/lower/expr_new.rs @@ -613,12 +613,15 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R // through the module call directly so `new JSCallback(...)` // observes that explicit object return instead of generic // `Expr::New` manufacturing and retaining a blank instance. - if module_name == "bun:ffi" && method_name == Some("JSCallback") { + if module_name == "bun:ffi" + && matches!(method_name, Some("JSCallback") | Some("CFunction")) + { + let method = method_name.unwrap_or("JSCallback").to_string(); return Ok(Expr::NativeMethodCall { module: "bun:ffi".to_string(), class_name: None, object: None, - method: "JSCallback".to_string(), + method, args: lower_optional_args(ctx, new_expr.args.as_deref())?, }); } diff --git a/crates/perry-hir/src/lower/expr_new/member.rs b/crates/perry-hir/src/lower/expr_new/member.rs index a005f6349b..fd7c535665 100644 --- a/crates/perry-hir/src/lower/expr_new/member.rs +++ b/crates/perry-hir/src/lower/expr_new/member.rs @@ -94,12 +94,12 @@ pub(crate) fn lower_new_member_native( .is_some_and(|(module, export)| { module == "bun:ffi" && (export.is_none() || export == Some("default")) }); - if is_bun_ffi_module && prop_ident.sym.as_ref() == "JSCallback" { + if is_bun_ffi_module && matches!(prop_ident.sym.as_ref(), "JSCallback" | "CFunction") { return Ok(Some(Expr::NativeMethodCall { module: "bun:ffi".to_string(), class_name: None, object: None, - method: "JSCallback".to_string(), + method: prop_ident.sym.to_string(), args: lower_optional_args(ctx, new_expr.args.as_deref())?, })); } diff --git a/crates/perry-hir/src/lower/module_decl/native_default_import.rs b/crates/perry-hir/src/lower/module_decl/native_default_import.rs index 95ae32715c..b509d628d3 100644 --- a/crates/perry-hir/src/lower/module_decl/native_default_import.rs +++ b/crates/perry-hir/src/lower/module_decl/native_default_import.rs @@ -20,6 +20,7 @@ pub(crate) fn is_cjs_style_native_default_import(module_name: &str) -> bool { | "dns" | "dns/promises" | "events" + | "ffi" | "inspector" | "inspector/promises" | "module" diff --git a/crates/perry-runtime/src/bun_ffi/call.rs b/crates/perry-runtime/src/bun_ffi/call.rs index 8c23fa85f7..eb8b2d003f 100644 --- a/crates/perry-runtime/src/bun_ffi/call.rs +++ b/crates/perry-runtime/src/bun_ffi/call.rs @@ -1,5 +1,4 @@ -//! Typed C-ABI calls: argument marshalling + exact-arity register-image -//! call shims. +//! Typed C-ABI calls: argument marshalling + register/stack call shims. //! //! ## Why not libffi //! @@ -17,25 +16,13 @@ //! - **AAPCS64 (incl. Apple arm64)**: integer-class args take x0–x7; float //! args take v0–v7. Also independent. //! -//! 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. -//! -//! ### Exact arity (no over-calling) -//! -//! 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. +//! Arguments that exhaust their register class continue on the stack in +//! original source order. That last clause matters: OpenTUI's complete symbol +//! table contains 14-argument functions and FFF contains 13-argument +//! functions, so a register-only implementation cannot even finish `dlopen`. +//! The assembly helpers below construct the exact register and stack image +//! directly. They never transmute a native symbol to a mismatched Rust +//! function type and never pass surplus arguments. //! //! ### Residual assumptions (documented, not eliminated) //! @@ -57,32 +44,53 @@ //! - **Variadics** are unsupported (Apple arm64 passes variadic args on the //! stack) — a limitation shared with Bun's documented FFI surface. //! -//! `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). +//! `dlopen` enforces the public 16-scalar-argument cap and rejects unsupported +//! targets up front. 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; +#[cfg(target_arch = "x86_64")] +const ABI_INT_REGS: usize = 6; +#[cfg(target_arch = "aarch64")] +const ABI_INT_REGS: usize = 8; +#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] +const ABI_INT_REGS: usize = 0; + /// Marshalled register image for one call. -#[derive(Default)] pub(crate) struct ArgImage { - pub ints: [usize; MAX_INT_ARGS], + pub ints: [usize; 8], pub floats: [f64; MAX_FLOAT_ARGS], - /// Number of populated integer-class / float-class slots — the exact - /// arity the call shim transmutes to. + /// Arguments that did not fit their ABI register class, in original + /// declaration order, including the target ABI's padding. + pub stack: [u8; MAX_ARGS * 8], + /// Number of populated register and stack slots. pub n_int: usize, pub n_float: usize, + pub n_stack_bytes: usize, /// NUL-terminated temporaries for `cstring` args passed as JS strings. /// Kept alive until after the native call returns. pub temps: Vec>, } +impl Default for ArgImage { + fn default() -> Self { + Self { + ints: [0; 8], + floats: [0.0; MAX_FLOAT_ARGS], + stack: [0; MAX_ARGS * 8], + n_int: 0, + n_float: 0, + n_stack_bytes: 0, + temps: Vec::new(), + } + } +} + /// 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. @@ -93,102 +101,187 @@ pub(crate) const fn platform_supported() -> bool { )) } -#[cfg(all(unix, any(target_arch = "x86_64", target_arch = "aarch64")))] -mod raw { - //! 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, - ni: usize, - i: &[usize; 8], - nf: usize, - d: &[f64; 8], - ) -> u64 { - dispatch_exact!(f, i, d, u64, ni, nf) - } +#[cfg(target_vendor = "apple")] +macro_rules! call_symbol { + ($name:literal) => { + concat!("_", $name) + }; +} +#[cfg(not(target_vendor = "apple"))] +macro_rules! call_symbol { + ($name:literal) => { + $name + }; +} - #[inline(never)] - 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) - } +// SysV x86-64 helper ABI on entry: +// rdi=target, rsi=integer image, rdx=float image, rcx=stack image, +// r8=stack count. The helper builds the target call frame, then loads all +// target argument registers last so its own bookkeeping cannot clobber them. +#[cfg(all(unix, target_arch = "x86_64"))] +core::arch::global_asm!( + ".text", + ".p2align 4, 0x90", + concat!(".globl ", call_symbol!("perry_ffi_call_scalar_int")), + concat!(".globl ", call_symbol!("perry_ffi_call_scalar_f64")), + concat!(".globl ", call_symbol!("perry_ffi_call_scalar_f32")), + concat!(call_symbol!("perry_ffi_call_scalar_int"), ":"), + concat!(call_symbol!("perry_ffi_call_scalar_f64"), ":"), + concat!(call_symbol!("perry_ffi_call_scalar_f32"), ":"), + " push rbp", + " push r12", + " push r13", + " push r14", + " push r15", + " mov r12, rdi", + " mov r13, rsi", + " mov r14, rdx", + " mov r15, rcx", + " mov rbp, r8", + // Round stack byte length up to 16. RSP is 16-aligned after five pushes. + " lea r11, [r8 + 15]", + " and r11, -16", + " sub rsp, r11", + " xor r10d, r10d", + "2:", + " cmp r10, rbp", + " jae 3f", + " mov r11, qword ptr [r15 + r10]", + " mov qword ptr [rsp + r10], r11", + " add r10, 8", + " jmp 2b", + "3:", + " mov rdi, qword ptr [r13 + 0]", + " mov rsi, qword ptr [r13 + 8]", + " mov rdx, qword ptr [r13 + 16]", + " mov rcx, qword ptr [r13 + 24]", + " mov r8, qword ptr [r13 + 32]", + " mov r9, qword ptr [r13 + 40]", + " movq xmm0, qword ptr [r14 + 0]", + " movq xmm1, qword ptr [r14 + 8]", + " movq xmm2, qword ptr [r14 + 16]", + " movq xmm3, qword ptr [r14 + 24]", + " movq xmm4, qword ptr [r14 + 32]", + " movq xmm5, qword ptr [r14 + 40]", + " movq xmm6, qword ptr [r14 + 48]", + " movq xmm7, qword ptr [r14 + 56]", + " call r12", + " lea r11, [rbp + 15]", + " and r11, -16", + " add rsp, r11", + " pop r15", + " pop r14", + " pop r13", + " pop r12", + " pop rbp", + " ret", +); + +// AAPCS64 helper ABI on entry: x0=target, x1=integer image, x2=float +// image, x3=stack image, x4=stack count. +#[cfg(all(unix, target_arch = "aarch64"))] +core::arch::global_asm!( + ".text", + ".p2align 2", + concat!(".globl ", call_symbol!("perry_ffi_call_scalar_int")), + concat!(".globl ", call_symbol!("perry_ffi_call_scalar_f64")), + concat!(".globl ", call_symbol!("perry_ffi_call_scalar_f32")), + concat!(call_symbol!("perry_ffi_call_scalar_int"), ":"), + concat!(call_symbol!("perry_ffi_call_scalar_f64"), ":"), + concat!(call_symbol!("perry_ffi_call_scalar_f32"), ":"), + " stp x29, x30, [sp, #-48]!", + " stp x19, x20, [sp, #16]", + " stp x21, x22, [sp, #32]", + " mov x29, sp", + " mov x19, x0", + " mov x20, x1", + " mov x21, x2", + " mov x22, x3", + " add x9, x4, #15", + " and x9, x9, #-16", + " sub sp, sp, x9", + " mov x10, #0", + "2:", + " cmp x10, x4", + " b.hs 3f", + " ldr x11, [x22, x10]", + " str x11, [sp, x10]", + " add x10, x10, #8", + " b 2b", + "3:", + " ldp x0, x1, [x20, #0]", + " ldp x2, x3, [x20, #16]", + " ldp x4, x5, [x20, #32]", + " ldp x6, x7, [x20, #48]", + " ldp d0, d1, [x21, #0]", + " ldp d2, d3, [x21, #16]", + " ldp d4, d5, [x21, #32]", + " ldp d6, d7, [x21, #48]", + " blr x19", + " mov sp, x29", + " ldp x19, x20, [sp, #16]", + " ldp x21, x22, [sp, #32]", + " ldp x29, x30, [sp], #48", + " ret", +); - #[inline(never)] - 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) +#[cfg(all(unix, any(target_arch = "x86_64", target_arch = "aarch64")))] +mod raw { + extern "C" { + #[link_name = "perry_ffi_call_scalar_int"] + fn scalar_int( + target: usize, + ints: *const usize, + floats: *const f64, + stack: *const u8, + stack_len: usize, + ) -> u64; + #[link_name = "perry_ffi_call_scalar_f64"] + fn scalar_f64( + target: usize, + ints: *const usize, + floats: *const f64, + stack: *const u8, + stack_len: usize, + ) -> f64; + #[link_name = "perry_ffi_call_scalar_f32"] + fn scalar_f32( + target: usize, + ints: *const usize, + floats: *const f64, + stack: *const u8, + stack_len: usize, + ) -> f32; + } + + pub(crate) unsafe fn call_int(f: usize, image: &super::ArgImage) -> u64 { + scalar_int( + f, + image.ints.as_ptr(), + image.floats.as_ptr(), + image.stack.as_ptr(), + image.n_stack_bytes, + ) + } + + pub(crate) unsafe fn call_f64(f: usize, image: &super::ArgImage) -> f64 { + scalar_f64( + f, + image.ints.as_ptr(), + image.floats.as_ptr(), + image.stack.as_ptr(), + image.n_stack_bytes, + ) + } + + pub(crate) unsafe fn call_f32(f: usize, image: &super::ArgImage) -> f32 { + scalar_f32( + f, + image.ints.as_ptr(), + image.floats.as_ptr(), + image.stack.as_ptr(), + image.n_stack_bytes, + ) } } @@ -196,31 +289,13 @@ 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, - _ni: usize, - _i: &[usize; 8], - _nf: usize, - _d: &[f64; 8], - ) -> u64 { + pub(crate) unsafe fn call_int(_f: usize, _image: &super::ArgImage) -> u64 { unreachable!("bun:ffi call on unsupported target") } - pub(crate) unsafe fn call_f64( - _f: usize, - _ni: usize, - _i: &[usize; 8], - _nf: usize, - _d: &[f64; 8], - ) -> f64 { + pub(crate) unsafe fn call_f64(_f: usize, _image: &super::ArgImage) -> f64 { unreachable!("bun:ffi call on unsupported target") } - pub(crate) unsafe fn call_f32( - _f: usize, - _ni: usize, - _i: &[usize; 8], - _nf: usize, - _d: &[f64; 8], - ) -> f32 { + pub(crate) unsafe fn call_f32(_f: usize, _image: &super::ArgImage) -> f32 { unreachable!("bun:ffi call on unsupported target") } } @@ -387,13 +462,39 @@ unsafe fn value_to_cstring_arg(v: f64, temps: &mut Vec>) -> usize { value_to_pointer_arg(v) } +#[cfg(all(target_vendor = "apple", target_arch = "aarch64"))] +fn stack_size_align(ty: u8) -> (usize, usize) { + // Apple's arm64 ABI compacts stack arguments at their natural C size + // (unlike generic AAPCS64 and SysV's eight-byte scalar slots). + match ty { + T_BOOL | T_CHAR | T_I8 | T_U8 => (1, 1), + T_I16 | T_U16 => (2, 2), + T_I32 | T_U32 | T_F32 => (4, 4), + _ => (8, 8), + } +} + +#[cfg(not(all(target_vendor = "apple", target_arch = "aarch64")))] +fn stack_size_align(_ty: u8) -> (usize, usize) { + (8, 8) +} + +fn append_stack_arg(image: &mut ArgImage, ty: u8, bits: u64) { + let (size, align) = stack_size_align(ty); + let offset = (image.n_stack_bytes + align - 1) & !(align - 1); + let end = offset + size; + debug_assert!(end <= image.stack.len()); + image.stack[offset..end].copy_from_slice(&bits.to_ne_bytes()[..size]); + image.n_stack_bytes = end; +} + /// 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). +/// `arg_types` must have passed `dlopen` validation (≤ 16 total, no +/// 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; @@ -403,34 +504,31 @@ pub(crate) unsafe fn marshal_args(arg_types: &[u8], js_args: &[f64]) -> ArgImage .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; - } + let (bits, float_class) = match ty { + T_F64 => (value_to_f64_num(v).to_bits(), true), 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 | T_FUNCTION => { - 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; + (f.to_bits() as u64, true) } + T_BOOL => (crate::value::js_is_truthy(v) as u64, false), + T_PTR | T_FUNCTION => (value_to_pointer_arg(v) as u64, false), + T_CSTRING => (value_to_cstring_arg(v, &mut image.temps) as u64, false), // 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; + _ => (value_to_u64_int(v), false), + }; + if float_class { + if fi < MAX_FLOAT_ARGS { + image.floats[fi] = f64::from_bits(bits); + fi += 1; + } else { + append_stack_arg(&mut image, ty, bits); } + } else if ii < ABI_INT_REGS { + image.ints[ii] = bits as usize; + ii += 1; + } else { + append_stack_arg(&mut image, ty, bits); } } image.n_int = ii; @@ -450,11 +548,11 @@ fn bool_value(b: bool) -> f64 { }) } -fn bigint_value_i64(v: i64) -> f64 { +pub(crate) 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 { +pub(crate) fn bigint_value_u64(v: u64) -> f64 { crate::value::js_nanbox_bigint(crate::bigint::js_bigint_from_u64(v) as i64) } @@ -483,18 +581,37 @@ 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); + call_and_convert_mode(fn_ptr, ret_type, image, false) +} + +/// Node's `node:ffi` API exposes pointer results as `bigint`, unlike Bun's +/// number-or-null representation. Keep the machine call identical and vary +/// only the final boxing step. +pub(crate) unsafe fn call_and_convert_node(fn_ptr: usize, ret_type: u8, image: &ArgImage) -> f64 { + call_and_convert_mode(fn_ptr, ret_type, image, true) +} + +unsafe fn call_and_convert_mode( + fn_ptr: usize, + ret_type: u8, + image: &ArgImage, + pointer_bigint: bool, +) -> f64 { let result = match ret_type { T_F64 => { - let r = raw::call_f64(fn_ptr, ni, &image.ints, nf, &image.floats); + let r = raw::call_f64(fn_ptr, image); super::number_value(r) } T_F32 => { - let r = raw::call_f32(fn_ptr, ni, &image.ints, nf, &image.floats); + let r = raw::call_f32(fn_ptr, image); super::number_value(r as f64) } + T_PTR | T_FUNCTION if pointer_bigint => { + let r = raw::call_int(fn_ptr, image); + bigint_value_u64(r) + } _ => { - let r = raw::call_int(fn_ptr, ni, &image.ints, nf, &image.floats); + let r = raw::call_int(fn_ptr, image); convert_int_return(ret_type, r) } }; @@ -562,6 +679,29 @@ mod tests { a as i64 + b as i64 + c as i64 + d as i64 + e as i64 + f as i64 + g as i64 + h as i64 } + #[allow(clippy::too_many_arguments)] + extern "C" fn sum14_i32( + a: i32, + b: i32, + c: i32, + d: i32, + e: i32, + f: i32, + g: i32, + h: i32, + i: i32, + j: i32, + k: i32, + l: i32, + m: i32, + n: i32, + ) -> i64 { + [a, b, c, d, e, f, g, h, i, j, k, l, m, n] + .into_iter() + .map(i64::from) + .sum() + } + 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 } @@ -574,6 +714,61 @@ mod tests { v * 0.5 } + #[allow(clippy::too_many_arguments)] + extern "C" fn mixed_stack_order( + _i1: i32, + _i2: i32, + _i3: i32, + _i4: i32, + _i5: i32, + _i6: i32, + _f1: f64, + _f2: f64, + _f3: f64, + _f4: f64, + _f5: f64, + _f6: f64, + _f7: f64, + _f8: f64, + f9: f64, + i7: i32, + ) -> f64 { + f9 * 100.0 + i7 as f64 + } + + #[allow(clippy::too_many_arguments)] + extern "C" fn opentui_box_shape( + a1: u32, + a2: i32, + a3: i32, + a4: u32, + a5: u32, + a6: *const u8, + a7: u32, + a8: *const u8, + a9: *const u8, + a10: *const u8, + a11: *const u8, + a12: u32, + a13: *const u8, + a14: u32, + ) -> u64 { + a1 as u64 + + a2 as u64 + + a3 as u64 + + a4 as u64 + + a5 as u64 + + a6 as usize as u64 + + a7 as u64 + + a8 as usize as u64 + + a9 as usize as u64 + + a10 as usize as u64 + + a11 as usize as u64 + + a12 as u64 + + a13 as usize as u64 + + a14 as u64 + } + extern "C" fn u64_id(v: u64) -> u64 { v } @@ -588,40 +783,40 @@ mod tests { 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.n_int = ints.len(); - image.n_float = floats.len(); + let int_regs = ints.len().min(ABI_INT_REGS); + image.ints[..int_regs].copy_from_slice(&ints[..int_regs]); + image.n_int = int_regs; + for &value in &ints[int_regs..] { + append_stack_arg(&mut image, T_I32, value as u64); + } + let float_regs = floats.len().min(MAX_FLOAT_ARGS); + image.floats[..float_regs].copy_from_slice(&floats[..float_regs]); + image.n_float = float_regs; + for &value in &floats[float_regs..] { + append_stack_arg(&mut image, T_F64, value.to_bits()); + } 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 *const () as usize, - image.n_int, - &image.ints, - image.n_float, - &image.floats, - ) - }; + let r = unsafe { raw::call_int(sum8_i32 as *const () as usize, &image) }; assert_eq!(r as i64, 36); } + #[test] + fn stack_image_reaches_fourteen_int_args() { + let image = image_from(&[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], &[]); + assert!(image.n_stack_bytes >= 24); + let r = unsafe { raw::call_int(sum14_i32 as *const () as usize, &image) }; + assert_eq!(r as i64, 105); + } + #[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 *const () as usize, - image.n_int, - &image.ints, - image.n_float, - &image.floats, - ) - }; + let r = unsafe { raw::call_f64(dsum8 as *const () as usize, &image) }; assert_eq!(r, 32.0); } @@ -631,73 +826,65 @@ 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 *const () as usize, - image.n_int, - &image.ints, - image.n_float, - &image.floats, - ) - }; + let r = unsafe { raw::call_f64(mixed as *const () as usize, &image) }; assert_eq!(r, 10.0 + 4.0 + 60.0 + 16.0 + 150.0 + 9.0); } + #[test] + fn mixed_overflow_arguments_keep_source_stack_order() { + let types = [ + T_I32, T_I32, T_I32, T_I32, T_I32, T_I32, T_F64, T_F64, T_F64, T_F64, T_F64, T_F64, + T_F64, T_F64, T_F64, T_I32, + ]; + let values: Vec = (1..=16) + .map(|value| super::super::number_value(value as f64)) + .collect(); + let image = unsafe { marshal_args(&types, &values) }; + #[cfg(target_arch = "x86_64")] + assert_eq!(image.n_stack_bytes, 16); + #[cfg(target_arch = "aarch64")] + assert_eq!(image.n_stack_bytes, 8); + let result = unsafe { raw::call_f64(mixed_stack_order as *const () as usize, &image) }; + assert_eq!(result, 1516.0); + } + + #[test] + fn opentui_fourteen_argument_pointer_alignment_matches_host_abi() { + let types = [ + T_U32, T_I32, T_I32, T_U32, T_U32, T_PTR, T_U32, T_PTR, T_PTR, T_PTR, T_PTR, T_U32, + T_PTR, T_U32, + ]; + let values: Vec = (1..=14) + .map(|value| super::super::number_value(value as f64)) + .collect(); + let image = unsafe { marshal_args(&types, &values) }; + let result = unsafe { raw::call_int(opentui_box_shape as *const () as usize, &image) }; + assert_eq!(result, 105); + } + #[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 *const () as usize, - image.n_int, - &image.ints, - image.n_float, - &image.floats, - ) - }; + let r = unsafe { raw::call_f32(f32_half as *const () as usize, &image) }; 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 *const () as usize, - image.n_int, - &image.ints, - image.n_float, - &image.floats, - ) - }; + let r = unsafe { raw::call_int(u64_id as *const () as usize, &image) }; 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 *const () as usize, - image.n_int, - &image.ints, - image.n_float, - &image.floats, - ) - }; + let r = unsafe { raw::call_int(bool_not as *const () as usize, &image) }; // 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 *const () as usize, - image.n_int, - &image.ints, - image.n_float, - &image.floats, - ) - }; + let r = unsafe { raw::call_int(i8_neg as *const () as usize, &image) }; assert_eq!(r as u8 as i8, -5); } @@ -714,30 +901,14 @@ mod tests { #[test] fn exact_arity_three_ints() { let image = image_from(&[100, 20, 3], &[]); - let r = unsafe { - raw::call_int( - add3 as *const () as usize, - image.n_int, - &image.ints, - image.n_float, - &image.floats, - ) - }; + let r = unsafe { raw::call_int(add3 as *const () as usize, &image) }; assert_eq!(r, 123); } #[test] fn exact_arity_zero_args() { let image = image_from(&[], &[]); - let r = unsafe { - raw::call_int( - noargs as *const () as usize, - image.n_int, - &image.ints, - image.n_float, - &image.floats, - ) - }; + let r = unsafe { raw::call_int(noargs as *const () as usize, &image) }; assert_eq!(r as u32, 1234); } diff --git a/crates/perry-runtime/src/bun_ffi/callback.rs b/crates/perry-runtime/src/bun_ffi/callback.rs index f3bd2739cf..df7b1c7c2d 100644 --- a/crates/perry-runtime/src/bun_ffi/callback.rs +++ b/crates/perry-runtime/src/bun_ffi/callback.rs @@ -1,4 +1,4 @@ -//! Same-thread native-to-JS callback trampolines for `bun:ffi`. +//! Native-to-JS callback trampolines for `bun:ffi` and `node:ffi`. //! //! A callback pointer must carry both an arbitrary C scalar signature and a //! Perry callback identity. The hosted ABIs already split scalar arguments @@ -15,7 +15,8 @@ use super::call::{self, MAX_ARGS, MAX_FLOAT_ARGS}; use super::types::*; use crate::closure::ClosureHeader; use crate::value::JSValue; -use std::sync::Mutex; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; use std::thread::ThreadId; const MAX_CALLBACKS: usize = 128; @@ -33,10 +34,22 @@ struct CallbackRecord { ret: u8, argc: u8, args: [u8; MAX_ARGS], + threadsafe: bool, + owner_lib: Option, open: bool, } static CALLBACKS: Mutex> = Mutex::new(Vec::new()); +static ACTIVE_THREADSAFE_CALLBACKS: AtomicUsize = AtomicUsize::new(0); + +struct PendingCallback { + index: usize, + integer_registers: [usize; 8], + float_registers: [u64; 8], + completion: Arc<(Mutex>, Condvar)>, +} + +static PENDING_CALLBACKS: Mutex> = Mutex::new(Vec::new()); #[cfg(target_vendor = "apple")] macro_rules! callback_symbol { @@ -172,49 +185,76 @@ unsafe fn object_ptr(value: f64) -> Option<*mut crate::object::ObjectHeader> { } unsafe fn get_field(object: *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(object, key).bits()) + let scope = crate::gc::RuntimeHandleScope::new(); + let object = scope.root_raw_mut_ptr(object); + let key = scope.root_string_ptr(crate::string::js_string_from_bytes( + name.as_ptr(), + name.len() as u32, + )); + f64::from_bits( + object + .with_mut_ptr(|object| { + key.with_const_ptr(|key| crate::object::js_object_get_field_by_name(object, key)) + }) + .bits(), + ) } fn set_field(object: *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(object, key, value); + let scope = crate::gc::RuntimeHandleScope::new(); + let object = scope.root_raw_mut_ptr(object); + let value = scope.root_nanbox_f64(value); + let key = scope.root_string_ptr(crate::string::js_string_from_bytes( + name.as_ptr(), + name.len() as u32, + )); + object.with_mut_ptr(|object| { + key.with_const_ptr(|key| { + crate::object::js_object_set_field_by_name(object, key, value.get_nanbox_f64()) + }) + }); } fn throw_type(message: &str) -> ! { crate::fs::validate::throw_type_error_with_code(message, "ERR_INVALID_ARG_TYPE") } -fn parse_signature(definition: f64) -> (u8, u8, [u8; MAX_ARGS]) { +fn parse_signature(definition: f64) -> (u8, u8, [u8; MAX_ARGS], bool) { let Some(object) = (unsafe { object_ptr(definition) }) else { throw_type("JSCallback callback definition must be an object"); }; + let scope = crate::gc::RuntimeHandleScope::new(); + let object = scope.root_raw_mut_ptr(object); - let threadsafe = unsafe { get_field(object, "threadsafe") }; - if !JSValue::from_bits(threadsafe.to_bits()).is_undefined() - && crate::value::js_is_truthy(threadsafe) != 0 - { - crate::fs::validate::throw_error_with_code( - "bun:ffi: threadsafe JSCallback is not supported; callbacks must be invoked on their creating thread", - "ERR_NOT_IMPLEMENTED", - ); - } + let threadsafe = object.with_mut_ptr(|object: *mut crate::object::ObjectHeader| unsafe { + get_field(object, "threadsafe") + }); + let threadsafe = !JSValue::from_bits(threadsafe.to_bits()).is_undefined() + && crate::value::js_is_truthy(threadsafe) != 0; let mut args = [T_VOID; MAX_ARGS]; - let args_value = unsafe { get_field(object, "args") }; - let args_jv = JSValue::from_bits(args_value.to_bits()); + let args_value = scope.root_nanbox_f64(object.with_mut_ptr( + |object: *mut crate::object::ObjectHeader| unsafe { get_field(object, "args") }, + )); + let args_jv = JSValue::from_bits(args_value.get_nanbox_f64().to_bits()); let mut argc = 0usize; if !args_jv.is_undefined() && !args_jv.is_null() { - if !JSValue::from_bits(crate::array::js_array_is_array(args_value).to_bits()).as_bool() { + if !JSValue::from_bits( + crate::array::js_array_is_array(args_value.get_nanbox_f64()).to_bits(), + ) + .as_bool() + { throw_type("JSCallback definition.args must be an array"); } - let array = crate::value::js_nanbox_get_pointer(args_value) as usize + let array = crate::value::js_nanbox_get_pointer(args_value.get_nanbox_f64()) as usize as *const crate::array::ArrayHeader; let len = crate::array::js_array_length(array) as usize; if len > MAX_ARGS { throw_type(&format!("JSCallback supports at most {MAX_ARGS} arguments")); } for (index, slot) in args.iter_mut().take(len).enumerate() { + let array = crate::value::js_nanbox_get_pointer(args_value.get_nanbox_f64()) as usize + as *const crate::array::ArrayHeader; let value = crate::array::js_array_get(array, index as u32); *slot = unsafe { super::types::parse_ffi_type_checked(f64::from_bits(value.bits())) } .unwrap_or_else(|message| throw_type(&format!("JSCallback: {message}"))); @@ -222,7 +262,9 @@ fn parse_signature(definition: f64) -> (u8, u8, [u8; MAX_ARGS]) { argc = len; } - let returns = unsafe { get_field(object, "returns") }; + let returns = object.with_mut_ptr(|object: *mut crate::object::ObjectHeader| unsafe { + get_field(object, "returns") + }); let returns_jv = JSValue::from_bits(returns.to_bits()); let ret = if returns_jv.is_undefined() || returns_jv.is_null() { T_VOID @@ -231,9 +273,14 @@ fn parse_signature(definition: f64) -> (u8, u8, [u8; MAX_ARGS]) { .unwrap_or_else(|message| throw_type(&format!("JSCallback: {message}"))) }; + validate_callback_types(ret, &args[..argc]); + (ret, argc as u8, args, threadsafe) +} + +fn validate_callback_types(ret: u8, args: &[u8]) { let mut ints = 0usize; let mut floats = 0usize; - for &ty in &args[..argc] { + for &ty in args { match ty { T_VOID => throw_type("JSCallback: void is not a valid argument type"), T_NAPI_ENV | T_NAPI_VALUE | T_BUFFER => { @@ -262,15 +309,12 @@ fn parse_signature(definition: f64) -> (u8, u8, [u8; MAX_ARGS]) { } _ => {} } - - (ret, argc as u8, args) } extern "C" fn callback_close_thunk(closure: *const ClosureHeader) -> f64 { let index = crate::closure::js_closure_get_capture_bits(closure, 0) as usize; if let Some(record) = CALLBACKS.lock().unwrap().get_mut(index) { - record.open = false; - record.callback_bits = crate::value::TAG_UNDEFINED; + close_record(record); } super::undefined() } @@ -294,11 +338,51 @@ pub(crate) fn js_callback_value(callback: f64, definition: f64) -> f64 { "ERR_NOT_IMPLEMENTED", ); } - if !crate::object::value_is_callable(callback) { + let scope = crate::gc::RuntimeHandleScope::new(); + let callback = scope.root_nanbox_f64(callback); + let definition = scope.root_nanbox_f64(definition); + if !crate::object::value_is_callable(callback.get_nanbox_f64()) { throw_type("JSCallback expects a function as its first argument"); } - let (ret, argc, args) = parse_signature(definition); + let (ret, argc, args, threadsafe) = parse_signature(definition.get_nanbox_f64()); + + let (index, pointer) = + register_callback(callback.get_nanbox_f64(), ret, argc, args, threadsafe, None); + + // The registry roots callback before any of the following JS allocations. + let object = crate::object::js_object_alloc(0, 3); + let object = scope.root_raw_mut_ptr(object); + let ptr_value = super::number_value(pointer as f64); + object.with_mut_ptr(|o: *mut crate::object::ObjectHeader| set_field(o, "ptr", ptr_value)); + object.with_mut_ptr(|o: *mut crate::object::ObjectHeader| { + set_field( + o, + "threadsafe", + f64::from_bits(if threadsafe { + crate::value::TAG_TRUE + } else { + crate::value::TAG_FALSE + }), + ) + }); + let close = scope.root_nanbox_f64(close_closure(index)); + let close_value = close.get_nanbox_f64(); + object.with_mut_ptr(|o: *mut crate::object::ObjectHeader| set_field(o, "close", close_value)); + f64::from_bits( + object + .with_mut_ptr(|o: *mut crate::object::ObjectHeader| JSValue::object_ptr(o as *mut u8)) + .bits(), + ) +} +pub(crate) fn register_callback( + callback: f64, + ret: u8, + argc: u8, + args: [u8; MAX_ARGS], + threadsafe: bool, + owner_lib: Option, +) -> (usize, usize) { let index = { let mut callbacks = CALLBACKS.lock().unwrap(); if callbacks.len() >= MAX_CALLBACKS { @@ -314,29 +398,126 @@ pub(crate) fn js_callback_value(callback: f64, definition: f64) -> f64 { ret, argc, args, + threadsafe, + owner_lib, open: true, }); + if threadsafe { + ACTIVE_THREADSAFE_CALLBACKS.fetch_add(1, Ordering::Release); + } index }; let pointer = trampoline_ptr(index).expect("supported callback target has trampoline table"); + (index, pointer) +} - // The registry roots callback before any of the following JS allocations. +pub(crate) unsafe fn node_register_callback_value( + lib: usize, + signature: f64, + callback: f64, +) -> f64 { let scope = crate::gc::RuntimeHandleScope::new(); - let object = crate::object::js_object_alloc(0, 3); - let object = scope.root_raw_mut_ptr(object); - let ptr_value = super::number_value(pointer as f64); - object.with_mut_ptr(|o: *mut crate::object::ObjectHeader| set_field(o, "ptr", ptr_value)); - object.with_mut_ptr(|o: *mut crate::object::ObjectHeader| { - set_field(o, "threadsafe", f64::from_bits(crate::value::TAG_FALSE)) - }); - let close = scope.root_nanbox_f64(close_closure(index)); - let close_value = close.get_nanbox_f64(); - object.with_mut_ptr(|o: *mut crate::object::ObjectHeader| set_field(o, "close", close_value)); - f64::from_bits( + let signature = scope.root_nanbox_f64(signature); + let callback = scope.root_nanbox_f64(callback); + let (has_signature, callback_value) = + if crate::object::value_is_callable(callback.get_nanbox_f64()) { + (true, callback.get_nanbox_f64()) + } else if crate::object::value_is_callable(signature.get_nanbox_f64()) { + (false, signature.get_nanbox_f64()) + } else { + throw_type("ffi.registerCallback expects a function argument"); + }; + let callback = scope.root_nanbox_f64(callback_value); + let object = if has_signature { + object_ptr(signature.get_nanbox_f64()).map(|object| scope.root_raw_mut_ptr(object)) + } else { + None + }; + if has_signature && object.is_none() { + throw_type("ffi.registerCallback expects a signature object"); + } + let arguments = scope.root_nanbox_f64(object.as_ref().map_or(super::undefined(), |object| { object - .with_mut_ptr(|o: *mut crate::object::ObjectHeader| JSValue::object_ptr(o as *mut u8)) - .bits(), + .with_mut_ptr(|object: *mut crate::object::ObjectHeader| get_field(object, "arguments")) + })); + let arguments_jv = JSValue::from_bits(arguments.get_nanbox_f64().to_bits()); + let len = if arguments_jv.is_undefined() || arguments_jv.is_null() { + 0 + } else if JSValue::from_bits( + crate::array::js_array_is_array(arguments.get_nanbox_f64()).to_bits(), ) + .as_bool() + { + let array = crate::value::js_nanbox_get_pointer(arguments.get_nanbox_f64()) as usize + as *const crate::array::ArrayHeader; + crate::array::js_array_length(array) as usize + } else { + throw_type("ffi callback signature.arguments must be an array"); + }; + if len > MAX_ARGS { + throw_type(&format!( + "ffi callbacks support at most {MAX_ARGS} arguments" + )); + } + let mut args = [T_VOID; MAX_ARGS]; + for (index, slot) in args.iter_mut().take(len).enumerate() { + let array = crate::value::js_nanbox_get_pointer(arguments.get_nanbox_f64()) as usize + as *const crate::array::ArrayHeader; + let value = crate::array::js_array_get(array, index as u32); + *slot = super::dlopen::parse_node_ffi_type(f64::from_bits(value.bits()), false) + .unwrap_or_else(|message| throw_type(&message)); + } + let return_value = object.as_ref().map_or(super::undefined(), |object| { + object.with_mut_ptr(|object: *mut crate::object::ObjectHeader| get_field(object, "return")) + }); + let return_jv = JSValue::from_bits(return_value.to_bits()); + let ret = if return_jv.is_undefined() || return_jv.is_null() { + T_VOID + } else { + super::dlopen::parse_node_ffi_type(return_value, true) + .unwrap_or_else(|message| throw_type(&message)) + }; + validate_callback_types(ret, &args[..len]); + let (_index, pointer) = register_callback( + callback.get_nanbox_f64(), + ret, + len as u8, + args, + false, + Some(lib), + ); + call::bigint_value_u64(pointer as u64) +} + +pub(crate) unsafe fn node_unregister_callback_value(pointer: f64) -> f64 { + let pointer = call::value_to_pointer_arg(pointer); + let mut callbacks = CALLBACKS.lock().unwrap(); + for (index, record) in callbacks.iter_mut().enumerate() { + if trampoline_ptr(index) == Some(pointer) { + close_record(record); + break; + } + } + super::undefined() +} + +pub(crate) fn close_callbacks_for_library(lib: usize) { + for record in CALLBACKS.lock().unwrap().iter_mut() { + if record.owner_lib == Some(lib) { + close_record(record); + } + } +} + +fn close_record(record: &mut CallbackRecord) { + if !record.open { + return; + } + record.open = false; + record.callback_bits = crate::value::TAG_UNDEFINED; + if record.threadsafe { + ACTIVE_THREADSAFE_CALLBACKS.fetch_sub(1, Ordering::AcqRel); + } } unsafe fn native_arg_value(ty: u8, bits: u64) -> f64 { @@ -358,27 +539,11 @@ unsafe fn native_return_bits(ty: u8, value: f64) -> u64 { } } -/// Assembly callback target. Never unwinds across native code: an uncaught JS -/// exception is trapped and converted to the declared ABI's zero value. This -/// matches the module's fail-closed same-thread contract and, critically, -/// never sends a Perry unwind through an arbitrary third-party C frame. -#[no_mangle] -pub unsafe extern "C" fn perry_ffi_callback_dispatch( - index: usize, +unsafe fn invoke_record( + record: &CallbackRecord, integer_registers: *const usize, float_registers: *const u64, ) -> u64 { - let record = { - let callbacks = CALLBACKS.lock().unwrap(); - let Some(record) = callbacks.get(index) else { - return 0; - }; - if !record.open || record.owner != std::thread::current().id() { - return 0; - } - record.clone() - }; - let scope = crate::gc::RuntimeHandleScope::new(); let callback = scope.root_nanbox_f64(f64::from_bits(record.callback_bits)); let mut argument_roots = Vec::with_capacity(record.argc as usize); @@ -413,6 +578,113 @@ pub unsafe extern "C" fn perry_ffi_callback_dispatch( } } +/// Assembly callback target. Never unwinds across native code: an uncaught JS +/// exception is trapped and converted to the declared ABI's zero value. This +/// matches the module's fail-closed same-thread contract and, critically, +/// never sends a Perry unwind through an arbitrary third-party C frame. +#[no_mangle] +pub unsafe extern "C" fn perry_ffi_callback_dispatch( + index: usize, + integer_registers: *const usize, + float_registers: *const u64, +) -> u64 { + let record = { + let callbacks = CALLBACKS.lock().unwrap(); + let Some(record) = callbacks.get(index) else { + return 0; + }; + if !record.open { + return 0; + } + record.clone() + }; + + if record.owner == std::thread::current().id() { + return invoke_record(&record, integer_registers, float_registers); + } + if !record.threadsafe { + return 0; + } + + // A native worker may release or reuse pointer arguments as soon as the + // callback returns, so copy the ABI register images and synchronously wait + // for the owning JS thread to execute the callback. No JS/GC state is + // touched on this foreign thread. + let mut integers = [0usize; 8]; + let mut floats = [0u64; 8]; + std::ptr::copy_nonoverlapping( + integer_registers, + integers.as_mut_ptr(), + MAX_CALLBACK_INT_ARGS, + ); + std::ptr::copy_nonoverlapping(float_registers, floats.as_mut_ptr(), MAX_FLOAT_ARGS); + let completion = Arc::new((Mutex::new(None), Condvar::new())); + PENDING_CALLBACKS.lock().unwrap().push(PendingCallback { + index, + integer_registers: integers, + float_registers: floats, + completion: Arc::clone(&completion), + }); + crate::event_pump::js_notify_main_thread(); + let (lock, ready) = &*completion; + let mut result = lock.lock().unwrap_or_else(|poison| poison.into_inner()); + while result.is_none() { + result = ready + .wait(result) + .unwrap_or_else(|poison| poison.into_inner()); + } + result.unwrap_or(0) +} + +/// Execute foreign-thread callbacks on their owning JS thread. Called at the +/// beginning of every microtask/event-loop pump. +pub(crate) fn drain_threadsafe_callbacks() -> i32 { + let owner = std::thread::current().id(); + let pending = { + let mut queue = PENDING_CALLBACKS.lock().unwrap(); + let mut mine = Vec::new(); + let mut index = 0; + while index < queue.len() { + let belongs_here = CALLBACKS + .lock() + .unwrap() + .get(queue[index].index) + .is_some_and(|record| record.owner == owner); + if belongs_here { + mine.push(queue.remove(index)); + } else { + index += 1; + } + } + mine + }; + let count = pending.len() as i32; + for pending in pending { + let record = CALLBACKS + .lock() + .unwrap() + .get(pending.index) + .filter(|record| record.open) + .cloned(); + let result = record.map_or(0, |record| unsafe { + invoke_record( + &record, + pending.integer_registers.as_ptr(), + pending.float_registers.as_ptr(), + ) + }); + let (lock, ready) = &*pending.completion; + *lock.lock().unwrap_or_else(|poison| poison.into_inner()) = Some(result); + ready.notify_one(); + } + count +} + +#[no_mangle] +pub extern "C" fn js_bun_ffi_has_active_threadsafe_callbacks() -> i32 { + (ACTIVE_THREADSAFE_CALLBACKS.load(Ordering::Acquire) != 0) as i32 +} + pub(crate) fn scan_callback_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { let owner = std::thread::current().id(); for record in CALLBACKS.lock().unwrap().iter_mut() { diff --git a/crates/perry-runtime/src/bun_ffi/dlopen.rs b/crates/perry-runtime/src/bun_ffi/dlopen.rs index 5f48d0b37b..c237ac9116 100644 --- a/crates/perry-runtime/src/bun_ffi/dlopen.rs +++ b/crates/perry-runtime/src/bun_ffi/dlopen.rs @@ -11,7 +11,7 @@ //! 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::call::{self, MAX_ARGS}; use super::types::{self, T_BUFFER, T_NAPI_ENV, T_NAPI_VALUE, T_VOID}; use crate::closure::ClosureHeader; use crate::value::JSValue; @@ -23,14 +23,17 @@ use std::sync::Mutex; // supports unix x86_64/aarch64 only. #[cfg(unix)] -unsafe fn open_library(path: &str) -> Result { - let c_path = match std::ffi::CString::new(path) { +unsafe fn open_library(path: Option<&str>) -> Result { + let c_path = match path.map(std::ffi::CString::new).transpose() { 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); + let raw_path = c_path + .as_ref() + .map_or(std::ptr::null(), |path| path.as_ptr()); + let h = libc::dlopen(raw_path, libc::RTLD_NOW | libc::RTLD_LOCAL); if h.is_null() { let err = libc::dlerror(); let msg = if err.is_null() { @@ -61,7 +64,7 @@ unsafe fn close_library(handle: usize) { } #[cfg(not(unix))] -unsafe fn open_library(_path: &str) -> Result { +unsafe fn open_library(_path: Option<&str>) -> Result { Err("bun:ffi is not supported on this platform".to_string()) } #[cfg(not(unix))] @@ -85,13 +88,16 @@ struct LibRecord { #[derive(Clone, Copy)] pub(crate) struct SymRecord { fn_ptr: usize, - lib: usize, + /// `None` for `CFunction` / `linkSymbols`, whose pointer is caller-owned. + lib: Option, 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, + /// Node's compatibility API boxes pointer/function returns as BigInt. + pointer_bigint: bool, } static LIBS: Mutex> = Mutex::new(Vec::new()); @@ -132,13 +138,33 @@ unsafe fn object_ptr_of(v: f64) -> Option<*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()) + let scope = crate::gc::RuntimeHandleScope::new(); + let obj = scope.root_raw_mut_ptr(obj); + let key = scope.root_string_ptr(crate::string::js_string_from_bytes( + name.as_ptr(), + name.len() as u32, + )); + f64::from_bits( + obj.with_mut_ptr(|obj| { + key.with_const_ptr(|key| 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); + let scope = crate::gc::RuntimeHandleScope::new(); + let obj = scope.root_raw_mut_ptr(obj); + let value = scope.root_nanbox_f64(value); + let key = scope.root_string_ptr(crate::string::js_string_from_bytes( + name.as_ptr(), + name.len() as u32, + )); + obj.with_mut_ptr(|obj| { + key.with_const_ptr(|key| { + crate::object::js_object_set_field_by_name(obj, key, value.get_nanbox_f64()) + }) + }); } // ── the per-arity call-stub thunks ────────────────────────────────────────── @@ -158,17 +184,23 @@ unsafe fn invoke_from_closure(closure: *const ClosureHeader, js_args: &[f64]) -> } } }; - 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", - ); + if let Some(lib) = sym.lib { + if let Some(path) = lib_is_closed(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) + if sym.pointer_bigint { + call::call_and_convert_node(sym.fn_ptr, sym.ret, &image) + } else { + call::call_and_convert(sym.fn_ptr, sym.ret, &image) + } } macro_rules! sym_thunk { @@ -302,16 +334,21 @@ fn sym_thunk_for(arity: usize) -> *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; + close_library_index(lib_index); + super::undefined() +} + +pub(crate) fn close_library_index(lib_index: 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); + super::callback::close_callbacks_for_library(lib_index); unsafe { close_library(handle) }; } } - super::undefined() } /// Allocate a call-stub closure whose capture 0 is a plain (non-pointer) @@ -328,6 +365,19 @@ fn index_closure(func: *const u8, index: usize, arity: u32, name: &str) -> f64 { crate::value::js_nanbox_pointer(closure as i64) } +extern "C" fn noop_close_thunk(_closure: *const ClosureHeader) -> f64 { + super::undefined() +} + +fn no_capture_closure(func: *const u8, 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, 0); + 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) -> ! { @@ -341,15 +391,12 @@ fn throw_dlopen_failed(name: &str, detail: &str) -> ! { /// `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_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 { @@ -366,18 +413,6 @@ fn validate_signature_checked(sym: &str, args: &[u8], ret: u8) -> Result<(), Str "more than {MAX_ARGS} arguments are not supported" ))); } - if ints > MAX_INT_ARGS { - 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 { - return Err(reject(&format!( - "more than {MAX_FLOAT_ARGS} float arguments are not supported \ - by perry's stage-1 call stubs" - ))); - } Ok(()) } @@ -391,6 +426,86 @@ struct PreparedSym { args: [u8; MAX_ARGS], } +unsafe fn parse_bun_definition( + name: String, + entry: *mut crate::object::ObjectHeader, + handle: Option<(usize, &str)>, +) -> Result { + let type_err = |m: String| (m, "ERR_INVALID_ARG_TYPE"); + let scope = crate::gc::RuntimeHandleScope::new(); + let entry = scope.root_raw_mut_ptr(entry); + let mut args = [0u8; MAX_ARGS]; + let mut argc = 0usize; + let args_value = scope.root_nanbox_f64( + entry.with_mut_ptr(|entry: *mut crate::object::ObjectHeader| get_field(entry, "args")), + ); + let args_jv = JSValue::from_bits(args_value.get_nanbox_f64().to_bits()); + if !args_jv.is_undefined() && !args_jv.is_null() { + if !JSValue::from_bits( + crate::array::js_array_is_array(args_value.get_nanbox_f64()).to_bits(), + ) + .as_bool() + { + return Err(type_err(format!( + "bun:ffi: symbol \"{name}\": args must be an array" + ))); + } + let args_array = || { + crate::value::js_nanbox_get_pointer(args_value.get_nanbox_f64()) as usize + as *const crate::array::ArrayHeader + }; + let len = crate::array::js_array_length(args_array()); + if len as usize > MAX_ARGS { + return Err(type_err(format!( + "bun:ffi: symbol \"{name}\": more than {MAX_ARGS} arguments are not supported" + ))); + } + for j in 0..len { + let value = crate::array::js_array_get(args_array(), j); + args[argc] = types::parse_ffi_type_checked(f64::from_bits(value.bits())) + .map_err(|m| type_err(format!("bun:ffi: symbol \"{name}\": {m}")))?; + argc += 1; + } + } + let returns_value = + entry.with_mut_ptr(|entry: *mut crate::object::ObjectHeader| 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_checked(returns_value) + .map_err(|m| type_err(format!("bun:ffi: symbol \"{name}\": {m}")))? + }; + validate_signature_checked(&name, &args[..argc], ret).map_err(type_err)?; + + let ptr_value = + entry.with_mut_ptr(|entry: *mut crate::object::ObjectHeader| get_field(entry, "ptr")); + let ptr_jv = JSValue::from_bits(ptr_value.to_bits()); + let fn_ptr = if !ptr_jv.is_undefined() && !ptr_jv.is_null() { + call::value_to_pointer_arg(ptr_value) + } else if let Some((handle, path)) = handle { + find_symbol(handle, &name) + .ok_or_else(|| type_err(format!("Symbol \"{name}\" not found in \"{path}\"")))? + } else { + return Err(type_err(format!( + "bun:ffi: symbol \"{name}\": expected a non-zero ptr" + ))); + }; + if fn_ptr == 0 { + return Err(type_err(format!( + "bun:ffi: symbol \"{name}\": ptr cannot be zero" + ))); + } + + Ok(PreparedSym { + name, + fn_ptr, + ret, + argc, + args, + }) +} + /// 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: @@ -403,8 +518,13 @@ unsafe fn prepare_symbols( ) -> Result, (String, &'static str)> { let type_err = |m: String| (m, "ERR_INVALID_ARG_TYPE"); - let keys = crate::object::js_object_keys(table); - let key_count = crate::array::js_array_length(keys); + let scope = crate::gc::RuntimeHandleScope::new(); + let table = scope.root_raw_mut_ptr(table); + let keys = scope.root_raw_mut_ptr(table.with_mut_ptr( + |table: *mut crate::object::ObjectHeader| crate::object::js_object_keys(table), + )); + let key_count = keys + .with_mut_ptr(|keys: *mut crate::array::ArrayHeader| crate::array::js_array_length(keys)); if key_count == 0 { return Err(( format!("Failed to open library \"{path}\": Expected at least 1 symbol"), @@ -414,72 +534,22 @@ unsafe fn prepare_symbols( 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 key_value = keys.with_mut_ptr(|keys: *mut crate::array::ArrayHeader| { + 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 { + let field = + table.with_mut_ptr(|table: *mut crate::object::ObjectHeader| get_field(table, &name)); + let Some(entry) = object_ptr_of(field) else { return Err(type_err(format!( "bun:ffi: symbol \"{name}\": expected {{ args, returns }}" ))); }; - // 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() { - // #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 = 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 { - 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_checked(f64::from_bits(t.bits())) - .map_err(|m| type_err(format!("bun:ffi: symbol \"{name}\": {m}")))?; - 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_checked(returns_value) - .map_err(|m| type_err(format!("bun:ffi: symbol \"{name}\": {m}")))? - }; - validate_signature_checked(&name, &args[..argc], ret).map_err(type_err)?; - - let Some(fn_ptr) = find_symbol(handle, &name) else { - return Err(type_err(format!( - "Symbol \"{name}\" not found in \"{path}\"" - ))); - }; - - prepared.push(PreparedSym { - name, - fn_ptr, - ret, - argc, - args, - }); + prepared.push(parse_bun_definition(name, entry, Some((handle, path)))?); } Ok(prepared) } @@ -506,7 +576,7 @@ pub(crate) unsafe fn dlopen_value(path_arg: f64, table_arg: f64) -> f64 { ); }; - let handle = match open_library(&path) { + let handle = match open_library(Some(&path)) { Ok(h) => h, Err(msg) => throw_dlopen_failed(&path, &msg), }; @@ -546,11 +616,12 @@ pub(crate) unsafe fn dlopen_value(path_arg: f64, table_arg: f64) -> f64 { let leaked_name: &'static str = p.name.clone().leak(); syms.push(SymRecord { fn_ptr: p.fn_ptr, - lib: lib_index, + lib: Some(lib_index), ret: p.ret, argc: p.argc as u8, args: p.args, name: leaked_name, + pointer_bigint: false, }); committed.push(Committed { name: p.name, @@ -568,11 +639,9 @@ pub(crate) unsafe fn dlopen_value(path_arg: f64, table_arg: f64) -> f64 { 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( - symbols_handle.get_raw_mut_ptr::(), - &p.name, - value_handle.get_nanbox_f64(), - ); + symbols_handle.with_mut_ptr(|symbols: *mut crate::object::ObjectHeader| { + set_field(symbols, &p.name, value_handle.get_nanbox_f64()) + }); } let close_value = index_closure(close_thunk as *const u8, lib_index, 0, "close"); @@ -580,19 +649,404 @@ pub(crate) unsafe fn dlopen_value(path_arg: f64, table_arg: f64) -> f64 { 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::(), + let symbols_value = symbols_handle + .with_mut_ptr(|symbols: *mut u8| f64::from_bits(JSValue::object_ptr(symbols).bits())); + result_handle.with_mut_ptr(|result: *mut crate::object::ObjectHeader| { + set_field(result, "symbols", symbols_value) + }); + result_handle.with_mut_ptr(|result: *mut crate::object::ObjectHeader| { + set_field(result, "close", close_handle.get_nanbox_f64()) + }); + result_handle.with_mut_ptr(|result: *mut u8| f64::from_bits(JSValue::object_ptr(result).bits())) +} + +fn commit_pointer_symbol(prepared: PreparedSym, pointer_bigint: bool) -> f64 { + let argc = prepared.argc as u32; + let name = prepared.name; + let leaked_name: &'static str = name.clone().leak(); + let index = { + let mut syms = SYMS.lock().unwrap(); + syms.push(SymRecord { + fn_ptr: prepared.fn_ptr, + lib: None, + ret: prepared.ret, + argc: argc as u8, + args: prepared.args, + name: leaked_name, + pointer_bigint, + }); + syms.len() - 1 + }; + index_closure(sym_thunk_for(argc as usize), index, argc, &name) +} + +/// `CFunction({ ptr, args, returns })` wraps a caller-owned function pointer. +pub(crate) unsafe fn c_function_value(definition: f64) -> f64 { + if !call::platform_supported() { + crate::fs::validate::throw_error_with_code( + "bun:ffi CFunction is supported only on unix x86_64 / aarch64", + "ERR_NOT_IMPLEMENTED", + ); + } + let Some(entry) = object_ptr_of(definition) else { + crate::fs::validate::throw_type_error_with_code( + "CFunction expects a { ptr, args, returns } object", + "ERR_INVALID_ARG_TYPE", + ); + }; + match parse_bun_definition("CFunction".to_string(), entry, None) { + Ok(prepared) => commit_pointer_symbol(prepared, false), + Err((message, code)) => crate::fs::validate::throw_error_with_code(&message, code), + } +} + +/// `linkSymbols({ name: { ptr, args, returns } })` uses the same typed stubs +/// as `dlopen`, but does not own or close the supplied function pointers. +pub(crate) unsafe fn link_symbols_value(table_arg: f64) -> f64 { + let Some(table) = object_ptr_of(table_arg) else { + crate::fs::validate::throw_type_error_with_code( + "linkSymbols expects a symbol definitions object", + "ERR_INVALID_ARG_TYPE", + ); + }; + let scope = crate::gc::RuntimeHandleScope::new(); + let table = scope.root_raw_mut_ptr(table); + let keys = scope.root_raw_mut_ptr(table.with_mut_ptr( + |table: *mut crate::object::ObjectHeader| crate::object::js_object_keys(table), + )); + let count = keys + .with_mut_ptr(|keys: *mut crate::array::ArrayHeader| crate::array::js_array_length(keys)); + let mut prepared = Vec::with_capacity(count as usize); + for index in 0..count { + let key = keys.with_mut_ptr(|keys: *mut crate::array::ArrayHeader| { + crate::array::js_array_get(keys, index) + }); + let Some(name) = value_to_owned_string(f64::from_bits(key.bits())) else { + continue; + }; + let field = + table.with_mut_ptr(|table: *mut crate::object::ObjectHeader| get_field(table, &name)); + let Some(entry) = object_ptr_of(field) else { + crate::fs::validate::throw_type_error_with_code( + &format!("bun:ffi: symbol \"{name}\": expected {{ ptr, args, returns }}"), + "ERR_INVALID_ARG_TYPE", + ); + }; + match parse_bun_definition(name, entry, None) { + Ok(symbol) => prepared.push(symbol), + Err((message, code)) => crate::fs::validate::throw_error_with_code(&message, code), + } + } + + let symbols = scope.root_raw_mut_ptr(crate::object::js_object_alloc(0, count)); + for symbol in prepared { + let name = symbol.name.clone(); + let value = scope.root_nanbox_f64(commit_pointer_symbol(symbol, false)); + symbols.with_mut_ptr(|symbols: *mut crate::object::ObjectHeader| { + set_field(symbols, &name, value.get_nanbox_f64()) + }); + } + let close = scope.root_nanbox_f64(no_capture_closure( + noop_close_thunk as *const u8, + 0, "close", - close_handle.get_nanbox_f64(), + )); + let result = scope.root_raw_mut_ptr(crate::object::js_object_alloc(0, 2)); + let symbols_value = symbols + .with_mut_ptr(|symbols: *mut u8| f64::from_bits(JSValue::object_ptr(symbols).bits())); + result.with_mut_ptr(|result: *mut crate::object::ObjectHeader| { + set_field(result, "symbols", symbols_value) + }); + result.with_mut_ptr(|result: *mut crate::object::ObjectHeader| { + set_field(result, "close", close.get_nanbox_f64()) + }); + result.with_mut_ptr(|result: *mut u8| f64::from_bits(JSValue::object_ptr(result).bits())) +} + +/// Return deterministic diagnostics for Bun's development-only `viewSource` +/// helper. Perry uses one shared native assembly thunk rather than generated +/// JavaScript, so the useful source description is the signature inventory. +pub(crate) unsafe fn view_source_value(definitions: f64) -> f64 { + let Some(table) = object_ptr_of(definitions) else { + crate::fs::validate::throw_type_error_with_code( + "viewSource expects a symbol definitions object", + "ERR_INVALID_ARG_TYPE", + ); + }; + let scope = crate::gc::RuntimeHandleScope::new(); + let table = scope.root_raw_mut_ptr(table); + let keys = scope.root_raw_mut_ptr(table.with_mut_ptr( + |table: *mut crate::object::ObjectHeader| crate::object::js_object_keys(table), + )); + let count = keys + .with_mut_ptr(|keys: *mut crate::array::ArrayHeader| crate::array::js_array_length(keys)); + let mut result = crate::array::js_array_alloc(count); + let result_handle = scope.root_raw_mut_ptr(result); + for index in 0..count { + let key = keys.with_mut_ptr(|keys: *mut crate::array::ArrayHeader| { + crate::array::js_array_get(keys, index) + }); + let name = value_to_owned_string(f64::from_bits(key.bits())) + .unwrap_or_else(|| "".to_string()); + let line = super::string_value(&format!("/* Perry C-ABI scalar thunk for {name} */")); + result = result_handle.with_mut_ptr(|result: *mut crate::array::ArrayHeader| { + crate::array::js_array_push_f64(result, line) + }); + result_handle.set_raw_mut_ptr(result); + } + result_handle.with_mut_ptr(|result: *mut u8| f64::from_bits(JSValue::object_ptr(result).bits())) +} + +// ── node:ffi compatibility ───────────────────────────────────────────────── + +pub(crate) unsafe fn parse_node_ffi_type(value: f64, is_return: bool) -> Result { + let jv = JSValue::from_bits(value.to_bits()); + if !jv.is_any_string() { + return types::parse_ffi_type_checked(value); + } + let name = value_to_owned_string(value).unwrap_or_default(); + let ty = match name.as_str() { + "void" if is_return => T_VOID, + "char" | "i8" | "int8" | "int8_t" => types::T_I8, + "u8" | "uint8" | "uint8_t" => types::T_U8, + "i16" | "int16" | "int16_t" => types::T_I16, + "u16" | "uint16" | "uint16_t" => types::T_U16, + "i32" | "int" | "int32" | "int32_t" => types::T_I32, + "u32" | "uint" | "uint32" | "uint32_t" => types::T_U32, + "i64" | "int64" | "int64_t" | "isize" => types::T_I64, + "u64" | "uint64" | "uint64_t" | "usize" => types::T_U64, + "f32" | "float" | "float32" => types::T_F32, + "f64" | "double" | "float64" => types::T_F64, + "bool" => types::T_U8, + "pointer" | "ptr" | "void*" | "buffer" | "arraybuffer" => types::T_PTR, + "function" | "callback" | "fn" => types::T_FUNCTION, + "string" | "str" | "cstring" => types::T_CSTRING, + _ => return Err(format!("node:ffi: unsupported FFI type \"{name}\"")), + }; + if ty == T_VOID && !is_return { + return Err("node:ffi: void is not a valid argument type".to_string()); + } + Ok(ty) +} + +unsafe fn parse_node_definition( + name: String, + entry: *mut crate::object::ObjectHeader, + handle: usize, + path: &str, +) -> Result { + let scope = crate::gc::RuntimeHandleScope::new(); + let entry = scope.root_raw_mut_ptr(entry); + let arguments = scope.root_nanbox_f64( + entry.with_mut_ptr(|entry: *mut crate::object::ObjectHeader| get_field(entry, "arguments")), ); - f64::from_bits(JSValue::object_ptr(result_handle.get_raw_mut_ptr::()).bits()) + let arguments_jv = JSValue::from_bits(arguments.get_nanbox_f64().to_bits()); + let argc = if arguments_jv.is_undefined() || arguments_jv.is_null() { + 0 + } else if JSValue::from_bits( + crate::array::js_array_is_array(arguments.get_nanbox_f64()).to_bits(), + ) + .as_bool() + { + let array = crate::value::js_nanbox_get_pointer(arguments.get_nanbox_f64()) as usize + as *const crate::array::ArrayHeader; + crate::array::js_array_length(array) as usize + } else { + return Err(format!( + "node:ffi: symbol \"{name}\": arguments must be an array" + )); + }; + if argc > MAX_ARGS { + return Err(format!( + "node:ffi: symbol \"{name}\": more than {MAX_ARGS} arguments are not supported" + )); + } + let mut args = [T_VOID; MAX_ARGS]; + for (index, slot) in args.iter_mut().take(argc).enumerate() { + let array = crate::value::js_nanbox_get_pointer(arguments.get_nanbox_f64()) as usize + as *const crate::array::ArrayHeader; + let value = crate::array::js_array_get(array, index as u32); + *slot = parse_node_ffi_type(f64::from_bits(value.bits()), false)?; + } + let return_value = + entry.with_mut_ptr(|entry: *mut crate::object::ObjectHeader| get_field(entry, "return")); + let return_jv = JSValue::from_bits(return_value.to_bits()); + let ret = if return_jv.is_undefined() || return_jv.is_null() { + T_VOID + } else { + parse_node_ffi_type(return_value, true)? + }; + validate_signature_checked(&name, &args[..argc], ret)?; + let fn_ptr = find_symbol(handle, &name) + .ok_or_else(|| format!("Symbol \"{name}\" not found in \"{path}\""))?; + Ok(PreparedSym { + name, + fn_ptr, + ret, + argc, + args, + }) +} + +extern "C" fn node_register_callback_thunk( + closure: *const ClosureHeader, + signature: f64, + callback: f64, +) -> f64 { + let lib = crate::closure::js_closure_get_capture_bits(closure, 0) as usize; + unsafe { super::callback::node_register_callback_value(lib, signature, callback) } +} + +extern "C" fn node_unregister_callback_thunk(_closure: *const ClosureHeader, pointer: f64) -> f64 { + unsafe { super::callback::node_unregister_callback_value(pointer) } +} + +/// Node 26's `ffi.dlopen(path, definitions)` compatibility shape used by +/// OpenTUI's Node adapter: `{ lib, functions }` with callbacks rooted by lib. +pub(crate) unsafe fn node_dlopen_value(path_arg: f64, definitions_arg: f64) -> f64 { + if !call::platform_supported() { + crate::fs::validate::throw_error_with_code( + "node:ffi is supported only on unix x86_64 / aarch64", + "ERR_NOT_IMPLEMENTED", + ); + } + let path_jv = JSValue::from_bits(path_arg.to_bits()); + let path = if path_jv.is_null() || path_jv.is_undefined() { + None + } else { + Some(value_to_owned_string(path_arg).unwrap_or_else(|| { + crate::fs::validate::throw_type_error_with_code( + "ffi.dlopen(path, definitions) expects a string or null path", + "ERR_INVALID_ARG_TYPE", + ) + })) + }; + let label = path.as_deref().unwrap_or(""); + let Some(table) = object_ptr_of(definitions_arg) else { + crate::fs::validate::throw_type_error_with_code( + "ffi.dlopen(path, definitions) expects a definitions object", + "ERR_INVALID_ARG_TYPE", + ); + }; + let handle = match open_library(path.as_deref()) { + Ok(handle) => handle, + Err(message) => throw_dlopen_failed(label, &message), + }; + let scope = crate::gc::RuntimeHandleScope::new(); + let table = scope.root_raw_mut_ptr(table); + let keys = scope.root_raw_mut_ptr(table.with_mut_ptr( + |table: *mut crate::object::ObjectHeader| crate::object::js_object_keys(table), + )); + let count = keys + .with_mut_ptr(|keys: *mut crate::array::ArrayHeader| crate::array::js_array_length(keys)); + let mut prepared = Vec::with_capacity(count as usize); + for index in 0..count { + let key = keys.with_mut_ptr(|keys: *mut crate::array::ArrayHeader| { + crate::array::js_array_get(keys, index) + }); + let Some(name) = value_to_owned_string(f64::from_bits(key.bits())) else { + continue; + }; + let field = + table.with_mut_ptr(|table: *mut crate::object::ObjectHeader| get_field(table, &name)); + let Some(entry) = object_ptr_of(field) else { + close_library(handle); + crate::fs::validate::throw_type_error_with_code( + &format!("node:ffi: symbol \"{name}\": expected a signature object"), + "ERR_INVALID_ARG_TYPE", + ); + }; + match parse_node_definition(name, entry, handle, label) { + Ok(symbol) => prepared.push(symbol), + Err(message) => { + close_library(handle); + crate::fs::validate::throw_type_error_with_code(&message, "ERR_INVALID_ARG_TYPE"); + } + } + } + + let lib_index = { + let mut libs = LIBS.lock().unwrap(); + libs.push(LibRecord { + handle, + path: label.to_string(), + closed: false, + }); + libs.len() - 1 + }; + let functions = scope.root_raw_mut_ptr(crate::object::js_object_alloc(0, count)); + for symbol in prepared { + let name = symbol.name.clone(); + let argc = symbol.argc as u32; + let leaked_name: &'static str = name.clone().leak(); + let sym_index = { + let mut syms = SYMS.lock().unwrap(); + syms.push(SymRecord { + fn_ptr: symbol.fn_ptr, + lib: Some(lib_index), + ret: symbol.ret, + argc: argc as u8, + args: symbol.args, + name: leaked_name, + pointer_bigint: true, + }); + syms.len() - 1 + }; + let function = scope.root_nanbox_f64(index_closure( + sym_thunk_for(argc as usize), + sym_index, + argc, + &name, + )); + let pointer = scope.root_nanbox_f64(call::bigint_value_u64(symbol.fn_ptr as u64)); + let function_object = crate::value::js_nanbox_get_pointer(function.get_nanbox_f64()) + as usize as *mut crate::object::ObjectHeader; + set_field(function_object, "pointer", pointer.get_nanbox_f64()); + functions.with_mut_ptr(|functions: *mut crate::object::ObjectHeader| { + set_field(functions, &name, function.get_nanbox_f64()) + }); + } + + let lib = scope.root_raw_mut_ptr(crate::object::js_object_alloc(0, 3)); + let close = scope.root_nanbox_f64(index_closure( + close_thunk as *const u8, + lib_index, + 0, + "close", + )); + let register = scope.root_nanbox_f64(index_closure( + node_register_callback_thunk as *const u8, + lib_index, + 2, + "registerCallback", + )); + let unregister = scope.root_nanbox_f64(index_closure( + node_unregister_callback_thunk as *const u8, + lib_index, + 1, + "unregisterCallback", + )); + lib.with_mut_ptr(|lib: *mut crate::object::ObjectHeader| { + set_field(lib, "close", close.get_nanbox_f64()) + }); + lib.with_mut_ptr(|lib: *mut crate::object::ObjectHeader| { + set_field(lib, "registerCallback", register.get_nanbox_f64()) + }); + lib.with_mut_ptr(|lib: *mut crate::object::ObjectHeader| { + set_field(lib, "unregisterCallback", unregister.get_nanbox_f64()) + }); + let result = scope.root_raw_mut_ptr(crate::object::js_object_alloc(0, 2)); + let lib_value = + lib.with_mut_ptr(|lib: *mut u8| f64::from_bits(JSValue::object_ptr(lib).bits())); + result.with_mut_ptr(|result: *mut crate::object::ObjectHeader| { + set_field(result, "lib", lib_value) + }); + let functions_value = functions + .with_mut_ptr(|functions: *mut u8| f64::from_bits(JSValue::object_ptr(functions).bits())); + result.with_mut_ptr(|result: *mut crate::object::ObjectHeader| { + set_field(result, "functions", functions_value) + }); + result.with_mut_ptr(|result: *mut u8| f64::from_bits(JSValue::object_ptr(result).bits())) } // ── ptr / CString ─────────────────────────────────────────────────────────── @@ -638,8 +1092,8 @@ pub(crate) unsafe fn ptr_value(view_arg: f64, offset_arg: f64) -> f64 { /// /// 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. +/// identical. A falsy pointer returns an empty string; `cstring` function +/// returns still use `null` for a native NULL pointer. 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()); // `managed_end`: exclusive upper bound of the SOURCE's managed storage, @@ -671,7 +1125,7 @@ pub(crate) unsafe fn cstring_value(ptr_arg: f64, offset_arg: f64, length_arg: f6 ); }; if base == 0 { - return super::null(); + return super::string_value(""); } let offset_jv = JSValue::from_bits(offset_arg.to_bits()); let offset = if offset_jv.is_int32() { @@ -772,21 +1226,12 @@ mod tests { } #[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()); + fn accepts_stack_arguments_up_to_the_public_limit() { + // OpenTUI reaches 14 scalar arguments and FFF reaches 13. Both must + // pass validation; the ABI shim places overflow arguments on stack. + assert!(validate_signature_checked("opentui", &[T_I32; 14], T_VOID).is_ok()); + assert!(validate_signature_checked("fff", &[T_I32; 13], T_VOID).is_ok()); + assert!(validate_signature_checked("max", &[T_F64; MAX_ARGS], T_VOID).is_ok()); } #[test] diff --git a/crates/perry-runtime/src/bun_ffi/memory.rs b/crates/perry-runtime/src/bun_ffi/memory.rs index 7ba277d8e4..a739524192 100644 --- a/crates/perry-runtime/src/bun_ffi/memory.rs +++ b/crates/perry-runtime/src/bun_ffi/memory.rs @@ -115,6 +115,56 @@ pub(crate) unsafe fn view_value( f64::from_bits(JSValue::pointer(buffer as *mut u8).bits()) } +/// Node's raw-pointer helper. Unlike Bun's `ptr`, Node always returns a +/// BigInt so addresses remain lossless on every 64-bit host. +pub(crate) unsafe fn node_get_raw_pointer_value(buffer: f64) -> f64 { + let Some((data, _length)) = super::call::value_buffer_span(buffer) else { + throw_type("ffi.getRawPointer expects an ArrayBuffer or ArrayBufferView"); + }; + super::call::bigint_value_u64(data as usize as u64) +} + +/// Node's `(pointer, length, copy = true)` memory-view shape. Passing false +/// produces the zero-copy external backing required by OpenTUI/Yoga. +pub(crate) unsafe fn node_view_value( + pointer_arg: f64, + length_arg: f64, + copy_arg: f64, + array_buffer: bool, +) -> f64 { + let address = pointer_address(pointer_arg); + let length = match optional_integer(length_arg, "length") { + Some(n) if n >= 0 && n <= u32::MAX as i64 => n as u32, + _ => throw_range("length is outside Perry's ArrayBuffer range"), + }; + let copy_jv = JSValue::from_bits(copy_arg.to_bits()); + let copy = copy_jv.is_undefined() || crate::value::js_is_truthy(copy_arg) != 0; + let buffer = if copy { + let buffer = crate::buffer::buffer_alloc(length); + std::ptr::copy_nonoverlapping( + address as *const u8, + crate::buffer::buffer_data_mut(buffer), + length as usize, + ); + (*buffer).length = length; + buffer + } else { + crate::buffer::buffer_alloc_foreign(address as *mut u8, length) + }; + if array_buffer { + crate::buffer::mark_as_array_buffer(buffer as usize); + } + f64::from_bits(JSValue::pointer(buffer as *mut u8).bits()) +} + +pub(crate) unsafe fn node_to_string_value(pointer: f64) -> f64 { + let address = super::call::value_to_pointer_arg(pointer); + if address == 0 { + return super::null(); + } + super::call::read_cstring_value(address) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/perry-runtime/src/bun_ffi/mod.rs b/crates/perry-runtime/src/bun_ffi/mod.rs index 5ecbf1f8cf..9f17d0d465 100644 --- a/crates/perry-runtime/src/bun_ffi/mod.rs +++ b/crates/perry-runtime/src/bun_ffi/mod.rs @@ -12,11 +12,13 @@ //! (or length-bounded) UTF-8 string from a native pointer. //! - `toArrayBuffer` / `toBuffer` — zero-copy JS views over native-owned //! memory. The native allocation remains caller-owned. -//! - `JSCallback` / `FFIType.function` — same-thread native→JS callbacks. +//! - `JSCallback` / `FFIType.function` — native→JS callbacks, including a +//! synchronous main-thread handoff for Bun's `threadsafe` callbacks. +//! - `node:ffi` — Node's `{ lib, functions }` adapter, raw-pointer helpers, +//! and library-owned callback registration used by OpenTUI/Yoga. //! - `suffix` — platform dylib suffix ("dylib" / "so" / "dll"). //! -//! `linkSymbols`, `CFunction`, `viewSource` and `read` remain declared but -//! throw a clear "not yet supported" error. +//! `read` exposes direct native-endian scalar loads used by struct wrappers. //! //! ## Pointer lifetime / pinning contract (the part that must not be wrong) //! @@ -60,11 +62,10 @@ //! 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 +//! one assembly thunk with the marshalled values packed in class order +//! therefore produces exactly the register and stack image the callee's real +//! prototype expects. See `call.rs` for the 16-scalar public +//! limit and the f32 bit-image trick. Signatures beyond that limit, and non-unix or //! non-{x86_64, aarch64} targets, throw a descriptive error at `dlopen` //! time rather than corrupting registers at call time. @@ -72,6 +73,7 @@ pub mod call; pub mod callback; pub mod dlopen; pub mod memory; +pub mod read; pub mod types; use crate::value::JSValue; @@ -115,24 +117,11 @@ pub(crate) fn suffix_str() -> &'static str { /// side-table scanners. pub fn scan_bun_ffi_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { types::scan_ffi_type_cache_mut(visitor); + read::scan_read_cache_mut(visitor); callback::scan_callback_roots_mut(visitor); } -/// Later-stage 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_unsupported(what: &str) -> ! { - crate::fs::validate::throw_error_with_code( - &format!( - "bun:ffi: {what} is not supported yet in perry (#6562). \ - Available: dlopen, FFIType, ptr, CString, JSCallback, \ - toArrayBuffer, toBuffer, suffix." - ), - "ERR_NOT_IMPLEMENTED", - ) -} - -/// Method dispatch for the `bun:ffi` namespace — the single entry the +/// Method dispatch for the `bun:ffi` / `node:ffi` namespaces — the single entry the /// `nm_dispatch_bun_ffi` bucket routes through. `args` are NaN-boxed /// JSValues. /// @@ -140,6 +129,7 @@ fn throw_unsupported(what: &str) -> ! { /// `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( + module_name: &str, method_name: &str, args_ptr: *const f64, args_len: usize, @@ -151,6 +141,17 @@ pub(crate) unsafe fn dispatch( undefined() } }; + if matches!(module_name, "ffi" | "ffi.default") { + return match method_name { + "dlopen" => Some(dlopen::node_dlopen_value(arg(0), arg(1))), + "getRawPointer" => Some(memory::node_get_raw_pointer_value(arg(0))), + "toArrayBuffer" => Some(memory::node_view_value(arg(0), arg(1), arg(2), true)), + "toBuffer" => Some(memory::node_view_value(arg(0), arg(1), arg(2), false)), + "toString" => Some(memory::node_to_string_value(arg(0))), + "suffix" => Some(string_value(suffix_str())), + _ => None, + }; + } match method_name { "dlopen" => Some(dlopen::dlopen_value(arg(0), arg(1))), "ptr" => Some(dlopen::ptr_value(arg(0), arg(1))), @@ -161,10 +162,10 @@ pub(crate) unsafe fn dispatch( "suffix" => Some(string_value(suffix_str())), "toArrayBuffer" => Some(memory::view_value(arg(0), arg(1), arg(2), true)), "JSCallback" => Some(callback::js_callback_value(arg(0), arg(1))), - "CFunction" => throw_unsupported("CFunction"), - "linkSymbols" => throw_unsupported("linkSymbols"), - "viewSource" => throw_unsupported("viewSource"), - "read" => throw_unsupported("the read namespace"), + "CFunction" => Some(dlopen::c_function_value(arg(0))), + "linkSymbols" => Some(dlopen::link_symbols_value(arg(0))), + "viewSource" => Some(dlopen::view_source_value(arg(0))), + "read" => Some(read::read_object_value()), "toBuffer" => Some(memory::view_value(arg(0), arg(1), arg(2), false)), _ => None, } diff --git a/crates/perry-runtime/src/bun_ffi/read.rs b/crates/perry-runtime/src/bun_ffi/read.rs new file mode 100644 index 0000000000..1a5e34cc94 --- /dev/null +++ b/crates/perry-runtime/src/bun_ffi/read.rs @@ -0,0 +1,201 @@ +//! Direct scalar reads for the `bun:ffi` `read` namespace. +//! +//! These mirror a native-endian `DataView` over an unsafe pointer without +//! allocating an intermediate ArrayBuffer. FFF uses them heavily for result +//! structs (`read.ptr`, `read.u32`, `read.i64`, and friends). + +use crate::closure::ClosureHeader; +use crate::value::JSValue; +use std::cell::Cell; + +const READERS: &[(&str, u8)] = &[ + ("ptr", super::types::T_PTR), + ("i8", super::types::T_I8), + ("i16", super::types::T_I16), + ("i32", super::types::T_I32), + ("i64", super::types::T_I64), + ("u8", super::types::T_U8), + ("u16", super::types::T_U16), + ("u32", super::types::T_U32), + ("u64", super::types::T_U64), + ("f32", super::types::T_F32), + ("f64", super::types::T_F64), +]; + +thread_local! { + static READ_OBJECT_CACHE: Cell = const { Cell::new(0) }; +} + +fn throw_type(message: &str) -> ! { + crate::fs::validate::throw_type_error_with_code(message, "ERR_INVALID_ARG_TYPE") +} + +unsafe fn address(pointer: f64, offset: f64) -> usize { + let base = super::call::value_to_pointer_arg(pointer); + if base == 0 { + throw_type("bun:ffi read pointer must be non-zero"); + } + let offset_value = JSValue::from_bits(offset.to_bits()); + let displacement = if offset_value.is_undefined() { + 0i64 + } else if offset_value.is_int32() { + offset_value.as_int32() as i64 + } else if offset_value.is_number() { + let value = offset_value.as_number(); + if !value.is_finite() + || value.fract() != 0.0 + || value < i64::MIN as f64 + || value > i64::MAX as f64 + { + throw_type("bun:ffi read byteOffset must be an integer"); + } + value as i64 + } else { + throw_type("bun:ffi read byteOffset must be a number"); + }; + let result = base as i128 + displacement as i128; + if result <= 0 || result > usize::MAX as i128 { + crate::fs::validate::throw_range_error_named( + "bun:ffi read pointer + byteOffset is outside the native pointer range", + "ERR_OUT_OF_RANGE", + ); + } + result as usize +} + +unsafe fn read_value(kind: u8, pointer: f64, offset: f64) -> f64 { + let pointer = address(pointer, offset) as *const u8; + match kind { + super::types::T_PTR => super::call::convert_int_return( + super::types::T_PTR, + std::ptr::read_unaligned(pointer.cast::()) as u64, + ), + super::types::T_I8 => { + super::number_value(std::ptr::read_unaligned(pointer.cast::()) as f64) + } + super::types::T_U8 => super::number_value(std::ptr::read_unaligned(pointer) as f64), + super::types::T_I16 => { + super::number_value(std::ptr::read_unaligned(pointer.cast::()) as f64) + } + super::types::T_U16 => { + super::number_value(std::ptr::read_unaligned(pointer.cast::()) as f64) + } + super::types::T_I32 => { + super::number_value(std::ptr::read_unaligned(pointer.cast::()) as f64) + } + super::types::T_U32 => { + super::number_value(std::ptr::read_unaligned(pointer.cast::()) as f64) + } + super::types::T_I64 => super::call::convert_int_return( + super::types::T_I64, + std::ptr::read_unaligned(pointer.cast::()) as u64, + ), + super::types::T_U64 => super::call::convert_int_return( + super::types::T_U64, + std::ptr::read_unaligned(pointer.cast::()), + ), + super::types::T_F32 => { + super::number_value(std::ptr::read_unaligned(pointer.cast::()) as f64) + } + super::types::T_F64 => super::number_value(std::ptr::read_unaligned(pointer.cast::())), + _ => super::undefined(), + } +} + +macro_rules! reader { + ($name:ident, $kind:expr) => { + extern "C" fn $name(_closure: *const ClosureHeader, pointer: f64, offset: f64) -> f64 { + unsafe { read_value($kind, pointer, offset) } + } + }; +} + +reader!(read_ptr, super::types::T_PTR); +reader!(read_i8, super::types::T_I8); +reader!(read_i16, super::types::T_I16); +reader!(read_i32, super::types::T_I32); +reader!(read_i64, super::types::T_I64); +reader!(read_u8, super::types::T_U8); +reader!(read_u16, super::types::T_U16); +reader!(read_u32, super::types::T_U32); +reader!(read_u64, super::types::T_U64); +reader!(read_f32, super::types::T_F32); +reader!(read_f64, super::types::T_F64); + +fn reader_function(kind: u8) -> *const u8 { + match kind { + super::types::T_PTR => read_ptr as *const u8, + super::types::T_I8 => read_i8 as *const u8, + super::types::T_I16 => read_i16 as *const u8, + super::types::T_I32 => read_i32 as *const u8, + super::types::T_I64 => read_i64 as *const u8, + super::types::T_U8 => read_u8 as *const u8, + super::types::T_U16 => read_u16 as *const u8, + super::types::T_U32 => read_u32 as *const u8, + super::types::T_U64 => read_u64 as *const u8, + super::types::T_F32 => read_f32 as *const u8, + _ => read_f64 as *const u8, + } +} + +fn closure(name: &str, kind: u8) -> f64 { + let function = reader_function(kind); + crate::closure::js_register_closure_arity(function, 2); + crate::closure::js_register_closure_length(function, 1); + let closure = crate::closure::js_closure_alloc(function, 0); + crate::object::set_bound_native_closure_name(closure, name); + crate::object::set_builtin_closure_length(closure as usize, 1); + crate::value::js_nanbox_pointer(closure as i64) +} + +pub(crate) fn read_object_value() -> f64 { + let cached = READ_OBJECT_CACHE.with(|slot| slot.get()); + if cached != 0 { + return f64::from_bits(cached); + } + let scope = crate::gc::RuntimeHandleScope::new(); + let object = scope.root_raw_mut_ptr(crate::object::js_object_alloc(0, READERS.len() as u32)); + for &(name, kind) in READERS { + let value = scope.root_nanbox_f64(closure(name, kind)); + let key = scope.root_string_ptr(crate::string::js_string_from_bytes( + name.as_ptr(), + name.len() as u32, + )); + object.with_mut_ptr(|object: *mut crate::object::ObjectHeader| { + key.with_const_ptr(|key| { + crate::object::js_object_set_field_by_name(object, key, value.get_nanbox_f64()) + }) + }); + } + let value = + object.with_mut_ptr(|object: *mut u8| f64::from_bits(JSValue::object_ptr(object).bits())); + READ_OBJECT_CACHE.with(|slot| slot.set(value.to_bits())); + value +} + +pub(crate) fn scan_read_cache_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { + READ_OBJECT_CACHE.with(|slot| { + let mut bits = slot.get(); + if bits != 0 { + visitor.visit_nanbox_u64_slot(&mut bits); + slot.set(bits); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn direct_reads_are_native_endian_and_unaligned() { + let mut bytes = [0u8; 32]; + bytes[1..5].copy_from_slice(&0x7856_3412u32.to_ne_bytes()); + let pointer = super::super::number_value(bytes.as_mut_ptr() as usize as f64); + let value = unsafe { read_value(super::super::types::T_U32, pointer, 1.0) }; + assert_eq!( + JSValue::from_bits(value.to_bits()).as_number(), + 0x7856_3412u32 as f64 + ); + } +} diff --git a/crates/perry-runtime/src/event_pump.rs b/crates/perry-runtime/src/event_pump.rs index 6495dd0208..bf6799de16 100644 --- a/crates/perry-runtime/src/event_pump.rs +++ b/crates/perry-runtime/src/event_pump.rs @@ -457,6 +457,9 @@ pub extern "C" fn perry_has_work() -> i32 { if unsafe { js_stdlib_has_active_handles() } != 0 { return 1; } + if crate::bun_ffi::callback::js_bun_ffi_has_active_threadsafe_callbacks() != 0 { + return 1; + } 0 } diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index c75bc54364..da1923b313 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -628,6 +628,7 @@ pub(crate) fn cjs_default_base_module(module_name: &str) -> Option<&'static str> "constants.default" => Some("constants"), "dns.default" => Some("dns"), "dns/promises.default" => Some("dns/promises"), + "ffi.default" => Some("ffi"), "inspector.default" => Some("inspector"), "inspector/promises.default" => Some("inspector/promises"), "module.default" => Some("module"), @@ -656,6 +657,7 @@ fn cjs_default_namespace_name(module_name: &str) -> Option<&'static str> { "constants" => Some("constants.default"), "dns" => Some("dns.default"), "dns/promises" => Some("dns/promises.default"), + "ffi" => Some("ffi.default"), "inspector" => Some("inspector.default"), "inspector/promises" => Some("inspector/promises.default"), "module" => Some("module.default"), @@ -712,9 +714,9 @@ pub(crate) fn cjs_default_export_value(module_name: &str) -> Option { b"wasi.default".as_ptr(), "wasi.default".len(), )), - "async_hooks" | "child_process" | "constants" | "dns" | "dns/promises" | "node-pty" - | "os" | "path" | "path.posix" | "path.win32" | "punycode" | "querystring" | "repl" - | "sea" | "url" | "util" | "inspector" | "inspector/promises" => { + "async_hooks" | "child_process" | "constants" | "dns" | "dns/promises" | "ffi" + | "node-pty" | "os" | "path" | "path.posix" | "path.win32" | "punycode" | "querystring" + | "repl" | "sea" | "url" | "util" | "inspector" | "inspector/promises" => { create_cjs_default_namespace(module_name) } _ => None, diff --git a/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs b/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs index b83c249c31..43462cdb58 100644 --- a/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs +++ b/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs @@ -8,7 +8,9 @@ fn native_callable_export_arity_reference(module: &str, prop: &str) -> Option Some(2), ("bun:ffi", "toArrayBuffer" | "toBuffer") => Some(3), ("bun:ffi", "viewSource") => Some(2), - ("bun:ffi", "read") => Some(0), + ("ffi", "dlopen") => Some(2), + ("ffi", "getRawPointer" | "toString") => Some(1), + ("ffi", "toArrayBuffer" | "toBuffer") => Some(3), // #3687: node:cluster — module-method `.length` matches Node. ("cluster", "fork" | "disconnect" | "setupPrimary" | "setupMaster" | "Worker") => Some(1), ("cluster", "emit") => Some(1), @@ -285,7 +287,6 @@ static CALLABLE_EXPORT_ARITY_TABLE: &[(&str, &[(&str, u32)])] = &[ ("dlopen", 2), ("linkSymbols", 1), ("ptr", 1), - ("read", 0), ("toArrayBuffer", 3), ("toBuffer", 3), ("viewSource", 2), @@ -370,6 +371,16 @@ static CALLABLE_EXPORT_ARITY_TABLE: &[(&str, &[(&str, u32)])] = &[ ("setMaxListeners", 0), ], ), + ( + "ffi", + &[ + ("dlopen", 2), + ("getRawPointer", 1), + ("toArrayBuffer", 3), + ("toBuffer", 3), + ("toString", 1), + ], + ), ( "fs", &[ 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 a14850469f..7636d24437 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 @@ -50,7 +50,14 @@ pub(crate) fn is_native_module_callable_export_reference(module: &str, prop: &st | "CFunction" | "linkSymbols" | "viewSource" - | "read" + ) + { + return true; + } + if module == "ffi" + && matches!( + prop, + "dlopen" | "getRawPointer" | "toArrayBuffer" | "toBuffer" | "toString" ) { return true; diff --git a/crates/perry-runtime/src/object/native_module/callable_export_table.rs b/crates/perry-runtime/src/object/native_module/callable_export_table.rs index 89e341cac1..490e99c3e6 100644 --- a/crates/perry-runtime/src/object/native_module/callable_export_table.rs +++ b/crates/perry-runtime/src/object/native_module/callable_export_table.rs @@ -90,7 +90,6 @@ pub(super) static CALLABLE_EXPORT_TABLE: &[(&str, &[&str])] = &[ "dlopen", "linkSymbols", "ptr", - "read", "toArrayBuffer", "toBuffer", "viewSource", @@ -314,6 +313,16 @@ pub(super) static CALLABLE_EXPORT_TABLE: &[(&str, &[&str])] = &[ "setMaxListeners", ], ), + ( + "ffi", + &[ + "dlopen", + "getRawPointer", + "toArrayBuffer", + "toBuffer", + "toString", + ], + ), ( "fs", &[ diff --git a/crates/perry-runtime/src/object/native_module/constants.rs b/crates/perry-runtime/src/object/native_module/constants.rs index 95390227e2..03c7625d2c 100644 --- a/crates/perry-runtime/src/object/native_module/constants.rs +++ b/crates/perry-runtime/src/object/native_module/constants.rs @@ -260,9 +260,13 @@ pub(crate) unsafe fn get_native_module_constant( match property { "FFIType" => return Some(crate::bun_ffi::types::ffi_type_object_value()), "suffix" => return Some(crate::bun_ffi::types::suffix_value()), + "read" => return Some(crate::bun_ffi::read::read_object_value()), _ => {} } } + if module_name == "ffi" && property == "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 ef83fd3388..cbcd2273c9 100644 --- a/crates/perry-runtime/src/object/native_module/module_keys.rs +++ b/crates/perry-runtime/src/object/native_module/module_keys.rs @@ -1643,6 +1643,14 @@ pub(crate) fn native_module_enumerable_keys(module_name: &str) -> Option<&'stati b"viewSource", b"read", ]), + "ffi" | "ffi.default" => Some(&[ + b"dlopen", + b"getRawPointer", + b"toArrayBuffer", + b"toBuffer", + b"toString", + b"suffix", + ]), "sea" => Some(SEA_NAMESPACE_KEYS), "sea.default" => Some(SEA_DEFAULT_KEYS), "module" => Some(MODULE_NAMESPACE_KEYS), diff --git a/crates/perry-runtime/src/object/native_module_dispatch.rs b/crates/perry-runtime/src/object/native_module_dispatch.rs index fce72f6372..fa43e0aa64 100644 --- a/crates/perry-runtime/src/object/native_module_dispatch.rs +++ b/crates/perry-runtime/src/object/native_module_dispatch.rs @@ -270,6 +270,9 @@ pub(crate) unsafe fn dispatch_native_module_method( // #3687: cluster default-import method calls (`cluster.fork()`, // `cluster.emit(...)`) dispatch against the base `cluster` arms. "cluster.default" => ("cluster", false), + // `createRequire(...)("node:ffi")` exposes the CJS default namespace. + // Keep method calls on that namespace on node:ffi's compatibility path. + "ffi.default" => ("ffi", false), // #6563: `(await import("node-pty")).default.spawn(...)` — the CJS // interop shape esbuild-bundled consumers produce. "node-pty.default" => ("node-pty", false), 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 7ef7794ffe..93174de757 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 @@ -219,10 +219,10 @@ pub(crate) unsafe fn nm_dispatch_bun_ffi(ctx: &NmCtx, module_name: &str, method_ assert_skip_prototype, } = *ctx; let _ = (obj, assert_skip_prototype); - if module_name != "bun:ffi" { + if !matches!(module_name, "bun:ffi" | "ffi" | "ffi.default") { return f64::from_bits(JSValue::undefined().bits()); } - match crate::bun_ffi::dispatch(method_name, args_ptr, args_len) { + match crate::bun_ffi::dispatch(module_name, method_name, args_ptr, args_len) { Some(v) => v, None => f64::from_bits(JSValue::undefined().bits()), } diff --git a/crates/perry-runtime/src/object/native_module_registry.rs b/crates/perry-runtime/src/object/native_module_registry.rs index 0552fe0107..42d30b2f34 100644 --- a/crates/perry-runtime/src/object/native_module_registry.rs +++ b/crates/perry-runtime/src/object/native_module_registry.rs @@ -82,7 +82,7 @@ fn nm_module_index(name: &str) -> Option { "bun" => Some(NmBucket::Bun), // #6562: the `bun:` prefix is part of the name (not stripped like // `node:`). - "bun:ffi" => Some(NmBucket::BunFfi), + "bun:ffi" | "ffi" | "ffi.default" => Some(NmBucket::BunFfi), "child_process" => Some(NmBucket::ChildProcess), "cluster" => Some(NmBucket::Cluster), "console" => Some(NmBucket::Console), diff --git a/crates/perry-runtime/src/process.rs b/crates/perry-runtime/src/process.rs index 7ddf2ce4e6..995d40da8c 100644 --- a/crates/perry-runtime/src/process.rs +++ b/crates/perry-runtime/src/process.rs @@ -122,7 +122,7 @@ pub(crate) fn is_function_value(value: f64) -> bool { /// - `_`-prefixed legacy internals (`_http_agent`, …): Node still serves /// them, Perry has no implementation — they must keep failing with an /// error that names the module, not resolve to a method-dead namespace. -/// - Scheme-only builtins (`node:sea`, `node:sqlite`, `node:test`, +/// - Scheme-only builtins (`node:ffi`, `node:sea`, `node:sqlite`, `node:test`, /// `node:test/reporters` — stored WITH the prefix, exactly as Node spells /// them in `module.builtinModules`): resolve only when the caller wrote /// the `node:` prefix. The bare spelling is an ordinary npm package name @@ -251,6 +251,7 @@ pub(crate) const MODULE_BUILTIN_MODULES: &[&str] = &[ "wasi", "worker_threads", "zlib", + "node:ffi", "node:sea", "node:sqlite", "node:test", @@ -814,8 +815,8 @@ mod builtin_module_list_tests { let prefixed = format!("node:{entry}"); assert_eq!(supported_builtin_module_name(&prefixed), None, "{prefixed}"); } else if let Some(bare) = entry.strip_prefix("node:") { - // Scheme-only builtins (node:sea, node:sqlite, node:test, - // node:test/reporters): the prefixed spelling resolves, the + // Scheme-only builtins (node:ffi, node:sea, node:sqlite, + // node:test, node:test/reporters): the prefixed spelling resolves, the // bare spelling is an ordinary npm name (Node parity). assert_eq!(supported_builtin_module_name(entry), Some(bare), "{entry}"); assert_eq!(supported_builtin_module_name(bare), None, "{bare}"); diff --git a/crates/perry-runtime/src/promise/microtasks.rs b/crates/perry-runtime/src/promise/microtasks.rs index 2cc00ef346..84f7813443 100644 --- a/crates/perry-runtime/src/promise/microtasks.rs +++ b/crates/perry-runtime/src/promise/microtasks.rs @@ -215,6 +215,8 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { }); let mut ran = 0; + ran += crate::bun_ffi::callback::drain_threadsafe_callbacks(); + ran += crate::async_hooks::drain_gc_destroy_queue(); // FinalizationRegistry cleanup jobs recorded by AUTOMATIC collection diff --git a/crates/perry/tests/bun_ffi_stage1.rs b/crates/perry/tests/bun_ffi_stage1.rs index 47982b9d67..30f0a59c9d 100644 --- a/crates/perry/tests/bun_ffi_stage1.rs +++ b/crates/perry/tests/bun_ffi_stage1.rs @@ -88,6 +88,7 @@ fn build_test_dylib(dir: &Path) -> PathBuf { .arg("-o") .arg(&lib_path) .arg(&c_path) + .arg("-pthread") .status() .expect("run cc (the perry link driver requires it too)"); assert!(status.success(), "cc failed to build the test dylib"); @@ -101,6 +102,8 @@ const TEST_LIB_C: &str = r#" #include #include #include +#include +#include #define EXPORT __attribute__((visibility("default"))) @@ -146,6 +149,12 @@ 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 int64_t ffi_sum14(int32_t a, int32_t b, int32_t c, int32_t d, + int32_t e, int32_t f, int32_t g, int32_t h, + int32_t i, int32_t j, int32_t k, int32_t l, + int32_t m, int32_t n) { + return (int64_t)a+b+c+d+e+f+g+h+i+j+k+l+m+n; +} EXPORT void *ffi_ptr_identity(void *p) { return p; } EXPORT void *ffi_null_ptr(void) { return NULL; } @@ -203,13 +212,39 @@ EXPORT int32_t ffi_call_cstring_callback( return callback("native callback"); } EXPORT void *ffi_echo_callback(void *callback) { return callback; } +EXPORT void *ffi_i32_add1_ptr(void) { return (void *)&ffi_i32_add1; } + +struct threaded_callback_ctx { void (*callback)(uint32_t); uint32_t value; }; +static void *ffi_thread_entry(void *raw) { + struct threaded_callback_ctx *ctx = raw; + ctx->callback(ctx->value); + free(ctx); + return NULL; +} +EXPORT void ffi_start_thread_callback(void (*callback)(uint32_t), uint32_t value) { + struct threaded_callback_ctx *ctx = malloc(sizeof(*ctx)); + ctx->callback = callback; + ctx->value = value; + pthread_t thread; + if (pthread_create(&thread, NULL, ffi_thread_entry, ctx) == 0) { + pthread_detach(thread); + } else { + free(ctx); + } +} "#; const TIER1_TS: &str = r#" -import { dlopen, FFIType, ptr, CString, JSCallback, suffix, toArrayBuffer, toBuffer } from "bun:ffi"; +import { dlopen, FFIType, ptr, CString, JSCallback, CFunction, linkSymbols, viewSource, read, suffix, toArrayBuffer, toBuffer } from "bun:ffi"; import * as ffiNamespace from "bun:ffi"; +import * as nodeFfi from "node:ffi"; +import nodeFfiDefault from "node:ffi"; +import { createRequire } from "node:module"; + +const nodeFfiRequired = createRequire(import.meta.url)("node:ffi"); console.log("suffix-ok:", suffix === "dylib" || suffix === "so"); +console.log("node-require:", typeof nodeFfiRequired.dlopen, nodeFfiRequired.suffix === nodeFfi.suffix); 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); @@ -244,6 +279,10 @@ const lib = dlopen(process.env.FFI_TEST_LIB!, { args: [FFIType.f64, FFIType.f64, FFIType.f64, FFIType.f64, FFIType.f64, FFIType.f64, FFIType.f64, FFIType.f64], returns: FFIType.f64, }, + ffi_sum14: { + args: [FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32], + returns: FFIType.i64, + }, 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 }, @@ -278,6 +317,7 @@ const lib = dlopen(process.env.FFI_TEST_LIB!, { args: [FFIType.function], returns: FFIType.function, }, + ffi_i32_add1_ptr: { args: [], returns: FFIType.ptr }, }); const s = lib.symbols; @@ -314,6 +354,7 @@ console.log("f32:", s.ffi_f32_half(9), "f64:", s.ffi_f64_half(9)); 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)); +console.log("sum14:", s.ffi_sum14(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14)); // pointers: JS buffer -> native (read) and native -> JS buffer (write) const buf = new Uint8Array(16); @@ -342,10 +383,12 @@ 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)); +console.log("cstring-null:", JSON.stringify(CString(null as any))); // Stage 2: zero-copy native-memory wrappers. Both directions must alias the // C static allocation; a copy would fail one of these checks. const externalPtr = s.ffi_external_ptr(); +console.log("read-namespace:", read.u8(externalPtr), read.u16(externalPtr)); const externalAB = toArrayBuffer(externalPtr, 1, 3); const externalView = new Uint8Array(externalAB); console.log("external-ab:", externalAB instanceof ArrayBuffer, externalAB.byteLength); @@ -412,6 +455,16 @@ namespaceCallback.close(); stringCallback.close(); throwingCallback.close(); +// Pointer-owned typed functions and tables use the same ABI stubs without a +// library handle. `new CFunction` must preserve its explicit callable return. +const addPointer = s.ffi_i32_add1_ptr(); +const cFunction = new CFunction({ ptr: addPointer, args: [FFIType.i32], returns: FFIType.i32 }); +console.log("cfunction:", cFunction(41)); +const linked = linkSymbols({ addOne: { ptr: addPointer, args: [FFIType.i32], returns: FFIType.i32 } }); +console.log("linksymbols:", linked.symbols.addOne(41), typeof linked.close); +console.log("viewsource:", Array.isArray(viewSource({ addOne: { ptr: addPointer, args: [FFIType.i32], returns: FFIType.i32 } }))); +linked.close(); + lib.close(); // use-after-close throws a descriptive error instead of crashing @@ -423,6 +476,47 @@ try { } console.log("closed-throws:", closedError.includes("close()")); +// Node 26's node:ffi shape, including lossless bigint pointers, zero-copy +// native memory, library-owned callbacks, and the full stack-argument call. +const nodeOpened = nodeFfi.dlopen(process.env.FFI_TEST_LIB!, { + ffi_sum14: { arguments: ["i32", "i32", "i32", "i32", "i32", "i32", "i32", "i32", "i32", "i32", "i32", "i32", "i32", "i32"], return: "i64" }, + ffi_external_ptr: { arguments: [], return: "pointer" }, + ffi_external_get: { arguments: ["i32"], return: "u8" }, + ffi_call_callback: { arguments: ["function", "i32", "f64", "f32"], return: "i32" }, +}); +const requiredOpened = nodeFfiRequired.dlopen(process.env.FFI_TEST_LIB!, { + ffi_i32_add1: { arguments: ["i32"], return: "i32" }, +}); +console.log("node-require-call:", requiredOpened.functions.ffi_i32_add1(41)); +requiredOpened.lib.close(); +console.log("node-default:", nodeFfiDefault.suffix === nodeFfi.suffix); +console.log("node-sum14:", nodeOpened.functions.ffi_sum14(1,2,3,4,5,6,7,8,9,10,11,12,13,14)); +const nodePointer = nodeOpened.functions.ffi_external_ptr(); +const nodeExternal = nodeFfi.toArrayBuffer(nodePointer, 4, false); +console.log("node-pointer-view:", typeof nodePointer, new Uint8Array(nodeExternal)[0]); +console.log("node-raw-pointer:", nodeFfi.getRawPointer(nodeExternal) === nodePointer); +const nodeCallbackPointer = nodeOpened.lib.registerCallback( + { arguments: ["i32", "f64", "f32"], return: "i32" }, + (a: number, b: number, c: number) => a + b + c, +); +console.log("node-callback:", typeof nodeCallbackPointer, nodeOpened.functions.ffi_call_callback(nodeCallbackPointer, 10, 20.5, 11.5)); +nodeOpened.lib.unregisterCallback(nodeCallbackPointer); +nodeOpened.lib.close(); + +// FFF uses a `threadsafe: true` JSCallback from a native watcher thread. +// Keep this dylib open until process exit: the foreign thread returns through +// its code immediately after the owning JS thread services the callback. +const threadedLib = dlopen(process.env.FFI_TEST_LIB!, { + ffi_start_thread_callback: { args: [FFIType.function, FFIType.u32], returns: FFIType.void }, +}); +let threadedCallback: any; +threadedCallback = new JSCallback((value: number) => { + console.log("threadsafe-callback:", value); + threadedCallback.close(); +}, { args: [FFIType.u32], returns: FFIType.void, threadsafe: true }); +console.log("threadsafe-shape:", threadedCallback.threadsafe); +threadedLib.symbols.ffi_start_thread_callback(threadedCallback.ptr, 6562); + console.log("TIER1-DONE"); "#; @@ -450,6 +544,7 @@ fn tier1_every_ffi_type_against_test_dylib() { assert!(ok, "binary failed\nstdout:\n{stdout}\nstderr:\n{stderr}"); for needle in [ "suffix-ok: true", + "node-require: function true", "ffitype: 5 14 12 13 8", "ffitype-aliases: true true true", "void-calls: 2", @@ -473,6 +568,7 @@ fn tier1_every_ffi_type_against_test_dylib() { "mixed: 249", "sum8: 36n", "dsum8: 32", + "sum14: 105n", "ptr-type: number true", "ptr-identity: true", "ptr-offset: true", @@ -487,6 +583,8 @@ fn tier1_every_ffi_type_against_test_dylib() { "strlen: 11", "concat: foobar", "cstring-read: caf\u{e9} \u{2713}", + "cstring-null: \"\"", + "read-namespace: 65 16961", "external-ab: true 3", "external-initial: 66 0 67", "external-native-write: 88", @@ -506,7 +604,19 @@ fn tier1_every_ffi_type_against_test_dylib() { "callback-cstring-return: 15", "callback-throw-zero: 0", "callback-closed-zero: 0", + "cfunction: 42", + "linksymbols: 42 function", + "viewsource: true", "closed-throws: true", + "node-sum14: 105n", + "node-default: true", + "node-require-call: 42", + // Reopening the dylib after `lib.close()` resets its static fixture. + "node-pointer-view: bigint 65", + "node-raw-pointer: true", + "node-callback: bigint 42", + "threadsafe-shape: true", + "threadsafe-callback: 6562", "TIER1-DONE", ] { assert!( @@ -548,19 +658,17 @@ try { console.log("function-type: false"); } -// Constructor validation is explicit, including the same-thread contract. +// Constructor validation is explicit; threadsafe callbacks advertise the +// foreign-thread handoff and can be closed without invocation. try { JSCallback(1 as any, {}); console.log("jscallback-type: false"); } catch (e: any) { console.log("jscallback-type:", String(e.message).includes("expects a function")); } -try { - JSCallback(() => {}, { threadsafe: true }); - console.log("jscallback-threadsafe: false"); -} catch (e: any) { - console.log("jscallback-threadsafe:", String(e.message).includes("creating thread")); -} +const threadsafe = JSCallback(() => {}, { threadsafe: true }); +console.log("jscallback-threadsafe:", threadsafe.threadsafe); +threadsafe.close(); // strings are not pointers (Bun-compatible hint) try { diff --git a/scripts/raw_handle_debt_baseline.txt b/scripts/raw_handle_debt_baseline.txt index 6a21a128a7..d6904a94ba 100644 --- a/scripts/raw_handle_debt_baseline.txt +++ b/scripts/raw_handle_debt_baseline.txt @@ -1 +1 @@ -918 +913 diff --git a/scripts/raw_handle_debt_files.txt b/scripts/raw_handle_debt_files.txt index b4d39ee815..3825d7b805 100644 --- a/scripts/raw_handle_debt_files.txt +++ b/scripts/raw_handle_debt_files.txt @@ -56,7 +56,6 @@ 5 crates/perry-runtime/src/atomics.rs 7 crates/perry-runtime/src/builtins/console.rs 12 crates/perry-runtime/src/builtins/globals.rs -5 crates/perry-runtime/src/bun_ffi/dlopen.rs 3 crates/perry-runtime/src/child_process/v8_serde.rs 5 crates/perry-runtime/src/dns.rs 6 crates/perry-runtime/src/embedded.rs