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