SCUA

How-to

Bind runtime-owned userdata (experimental)

This experimental API lets a C binding return an opaque object whose storage and identity SCUA manages, without a binding-owned allocation or handle map. It also supports adopting existing owned or borrowed host pointers, with creation-time dependencies and dependent-first cleanup across all three modes. Explicit redacted checkpoints are available for idle partitions and for individual actors at an owner-thread scheduler boundary.

For the choice between compiled bindings and dynamic calls, start with Native libraries and FFI. Dynamic FFI resources have a separate lifetime policy and cannot use userdata's redacted-persistence opt-in.

Run the paired example from a source checkout:

zig build embed-userdata

examples/embed_userdata.c registers mock context/texture and mutable image/pixel-view operations; examples/embed_userdata.scua calls them directly. No raylib installation is required, and this is not yet a real raylib validation.

#Storage and access

Configure finite limits with scua_userdata_configure, then register a copied size/version-tagged scua_UserdataTypeSpec. Fixed-size types specify a size; size zero permits variable-size instances. Alignments must be powers of two up to 16. Storage is zeroed and stable outside the moving arena.

scua_return_userdata reserves storage and publication capacity before invoking the initializer. After initialization succeeds, it returns the opaque value directly to SCUA. On failure the initializer must undo partial external acquisitions; SCUA frees storage without calling the normal destructor. Neither initializer nor destructor may re-enter SCUA, yield, or unwind across the C ABI.

scua_arg_userdata checks type and live identity and returns a pointer borrowed through the current native call. Always check its status before dereferencing. Native userdata helpers arrange a catchable script fault on failure. Do not retain a scoped pointer after returning.

Host-side scua_userdata_new returns a pinned durable root: release that root using scua_unpin_global. Async retention needs both a strong root and an explicit checked lease obtained with scua_userdata_borrow; release the lease exactly once using scua_userdata_release, on the owner thread. Native payloads are not scanned for hidden references to SCUA values.

#Existing host pointers

The copied type descriptor's ownership selects the storage contract. Its default zero value, SCUA_USERDATA_RUNTIME, uses SCUA allocation and the initializer described above.

For an existing allocation, register SCUA_USERDATA_EXTERNAL_OWNED with a destructor and no initializer. Pass the already initialized pointer to scua_userdata_adopt (host form) or scua_return_external_userdata (native form). Ownership transfers only on SCUA_OK. On failure, the host must release its acquisition; no destructor is called by SCUA. On success, the destructor must release/free the external resource using its original allocator. SCUA does not free it again.

For SCUA_USERDATA_BORROWED, both initializer and destructor must be null. The same adoption helpers register the original pointer without copying or wrapping its data. A checked borrow returns that pointer directly. SCUA never frees or calls a destructor on borrowed memory, including during GC or partition teardown.

Before the host frees or replaces borrowed memory, it must invalidate the registration using scua_userdata_close. Keep a pinned root so the host can do this. Busy means the object is still live: do not free it while leases or dependents remain. Once close succeeds, the host may free the memory even before a drain; later cleanup only releases registry/dependency metadata. If a borrowed view points into an SCUA-owned resource (as in the example), register that resource as its provider instead of independently freeing the underlying memory.

Pointers must be non-null, stable host storage with the registered alignment and size. Do not adopt moving SCUA storage, whether in this partition, another partition, or a GC-managed external buffer. Reported sizes count toward max_bytes (at least one byte per object), but SCUA cannot validate the actual allocation size or bound allocations made internally by native libraries. Do not register the same external resource twice as independently owned; use aliases or explicit host sharing.

#Provider dependencies

Use scua_return_userdata_with_dependencies when an object's destructor needs another object's native resources to remain alive. Its dependency inputs are zero-based native argument indexes:

/* Script calls ray.load_texture(context, width); argument 0 is the provider. */
const int provider_argument = 0;
int status = scua_return_userdata_with_dependencies(
    ctx, texture_type, sizeof(Texture), &args, &provider_argument, 1);
