← Back to AILP Home

Memory diagnostics

This chapter lists the diagnostic codes the compiler emits for region/lifetime/leak issues, with the smallest reproducer for each and the canonical fix. For borrow-checker diagnostics that are not region-specific (ARIA-022, ARIA-023, ARIA-026), see borrow/diagnostics.md.


ARIA-014 — wild allocation without matching defer free

Every wild allocation must be paired with a release — the simplest form is defer free(...) in the same scope.

func:bad = int32() {
    wild int32:buf = 0i32;
    pass 0i32;                 // [ARIA-014] no defer free for buf
};

Fix: add defer free(buf); immediately after the allocation, or explicitly call the matching release before every exit path.

func:good = int32() {
    wild int32:buf = 0i32;
    defer free(buf);
    pass 0i32;
};

Alternative (v0.29.3+): import drop.npk and use a struct binding to opt the binding into RAII auto-free, which silences ARIA-014 for that shape. See the guide/drop/ cookbook.


ARIA-015 — use of released wild variable

Using a wild value after it has been freed, moved, or otherwise drop-tracked is rejected.

wild int32:buf = 0i32;
free(buf);
pass buf;                       // [ARIA-015] buf is released

Fix: rebuild the value (allocate again) or restructure the code so the release happens after the last use.


ARIA-028 — stack escape (return/pass of borrow into a local)

You tried to return, pass, or fail a borrow whose host is a binding local to the current function (or to an inner block) — the host's stack frame is destroyed at the function (or block) boundary, so the reference would dangle.

func:bad = $$m int32() {
    stack int32:tmp = 7i32;
    $$m int32:r = tmp;
    pass r;                    // [ARIA-028] tmp's frame ends here
};

The same diagnostic also fires when the host is a gc binding: the underlying object survives on the heap, but the named binding is local and the borrow path goes out of scope.

Fix: return by value, take ownership in the caller and pass a borrow in, or accept a borrow parameter and return that.

Note (v0.26.6): earlier compiler versions emitted these errors as ARIA-017; the docs cited ARIA-027. Both are unified under ARIA-028 STACK_ESCAPE from v0.26.6 on. The full text now mentions the host's stack frame and includes an inline fix hint.


Reserved / deferred codes

ARIA-029 and ARIA-031 were reserved here through v0.26.x and landed in v0.27.2 / v0.27.3 respectively — see the dedicated sections above. ARIA-032 (handle outlives arena) landed in v0.27.9.

No region codes are currently in the "reserved" state.


ARIA-029 — GC reference from wild (v0.27.2)

A gc binding (or gc field) is being initialised from a borrow whose origin is a wild allocation. The type checker emits a hint suggesting the pin operator #x, since the underlying shape would otherwise leak a wild-region reference into a GC root.

wild int32:w = 5i32;
gc int32@:p  = @w;            // [ARIA-029] consider `gc int32@:p = #w;`

