SCUA

How-to

Call native libraries with dynamic FFI

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.