SCUA

How-to

Manage native resource lifetimes

Choose who owns each allocation and how long C may use it. Closing a SCUA handle can invalidate other handles that depend on it, but cannot revoke raw pointers retained by C.

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.

#Lifetime, close and persistence

All FFI resource values use partition-local, generation-checked opaque handles. ffi.close(symbol) releases that binding. ffi.close(library) closes its pointers/dependents and symbols before unloading it. Subsequent calls through stale aliases fault, including after another library reuses registry slots. Closing an already-closed handle faults; unlike userdata close, FFI close is not idempotent.

Libraries, bindings, buffers, pointers and callbacks default to explicit close or partition-teardown cleanup. Dropping the last script reference alone does not unload a library. For an individual handle, opt into automatic cleanup with ffi.autoclose(handle); it returns the same handle. ffi.autoclose(handle, false) restores manual mode. Explicit ffi.close remains available in either mode. For example:

let vertices = ffi.autoclose(ffi.buffer("f32", 300))
-- Use vertices; explicit close is still preferable when its lifetime is known.
ffi.close(vertices)

A completed live GC identifies unreachable opted-in resources. Native destruction runs at the next outer FFI operation or embedding execution-return boundary, never inside collection or an active native call/finalizer. A collection during a foreign call defers this work until a later collection. Cleanup timing is not guaranteed. Live dependent handles retain their providers and libraries; manual resources remain roots until explicitly closed, even after their script wrapper is lost. Callback closures remain pinned, so reference cycles through captured handles can also retain resources: close those explicitly. Type descriptors do not support autoclose.

Only opt in when native code does not retain untracked aliases, or a separate live handle records the required dependency. GC cannot see C globals, retained pointers, registered callback addresses or aliases made through unsafe APIs. Automatic cleanup does not infer ownership or a destructor: borrowed handles remain borrowed, and owned handles use their already-declared release function. Existing close, revocation and persistence rules are unchanged. FFI policy is separate from scua_UserdataLimits.

Reuse bound symbols instead of binding on each iteration. Each partition permits at most 16 grants, 32 open library registrations and 256 live symbol bindings, 256 buffers, 256 opaque pointers and 128 type descriptors. Buffers are limited to 16 MiB each and 64 MiB total per partition; zero-length allocations are refused. Record/inline-array descriptors have at most 256 fields/elements and 64 KiB layout size. Type descriptors live until partition teardown and cannot be individually closed; define/reuse them outside loops. Closing other resources releases their slots. These limits are separate from scua_UserdataLimits and do not bound allocations made by native libraries.

The host can revoke an ID with scua_revoke_ffi(p, "demo"). This closes its open libraries and bindings; unknown IDs are harmless. Revocation is refused during active execution/borrows as required by the host API. Use ffi.close inside scripts, not legacy host-resource close helpers.

Library/symbol/type/buffer/pointer values are Unsendable and refuse both ordinary and redacted persistence, including closed leaves that remain reachable. Remove those leaves from persistent application data before saving. Grants, addresses and prepared call interfaces are never restored from a snapshot. Re-grant and explicitly open/bind again after load.

#Tracked writes to native-owned storage

ffi.track(view, values) opts a read-only foreign view into checked mutation and dependency tracking. Start with ffi.unsafe_view(pointer, Record, count) and provide one declared value per element. The values must match the native fields; padding is ignored during comparison. Assigned pointers must be existing compatible handles, not raw addresses. This does not allocate or take ownership of the native storage. The binding author guarantees the backing address is writable, stable and valid for the declared extent.

let Record = ffi.struct(["ptr", "i64"], ["value", "factor"])
let view = ffi.unsafe_view(native_record_pointer, Record, 1)
ffi.track(view, [{value = known_pointer, factor = 2}])
ffi.field_set(view, 0, "factor", 3)

-- A native function may change these fields through a writable pointer argument.
ffi.call(change_record, view, another_known_pointer, 4)
ffi.reconcile(view, [{value = another_known_pointer, factor = 4}])

ffi.set, ffi.field_set, ffi.write and record ffi.replace use transactional validation and retain assigned resources. A native writable pointer argument may receive a tracked view. If native bytes change, subsequent checked reads, calls, writes and derived-handle accesses fault until ffi.reconcile(view, values) verifies the current fields and replaces the dependency metadata. Failed reconciliation preserves the old dependencies and does not change native bytes. Successful writes or reconciliation invalidate previously decoded aliases. Even a change confined to padding conservatively requires reconciliation. Closing dirty views remains allowed.

Reconciliation must use independently retained handles, not aliases decoded from the view being reconciled. Unknown pointers are not automatically adopted or freed. Pointee ownership stays with its existing handles: native code must not independently free SCUA-owned pointees. Old dependencies remain retained until successful reconciliation or close. Closing a provider explicitly invalidates dependent views. The backing allocation still needs its own owner; tracking does not infer C ownership.

