← Back to AILP Home

Handles across module boundaries

The intra-function ARIA-032 rule (lifetimes) was extended across function and module boundaries by the v0.30.x cycle. This chapter is the user-facing summary of what flows through use, what shape the warnings take, and how library authors annotate FFI ownership when there is no Nitpick body to scan.

TL;DR

What flows through use

Every pub func: declaration in an imported module contributes a FunctionBorrowSummary to the borrow checker. The fields the checker reads cross-module are:

Field Source Consumer site
destroys_param_indices body scan direct call: caller's arena marked destroyed (error)
transitively_destroys_param_indices fixpoint over callees wrapper call: caller's arena marked destroyed (warning)
destroys_attribute_param_indices #[destroys_arena] direct call: caller's arena marked destroyed (error)
escapes_param_arena_indices return-path scan + fixpoint caller binding: handle marked as escaping caller's local arena (warning)
return_borrow_source_param single-borrow heuristic $$i binding inherits the source path (no diagnostic until misuse)
is_extern declaration form extern-passthrough warning at call site

The summary is computed once per module, when the module is loaded. Cross-module call sites read it identically to intra-module call sites; the only visible difference is the diagnostic severity (warning, not error, per IPC-DEC-004).

Cross-module destroy

// helper.npk
use "handle.npk".*;
pub func:wipe = void(int64:a) {
    raw HandleArena.destroy(a);
};
// main.npk
use "handle.npk".*;
use "helper.npk".*;

func:main = int32() {
    int64:a            = raw HandleArena.create();
    Handle<int32>:h    = raw HandleArena.alloc(a, 4i64);
    raw wipe(a);                              // imported callee destroys a
    int64:p = raw HandleArena.deref(h);       // [ARIA-032] warning
    exit 0;
};
warning[ARIA-032]: Handle 'h' outlives its arena 'a'. The arena
was destroyed transitively through a callee imported from
'helper.npk'. This will become an error in a future release.

Before v0.30.3 this case was silent — the imported wipe had no summary at the call site. After v0.30.3 the summary is seeded into the borrow checker before the main module is analysed.

Cross-module escape

The return-path scan from v0.30.2 also crosses module boundaries:

// helper.npk
use "handle.npk".*;
pub func:make_handle = Handle<int32>(int64:a) {
    pass raw HandleArena.alloc(a, 4i64);
};
// main.npk
use "handle.npk".*;
use "helper.npk".*;

func:bad = Handle<int32>() {
    int64:a = raw HandleArena.create();
    Handle<int32>:h = raw make_handle(a);     // h escapes a's lifetime
    pass h;                                    // [ARIA-032] warning
};

The escape is inferred from the imported callee's summary, which records that parameter index 0's arena leaks out of make_handle through the return value.

Cross-module $$i return-borrow

$$i return borrows track their source parameter transparently across use:

// helper.npk
pub func:get_a = $$i int32($$i Pair:s) {
    pass s.a;
};
// main.npk
use "helper.npk".*;

func:demo = int32($$m Pair:p) {
    $$i int32:y = raw get_a(p);        // y borrows from p
    p.a = 99i32;                       // [ARIA-026] — p mutated while y live
    pass y;
};

No annotation needed on the importer side; the source- parameter mapping rides along in the summary.

When the imported callee takes two or more borrow parameters and returns $$i, the single-source heuristic refuses to guess and emits ARIA-023. Provide an explicit annotation (when the syntax lands) or restructure to a single-borrow signature.

#[destroys_arena(<param>)] for externs

Externs have no body, so the body-scan path cannot populate destroys_param_indices. Declarative annotation fills the gap. See ffi.md for the surface syntax and self-hosted use; the cross-module half is the same: an imported extern's attribute flows through the summary and fires ARIA-032 at any call site that passes a locally-bound arena.

Local re-declaration wins

The pre-v0.30.3 workaround — locally re-declaring an imported extern to force the borrow checker to see it — is no longer required, but it still works. The borrow checker resolves summaries by function name; the main module's collectFunctionSummaries runs after seedImportedSummaries, so a local declaration of the same name overwrites the imported summary. No duplicate-extern error, no doubled diagnostic.

Legacy code that carries the workaround compiles identically. bug321 is the regression test that pins this contract.

bug277 originally needed the workaround; its v0.30.3 version no longer does.

What does not flow through use

Warning vs error — the migration window

Per IPC-DEC-004, every newly-detected case introduced in v0.30.x ships as a warning for one cycle. The intra- function rule (the original v0.27.9 ARIA-032) stays an error. The plan is to promote the cross-module and transitive cases to errors in a later cycle once the false-positive rate is known. v0.30.6 already pruned the two known FP shapes (reassign-after-destroy, destroy-in-exiting-branch).

If you want to treat the warnings as errors today, the project's diagnostic filter machinery accepts a warning code list — promote ARIA-032 there.

Validation

Bug What it pins
bug311bug315 v0.30.1 transitive destroy (depth 2/3, fan-out, recursion)
bug316bug320 v0.30.2 transitive escape (direct, depth 2, local binding)
bug321 v0.30.3 local extern re-decl still shadows imported summary
bug277 v0.30.3 cross-module FFI passthrough warns without workaround
bug322bug325 v0.30.4 #[destroys_arena] attribute on extern + wrapper
bug326bug329 v0.30.5 cross-module $$i return-borrow source tracking
bug330bug333 v0.30.6 precision (reassign / branch-exit FP fixes)

See also