From 68f500c4b06888b79c0cbe13b91b7136e5d2b577 Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 2 Jun 2026 13:41:57 +0200 Subject: [PATCH] fix(codegen): drop owned params on tail-call-terminated match arms (#63 leg 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/ailang-codegen/src/match_lower.rs | 84 ++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/crates/ailang-codegen/src/match_lower.rs b/crates/ailang-codegen/src/match_lower.rs index e872138..7ec413f 100644 --- a/crates/ailang-codegen/src/match_lower.rs +++ b/crates/ailang-codegen/src/match_lower.rs @@ -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)?; // arm-close pattern-binder dec. Symmetric to // 18c.3/18c.4's `Term::Let`-scope-close drop emission, but