From e3a9065aa70c6c57d8d992652ef4a354e64af6d8 Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 2 Jun 2026 13:53:14 +0200 Subject: [PATCH] fix(codegen): drop owned values on let/seq-wrapped tail-call arms (#63 leg 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After leg 1, a tail call wrapped in a trailing `(let ...)` / `(seq ...)` still leaked: `arm_body_is_tail_call` matched only a direct App/Do{tail:true} arm body, so for an MTerm::Let/Seq body the gate was false and both pre-tail-call drops (the scrutinee-husk shallow-dec and the leg-1 owned-param dec) were skipped — while lower_term still descended through the wrapper and emitted the inner call as musttail. The fix replaces the one-level match with a local recursive `tail_position_is_tail_call` that peels through a trailing Let-body / Seq-rhs chain to its tail-position sub-term. This is a contained fix: the predicate feeds only the two drop-emission gates, never the musttail-emission decision (which is independent, in lower_term off App{tail:true}), so widening it restores the drops without changing which calls are tail calls. The let-bound value's scope-close drop is gated on `!block_terminated` and stays correctly skipped, so the forwarded let binding is not double-freed. Verified: the leg-2 pin goes green (live=0, was live=4); the leg-1 pin stays green; full `cargo test --workspace` 0 failures, no double-free / underflow / IR-snapshot delta; lockstep pairs untouched. examples/series_sma.ail drops from live=8 to live=2 with unchanged stdout (3.0 / 5.33333 / 5.66667 / 5.33333). Not yet leak-clean: the residual live=2 is a THIRD, distinct leg — an (own) heap param consumed on one match arm but live on a sibling base-case (non-tail fall-through) arm is not dropped there, because the fn-return Own-param dec gates on the per-fn AGGREGATE consume_count (>=1 here, from the recursive arm) rather than per-arm liveness. Routed as its own RED-first bugfix. refs #63 --- crates/ailang-codegen/src/match_lower.rs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/ailang-codegen/src/match_lower.rs b/crates/ailang-codegen/src/match_lower.rs index 7ec413f..aae6c6e 100644 --- a/crates/ailang-codegen/src/match_lower.rs +++ b/crates/ailang-codegen/src/match_lower.rs @@ -714,10 +714,23 @@ impl<'a> Emitter<'a> { // values that are now owned by downstream frames — // that's exactly the bug 18d.3's `moved_slots` was set // up to prevent. - let arm_body_is_tail_call = matches!( - &arm.body, - MTerm::App { tail: true, .. } | MTerm::Do { tail: true, .. } - ); + // A tail call may be the arm body directly, or wrapped in a + // trailing `(let ...)` / `(seq ...)` chain whose tail-position + // sub-term (a `Let`'s body, a `Seq`'s rhs) is the tail call. + // `lower_term` descends through the wrapper and still emits the + // inner call as `musttail`, so the two drop-emission gates below + // (husk-dec, leg-1 owned-param dec) must fire for the wrapped + // form too — otherwise the scrutinee husk and the non-forwarded + // owned param each leak one slab per recursion step (#63, leg 2). + fn tail_position_is_tail_call(t: &MTerm) -> bool { + match t { + MTerm::App { tail: true, .. } | MTerm::Do { tail: true, .. } => true, + MTerm::Let { body, .. } => tail_position_is_tail_call(body), + MTerm::Seq { rhs, .. } => tail_position_is_tail_call(rhs), + _ => false, + } + } + let arm_body_is_tail_call = tail_position_is_tail_call(&arm.body); if matches!(self.alloc, AllocStrategy::Rc) && arm_body_is_tail_call && scrutinee_is_owned