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
+195 -6
View File
@@ -1037,8 +1037,19 @@ impl<'a> Emitter<'a> {
}
fn emit_fn(&mut self, f: &FnDef) -> Result<()> {
let (param_tys, ret_ty) = match &f.ty {
Type::Fn { params, ret, .. } => (params.clone(), (**ret).clone()),
// Iter 18d.4: also lift `param_modes` out of the fn type. The
// fn-return Own-param dec emission below consults it to decide
// which params get a drop call before `ret`. `Implicit`
// entries (legacy / unannotated) and `Borrow` entries are
// skipped — only `Own` carries the static "caller handed off
// ownership" signal.
let (param_tys, ret_ty, param_modes) = match &f.ty {
Type::Fn {
params,
ret,
param_modes,
..
} => (params.clone(), (**ret).clone(), param_modes.clone()),
other => {
return Err(CodegenError::NotFnType(
ailang_core::pretty::type_to_string(other),
@@ -1112,6 +1123,93 @@ impl<'a> Emitter<'a> {
f.name
)));
}
// Iter 18d.4: fn-return Own-param dec. Symmetric to
// 18c.3/18c.4's `Term::Let`-scope-close drop and 18d.4's
// arm-close pattern-binder dec, fired at the lexical
// close of a fn body. For each parameter with
// `ParamMode::Own`, emit a drop call iff:
// - alloc strategy is `Rc`,
// - the parameter's lowered type is `ptr`,
// - uniqueness inference recorded `consume_count == 0`
// for the param in this fn's body (no internal use
// consumed it; the param's slot owns the only ref the
// callee received from the caller's hand-off),
// - the param's SSA is not the body's tail value
// (returning the param transfers ownership back to
// the caller's frame; caller dec's, not us),
// - the current block is still open.
//
// `Implicit`-mode params do NOT get this dec: they have
// no static "caller handed off ownership" signal —
// emitting a dec here might double-dec a value the caller
// also dec's. `Borrow`-mode params definitely don't get
// dec'd (the caller still owns them).
//
// Closes the 18c.3/18c.4 carve-out: "fn parameters still
// don't get dec'd at fn return — the caller-handed-off-
// ownership signal is the `(own T)` mode, but wiring it
// through codegen is part of the wider mode-aware story."
if matches!(self.alloc, AllocStrategy::Rc) {
for (i, ((pname, plty), pty_ail)) in f
.params
.iter()
.zip(llvm_param_tys.iter())
.zip(param_tys.iter())
.enumerate()
{
if plty != "ptr" {
continue;
}
let mode = param_modes.get(i).copied().unwrap_or(ParamMode::Implicit);
if !matches!(mode, ParamMode::Own) {
continue;
}
let consume_count = self
.uniqueness
.get(&(self.current_def.clone(), pname.clone()))
.map(|info| info.consume_count)
.unwrap_or(u32::MAX);
if consume_count != 0 {
continue;
}
let p_ssa = format!("%arg_{}", pname);
if val == p_ssa {
// The param IS the fn's return value —
// ownership transfers back to the caller.
continue;
}
let moves = self
.moved_slots
.get(pname)
.cloned()
.unwrap_or_default();
if moves.is_empty() {
// Route through the per-type drop fn for the
// param's static type. `field_drop_call`
// resolves `Type::Con` to `drop_<owner>_<T>`
// and falls back to `ailang_rc_dec` for
// closure / Var fields — closure-typed Own
// params therefore use the same shallow free
// 18c.4 set up for closure-typed ADT fields,
// matching the iter brief's "closure-typed
// Own params follow whichever debt path
// 18c.4 set up" carve-out.
let drop_call = self.field_drop_call(pty_ail);
self.body.push_str(&format!(
" call void @{drop_call}(ptr {p_ssa})\n"
));
} else {
// Iter 18d.4 debt: dynamic-tag partial-drop —
// see the symmetric arm-close branch in
// `lower_match` for the rationale. Falls back
// to shallow `ailang_rc_dec` of the outer
// cell.
self.body.push_str(&format!(
" call void @ailang_rc_dec(ptr {p_ssa})\n"
));
}
}
}
self.body
.push_str(&format!(" ret {val_ty} {val}\n}}\n\n"));
} else {
@@ -2324,7 +2422,13 @@ impl<'a> Emitter<'a> {
// body closes (these binders are themselves freshly named
// here — their move tracking starts empty and ends at arm
// body close).
let mut arm_pushed_binders: Vec<String> = Vec::new();
//
// Iter 18d.4: the metadata is widened to (name, SSA,
// llvm-type, AILang-type) so that the arm-close drop
// emission can route the call through the binder's
// per-type drop symbol without re-walking `self.locals`.
// The same tuple shape `self.locals` carries.
let mut arm_pushed_binders: Vec<(String, String, String, Type)> = Vec::new();
for (idx, binding) in bindings.iter().enumerate() {
if let Some(bname) = binding {
let raw_ail = cref.ail_fields.get(idx).cloned().unwrap_or(Type::unit());
@@ -2364,12 +2468,97 @@ impl<'a> Emitter<'a> {
.insert(idx);
}
}
self.locals.push((bname.clone(), v, fty, bind_ail));
arm_pushed_binders.push(bname.clone());
self.locals.push((
bname.clone(),
v.clone(),
fty.clone(),
bind_ail.clone(),
));
arm_pushed_binders.push((bname.clone(), v, fty, bind_ail));
pushed += 1;
}
}
let (val, vty) = self.lower_term(&arm.body)?;
// Iter 18d.4: arm-close pattern-binder dec. Symmetric to
// 18c.3/18c.4's `Term::Let`-scope-close drop emission, but
// fired at the lexical close of a match arm. For each
// pattern-bound binder this arm pushed, emit a drop call
// iff:
// - alloc strategy is `Rc`,
// - the binder's lowered type is `ptr` (heap-allocated),
// - uniqueness inference recorded `consume_count == 0`
// for the binder in this fn's body (no downstream use
// consumed it; the binder's slot owns the only ref),
// - the arm's tail SSA value is not the binder itself
// (returning the binder transfers ownership to the
// phi-join consumer; that consumer dec's, not us),
// - the current block is still open (a tail-call /
// `unreachable` already exited; nothing to emit).
//
// Closes the 18d.3-shipped regression on
// `alloc_rc_borrow_only_recursive_list_drop`: the `t`
// pattern-binder of `(Cons h t)` is unused, has
// `consume_count == 0`, and previously leaked because
// 18d.3's `moved_slots` interrupts the `xs`-cascade
// through the tail slot. Now the arm itself dec's `t`,
// freeing the 4-cell tail.
if matches!(self.alloc, AllocStrategy::Rc) && !self.block_terminated {
for (bname, b_ssa, b_lty, b_ail) in &arm_pushed_binders {
if b_lty != "ptr" {
continue;
}
let consume_count = self
.uniqueness
.get(&(self.current_def.clone(), bname.clone()))
.map(|info| info.consume_count)
.unwrap_or(u32::MAX);
if consume_count != 0 {
continue;
}
if &val == b_ssa {
// The binder IS the arm's tail value —
// ownership transfers to the join consumer.
continue;
}
let moves = self
.moved_slots
.get(bname)
.cloned()
.unwrap_or_default();
if moves.is_empty() {
// Common case (canonical regression `t`):
// route through the per-type drop fn for the
// binder's static type. `field_drop_call`
// resolves `Type::Con` to `drop_<owner>_<T>`
// and falls back to `ailang_rc_dec` for
// closure / Var fields (same fallback the
// recursive cascade uses).
let drop_call = self.field_drop_call(b_ail);
self.body.push_str(&format!(
" call void @{drop_call}(ptr {b_ssa})\n"
));
} else {
// Iter 18d.4 debt: pattern-binder with
// statically-recorded moved slots requires
// tag-conditional partial-drop emission. The
// 18d.3 `emit_inlined_partial_drop` helper
// assumes a static ctor (it's keyed against a
// `Term::Ctor` node), but a pattern-bound
// binder's runtime tag is dynamic. Until a
// dynamic-tag-aware partial-drop lands, fall
// back to a shallow `ailang_rc_dec` of the
// outer cell. The non-moved fields of the
// active ctor (if any) leak under this path —
// the canonical 18d.4 fixtures avoid this
// case by construction (the regression `t`
// is never re-scrutinised inside the arm,
// so its `moved_slots` entry is empty).
self.body.push_str(&format!(
" call void @ailang_rc_dec(ptr {b_ssa})\n"
));
}
}
}
// pop bindings
for _ in 0..pushed {
self.locals.pop();
@@ -2377,7 +2566,7 @@ impl<'a> Emitter<'a> {
// Iter 18d.3: arm-bound binders go out of scope at arm
// body close. Drop their move-tracking entries (any moves
// recorded against `h`/`t` belonged to this arm only).
for bname in &arm_pushed_binders {
for (bname, _, _, _) in &arm_pushed_binders {
self.moved_slots.remove(bname);
}
// Iter 14e: if the arm body lowered to a `musttail call` +