feat(codegen): StrRep loop-carried Str ⇒ Heap, close the #49 recur leak

mir.4 closes bug #49 (a heap-Str loop binder replaced across `recur`
leaks every superseded slab; the loop result leaks too) structurally,
by making every Str a loop binder holds OR a loop returns an owned heap
slab — then deleting the `!is_str` carve-out from BOTH codegen gates
that carried it. The leg-by-leg `!is_str` patches are gone.

Mechanism (the milestone thesis: representation in MIR, codegen reads
it). lower_to_mir sets rep = StrRep::Heap on every Str literal in a
loop-carried position; codegen's MTerm::Str arm promotes a Heap literal
via ailang_str_clone (a fresh ailang_rc_alloc slab, rc_header refcount
1). Three promotion positions, all in the Loop/Recur lowering:
  1. binder seed inits (is_str_ty on the binder type);
  2. recur args at Str-binder positions (read off the innermost
     loop_stack frame);
  3. exit-arm tail result literals (promote_tail_str_literals walks the
     loop body's tail positions when the loop returns Str).
With every loop-carried Str now owned-heap, both gates lose !is_str:
the recur superseded-value dec (lib.rs) frees each intermediate slab;
the loop-result trackability gate (drop.rs:493) lets the caller's
scope-close free the final slab.

Why both gates, and the planning-time error this corrects. The original
plan/spec scoped the deletion to the recur dec gate only, reasoning the
#49 loop result is "consumed by print". The implement-orchestrator
landed that scope (Tasks 1-2) clean and correctly BLOCKED: it took #49
from live=3 to live=1, the residual being the loop RESULT. I verified
the diagnosis empirically:
  - print BORROWS its arg (prelude: `(let s (show x) (do io/print_str
    s))`, the eob.1 heap-Str discipline), so `(print s)` does not free
    the loop result; the caller's let-binder does, via drop.rs:493.
    "consumed by print" != "freed by print" — that was the error.
  - Deleting drop.rs:493's !is_str takes #49 and the recur-literal
    fixture to live=0.
  - Deleting drop.rs:493 WITHOUT exit-arm promotion SIGSEGVs (exit 139)
    on a loop returning a static Str literal — hence the third
    promotion position and the static-exit witness.
The agent surfaced a real spec defect via an empirical BLOCK rather
than guessing; I corrected the spec refinement note (both gates, three
positions) and completed the loop-result leg inline, the context
already loaded from verifying the BLOCK (CLAUDE.md "already loaded
context" carve-out). drop.rs:493's Str carve-out is now sound to delete.

Boundary (asserted ill-typed, not constructible): a borrowed static-Str
Var seeded/recur'd into a consumed Str loop binder — rejected upstream
by uniqueness (a consumed binder position is owned).

Verification (orchestrator-run): all four leak fixtures reach live=0
under AILANG_RC_STATS — #49 (xyyy), recur-literal (reset), static-exit
(result), and the f488d31 boxed-ADT guard (2, no regression). The #49
#[ignore] is lifted. Full workspace: 708 passed / 0 failed / 2 ignored
(mir.3b baseline 702/0/3; +6 passed, -1 ignored = #49 lifted; the 2
remaining ignores are doctests). ir_snapshot: no drift (the Heap
promotion does not reach non-loop-carried literals — the
non_loop_str_literal_stays_static pin confirms at the unit level).
Neither CLAUDE.md lockstep pair touches Str representation.

New witness fixtures: examples/loop_str_recur_literal_no_leak_pin.ail
(recur-arg leg) and examples/loop_str_static_exit_no_leak_pin.ail
(loop-result leg / static-exit soundness). New pins: lower_to_mir_ty
exit-arm producer pin + the flipped seed/recur-arg pins; two runtime
leak pins alongside the lifted #49.

closes #49
This commit is contained in:
2026-06-01 00:54:44 +02:00
parent 2536b5f535
commit c5fd16a4eb
10 changed files with 426 additions and 104 deletions
+86 -4
View File
@@ -191,6 +191,46 @@ fn classify_callee(ctx: &Ctx, callee: &Term) -> CalleeClass {
CalleeClass::Indirect
}
/// True for the `Str` con-type. mir.4 uses it to recognise a
/// loop-binder position whose seed / recur-arg `Str` literals must be
/// promoted to `StrRep::Heap` (an owned heap slab), so codegen's
/// superseded-value dec on the binder alloca is sound. `Type::Con` is
/// the same shape codegen matches at `lib.rs:2232`.
fn is_str_ty(t: &Type) -> bool {
matches!(t, Type::Con { name, .. } if name == "Str")
}
/// Promote every `Str` literal in a **tail (result) position** of a
/// `Str`-returning loop body to `StrRep::Heap`. The loop result flows to
/// the caller's let-binder, whose scope-close dec (codegen's loop-result
/// trackability path, `drop.rs`) frees it once the `!is_str` carve-out
/// there is gone — and dec'ing a header-less static literal is UB. So a
/// bare `Str` literal in an exit arm must become an owned heap slab,
/// mirroring the seed / recur-arg promotion. `Recur` is not a result
/// position (it loops back), so it is not descended; a nested `Loop`
/// tail was already promoted when that inner loop was lowered. Every
/// other tail leaf is already owned-heap (a promoted binder `Var`),
/// heap-returning (`str_concat` / a call), or a non-`Str` value.
fn promote_tail_str_literals(t: &mut MTerm) {
match t {
MTerm::Str { rep, .. } => *rep = StrRep::Heap,
MTerm::If { then, else_, .. } => {
promote_tail_str_literals(then);
promote_tail_str_literals(else_);
}
MTerm::Let { body, .. } => promote_tail_str_literals(body),
MTerm::LetRec { in_term, .. } => promote_tail_str_literals(in_term),
MTerm::Match { arms, .. } => {
for a in arms.iter_mut() {
promote_tail_str_literals(&mut a.body);
}
}
MTerm::Seq { rhs, .. } => promote_tail_str_literals(rhs),
MTerm::ReuseAs { body, .. } => promote_tail_str_literals(body),
_ => {}
}
}
/// Lower one term to `MTerm`, filling `ty` from `synth_pure`.
fn lower_term(ctx: &mut Ctx, t: &Term) -> Result<MTerm> {
let ty = ctx.synth_pure(t)?;
@@ -367,10 +407,22 @@ fn lower_term(ctx: &mut Ctx, t: &Term) -> Result<MTerm> {
Term::Loop { binders, body } => {
let mut m_binders = Vec::with_capacity(binders.len());
for b in binders {
let mut init = lower_term(ctx, &b.init)?;
// mir.4: a `Str` literal seeding a loop binder must be
// an owned heap slab — the binder alloca is dec'd when a
// later `recur` supersedes it (codegen, lib.rs recur
// arm), and dec'ing a header-less static literal is UB.
// Flip the seed literal's rep to Heap; codegen's
// MTerm::Str arm then promotes it via `str_clone`.
if is_str_ty(&b.ty) {
if let MTerm::Str { rep, .. } = &mut init {
*rep = StrRep::Heap;
}
}
m_binders.push(MLoopBinder {
name: b.name.clone(),
ty: b.ty.clone(),
init: lower_term(ctx, &b.init)?,
init,
});
}
let saved_locals = ctx.locals.clone();
@@ -380,16 +432,46 @@ fn lower_term(ctx: &mut Ctx, t: &Term) -> Result<MTerm> {
ctx.locals.insert(b.name.clone(), b.ty.clone());
}
ctx.loop_stack.push(frame);
let m_body = Box::new(lower_term(ctx, body)?);
let mut m_body = lower_term(ctx, body)?;
ctx.loop_stack.pop();
ctx.locals = saved_locals;
MTerm::Loop { binders: m_binders, body: m_body, ty }
// mir.4: a `Str` literal in a tail (result) position of the
// loop body must be an owned heap slab too — the loop result
// flows to the caller's let-binder whose scope-close dec
// frees it (once codegen's loop-result `!is_str` carve-out is
// gone). Only meaningful when the loop returns `Str`.
if is_str_ty(&ty) {
promote_tail_str_literals(&mut m_body);
}
MTerm::Loop { binders: m_binders, body: Box::new(m_body), ty }
}
Term::Recur { args } => {
// mir.4: a `Str` literal recur arg at a `Str`-binder
// position must be an owned heap slab, for the same reason
// the seed is (the binder alloca is dec'd on the next
// supersede). The innermost loop frame (`loop_stack.last()`,
// the loop this `recur` targets) gives the per-position
// binder types. `(recur "reset" …)` is well-typed, so this
// leg is load-bearing even though #49 itself recurs with a
// `str_concat` (already heap) arg.
let binder_tys: Vec<Type> = ctx
.loop_stack
.last()
.map(|frame| frame.iter().map(|(_, t)| t.clone()).collect())
.unwrap_or_default();
let m_args = args
.iter()
.map(|a| Ok(arg(lower_term(ctx, a)?)))
.enumerate()
.map(|(i, a)| {
let mut m = lower_term(ctx, a)?;
if binder_tys.get(i).map_or(false, is_str_ty) {
if let MTerm::Str { rep, .. } = &mut m {
*rep = StrRep::Heap;
}
}
Ok(arg(m))
})
.collect::<Result<Vec<_>>>()?;
MTerm::Recur { args: m_args, ty }
}