Bind a C function by its exported name and signature, then call it from SCUA. The host chooses which libraries a partition may open.
This feature is experimental and unreleased. Read Native libraries and FFI for a complete first run, platform support and the choice between dynamic FFI and compiled bindings.
#Build and grant
zig build -Dffi=true
zig build embed-ffi -Dffi=true
Normal builds default to -Dffi=false and do not link
libffi. Enabled builds use the macOS SDK's libffi headers/library and
libc; Linux uses its libffi development package and pkg-config. Hosts
linking the resulting static library must also link libffi
(-lffi). For cross builds,
-Dffi-prefix=/path/to/target/prefix supplies
include/ and lib/; a macOS SDK's
usr directory is a suitable prefix when targeting the other
Mac architecture. Windows x86-64 is a preview requiring
-Dffi-windows-preview=true and a target libffi prefix; it
has Wine smoke evidence, not real-Windows validation. Other targets are
refused. There is no blanket --allow-ffi CLI switch, and
--allow-all does not grant native-library access.
Configure the partition before compiling/loading the script:
scua_partition *p = scua_partition_new();
if (!scua_ffi_available()) { /* This binary has no FFI provider. */ }
int status = scua_grant_ffi(p, "demo", "/absolute/path/to/libdemo.dylib");
/* Check status before scua_eval or scua_load_script. */
The runtime copies the mapping. IDs are 1-64 ASCII letters/digits/underscores/hyphens; paths must be absolute and at most 4,096 bytes. A duplicate ID is refused: revoke it before replacing it. Granting does not load the library. Scripts select an approved ID, never supply a load path. Grants are local to the partition; actors, sandbox partitions and reloaded partitions do not inherit them automatically. Adding a grant after installing a persistent script does not rebuild its module globals; set grants first.
#Declare and call
For a native function declared as
int64_t add(int64_t a, int64_t b):
import ffi
let library = ffi.open("demo")
let add = ffi.bind(library, "add", "i64", ["i64", "i64"])
let answer = ffi.call(add, 20, 22)
assert(answer == 42)
ffi.close(library)
ffi.bind(library, symbol_name, result_type, argument_types)
prepares and caches a call interface. Use [] for a function
taking no arguments. There is no C declaration parser.
| Declared type | Argument accepted | Result |
|---|---|---|
i8, i16 |
SCUA integer in the declared signed range | SCUA integer |
i32 |
SCUA integer in signed 32-bit range | SCUA integer |
i64 |
SCUA integer in signed 64-bit range | SCUA integer |
u8, u16, u32,
u64 |
Nonnegative integer or digit-only decimal string in range | Integer through INT64_MAX; otherwise an exact decimal
string |
bool |
SCUA boolean | SCUA boolean |
f32 |
Finite SCUA float/integer, representable without float32 overflow | SCUA float |
f64 |
Finite SCUA float/integer | SCUA float |
cstr |
String without embedded NUL, or nil — a copy freed when
the call returns; declare {type = "cstr", retained = true}
and pass ffi.native_string(…) when C keeps the pointer |
Copied UTF-8 string, or nil for NULL |
ptr |
Live opaque FFI pointer handle, or nil |
Opaque handle, or nil for NULL |
void |
Not permitted as an argument | nil |
On supported 64-bit targets: short/ushort =
i16/u16; int/uint =
i32/u32; intptr,
ssize_t, ptrdiff_t = i64;
size_t, uintptr = u64.
char follows the C compiler's signedness;
long/ulong follow the target C ABI width.
Integer aliases do not permit forging pointer handles.
Integer-to-floating conversion and float32 narrowing can round. Floating return values are not restricted to finite values. Float-to-integer coercion is not performed. Argument count and types are checked on every call. Calls use the platform-default ABI and at most 16 arguments.
The declaration must match the real C signature. libffi cannot discover or verify a symbol's C prototype, or prove that a named symbol is a function rather than data. Supported-but-incorrect declarations can crash or corrupt the host. SCUA rejects unsupported declarations and mismatched SCUA arguments; it does not make arbitrary native code safe.
#Calling conventions and binding options
The optional fifth ffi.bind argument accepts the
original release-symbol string or an options table. Unknown keys are
rejected. Keys are abi, release (a symbol
name, or {symbol = "…", result = "i32"} for a destructor
that returns int and must have its refusal reported),
ownership, depends_on,
output_counts, transferable and
fixed (the positive fixed-argument count of a variadic
function). See data and ownership declarations
and callback
handoff before using the lifetime options.
let foreign = ffi.bind(library, "foreign", "i64", ["i64", "f64"], {abi = "win64"})
let owned = ffi.bind(library, "owned", "cstr", [], {release = "release_text"})
ffi.abi_supported(name) reports provider availability.
default/cdecl, sysv
(unix64 on x86-64) and win64 are recognized
where provided by target libffi. Unsupported conventions such as
stdcall on these 64-bit targets fault rather than silently
using the default ABI. The selected ABI must match the actual native
function. Release functions currently use the platform-default C ABI
independently of the called function's ABI.
Function-pointer declarations accept a third ABI argument:
ffi.fn_type(result, arguments, abi). The third argument may
instead be {abi = "default", fixed = N} for a variadic
function pointer. N is the positive number of fixed C
parameters; the argument list includes the exact promoted trailing types
for this call shape. Narrow integers/bool must be declared as
i32 and floats as f64 in the variadic tail.
Different call shapes require separate descriptors.
let Variadic = ffi.fn_type("f64", ["i32", "i32", "f64"], {fixed = 1})
let get_function = ffi.bind(library, "get_variadic_function", Variadic, [])
let function = ffi.call(get_function)
print(ffi.call(function, 1, 20, 22.0))
Such pointers can be returned, passed to compatible C parameters and
stored in supported read-only pointer views/records. Compatibility
includes the fixed count as well as ABI and argument/result types. They
keep the usual borrowed-handle dependencies. ffi.callback
also accepts a variadic descriptor for one fixed call shape, using its
declared fixed count and promoted tail. The C caller must always supply
exactly those argument types/counts; this is not va_list
introspection or a callback accepting arbitrary arguments. The
owner-thread, fault, lifetime and retirement rules are unchanged.
Executable evidence covers macOS ARM64/x86-64 and Linux ARM64/x86-64,
including fixed-only and register-overflow shapes. Native-call dispatch
can use the specialized scalar/pointer thunk; callbacks use libffi
closures. It is applied to both returned-function calls and callback
trampolines; mismatched function-pointer ABIs are rejected.
ffi.bind_var accepts an optional sixth options argument, or
use fixed in the ordinary ffi.bind options
table. It can therefore also declare an owned variadic result.
#Variadic functions
Use
ffi.bind_var(library, name, result, argument_types, fixed_count)
for a C function ending in .... The list includes both
fixed and trailing argument types. fixed_count must be
positive and no larger than the list length; the total remains limited
to 16.
-- C: double sum_pairs(int count, ...); each pair is int, double
let sum_pairs = ffi.bind_var(library, "sum_pairs", "f64",
["i32", "i32", "f64", "i32", "f64"], 1)
print(ffi.call(sum_pairs, 2, 4, 0.5, -2, 3.25))
Declare trailing arguments using their promoted C ABI
types: i32 for narrow integers and booleans,
f64 for float. Unpromoted declarations are
rejected. Bind a separate symbol handle for each trailing type/count
combination. The fixed-only case still uses the variadic ABI, not the
ordinary fixed-arity ABI. Optional binding options supply an ABI and
release annotation.
#Native execution is trusted
Calls execute synchronously on the owning thread through the same checked boundary under the interpreter and JIT. Selected supported signatures use specialized JIT call paths; other calls use libffi. This does not change argument checks or ownership rules. Native loading/unloading can itself execute library constructors/destructors; arbitrary reentry is guarded. Only explicitly created owner-thread callbacks may reenter through the callback boundary.
A path allowlist controls selection, not what a library or its dependencies can do. Native code has process-wide authority, can crash, and can block indefinitely. SCUA fuel, deadlines and memory limits cannot interrupt a native call or cap its allocations. Enforceable isolation requires a separate process.
#Troubleshooting
| Symptom | Check |
|---|---|
ffi unavailable or library ID refused |
Build with FFI enabled and grant the exact ID before compiling or loading the script. CLI permissions do not substitute for a host grant. |
| Library or symbol cannot be loaded | Use an absolute path, the right CPU architecture, exported symbol names, and installed native dependencies. |
| Unsupported native signature | Check ABI availability and layout support. Matching record size alone does not establish by-value compatibility. |
| Type or extent mismatch | Reuse nominal descriptors, check integer ranges, and declare counts in elements. |
| Stale handle | Its owner, view, callback scope or library may have ended. Opening a new library does not revive old handles. |
| Callback rejected | Check owning thread and active call scope; use staged calls for managed arrays with callbacks. |
| Native mutation detected | Reconcile a tracked view using independently retained handles, or close the view. |
| Persistence refused | Remove FFI resources and type descriptors from the saved graph, including closed handles and captured values. |
#Next steps
Pass strings, buffers and records, call back into SCUA, manage lifetimes, or generate declarations.