← Back to AILP Home

Handles and FFI

Handles cross the FFI boundary in two shapes:

  1. As int64 tokens — pure value, safe to pass to any C function that wants to round-trip an opaque id back later.
  2. As the dereferenced buffer pointerHandleArena.deref(h) returns an int64 that 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:

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:

  1. Silences the warning — the cast is documented as the surface intent for "I know this handle is leaving the safety net."
  2. Type-checks bidirectionally@cast<int64> accepts a Handle<T> source, and @cast<Handle<T>> accepts an int64 source, 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.

bug322bug325 are the regression fixtures.

Validation

See also