Handles and FFI
Handles cross the FFI boundary in two shapes:
- As
int64tokens — pure value, safe to pass to any C function that wants to round-trip an opaque id back later. - As the dereferenced buffer pointer —
HandleArena.deref(h)returns anint64that is the raw allocation pointer. This is the shape C usually wants.
Passing the token
Handle<T> lowers to int64. To hand it to C you can assign through
an int64 binding:
extern func:c_remember_handle = void(int64:tok);
Handle<int32>:h = raw HandleArena.alloc(a, 4i64);
int64:tok = h; // typed → raw
c_remember_handle(tok);
The C side stores the token. When it later wants to reach back into the data:
extern func:c_recall_handle = int64();
int64:tok = c_recall_handle();
Handle<int32>:h2 = tok; // raw → typed
int64:p = raw HandleArena.deref(h2);
if (p == 0i64) {
// arena was destroyed (or slot freed) while C was holding the token.
// This is sound — no UB, just NULL.
exit 1;
};
The round trip is intentional. Handles are values; copies of the same handle deref the same slot until the generation changes.
Passing the buffer pointer
If C wants the raw bytes, deref first and pass the pointer:
extern func:c_write = void(int64:buf, int64:nbytes);
int64:p = raw HandleArena.deref(h);
if (p != 0i64) {
c_write(p, 4i64);
};
Two rules of thumb:
- Do not let the pointer outlive the deref site. If C stores
pfor later, a subsequentfree(h)ordestroy(arena)makes that copy ofpdangling — there is no generation check on the bare pointer. Either re-deref on every use, or copy out by value before returning to Nitpick. - Do not pass
pback to Nitpick as if it were a borrow. Handles do not produce$$i/$$mborrows. The pointer is awild-style raw value the moment it leaves the handle layer.
Storage shape
When you need to embed a handle in a struct that crosses FFI, use
int64:
Type:Job = {
int64:id; // handle as int64, opaque to C
int32:priority;
};
On the Nitpick side you can read it back as Handle<T> by assignment:
Handle<JobPayload>:h = job.id;
Lifetime gotcha
The static ARIA-032 rule
(lifetimes) does not follow handles through FFI
or through struct storage. A handle that left for C is no longer
tracked by the borrow checker; the runtime generation check is your
only safety net there.
Explicit-cast rule (v0.28.5)
As of v0.28.5 the checker does notice when you hand a typed
Handle<T> straight to an extern function. It emits an
ARIA-032 warning (not an error — extern ergonomics matter)
with the suggested fix:
extern func:c_remember_handle = void(int64:tok);
Handle<int32>:h = raw HandleArena.alloc(a, 4i64);
c_remember_handle(h); // [ARIA-032] warning
c_remember_handle(@cast<int64>(h)); // OK — explicit FFI escape
The cast does two jobs:
- Silences the warning — the cast is documented as the surface intent for "I know this handle is leaving the safety net."
- Type-checks bidirectionally —
@cast<int64>accepts aHandle<T>source, and@cast<Handle<T>>accepts anint64source, so the round-trip pattern from the top of this page is fully expressible.
bug276 shows the cast silencing the warning, bug277 shows
the direct pass producing the warning (the program still compiles
and runs), and bug278 exercises the alloc → @cast<int64> →
C → recover → HandleArena.deref round trip including the
stale-token-returns-0i64 runtime check.
Declarative ownership — #[destroys_arena(<param>)] (v0.30.4)
Externs have no Nitpick body, so the borrow checker cannot scan them
for HandleArena.destroy(p) calls. Library authors annotate the
ownership contract directly:
#[destroys_arena(arena)]
extern func:c_wipe_arena = void(int64:arena);
The attribute takes one or more parameter names (not indices). Unknown names are silently ignored in v0.30.x so older callers stay compatible; a future cycle will promote the unknown-name case to a diagnostic.
Call sites pick it up exactly as if the body had been scanned:
int64:a = raw HandleArena.create();
Handle<int32>:h = raw HandleArena.alloc(a, 4i64);
raw c_wipe_arena(a); // a now destroyed
int64:p = raw HandleArena.deref(h); // [ARIA-032] error
Wrapper Nitpick functions that forward to an annotated extern lift the destroy through the v0.30.1 transitive fixpoint, producing an ARIA-032 warning at the indirect call site (one-cycle migration window per IPC-DEC-004).
The attribute is also useful on self-hosted re-implementations as executable documentation that mirrors the runtime contract. The body-scan and the attribute set are unioned at the consumer site, so double-marking is idempotent — only one diagnostic fires.
bug322–bug325 are the regression fixtures.
Validation
tests/runtime/test_handle_v0277.cppexercises the ABI directly, including stale-handle deref and arena-destroy invalidation.bug251_wild_ffi_passthrough_pass.npkis the analogous pattern forwildpointers across FFI; the same idioms apply to handle- derived pointers.
See also
- Handles — the
Handle<T>surface. guide/memory/interop.md— broader GC ↔wildinterop rules; handles play by thewild-like rules once you deref past the handle layer.