SCUA

How-to

Call SCUA from a native library

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.buffer allocations 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.

Start and bind a library · Native data · Callbacks · Lifetimes and unsafe access · Declarations and generation