Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions changelog.d/8704-complete-c-abi-ffi.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions crates/perry-api-manifest/src/entries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
29 changes: 14 additions & 15 deletions crates/perry-api-manifest/src/entries/part_1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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"),
Expand Down
6 changes: 0 additions & 6 deletions crates/perry-api-manifest/tests/stub_inventory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, usize> =
expected.iter().map(|(k, v)| (k.to_string(), *v)).collect();
Expand All @@ -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!(
Expand Down
4 changes: 4 additions & 0 deletions crates/perry-codegen/src/codegen/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions crates/perry-codegen/src/expr/fs_await.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,9 +222,13 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
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");
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-codegen/src/nm_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/runtime_decls/strings_part2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 5 additions & 2 deletions crates/perry-hir/src/lower/expr_new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())?,
});
}
Expand Down
4 changes: 2 additions & 2 deletions crates/perry-hir/src/lower/expr_new/member.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())?,
}));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading