Iter 18d.4: pattern-binder + Own-param dec at scope close

Closes the memory-hygiene regression 18d.3 introduced and the
symmetric Own-param-leaks debt 18c.3 / 18c.4 left open. Both
share an emission shape — a binder going out of scope without
being consumed (consume_count == 0) gets a drop call emitted.

Two emission seams added to crates/ailang-codegen/src/lib.rs:

(A) Pattern-binder dec at arm body close (lower_match): after
each arm's body lowers, for each pattern-bound binder of this
arm with consume_count == 0 and ptr-typed value, emit drop.
Routes through field_drop_call when moved_slots[binder] is
empty (the common case — pattern-bound binders rarely have
their own moves recorded), falls back to shallow ailang_rc_dec
when moved_slots is non-empty (dynamic-tag partial-drop debt).
Wildcard slots are NOT dec'd here — they're the source's
responsibility, which 18d.3 already handles via emit_inlined_
partial_drop.

(B) Own-param dec at fn return (emit_fn): widened the fn-type
destructure to pull param_modes. After the body's tail value
lowers but before the ret instruction, for each fn parameter
with param_modes[i] == Own and consume_count == 0 and ptr-typed
value AND not the body's tail SSA, emit drop. Same routing as
(A). Borrow/Implicit params are explicitly excluded — Borrow
because the caller still owns, Implicit because there's no
static "caller handed off" signal under back-compat.

drop_<m>_<T> itself remains uniform-on-fully-owned. The partial-
drop complexity is at the call sites with the static info,
matching the design call from 18c.4 and 18d.3.

Tests:
- examples/rc_own_param_drop.{ailx,ail.json} — canonical Own-
  param fixture.
- alloc_rc_own_param_dec_at_fn_return E2E asserts (B)'s drop
  fires before ret in the fn body's IR.
- alloc_rc_borrow_only_recursive_list_drop expanded with IR-
  shape assertion that t's drop fires at arm close (closes the
  18d.3 regression).

Side effect (correctness improvement): sum_list in reuse_as_
demo declares (own (con List)) and has consume_count(xs) == 0;
(B) now fires shallow ailang_rc_dec(xs) at each recursive
return, so the chain frees one cell per stack frame on unwind.
Stdout unchanged (9); existing reuse-arm IR-shape assertions on
map_inc continue to hold (they don't see sum_list's body).

Test deltas: e2e 57 -> 58. All other buckets unchanged. cargo
test --workspace green.

Known debt (deliberate, deferred):
- Dynamic-tag partial-drop. emit_inlined_partial_drop requires
  a static Term::Ctor for layout; binders with non-empty
  moved_slots whose runtime tag is dynamic fall back to shallow
  ailang_rc_dec of the outer cell. Sound for shipping fixtures
  (the moved-out child has been dec'd elsewhere by the time
  iter B fires; remaining ctors have no further pointer fields
  to dec). General fix needs tag-switch in the dec emission —
  separate iter.
- Closure-typed Own params still go through field_drop_call's
  Type::Fn arm (shallow dec), same path 18c.4 carved out.
- (drop-iterative) worklist allocator — Iter 18e.
This commit is contained in:
2026-05-08 12:33:48 +02:00
parent 8e415dd4b4
commit ba63a16366
4 changed files with 428 additions and 19 deletions
+170 -13
View File
@@ -1520,25 +1520,38 @@ fn alloc_rc_recursive_list_sum() {
);
}
/// 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.
/// Iter 18c.4 / 18d.4: exercise the recursive drop cascade at
/// runtime AND the arm-close pattern-binder dec.
///
/// 18c.4 originally used this fixture to lock in the recursive
/// `drop_<m>_IntList` cascade at `xs`'s let-close. 18d.3 introduced
/// move-tracking and shipped a documented memory-hygiene regression:
/// the `t` pattern-binder of `(Cons h t)` was unused (consume_count
/// == 0) and the `xs` partial-drop now skipped slot 1 (moved into
/// `t`), so the 4-cell tail leaked.
///
/// 18d.4 closes that regression by emitting an arm-close drop on
/// `t` itself (symmetric to the let-close drop emission seam). At
/// runtime the Cons arm now:
/// 1. Loads slot 0 (h: Int → primitive, no drop).
/// 2. Loads slot 1 (t: IntList → moved out of `xs`).
/// 3. Prints h.
/// 4. **NEW (18d.4):** calls
/// `@drop_rc_list_drop_borrow_IntList(ptr %t)` — recursively
/// cascades through the 4-cell tail, freeing each cell.
/// 5. Branches to the join.
/// And at `xs`'s let-close, the inlined partial drop skips slot 1
/// (already freed by the arm) and dec's the outer Cons cell.
///
/// 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.
/// 3. The Cons arm's body emits the per-type drop on `t` between
/// the `print h` call and the arm's branch back to the match
/// join — the IR-shape signature of 18d.4's arm-close
/// pattern-binder dec.
#[test]
fn alloc_rc_borrow_only_recursive_list_drop() {
let example = "rc_list_drop_borrow.ail.json";
@@ -1550,6 +1563,50 @@ fn alloc_rc_borrow_only_recursive_list_drop() {
stdout_gc, stdout_rc,
"alloc=rc must match alloc=gc on rc_list_drop_borrow"
);
// Iter 18d.4 IR-shape assertion: the Cons arm of main's match
// emits a drop on `t` after lowering the body. We re-lower under
// rc and inspect main's IR — the arm sequence must contain
// call void @ai/print_int(...) (the `print h`)
// call void @drop_rc_list_drop_borrow_IntList(ptr %v...) (NEW: 18d.4 t-drop)
// br label %mjoin.<id> (arm-close branch)
// in that order, in the Cons arm's basic block.
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let json_path = workspace.join("examples").join(example);
let ws = ailang_core::load_workspace(&json_path).expect("load workspace");
let mut lifted_modules = std::collections::BTreeMap::new();
for (mname, m) in &ws.modules {
let desugared = ailang_core::desugar::desugar_module(m);
let lifted = ailang_check::lift_letrecs(&desugared)
.unwrap_or_else(|e| panic!("lift_letrecs in module `{mname}`: {e:?}"));
lifted_modules.insert(mname.clone(), lifted);
}
let lifted_ws = ailang_core::Workspace {
entry: ws.entry.clone(),
modules: lifted_modules,
root_dir: ws.root_dir.clone(),
};
let ir = ailang_codegen::lower_workspace_with_alloc(
&lifted_ws,
ailang_codegen::AllocStrategy::Rc,
)
.expect("lower workspace under rc");
let main_start = ir
.find("define i8 @ail_rc_list_drop_borrow_main(")
.expect("main definition present");
let main_end_offset = ir[main_start..]
.find("\n}\n")
.expect("main definition ends with `}`");
let main_body = &ir[main_start..main_start + main_end_offset];
// The arm-close drop must hit the per-type IntList drop fn (NOT
// the shallow `ailang_rc_dec` fallback for dynamic-tag binders
// with non-empty moved_slots — `t` has empty moved_slots in this
// fixture because the arm body doesn't re-scrutinise it).
assert!(
main_body.contains("call void @drop_rc_list_drop_borrow_IntList(ptr "),
"main's body must call `@drop_rc_list_drop_borrow_IntList(ptr ...)` for the unused Cons-arm `t` binder. main body was:\n{main_body}"
);
}
/// Iter 18d.3: move-aware pattern bindings — let-close inlines a
@@ -1641,3 +1698,103 @@ fn alloc_rc_partial_drop_skips_moved_keeps_wildcarded() {
"main's body must call `@ailang_rc_dec(ptr ...)` for the outer Pair box at let-close. main body was:\n{main_body}"
);
}
/// Iter 18d.4: Own-param dec at fn return. Symmetric to 18c.3/18c.4's
/// `Term::Let`-scope-close drop emission and 18d.4's arm-close
/// pattern-binder dec, fired at the lexical close of a fn body. The
/// `head_or_zero` fn declares its only parameter `xs` as
/// `(own (con IntList))` — the static "caller handed off ownership"
/// signal. Under `--alloc=rc`, codegen now emits a drop call against
/// the param SSA (`%arg_xs`) before the fn's `ret`.
///
/// Properties guarded:
/// 1. The binary runs to completion under both `--alloc=gc` and
/// `--alloc=rc` (no segfault from a mishandled cascade or
/// refcount underflow when the cascade hits the moved tail).
/// 2. Stdout matches `--alloc=gc` byte-for-byte (`11` — the head
/// of the 5-element list).
/// 3. `head_or_zero`'s body contains a drop call against
/// `%arg_xs` BEFORE the `ret i64`. The drop may be the
/// per-type `@drop_<m>_IntList(ptr %arg_xs)` (when no slots
/// were moved out of `xs` — not the canonical case here) OR
/// the inlined-partial fallback `@ailang_rc_dec(ptr %arg_xs)`
/// (the canonical case here, since the Cons arm pattern-binds
/// slot 1 into `t`, recording `moved_slots[xs] = {1}`). Either
/// shape is acceptable per the iter brief; what matters is
/// that ANY drop fires.
#[test]
fn alloc_rc_own_param_dec_at_fn_return() {
let example = "rc_own_param_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(), "11");
assert_eq!(stdout_rc.trim(), "11");
assert_eq!(
stdout_gc, stdout_rc,
"alloc=rc must match alloc=gc on rc_own_param_drop"
);
// Re-lower under rc to inspect head_or_zero's IR shape.
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let json_path = workspace.join("examples").join(example);
let ws = ailang_core::load_workspace(&json_path).expect("load workspace");
let mut lifted_modules = std::collections::BTreeMap::new();
for (mname, m) in &ws.modules {
let desugared = ailang_core::desugar::desugar_module(m);
let lifted = ailang_check::lift_letrecs(&desugared)
.unwrap_or_else(|e| panic!("lift_letrecs in module `{mname}`: {e:?}"));
lifted_modules.insert(mname.clone(), lifted);
}
let lifted_ws = ailang_core::Workspace {
entry: ws.entry.clone(),
modules: lifted_modules,
root_dir: ws.root_dir.clone(),
};
let ir = ailang_codegen::lower_workspace_with_alloc(
&lifted_ws,
ailang_codegen::AllocStrategy::Rc,
)
.expect("lower workspace under rc");
// Locate head_or_zero's body in the IR.
let fn_start = ir
.find("define i64 @ail_rc_own_param_drop_head_or_zero(")
.expect("head_or_zero definition present");
let fn_end_offset = ir[fn_start..]
.find("\n}\n")
.expect("head_or_zero ends with `}`");
let fn_body = &ir[fn_start..fn_start + fn_end_offset];
// The Own-param drop must fire on `%arg_xs` somewhere in
// head_or_zero's body. Either the per-type drop (empty
// moved_slots case) or the shallow `ailang_rc_dec` (non-empty
// moved_slots case) is acceptable. The canonical fixture here
// hits the latter: the Cons arm moves slot 1 into the arm-bound
// `t`, so moved_slots[xs] = {1} at fn return.
let has_per_type_drop =
fn_body.contains("call void @drop_rc_own_param_drop_IntList(ptr %arg_xs)");
let has_shallow_dec = fn_body.contains("call void @ailang_rc_dec(ptr %arg_xs)");
assert!(
has_per_type_drop || has_shallow_dec,
"head_or_zero's body must drop the Own-mode param `xs` (either via per-type drop or shallow dec) before the fn's `ret`. Body was:\n{fn_body}"
);
// The drop must precede the fn's `ret`. Scope: any `ret i64 ...`
// in the body comes AFTER the drop call site. We check by
// splitting on the `ret` instruction and ensuring at least one
// drop call occurred before each ret.
//
// Implementation note: the body has a single `ret` (the join
// block's value) since `head_or_zero` returns Int unconditionally
// at the body root. The drop is emitted right before that ret
// by the Iter 18d.4 emit_fn pre-ret seam.
let ret_idx = fn_body.find("\n ret i64").expect("head_or_zero has a ret i64");
let pre_ret = &fn_body[..ret_idx];
let drop_in_pre_ret = pre_ret.contains("call void @drop_rc_own_param_drop_IntList(ptr %arg_xs)")
|| pre_ret.contains("call void @ailang_rc_dec(ptr %arg_xs)");
assert!(
drop_in_pre_ret,
"head_or_zero's `%arg_xs` drop must appear BEFORE the `ret i64` instruction. Pre-ret slice was:\n{pre_ret}"
);
}