fix(codegen): drop owned values on let/seq-wrapped tail-call arms (#63 leg 2)

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
This commit is contained in:
2026-06-02 13:53:14 +02:00
parent 2488d9d345
commit e3a9065aa7
+17 -4
View File
@@ -714,10 +714,23 @@ impl<'a> Emitter<'a> {
// values that are now owned by downstream frames — // values that are now owned by downstream frames —
// that's exactly the bug 18d.3's `moved_slots` was set // that's exactly the bug 18d.3's `moved_slots` was set
// up to prevent. // up to prevent.
let arm_body_is_tail_call = matches!( // A tail call may be the arm body directly, or wrapped in a
&arm.body, // trailing `(let ...)` / `(seq ...)` chain whose tail-position
MTerm::App { tail: true, .. } | MTerm::Do { tail: true, .. } // 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) if matches!(self.alloc, AllocStrategy::Rc)
&& arm_body_is_tail_call && arm_body_is_tail_call
&& scrutinee_is_owned && scrutinee_is_owned