Use a callback when C needs to ask your script for a value during a native call. The native caller must run on the partition's owning thread.
These APIs are experimental and unreleased. Start with Native libraries and FFI for
setup and platform support. Code fragments below assume
import ffi, an open library handle, and C
functions with the signatures shown in comments. Symbol names are
illustrative unless linked to an executable example.
#Function pointers and owner-thread callbacks
Callbacks can be passed through independently created equivalent function descriptors. ABI, argument/result types and qualifiers, and output contracts must match; nominal native resource types remain distinct. Nested function-pointer signatures are compared recursively, with a sixteen-level limit and a shared budget of 1,024 nonidentical callable-layout comparisons. Exceeding either limit rejects compatibility rather than guessing; reusing the same descriptor is accepted without traversing its graph. Distinct cyclic signatures are not inferred equivalent.
-- C: typedef int64_t (*Transform)(int64_t);
-- C: int64_t apply(Transform callback, int64_t value);
let Transform = ffi.fn_type("i64", ["i64"])
let apply = ffi.bind(library, "apply", "i64", [Transform, "i64"])
fn increment(value) return value + 1 end
let callback = ffi.callback(library, Transform, increment)
print(ffi.call(apply, callback, 41))
ffi.close(callback)
A function-pointer declaration may be an argument or result type.
Pass a compatible bound symbol, a callback of the same descriptor, or
nil. A returned function pointer becomes a callable binding
for ffi.call, tied to its producing library and input
resources. Fixed-arity signatures are checked; variadic bindings cannot
be passed as fixed function pointers.
Callbacks run synchronously on the owning thread during a dynamic native call, using the VM's existing reentrant callback path. The script closure is rooted until close/retirement. Nested native calls are allowed, but closing FFI resources from a callback is refused. Suspension is forbidden; nesting is bounded by the VM's eight-level callback limit. Moving GC is deferred within callbacks as in the existing host-callback API; the memory ceiling still applies.
Callback signatures support numeric scalars, booleans, records
(including borrowed pointer fields), opaque pointers and function
pointers, plus copied cstr inputs and results. Native
pointer/function-pointer arguments are borrowed for the callback only:
captured handles become stale on return. A function-pointer argument can
be called with ffi.call during the callback. A
function-pointer result must be nil (when nullable) or a compatible live
binding/callback; signature mismatches fault before returning an address
to C. If C retains a returned function address, its original
binding/callback and library must remain live. Typed-buffer callback
arguments require a count_arg or count
annotation; see native data.
Non-NULL buffer addresses must satisfy the element type's alignment.
Invalid alignment is refused before entering the script callback,
alongside extent and overflow checks. Borrowed buffer callback results
use the bounded
native-storage contract.
A cstr callback result is copied into NUL-terminated
native storage owned by the callback. C may read it until the callback
or its library is closed (or the partition is destroyed), including
across later invocations of that callback. C must not modify or free it.
VM retirement retains these copies alongside the disabled trampoline
until library close. Return nil for NULL only when the
result declaration is nullable. Strings must be valid UTF-8 without
embedded NULs, at most 1 MiB each. Each callback retains at most 4,096
string results; copies and bookkeeping count toward the partition's 64
MiB native-storage budget. Exhaustion faults the call rather than
invalidating previously returned strings. Unregister a retained callback
before closing it to reclaim this storage; this API does not transfer
string ownership to C.
A callback fault, bad result or wrong-thread invocation returns a
zero native result, then faults the enclosing ffi.call;
native side effects are not rolled back. Foreign threads never enter
SCUA. Invocation outside an active owner-thread call is rejected; its
error is reported on a subsequent dynamic call. This is not an
asynchronous callback queue.
Unregister retained callbacks and stop/join native callers
before explicitly closing callbacks or libraries. SCUA
cannot revoke function addresses retained in arbitrary native code.
Library unload disables callbacks before native destructors run and
frees trampolines afterward. VM destruction invalidates that VM's
callbacks and retains inert trampolines until library close, preventing
code reuse after one-shot scua_eval. Use a persistent
loaded script for callbacks across host turns. At most 128 callback
registrations exist per partition, including retired trampolines pending
unload.
#Returning an owned allocation from a callback
Some C APIs require a callback to return an allocation that C will destroy. This is different from a borrowed callback result. Declare both the allocating function and the receiving callback explicitly:
let Values = ffi.pointer("i64")
let allocate = ffi.bind(library, "allocate_values",
{type = Values, count_arg = 0}, ["size_t"],
{ownership = "owned", release = "destroy_values",
transferable = true, depends_on = []})
let CreateValues = ffi.fn_type({type = Values, count_arg = 0}, ["size_t"])
let create = ffi.callback(library, CreateValues, fn(count)
let values = ffi.call(allocate, count)
assert(values != nil)
ffi.set(values, 0, 42)
return ffi.handoff(values)
end, "destroy_values")
This example assumes a positive count. allocate_values
returns a fresh native allocation of at least that many elements;
destroy_values has the default-ABI signature
void destroy_values(void *). The native consumer must call
that same destructor when finished. Keep the library loaded until C has
finished with every transferred allocation, even if C retains it beyond
the initiating call.
The fourth ffi.callback argument declares an
owning-result callback and names the matching
destructor; it does not arrange an additional SCUA cleanup after
transfer. Three-argument callbacks retain their existing borrowed-result
behavior. The allocator must explicitly declare
ownership = "owned", transferable = true, a
release function and depends_on = []. These are trusted
binding assertions of a fresh, independently owned allocation, not facts
SCUA can infer from C code.
ffi.handoff(handle) requests transfer and returns the
same handle. Transfer commits only when the callback successfully
returns that handle with a compatible result type and extent. SCUA then
invalidates the owner and its tracked descendants without freeing the
allocation. C owns destruction from that point onward. A later callback
failure in the same outer native call does not undo an earlier
transfer.
Before commit, failure leaves ownership with SCUA: the declared
destructor remains attached, and retained handles can still be closed.
If a callback abandons its allocation, partition teardown still cleans
it up; preserve a handle when earlier cleanup is required. A pending
request followed by a fault, a different result or an invalid extent
never transfers. An owning callback returning a non-null handle without
requesting handoff also faults. Return nil directly for a
nullable null result; buffer null results additionally require zero
extent.
Initial limits are deliberately narrow:
- Only transferable allocations returned by a bound allocator
inside this exact callback invocation are eligible.
Earlier allocations, outer/nested callback allocations, borrowed views,
output-slot results and ordinary
ffi.bufferallocations are refused. This prevents handing over storage in an ancestor active native call; callback depth alone is not used as lifetime identity. - Supported results are opaque/raw pointer handles and owned POD typed buffers. The allocation must have no tracked provider dependencies beyond its library. Its library instance and destructor must match the receiving callback.
- There is one handoff request per callback invocation. SCUA strings, copied C-string results and pointer-bearing buffer allocations are not transferable through this API. No arbitrary allocator conversion or copying is performed.
- SCUA invalidates tracked aliases, not pointers copied into C globals or obtained through unsafe APIs. C must honour the ownership contract and avoid double frees.
The manifest generator accepts the same transferable
function flag. The Clang importer does not infer it; add this explicit
contract to the manifest or bind the allocator directly. The executable fixture covers
successful pointer/buffer transfers and the refusal paths.
#Related guides
Start and bind a library · Native data · Callbacks · Lifetimes and unsafe access · Declarations and generation