← Back to AILP Home

Pitfalls

The Drop mechanism is opt-in and conservative, but there are sharp edges. This page is the honest list.

1. Hard exit skips drops

use "drop.npk".*;

func:demo = NIL() {
    wildx int8->:p = wildx_alloc(1024i64);
    exit 0;   // p is LEAKED — the kernel reclaims on process death
};

DROP-DEC-008. exit N translates to a _Exit-style hard process exit; no destructors run. If you need cleanup, use pass / fail to unwind back to main and exit from there.

The kernel reclaims the address-space allocation, so for in-memory wild/wildx/Handle resources this leak is harmless on process termination. For resources that survive process death (files, sockets, locks held in shared memory), explicit cleanup before exit is the only correct option.

2. Bare-identifier pass skips that binding's drop

use "drop.npk".*;

func:make_buf = wildx int8->() {
    wildx int8->:p = wildx_alloc(64i64);
    pass p;   // p's drop is SKIPPED — ownership moves to caller
};

DROP-DEC-004. This is the forcing-function behaviour that makes factory functions work — every Jit.compile_* helper in the stdlib relies on it. Without bare-identifier move, Jit.compile_add_i32() would auto-free its returned page and the JIT helper would UAF in every caller.

The pitfall is the inverse: if you write pass other_expr; (not a bare identifier — e.g. pass make_alias(p);), the move does not trigger and p still drops at scope end. If you intended to transfer ownership, the call may double-free or UAF downstream.

Rule of thumb: write the binding name plainly to transfer ownership. Anything fancier and the conservative default (drop everything) kicks in.

3. Mixing explicit free with auto-drop

use "drop.npk".*;

func:demo = NIL() {
    wildx int8->:p = wildx_alloc(64i64);
    raw wildx_free(p);   // manual free
    // ...auto-drop ALSO emits npk_wildx_free(p) at scope end → double-free
    pass;
};

In v0.31.6.4 (NODROP-DEC-013) this is a hard compile error: ARIA-022 fires on the explicit wildx_free(p), npk_free(p), HandleArena.free(h), HandleArena.destroy(a), or Jit.free(f) call as soon as the binding is in the corresponding RAII tracker (local_handles_, local_arenas_, auto_drop_bindings_, …). The earlier v0.31.5.3 ARIA-051 warning at the HandleArena.free site has been retired and folded into the unified ARIA-022 diagnostic.

The hint text recommends nodrop as the per-binding opt-out:

use "drop.npk".*;

func:demo = NIL() {
    // Opt this single binding out of RAII enrolment:
    wildx int8->:p = nodrop wildx_alloc(64i64);
    raw wildx_free(p);   // legal — the binding is on the
                         // explicit-free contract.
    pass;
};

nodrop is outer-only (NODROP-DEC-011). The wrapper must sit at the outermost position of the initializer. The following does NOT opt the binding out — nodrop is buried under a function call and the recognizers do not look that deep:

// BUG: still RAII-enrolled, explicit free below is ARIA-022.
Handle<int64>:h = identity(nodrop HandleArena.alloc(a, 8i64));
raw HandleArena.free(h);   // ARIA-022 fires here.

If you genuinely need to free early and nodrop does not fit (e.g. cross-scope ownership), do not import drop.npk in the file that holds the binding, and manage the lifetime by hand.

4. Destructor failure is undefined this cycle

impl:Drop:for:MyType = {
    func:drop = NIL($m MyType:self) {
        fail 1i32;   // UNDEFINED BEHAVIOUR in v0.29.x
    };
};

DROP-DEC-009. A drop method that fails has no defined semantics this cycle. The compiler does not statically reject fail inside a drop body; it just does not specify what happens. Future cycles will pick a policy (terminate the process, propagate to the enclosing failsafe, swallow silently — TBD). The four built-in NitpickXxxRaii sentinels all use pass; and so are unaffected.

5. Drop-during-drop is allowed but unscheduled

If a type's drop calls into another type's drop (e.g. by freeing a wild struct whose drop body in turn frees a child binding), Nitpick runs both. There is no special detection or re-entry guard — the per-thread drop stack just unwinds. This is well-defined for the v0.29.x built-in regions because their sentinel bodies do nothing user-visible; for user types, DROP-DEC-009 (above) is the relevant unconstraint.

6. main cannot use pass or fail

Nitpick's main is exit-code-only: pass and fail inside main are rejected by sema. To exercise the early-exit drop paths, write a helper function and call it from main:

func:do_work = int32() {
    wildx int8->:p = wildx_alloc(16i64);
    pass 0i32;          // drops fire here
};

func:main = int32() {
    Result<int32>:r = do_work();
    if (r.is_error) { exit 1; }
    exit 0;
};

The check r.is_error is a bool — compare directly, not r.is_error != 0i8.

7. return v; of a bare identifier is rejected

func:make = wildx int8->() {
    wildx int8->:p = wildx_alloc(64i64);
    return p;   // ERROR — "'return' cannot return a bare value"
};

Inside fallible functions, return requires an explicit Result{val: ..., err: ..., is_error: ...} literal. To return an owned value with move semantics, use pass v; instead.

This is a Nitpick syntax rule, not a Drop rule, but it bites people writing factory functions. The good news: pass v; does the right thing.

8. The Jit_compile_* recipe (the forcing-function gotcha)

When you add a new region to Drop, the very first thing to re-run is bug_tests_v0296. The Jit_compile_add_i32 helper in stdlib/jit.npk does pass page; where page is a wildx RAII binding. If the new region change breaks bare-identifier move semantics (DROP-DEC-004) — even indirectly — every JIT smoke test will UAF.

This came up live in v0.29.7: drops on pass were wired before move semantics were threaded through, and every JIT test crashed with exit 139. The fix was to thread moved_var_name through emitDropsForScope.

9. The gc region has no Drop

There is no impl:Drop for gc T:x = ... bindings. The GC owns those values; you free them by dropping the last reference and letting the collector reap. Importing drop.npk has no effect on gc bindings.

10. Region recognizer keys on the initializer shape

The recognizer for each region looks at the RHS of the binding, not just the type:

This is by design: the recognizer needs to know who allocated the resource. A function-returned wildx page has already had its drop transferred via DROP-DEC-004; auto-dropping at the callee's binding would double-free. The trade-off is that you sometimes need to rebind via a recognizable pattern, or accept explicit free for that binding.

11. The trait name must be Drop

impl:Cleanup:for:T = { /* ... */ };   // not Drop — registered as a regular trait

Only impl:Drop:for:T flips Drop semantics. The check is a literal string match in sema.

12. v0.29.x is opt-in everywhere or nothing

There is no per-file or per-binding use "drop.npk" scope distinction — the import flips compile-unit-wide flags. If you mix a file that imports drop.npk with a file that does not in the same program, both files get drops if any one of them imports. This is a v0.29.x limitation; future cycles may make the flags per-file.