Iter 18c.4: per-type drop fn + recursive dec cascade

Closes 18c.3's main scope cut. Codegen now emits a per-ADT and
per-closure drop function under --alloc=rc; the Term::Let
scope-close emission routes through these drops instead of
ailang_rc_dec directly, so a recursive ADT (List, Tree) under
--alloc=rc actually frees its tail cells when the outer binder
is dec'd.

Drop-symbol scheme:

- For every Def::Type T in module m: emit one
  define void @drop_<m>_<T>(ptr %p) under --alloc=rc.
  Body: null-guard, load tag, switch on tag, per-ctor arm dec's
  every pointer-typed field via field_drop_call, then dec's the
  outer cell and rets.

- For every escaping Term::Lam: emit drop_<m>_<lam>_env(ptr) for
  the captured-free-vars block + drop_<m>_<lam>_pair(ptr) for the
  {env, fn-ptr} closure pair. A new closure_drops side table on
  Emitter keys closure-pair SSAs to their pair-drop symbol.

Decision (preempted in dispatch): always emit @drop_<m>_<T> for
every ADT, even ones with no boxed children. The body for
no-boxed-children ADTs is null-guard + dec + ret. Call sites are
uniform; future codegen changes that thread cleanup through the
drop seam (atomic dec under threading, etc.) have one canonical
entry per type.

field_drop_call dispatches on field type:
- Type::Con { name, .. } → drop_<owner>_<name> (recursion).
- Type::Str / Type::Fn / Type::Var → fall back to ailang_rc_dec.
  These three fall-backs are 18d/18e/monomorphisation debt; they
  are flagged in-source for grep.

Term::Let scope-close emission is the same as 18c.3 modulo the
target: Term::Ctor binders → drop_<owner>_<type>; Term::Lam
binders → the closure_drops-recorded pair-drop symbol. All
other emission gates from 18c.3 (consume_count == 0,
body-tail-not-binder, block-not-terminated, non-escape) are
unchanged.

Recursion is stack-recursive — drop_IntList's Cons arm calls
itself on the tail. For 18c.4's 5-element fixtures the depth is
safe; long lists are out of scope until 18e replaces the
recursive call with a worklist allocator. The recursive site is
commented for grep.

Tests:
- examples/rc_list_drop.ail.json — 5-element IntList summed via
  sum_list (xs has consume_count == 1; codegen skips the let-
  close drop call but the IR shape is locked in by the unit test
  below).
- examples/rc_list_drop_borrow.ail.json — same 5-element IntList,
  match-only consumption (consume_count == 0). Codegen emits
  drop_<m>_IntList(xs) at scope close; the cascade runs over all
  5 cells at runtime.
- crates/ail/tests/e2e.rs — alloc_rc_recursive_list_sum +
  alloc_rc_borrow_only_recursive_list_drop. Both assert
  --alloc=rc stdout matches --alloc=gc and the binaries exit
  cleanly.
- crates/ailang-codegen/src/lib.rs — unit test asserting the IR
  shape: define void @drop_rclist_IntList header, recursive
  self-call inside, outer ailang_rc_dec at the tail; negative-
  complement under --alloc=gc.

Tests: E2E 53 -> 55, codegen unit 11 -> 12. cargo test
--workspace green.
This commit is contained in:
2026-05-08 10:37:25 +02:00
parent b4cddd830e
commit 298dcaf0f7
4 changed files with 753 additions and 6 deletions
+61
View File
@@ -1379,3 +1379,64 @@ fn alloc_rc_emits_dec_for_unique_let_bound_box() {
assert_eq!(stdout_rc.trim(), "42");
assert_eq!(stdout_gc, stdout_rc, "alloc=rc must match alloc=gc on rc_box_drop");
}
/// Iter 18c.4: per-type drop fns + recursive `dec` cascade. Builds a
/// 5-element `IntList` and reduces it via `sum_list`. Under
/// `--alloc=rc` the codegen emits `define void @drop_<m>_IntList`
/// whose `Cons` arm recursively calls itself on the `tail` field —
/// the first iter where a recursive ADT under RC frees its tail
/// cells when their drop fn is invoked. Stdout must match `--alloc=gc`
/// (`15`) and the binary must exit cleanly.
///
/// Note: in this fixture the binder `xs` has `consume_count == 1`
/// (passed to `sum_list`), so codegen does NOT emit a drop call at
/// `main`'s let-close — the cells stay leaked at process exit (the
/// caller / fn-param dec story is 18d). The fixture's purpose is to
/// (a) lock in the recursive-ADT IR-shape (covered in the codegen
/// IR-shape test below) and (b) confirm the program still runs
/// correctly. The companion `rc_list_drop_borrow` fixture exercises
/// the dec path itself.
#[test]
fn alloc_rc_recursive_list_sum() {
let example = "rc_list_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(), "15");
assert_eq!(stdout_rc.trim(), "15");
assert_eq!(
stdout_gc, stdout_rc,
"alloc=rc must match alloc=gc on rc_list_drop"
);
}
/// Iter 18c.4: actually exercise the recursive drop cascade at
/// runtime. The let-binder `xs` of a 5-element `IntList` has
/// `consume_count == 0` (only the match scrutinee, a borrow), so
/// codegen emits `call void @drop_<m>_IntList(ptr xs)` at scope
/// close. The drop fn's `Cons` arm recurses on the tail — for a
/// 5-cell list that is 5 nested calls, exactly the recursive shape
/// 18e will replace with an iterative worklist. The depth here is
/// safely below any reasonable stack limit; longer lists are out
/// of scope until 18e.
///
/// Properties guarded:
/// 1. The binary runs to completion (no segfault from a
/// mishandled recursive cascade or refcount underflow).
/// 2. Stdout matches `--alloc=gc` byte-for-byte (`11` — the head
/// of the 5-element list).
/// 3. The match arm's pattern bindings (`h`, `t`) do NOT trip
/// the dec machinery — `t` was loaded as a borrow inside the
/// arm and must not be dec'd; we rely on `lower_match`'s
/// pattern binding flow not adding a competing dec call.
#[test]
fn alloc_rc_borrow_only_recursive_list_drop() {
let example = "rc_list_drop_borrow.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(), "11");
assert_eq!(stdout_rc.trim(), "11");
assert_eq!(
stdout_gc, stdout_rc,
"alloc=rc must match alloc=gc on rc_list_drop_borrow"
);
}