if (status != SCUA_OK) return;

The host-side equivalent, scua_userdata_new_with_dependencies, takes an array of scua_Handle providers instead. Both forms require already-published, live runtime-owned userdata from the same partition, including new-style owned/borrowed external registrations. The runtime copies dependencies before initialization or ownership transfer and commits them only on success. Edges cannot be changed after creation: cycles and self-dependencies cannot be constructed, even when registry slots are reused. Duplicate inputs count as separate edges.

Set max_dependencies to a finite total edge limit; zero disables dependencies. Pending objects retain their edges and keep providers alive until their destructors complete. A lease retains the provider chain too. Explicit provider close returns Busy while any live or pending dependent exists. Teardown destroys dependents before providers, regardless of slot order.

Set nonzero max_graph_work in the limits. Creation requires one plus its dependency count to fit this budget. Each explicit/automatic drain admits at most this many registry-slot and dependency-edge visits as well as its callback limit. Its cursor resumes across calls; a successful drain may process zero objects while making scan progress, so zero is not an empty-queue signal. Use scua_userdata_pending to read the current queued count without a scan. Retained providers can join the queue later as their dependents are destroyed, so an empty queue does not mean all native resources are gone. Normal checked teardown deliberately drains all remaining objects instead of applying per-frame work limits. These work units bound admission, not elapsed time or the runtime of a C callback.

#Destruction

scua_close_userdata (native) or scua_userdata_close (host) invalidates access and queues destruction. Closing again is harmless; closing while borrowed or with dependents returns Busy without closing. Successful live GC also queues unreachable automatic objects. Neither operation calls the C destructor inline.

Use scua_userdata_drain at an owner-thread boundary to process a bounded number of objects. Setting auto_drain=1 also admits up to 64 objects after outermost eval/load_script/pcall returns. A callback cannot be interrupted by this budget. Explicit scua_userdata_collect collects but does not drain. Pending objects remain charged against memory limits. Cleanup is not prompt without GC and drains.

For runtime-owned userdata, the destructor releases native resources and SCUA frees storage afterward; external-owned destructors free their own allocations, and borrowed registrations have no destructor. Use scua_partition_free_checked for observable Busy/wrong-thread teardown failures; active leases currently block the whole drain and teardown. Keep callback code and destructor contexts alive until cleanup completes. Providers registered as userdata and attached as dependencies are ordered by SCUA; external providers not represented in that graph remain the host's shutdown-order responsibility.

#Checkpoint and rebuild

Ordinary persistence still refuses reachable host objects. To permit deliberate loss of a resource, register its type with persistence = 1 (default zero refuses), then explicitly request:

scua_RedactOptions options = {
    .struct_size = sizeof(options), .version = 1,
    .max_output_bytes = 16 * 1024 * 1024,
    .max_scratch_bytes = 16 * 1024 * 1024,
};
uint8_t *blob = NULL;
size_t length = 0, redacted_objects = 0;
int status = scua_persist_redacted(p, &options, &blob, &length, &redacted_objects);
/* On success: store the blob, then scua_blob_free(blob, length). */

Both permissions are host-only. Refusing types, legacy hostrefs and built-in OS capabilities still refuse. Released leaves retain their original type's policy even after registry-slot reuse. The count measures distinct resource leaves, not aliases. Save compacts the live arena but redacts only the output copy; it neither closes live resources nor runs destructors, including on failure. Output pointers/counts are cleared on failure. Insufficient scratch admission returns BUDGET; an output allocation/size-limit failure returns NOMEM. The scratch limit uses a conservative touched-byte admission estimate, not an OS RSS or allocator-overhead limit. Active pins, leases, native calls, parked frames and actor-bearing contexts refuse on this partition API. Use the separate actor API below.

Reloaded resources are opaque detached values with no address, type authority, registration or payload. Aliases remain aliases, and ordinary graph cycles remain intact. Use scua_is_detached from C or:

import host
if host.is_detached(world.texture) then
    -- Ask your binding to create a NEW texture using separately saved asset data.
end

Checked native access/close returns SCUA_ERR_DETACHED; binding helpers turn this into a catchable script fault. Re-registering types or creating resources cannot revive old detached aliases. Store logical asset paths/IDs separately and explicitly replace application fields with fresh resources. The C example includes this save/load/rebuild sequence. No load-time constructor or callback runs. Host objects and detached values are not dictionary keys or copy-on-send values; durable.encode does not gain resource support.

Ordinary resource-free snapshots retain format 7. Any snapshot containing detached values uses format 8, including subsequent ordinary saves. New readers validate both formats; old readers reject format 8. All shared loaders reject serialized live resource tags, validate object boundaries and reference target kinds, and zero detached reserved bytes before exposing the partition. Legacy raw native function addresses are also refused on load; new output writes inert native placeholders, which the host must re-register. This is not a general hostile-bytecode execution guarantee.

#Actor checkpoints

The host can explicitly redact one actor, including its state, queued mailbox, parked turn, captured values and scheduler bookkeeping:

int status = scua_persist_actor_redacted(
    runtime_partition, actor_id, &options, &blob, &length, &redacted_objects);
/* Reload with scua_load_actor after loading compatible script code into the destination runtime. */

Use the same scua_RedactOptions and type-level persistence=1 permission. Types/resources belong to the actor's partition, not the entry partition: bindings can obtain their current partition with scua_ctx_partition(ctx) and register/configure it there. Type IDs from another partition are not transferable. This API does not introduce a way to send userdata between actors.

Call between runtime drives, on the owner thread. Active execution/native callbacks and target-actor pins or leases refuse. A parked SCUA turn is allowed; a suspended native C call is not. The implementation copies the arena privately, compacts that copy, then redacts the output. It does not move the live actor's data, rebuild its JIT code, queue cleanup, close resources or invoke callbacks—even on OOM. External SCUA buffers are copied into the snapshot without transferring ownership out of the source.

The output limit includes the actor envelope. Scratch admission includes private arena copies, collector work and the intermediate partition blob, excluding allocator overhead/RSS. Successful counts refer to distinct resource leaves across the actor graph, not each alias. Refusal clears output and count; scua_blocking_resource(runtime_partition) describes a blocking resource.

Existing resume rules are unchanged: loading does not drain or execute native initialization. Live I/O operations are never reissued; an in-flight park becomes interrupted unless the host explicitly reconciles it under the existing wake protocol. Captured aliases remain detached even if another field is replaced with a fresh resource. Check/rebuild required resources before using them after resume. Mailbox copying and durable.encode still reject userdata and detached values.

The actor envelope remains v4; its nested partition image uses v8 when tombstones are present and v7 otherwise. Existing code/frame identity checks apply. Ordinary scua_persist_actor still refuses reachable userdata and does not silently opt into redaction.

#Measurement

scua_userdata_stats reports charged payload bytes, object/edge/pending counts, retained registry metadata and cumulative GC/drain work. Metadata excludes allocator overhead and durable C roots; external resource sizes are host declarations, not measurements of native/GPU allocations. Run zig build bench-userdata -Doptimize=ReleaseFast for access and churn trials, and append -- --snapshot-baseline or -- --snapshot-redacted for separate-process checkpoint measurements. These measurements describe the configured host workloads, not a guarantee about a library's own allocations.

The existing scua_HostType/scua_*handle API remains available with its legacy cleanup contract.

#Binding-specific failures

Use scua_native_error(ctx, SCUA_ERR_BUSY) (or another nonzero SCUA_ERR_* status) when a binding-specific ownership rule refuses an operation. For example, a graphics host can refuse context closure while textures remain live. The first error wins and becomes a catchable script fault after the callback returns; it does not unwind C or perform binding cleanup. Release temporary acquisitions and return explicitly. SCUA_OK is a no-op. The helper currently uses the same status messages as checked userdata operations, rather than accepting arbitrary error strings.