Fix: pin the wild value (#w), or restructure so the GC binding owns its value rather than referencing through wild storage.

The borrow-checker rejection in checkVarDecl is defense-in-depth; the type checker rejects this shape first in surface code.


ARIA-031 — stack reference into a GC field (v0.27.3)

Assigning the address of a stack binding into a field reached through a gc path would let the GC observe a soon-to-be-dangling stack pointer.

gc Node:n        = Node{ next: @local_stack_node };   // [ARIA-031]

Fix: allocate the referenced node in the gc region, or pin it (#local_stack_node) so the borrow checker enforces address stability — and accept that the stack lifetime still bounds the reference's validity.


ARIA-032 — handle outlives its arena (v0.27.9)

Within a function body, you destroyed an arena and then derefed (or freed) a Handle<T> bound to that arena.

int64:a            = raw HandleArena.create();
Handle<int32>:h    = raw HandleArena.alloc(a, 4i64);
raw HandleArena.destroy(a);
int64:p = raw HandleArena.deref(h);     // [ARIA-032]

Fix: move the deref/free before the HandleArena.destroy(a) call, or drop it entirely (a bulk-destroy obviates per-slot frees).

The rule has grown across v0.28.x:

For cases the static rule still does not catch (handles stashed in GC objects, handles round-tripped through opaque C state, indirect aliasing through arrays), the runtime generation check still applies: deref returns 0i64. See handles/lifetimes.md for the full rule, handles/cross_module.md for the v0.30.x cross-module story, and handles/diagnostics.md for the canonical examples.


Runtime bounds-check sentinel (v0.31.13.2+)

When a dynamic array index fails a runtime bounds check, the compiler-emitted guard dispatches the user's failsafe(tbb32:err) with a dedicated tbb32 sentinel value of 45 (named kFailsafeOobErrCode in ir_generator.cpp). Earlier compilers used the generic 99 fall-through code, which was indistinguishable from a generic unhandled main-return.

You can branch on the cause in your failsafe handler:

func:failsafe = int32(tbb32:err) {
    if (err == 45i32) {
        // OOB-specific recovery / logging
        exit 45;
    }
    exit 99;
};

If your failsafe does not branch on err, its existing behaviour is preserved — the bounds-check still dispatches into it; only the err value is more specific. The post-failsafe fallback exit code is also 45 on the OOB path (it would only be reached if the failsafe itself returns rather than calling exit).

The sentinel value is element-type-agnostic (fires the same for int32/int64/struct arrays) and fires for both over- and under-bound indices. See fixtures bug565bug572 in the compiler repo for the full lock.


ARIA-065 — pointer arithmetic without unsafe.npk (v0.32.5)

@ptr_add<T>(ptr, offset) and @ptr_sub<T>(ptr, offset) are compiler intrinsics behind a narrow stdlib gate. Without a wildcard import of unsafe.npk, the call is rejected before IR generation:

func:bad = int32(wild int32->:p) {
  int32->:q = @ptr_add<int32>(p, 1i64);  // [ARIA-065]
  pass <-q;
};

Fix: import the gate in the module that performs raw pointer arithmetic:

use "unsafe.npk".*;

The gate enables only pointer arithmetic. It does not weaken borrow checking, region checking, bounds checking, or other diagnostics.


ARIA-066 — pointer arithmetic on stack or gc (v0.32.5)

Even with unsafe.npk, pointer arithmetic is allowed only on wild and wildx pointers. Managed or frame-local pointers are rejected:

use "unsafe.npk".*;

func:bad_stack = int32() {
  stack int32:v = 1i32;
  stack int32->:p = @v;
  int32->:q = @ptr_add<int32>(p, 0i64);  // [ARIA-066 ARIA-PTRARITH-STACK]
  pass <-q;
};

gc sources report ARIA-066 ARIA-PTRARITH-GC for the same reason: moving a collector-managed address by byte/element offset would bypass the GC's object model and write barriers.

Fix: keep pointer arithmetic inside explicit wild/wildx buffers or move the arithmetic into a C shim whose ownership contract is documented at the FFI boundary.


ARIA-067 — address-of NULL (v0.32.5)

@NULL is rejected because NULL has no storage location:

int32->:p = @NULL;   // [ARIA-067 ARIA-NULL-ADDRESS]

Fix: take the address of a typed binding, or initialize a pointer variable to NULL directly if you need the null sentinel.


Runtime NULL-dereference sentinel (v0.32.5+)

Dereferencing NULL through <- p, *p, or p->field dispatches the user's failsafe(tbb32:err) with sentinel value 46 (kFailsafeNullDerefErrCode in ir_generator.cpp), then exits 46 if the failsafe returns.

func:failsafe = int32(tbb32:err) {
  if (err == 46i32) {
    exit 46;
  }
  exit 99;
};

Sentinel 45 remains reserved for runtime array-bounds failures. Sentinel 46 is reserved for NULL pointer dereference paths.


See also