fix(codegen): drop owned params on tail-call-terminated match arms (#63 leg 1)

An owned heap ADT param that a tail-recursive match arm neither
forwards into the tail call nor consumes had no drop site: the
fn-return Own-param dec in lib.rs is gated behind `!block_terminated`
and so fires only on the fall-through `ret` path, while the per-arm
drop in match_lower.rs handled only pattern binders. A
`musttail call`-terminated arm therefore leaked one slab per
recursion step (#63, leg 1).

match_lower.rs now emits a pre-tail-call Own-param dec, gated exactly
like the fn-return path: Rc strategy, `arm_body_is_tail_call`,
ParamMode::Own, ptr-typed, and uniqueness `consume_count == 0`. For an
Own param consume_count==0 also implies "not forwarded into the tail
call" — a forwarded Own arg is walked Position::Consume and carries
consume_count>=1 — so the gate cannot drop a param the callee took
ownership of. The drop routes through the same per-type /
partial-drop machinery (honouring moved_slots) as the fn-return path.

The match scrutinee param is explicitly skipped: its ownership is
already discharged by the pre-tail-call scrutinee-husk shallow-dec
plus the moved-out field binders, so dropping it here would dec the
same outer cell twice (a double-handling the first cut of this fix
introduced and the quality review caught — the pin read live=0 but
the IR showed two decs on the scrutinee cell).

Verified: the committed RED pin is green (live=0, allocs==frees);
full `cargo test --workspace` 0 failures; the fix introduces no
under-free (loop_recur_heap_binder / loop_recur_str_binder /
loop_str_recur_literal / rc_pin_recurse_implicit /
lit_pat_ctor_tail_drop all stay live=0, no underflow to depth 5).
No INTERCEPTS/(intrinsic) or pre_desugar_validation change; no IR
snapshot refresh needed.

Leg 1 only. Leg 2 — a `let`/`seq`-wrapped tail call makes the arm
body an MTerm::Let so `arm_body_is_tail_call` is false, skipping both
the husk-dec and this new param-dec — still leaks (examples/series_sma.ail
stays live=8). It gets its own RED pin and fix next.

refs #63
This commit is contained in:
2026-06-02 13:41:57 +02:00
parent 1be6e49b4f
commit 68f500c4b0
+84
View File
@@ -756,6 +756,90 @@ impl<'a> Emitter<'a> {
} }
} }
} }
// pre-tail-call Own-param dec. The fn-return Own-param dec
// (lib.rs, gated behind `!block_terminated`) fires only on
// the fall-through `ret` path. A tail-call-terminated arm
// emits `musttail call ... ret` and sets
// `block_terminated = true`, so that block is skipped — an
// owned param the arm neither forwards nor consumes then has
// no drop site and leaks one slab per recursion step (#63).
//
// The gate mirrors the fn-return path: Rc strategy, the
// param is `ParamMode::Own`, ptr-typed, and uniqueness
// recorded `consume_count == 0`. For an Own param,
// `consume_count == 0` also means it was not forwarded into
// the tail call: a forwarded Own arg is walked in
// Position::Consume by the uniqueness pass and would carry
// `consume_count >= 1`, so this gate cannot drop a param the
// callee took ownership of. Borrow params are excluded by
// the mode check. The drop routes through the same per-type
// / partial-drop machinery the fn-return path uses, honouring
// `moved_slots` so a param whose fields were moved out is not
// double-freed.
if matches!(self.alloc, AllocStrategy::Rc) && arm_body_is_tail_call {
let owned_params: Vec<(String, ParamMode)> = self
.current_param_modes
.iter()
.map(|(n, m)| (n.clone(), *m))
.collect();
for (pname, mode) in &owned_params {
if !matches!(mode, ParamMode::Own) {
continue;
}
// Skip the param that IS this match's scrutinee: its
// ownership is already discharged by the pre-tail-call
// scrutinee-husk shallow-dec above (plus the moved-out
// field binders). Dropping it again here would dec the
// same outer cell twice.
if scrutinee_binder.as_deref() == Some(pname.as_str()) {
continue;
}
let p_ssa = format!("%arg_{}", pname);
let p_ail = self
.locals
.iter()
.find(|(_, ssa, _, _)| ssa == &p_ssa)
.map(|(_, _, lty, ail)| (lty.clone(), ail.clone()));
let (p_lty, p_ail) = match p_ail {
Some(t) => t,
None => continue,
};
if p_lty != "ptr" {
continue;
}
let consume_count = self
.consume
.get(&(self.current_def.clone(), pname.clone()))
.copied()
.unwrap_or(u32::MAX);
if consume_count != 0 {
continue;
}
let moves = self
.moved_slots
.get(pname)
.cloned()
.unwrap_or_default();
if moves.is_empty() {
let drop_call = self.field_drop_call(&p_ail);
self.body.push_str(&format!(
" call void @{drop_call}(ptr {p_ssa})\n"
));
} else {
let sym = self.partial_drop_symbol_for_type(&p_ail);
let mask = Self::build_moved_mask(&moves);
if let (Some(sym), Some(mask)) = (sym, mask) {
self.body.push_str(&format!(
" call void @{sym}(ptr {p_ssa}, i64 {mask})\n"
));
} else {
self.body.push_str(&format!(
" call void @ailang_rc_dec(ptr {p_ssa})\n"
));
}
}
}
}
let (val, vty) = self.lower_term(&arm.body)?; let (val, vty) = self.lower_term(&arm.body)?;
// arm-close pattern-binder dec. Symmetric to // arm-close pattern-binder dec. Symmetric to
// 18c.3/18c.4's `Term::Let`-scope-close drop emission, but // 18c.3/18c.4's `Term::Let`-scope-close drop emission, but