Iter 18c.3: uniqueness inference + non-recursive RC inc/dec

Ends the leak-everything era under --alloc=rc. Codegen now emits
ailang_rc_inc at every Term::Clone site and ailang_rc_dec at
end-of-scope of trackable RC-allocated let-binders. Default
--alloc=boehm path is byte-identical to before this iter.

Two parts:

(A) New module ailang-check::uniqueness — post-typecheck
    dataflow producing UniquenessTable: BTreeMap<(def, binder),
    UniquenessInfo>. consume_count is max-over-paths: at if/match
    the walker saves state, walks each arm against the snapshot,
    and merges by taking the per-binder max. Term::Clone is a
    Borrow on its inner term — the explicit clone is the user's
    signal that a fresh ref is being produced via inc.

(B) Codegen emission in ailang-codegen. Term::Clone emits inc
    under --alloc=rc, gated on val_ty == "ptr" and
    !val_ssa.starts_with('@') (top-level fn closure-pair globals
    live in the LLVM data segment, not in runtime/rc.c's heap).
    Term::Let emits dec at scope close iff the value is
    rc_alloc'd (Term::Ctor / Term::Lam in escape set under
    --alloc=rc), val_ty == "ptr", consume_count == 0 (no callee
    or outer site already took ownership), body's tail SSA is
    not the binder itself (caller-takes-ownership case), and the
    block isn't already terminated.

Header declares of @ailang_rc_inc / @ailang_rc_dec are gated on
--alloc=rc so Boehm/Bump IR shape is unchanged.

Fixture examples/rc_box_drop.ail.json: single-cell Box(Int) ADT
where shallow free is sufficient (no boxed children). E2E test
alloc_rc_emits_dec_for_unique_let_bound_box runs it under both
gc and rc, asserts byte-identical stdout, clean exit, expected
output (42).

Scope cuts deferred to 18c.4: per-type drop fn / recursive dec
cascades for ADTs with boxed children (List, Tree still leak
their tails). Fn params have no dec emission yet (no static
"caller handed off ownership" signal under Implicit mode);
under explicit (own T) the signal exists but wiring is 18d.

Tests: E2E 52 -> 53, ailang-check unit 38 -> 43 (+5 uniqueness),
all other buckets unchanged. cargo test --workspace green.
This commit is contained in:
2026-05-08 10:25:22 +02:00
parent 7099af0a3a
commit 307a3ea848
5 changed files with 738 additions and 4 deletions
+34
View File
@@ -1345,3 +1345,37 @@ fn alloc_rc_matches_gc_on_std_list_demo() {
"alloc=rc must match alloc=gc on std_list_demo"
);
}
/// Iter 18c.3: codegen actually emits `ailang_rc_dec` at end-of-scope
/// for trackable RC binders under `--alloc=rc`. This is the first
/// fixture where the `dec` path runs at runtime — `b` is bound to a
/// heap-allocated `MkBox`, the box is read via `match` (a borrow,
/// `consume_count == 0`), and the let scope closes before the fn
/// returns Unit. Codegen emits `call void @ailang_rc_dec(ptr %v1)`
/// after the match's join block; the box's refcount drops to 0 and
/// the underlying `malloc`'d block is freed by `runtime/rc.c`.
///
/// Properties guarded:
/// (1) the binary still produces the expected stdout (`42`) — i.e.
/// dec does not free the box BEFORE `match` reads its `Int`
/// payload;
/// (2) the binary exits cleanly (exit code 0, no segfault) — i.e.
/// the dec call does not double-free or trip
/// `ailang_rc_dec`'s underflow guard;
/// (3) the GC and RC paths produce byte-identical stdout — i.e.
/// the dec emission did not perturb behaviour visible at the
/// output boundary.
///
/// Box has no boxed children, so the shallow-free contract of 18c.3's
/// `ailang_rc_dec` is sufficient. A recursive ADT (List, Tree) would
/// leak its boxed children silently — that's the 18c.4 scope, not
/// this iter.
#[test]
fn alloc_rc_emits_dec_for_unique_let_bound_box() {
let example = "rc_box_drop.ail.json";
let stdout_gc = build_and_run_with_alloc(example, "gc");
let stdout_rc = build_and_run_with_alloc(example, "rc");
assert_eq!(stdout_gc.trim(), "42");
assert_eq!(stdout_rc.trim(), "42");
assert_eq!(stdout_gc, stdout_rc, "alloc=rc must match alloc=gc on rc_box_drop");
}