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:
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user