This path supports encodable pointer/function-pointer fields, qualified buffer fields and records with explicit union selection. It does not copy SCUA strings into persistent C-string slots, adopt owned output allocations, or permit concurrent native mutation. Start with a foreign view retaining a separate pointer handle; direct ffi.global and owned buffers are not accepted by ffi.track. A global's address can be retained with ffi.unsafe_cast(library, "ptr", global_view) and then wrapped in a separate foreign view. Tracking snapshots count against the native memory budget. All of this remains trusted native access, not a memory sandbox.

For deliberately untracked writes, use ffi.unsafe_mutable_view instead. It does not provide these dependency or reconciliation guarantees.

#Unsafe addresses, casts and foreign memory

Numeric conversions are separate from address reinterpretation:

assert(ffi.cast("i8", 255) == -1)
assert(ffi.cast("u32", -1) == 4294967295)
assert(ffi.cast("i32", -3.75) == -3)
assert(ffi.cast("u64", -1) == "18446744073709551615")

ffi.cast(type, value) accepts integer, floating and boolean target types (including scalar aliases). Inputs are numeric/boolean values or the digit-only unsigned-64 decimal strings used by FFI. Integer narrowing retains the low bits; signed targets interpret them as two's complement. Float-to-integer conversion truncates toward zero and requires the truncated value in [-2^63, 2^64) before narrowing. NaN, infinity and out-of-domain values are rejected. Boolean conversion tests numeric nonzero; floating conversion may round and rejects nonfinite results, including f32 overflow. This is explicit conversion, not permission for ordinary ffi.call arguments to silently wrap. Pointer/aggregate target types are rejected; use the separately named unsafe operations below for address reinterpretation. Numeric casts still require an enabled provider and an existing host FFI grant.

These operations require the existing host FFI grant and opt-in build. They are deliberately named unsafe_*: a wrong address, extent, alignment, signature or native lifetime can corrupt memory or crash the process. FFI grants are for trusted code, not a memory-isolation boundary.

Operation Contract
ffi.unsafe_address(handle) Extracts the address of a pointer, buffer, function or callback; nil becomes zero. Large addresses use exact decimal strings. The returned number carries no lifetime tracking.
ffi.unsafe_pointer(library, address[, type]) Wraps a raw integer/decimal-string address, default type ptr; a negative integer is C's sentinel cast (-1 is (void *)-1, SQLITE_TRANSIENT and MAP_FAILED); opaque or function-pointer descriptors are accepted. Zero becomes nil unless the type requires non-null. Borrowed: never frees native memory.
ffi.unsafe_cast(library, type, handle) Reinterprets a resource address as an opaque or function pointer, retaining the source handle as a dependency. It does not convert numeric values or validate signature compatibility.
ffi.unsafe_mutable_view(pointer, element, count) Creates a writable foreign view, including pointer-bearing records and pointer/function-pointer slots. Stores validate values but do not retain assigned resources, release replaced pointers, or reconcile native writes. The binding author owns address validity, writability and every pointee lifetime.
ffi.unsafe_offset(pointer, bytes) Creates a borrowed pointer at a signed byte offset, preserving type and parent dependency. Address overflow/underflow is rejected; allocation bounds are not known.
ffi.unsafe_view(pointer, element, count[, writable]) Creates a native buffer view over a declared extent; read-only by default. Supports scalar/POD elements and read-only pointer-containing records, pointer/function slots, qualified typed-pointer slots and copied C-string slots. It retains the pointer, does not free its memory and accepts existing buffer operations.

| ffi.unsafe_read_bytes(pointer, count) | ONE checked copy of count bytes at a live pointer into a bytes value, for a length that comes from a second C call (sqlite3_column_blob + sqlite3_column_bytes). Unlike a view it cannot go stale when C reuses the buffer on the next call; the extent is still your claim, which is why it keeps the name. |

-- C returned a pointer to two live int64_t values. Its owner must outlive the view.
let values = ffi.unsafe_view(native_pointer, "i64", 2, true)
ffi.set(values, 1, 42)
ffi.close(values) -- does not free the native allocation

ffi.native_string(library, text) is the one non-unsafe operation in this family: a NUL-terminated copy of text in native memory that lives as long as its handle. It exists for a retained cstr argument — C keeps the pointer after the call, so the per-call copy a plain cstr makes would be freed under it — and closing it frees the copy. See data and ownership.

Closing a parent invalidates its derived pointers, views and function aliases before releasing memory. Every created wrapper also depends on the supplied/parent library. Extracting a numeric address and wrapping it again loses the source dependency; keep the original resource alive yourself. Closing a raw wrapper does not revoke copies of its numeric address. Views have checked indices within the declared extent, not proof that the underlying memory is valid. Raw address integers can be persisted like other integers; reconstructed unsafe pointers are the trusted caller's responsibility. The FFI resource handles themselves remain non-persistable.

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