Files
AILang/docs/plans/0119-mir.4-strrep-loop-carried-heap.md
T
Brummel c5fd16a4eb 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
2026-06-01 00:54:44 +02:00

27 KiB

mir.4 — StrRep: loop-carried Str ⇒ Heap — Implementation Plan

⚠ Corrected during implementation — read the spec, not this plan, for the as-shipped design. This plan's "Scope boundary" (delete !is_str only at the recur dec gate; leave drop.rs:493) was wrong: #49's live=0 requires deleting the !is_str carve-out at both codegen gates (recur dec and the drop.rs:493 loop-result drop), because print borrows its argument so the loop result is freed at the caller's scope-close — not by print. The fix also added a third promotion position (exit-arm tail literals, via promote_tail_str_literals) and a static-exit witness fixture. The corrected design is the > mir.4 refinement note in the parent spec; the git log for this iteration carries the full why. Tasks 1-2 below shipped as written; Tasks 3-5 shipped in the corrected form.

Parent spec: docs/specs/0060-typed-mir.md (the mir.4 row at the "Iteration decomposition" table + the > mir.4 refinement note).

For agentic workers: REQUIRED SUB-SKILL: use the implement skill to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Close bug #49 (a heap-Str loop binder replaced across recur leaks every superseded str_concat slab) structurally: make every value in a Str loop-binder alloca an owned heap slab, then drop the !is_str carve-out from codegen's recur superseded-value dec gate. #49 reaches live=0; its #[ignore] is lifted.

Architecture: Producer-side representation, codegen reads it (the milestone thesis). lower_to_mir sets rep = StrRep::Heap on every MTerm::Str literal flowing into a Str loop-binder alloca — both binder seed inits and recur args at Str-binder positions. codegen's MTerm::Str arm gains a Heap leg that promotes the static literal via ailang_str_clone (a fresh ailang_rc_alloc slab with an rc_header at refcount 1). With every loop-carried Str value now an owned heap slab, the recur dec gate's !is_str clause is deleted — the superseded-value dec is sound for Str exactly as for a boxed ADT.

Scope boundary (from the spec refinement): Only the recur dec gate (crates/ailang-codegen/src/lib.rs:2231) loses !is_str. The other !is_str site — the MTerm::Loop arm of is_rc_heap_allocated (crates/ailang-codegen/src/drop.rs:493, the loop-result trackability gate) — stays as-is in mir.4: deleting it needs a broader "every Str a loop can return is owned-heap" guarantee (a static exit-arm literal breaks it) and is not required by the #49 acceptance (the #49 loop result is consumed by print). Do not touch drop.rs.

Tech Stack: ailang-check (lower_to_mir), ailang-codegen (lower_term), ailang-mir (StrRep — already defined, both variants already present), ail integration tests, examples/ fixtures.


Files this plan creates or modifies:

  • Modify: crates/ailang-check/src/lower_to_mir.rs — the Term::Loop arm (seed promotion), the Term::Recur arm (recur-arg promotion), a new is_str_ty helper.
  • Modify: crates/ailang-check/tests/lower_to_mir_ty.rs — flip the existing Static-seed pin to Heap; add a recur-arg-Heap pin and a non-loop-stays-Static control pin.
  • Create: examples/loop_str_recur_literal_no_leak_pin.ail — a Str loop binder whose recur rebinds it to a fresh static literal (the recur-arg-promotion witness).
  • Modify: crates/ailang-codegen/src/lib.rs — the MTerm::Str arm (add the Heap leg), the recur dec gate (delete !is_str), the ailang_mir use-import (ensure StrRep).
  • Modify: crates/ail/tests/loop_recur_str_binder_no_leak_pin.rs — lift the #[ignore] on the #49 pin, reword its doc to past tense, append a runtime leak pin for the recur-literal fixture.

Task 1: Producer — lower_to_mir Heap-promotes loop-carried Str literals

Files:

  • Modify: crates/ailang-check/src/lower_to_mir.rs:367-395 (Loop + Recur arms) + a new helper

  • Modify: crates/ailang-check/tests/lower_to_mir_ty.rs:39-49,219-230

  • Create: examples/loop_str_recur_literal_no_leak_pin.ail

  • Step 1: Add the is_str_ty helper

In crates/ailang-check/src/lower_to_mir.rs, immediately above fn lower_term(ctx: &mut Ctx, t: &Term) -> Result<MTerm> { (currently line 195), insert:

/// 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")
}
  • Step 2: Promote the loop-binder seed in the Term::Loop arm

In crates/ailang-check/src/lower_to_mir.rs, replace the binder-build loop in the Term::Loop arm (currently lines 368-375):

            let mut m_binders = Vec::with_capacity(binders.len());
            for b in binders {
                m_binders.push(MLoopBinder {
                    name: b.name.clone(),
                    ty: b.ty.clone(),
                    init: lower_term(ctx, &b.init)?,
                });
            }

with:

            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,
                });
            }
  • Step 3: Promote literal recur args in the Term::Recur arm

In crates/ailang-check/src/lower_to_mir.rs, replace the Term::Recur arm (currently lines 389-395):

        Term::Recur { args } => {
            let m_args = args
                .iter()
                .map(|a| Ok(arg(lower_term(ctx, a)?)))
                .collect::<Result<Vec<_>>>()?;
            MTerm::Recur { args: m_args, ty }
        }

with:

        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()
                .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 }
        }
  • Step 4: Create the recur-literal witness fixture

Create examples/loop_str_recur_literal_no_leak_pin.ail:

(module loop_str_recur_literal_no_leak_pin
  ; mir.4 (#49 soundness, recur-arg leg): a Str loop binder whose
  ; `recur` rebinds it to a fresh static LITERAL each iteration. Without
  ; recur-arg Heap promotion the literal "reset" would be stored static
  ; into the binder alloca and dec'd on the next supersede — UB on a
  ; header-less static. lower_to_mir flips both the seed "x" and the
  ; recur literal "reset" to StrRep::Heap, so codegen str_clones each
  ; into an owned heap slab; every supersede dec then frees a real slab.
  ; Trace: seed slab1("x"); recur0 slab2("reset") decs slab1; recur1
  ; slab3 decs slab2; recur2 slab4 decs slab3; exit acc=slab4, print
  ; consumes/frees it. allocs=4 frees=4 live=0. Expected stdout: reset.
  (fn main (type (fn-type (params) (ret (con Unit)) (effects IO))) (params)
    (body
      (let s
        (loop (acc (con Str) "x") (i (con Int) 0)
          (if (app ge i 3)
              acc
              (recur "reset" (app + i 1))))
        (app print s)))))
  • Step 5: Flip the existing Static-seed pin to Heap; add the recur-arg and control pins

In crates/ailang-check/tests/lower_to_mir_ty.rs, replace the existing test (lines 39-49):

#[test]
fn str_literal_lowers_to_static_str_node() {
    // #49 witness: the loop seed "x" is a Str literal → MTerm::Str,
    // rep = Static at mir.1 (mir.4 flips loop seeds to Heap).
    let m = "loop_recur_str_binder_no_leak_pin";
    let mir = elaborate_fixture(m);
    assert!(
        find_static_str_seed(body(&mir, m, "main")),
        "loop seed \"x\" must lower to MTerm::Str {{ rep: Static }}"
    );
}

with:

#[test]
fn loop_seed_str_literal_lowers_to_heap_str_node() {
    // #49 witness: the loop seed "x" is a Str literal → MTerm::Str.
    // mir.4: a loop-carried seed is promoted to rep = Heap so codegen
    // emits the str_clone heap promotion and the recur superseded-value
    // dec is sound (the prior alloca value is always an owned slab).
    let m = "loop_recur_str_binder_no_leak_pin";
    let mir = elaborate_fixture(m);
    assert_eq!(
        loop_seed_rep(body(&mir, m, "main")),
        Some(StrRep::Heap),
        "loop seed \"x\" must lower to MTerm::Str {{ rep: Heap }}"
    );
}

#[test]
fn loop_recur_literal_arg_lowers_to_heap_str_node() {
    // mir.4: a literal recur arg at a Str-binder position is promoted
    // to rep = Heap, mirroring the seed promotion — `(recur "reset" …)`.
    let m = "loop_str_recur_literal_no_leak_pin";
    let mir = elaborate_fixture(m);
    assert!(
        find_recur_str_arg_with_rep(body(&mir, m, "main"), StrRep::Heap),
        "the recur arg literal \"reset\" must lower to MTerm::Str {{ rep: Heap }}"
    );
}

#[test]
fn non_loop_str_literal_stays_static() {
    // Control: a Str literal NOT in a loop-carried position keeps
    // rep = Static (no spurious heap promotion). `hello` prints the
    // literal "Hello, AILang." via io/print_str.
    let m = "hello";
    let mir = elaborate_fixture(m);
    assert!(
        find_str_node_with_rep(body(&mir, m, "main"), StrRep::Static),
        "a non-loop Str literal must stay MTerm::Str {{ rep: Static }}"
    );
}
  • Step 6: Replace the find_static_str_seed helper with rep-parameterised walkers

In crates/ailang-check/tests/lower_to_mir_ty.rs, replace the find_static_str_seed helper (lines 219-230):

fn find_static_str_seed(t: &MTerm) -> bool {
    match t {
        MTerm::Loop { binders, body, .. } => {
            binders.iter().any(|b| matches!(
                &b.init,
                MTerm::Str { rep: StrRep::Static, .. }
            )) || find_static_str_seed(body)
        }
        MTerm::Let { init, body, .. } => {
            find_static_str_seed(init) || find_static_str_seed(body)
        }
        _ => false,
    }
}

with:

/// The rep of the first loop binder seed that is an `MTerm::Str`,
/// searching `Let`-init / `Let`-body / the loop's own binders.
fn loop_seed_rep(t: &MTerm) -> Option<StrRep> {
    match t {
        MTerm::Loop { binders, body, .. } => binders
            .iter()
            .find_map(|b| match &b.init {
                MTerm::Str { rep, .. } => Some(*rep),
                _ => None,
            })
            .or_else(|| loop_seed_rep(body)),
        MTerm::Let { init, body, .. } => {
            loop_seed_rep(init).or_else(|| loop_seed_rep(body))
        }
        _ => None,
    }
}

/// True if any `Recur` arg in `t` is an `MTerm::Str` with the given rep.
fn find_recur_str_arg_with_rep(t: &MTerm, want: StrRep) -> bool {
    match t {
        MTerm::Recur { args, .. } => args.iter().any(|a| matches!(
            &a.term, MTerm::Str { rep, .. } if *rep == want
        )),
        MTerm::Loop { binders, body, .. } => {
            binders.iter().any(|b| find_recur_str_arg_with_rep(&b.init, want))
                || find_recur_str_arg_with_rep(body, want)
        }
        MTerm::Let { init, body, .. } => {
            find_recur_str_arg_with_rep(init, want)
                || find_recur_str_arg_with_rep(body, want)
        }
        MTerm::If { cond, then, else_, .. } => {
            find_recur_str_arg_with_rep(cond, want)
                || find_recur_str_arg_with_rep(then, want)
                || find_recur_str_arg_with_rep(else_, want)
        }
        _ => false,
    }
}

/// True if any `MTerm::Str` reachable in `t` carries the given rep
/// (used by the non-loop control: a plain literal stays Static).
fn find_str_node_with_rep(t: &MTerm, want: StrRep) -> bool {
    match t {
        MTerm::Str { rep, .. } => *rep == want,
        MTerm::Let { init, body, .. } => {
            find_str_node_with_rep(init, want) || find_str_node_with_rep(body, want)
        }
        MTerm::App { args, .. } => {
            args.iter().any(|a| find_str_node_with_rep(&a.term, want))
        }
        MTerm::Do { args, .. } => {
            args.iter().any(|a| find_str_node_with_rep(&a.term, want))
        }
        MTerm::If { cond, then, else_, .. } => {
            find_str_node_with_rep(cond, want)
                || find_str_node_with_rep(then, want)
                || find_str_node_with_rep(else_, want)
        }
        _ => false,
    }
}

Implementer note — the MTerm field shapes the walkers rely on (verified against crates/ailang-mir/src/lib.rs): MTerm::App, MTerm::Do, and MTerm::Recur all carry args: Vec<MArg> (the inner term is a.term) — Do is { op, args, tail, ty } (lib.rs:92), and find_app_with_fn at lower_to_mir_ty.rs:209 already uses &a.term for App. MTerm::If fields are cond/then/else_. hello's (do io/print_str "Hello, AILang.") lowers to MTerm::Do whose arg .term is the MTerm::Str literal — reached by the Do arm of find_str_node_with_rep. The assertion (a non-loop literal stays Static) is what must hold.

  • Step 7: Run the producer test suite

Run: cargo test -p ailang-check --test lower_to_mir_ty Expected: PASS — all tests green, including the three new/flipped pins (loop_seed_str_literal_lowers_to_heap_str_node, loop_recur_literal_arg_lowers_to_heap_str_node, non_loop_str_literal_stays_static). No test named str_literal_lowers_to_static_str_node remains.


Task 2: Codegen — MTerm::Str Heap leg + delete the recur !is_str gate

Files:

  • Modify: crates/ailang-codegen/src/lib.rs:1674-1690 (Str arm), :2229-2235 (recur gate), the ailang_mir use-import.

  • Step 1: Ensure StrRep is imported

Run: git grep -n "use ailang_mir::" crates/ailang-codegen/src/lib.rs If the import list does not already include StrRep, add it (alongside MTerm, Mode, Callee, …). The arm in Step 2 references StrRep::Static / StrRep::Heap.

  • Step 2: Add the Heap leg to the MTerm::Str arm

In crates/ailang-codegen/src/lib.rs, replace the MTerm::Str arm (lines 1674-1690):

            MTerm::Str { lit, .. } => {
                // language `Str` literals materialise as
                // a constexpr-GEP into the packed-struct global,
                // landing on the `len`-field (now the first field,
                // since the hs.1-era sentinel rc-header slot was
                // removed). IR-Str pointer carries len at 0, bytes
                // at +8. The `rep` field is not consumed at mir.1b
                // (mir.4 wires the heap-vs-static representation hook).
                let g = self.intern_str_literal("str", lit);
                let total = c_byte_len(lit); // bytes + NUL
                Ok((
                    format!(
                        "getelementptr inbounds (<{{ i64, [{total} x i8] }}>, ptr @{g}, i32 0, i32 0)",
                    ),
                    "ptr".into(),
                ))
            }

with:

            MTerm::Str { lit, rep } => {
                // language `Str` literals materialise as a constexpr-GEP
                // into the packed-struct global, landing on the `len`
                // field (the first field). IR-Str pointer carries len at
                // 0, bytes at +8.
                let g = self.intern_str_literal("str", lit);
                let total = c_byte_len(lit); // bytes + NUL
                let gep = format!(
                    "getelementptr inbounds (<{{ i64, [{total} x i8] }}>, ptr @{g}, i32 0, i32 0)",
                );
                match rep {
                    // Static: a constexpr-GEP into the rodata global, no
                    // rc_header — the default for every non-loop-carried
                    // literal. Never RC-dec'd (see drop.rs Str note).
                    StrRep::Static => Ok((gep, "ptr".into())),
                    // mir.4: a loop-carried Str literal (seed or recur
                    // arg, flipped to Heap by lower_to_mir) must be an
                    // owned heap slab so the recur superseded-value dec
                    // is sound. Promote via `ailang_str_clone`, which
                    // deep-copies into a fresh `ailang_rc_alloc` slab
                    // (rc_header refcount 1 — runtime/str.c). The GEP is
                    // a valid constexpr operand; str_clone reads `len` at
                    // offset 0 (ABI-shared between static and heap Str).
                    StrRep::Heap => {
                        let dst = self.fresh_ssa();
                        self.body.push_str(&format!(
                            "  {dst} = call ptr @ailang_str_clone(ptr {gep})\n"
                        ));
                        Ok((dst, "ptr".into()))
                    }
                }
            }

Implementer note: @ailang_str_clone is unconditionally declared at crates/ailang-codegen/src/lib.rs:595 (declare ptr @ailang_str_clone(ptr)), so the Heap leg needs no conditional declaration. self.fresh_ssa() and self.body.push_str are the same primitives the str_clone builtin arm uses at :2592.

  • Step 3: Delete the !is_str clause from the recur dec gate

In crates/ailang-codegen/src/lib.rs, replace the gate region of the MTerm::Recur arm (lines 2216-2235 — the // Bug #49: comment through the if matches!(…) && is_ptr && !is_str { line):

                    // Bug #49: this `recur` REPLACES the binder's prior
                    // heap value with `arg_ssa`. Without a dec here the
                    // superseded value leaks every iteration (the
                    // scope-close path in drop.rs only ever sees the
                    // loop's FINAL result). Dec the prior value iff the
                    // binder type is RC-heap-and-not-Str — the SAME gate
                    // the scope-close drop path uses (lowers to `ptr` AND
                    // not Str; the Str exclusion is the static-literal
                    // representation rule from commit 8bcdae1: a `Str`
                    // loop seed may be a static global with no rc_header,
                    // so dec'ing it is UB). Primitives (Int/Bool/Float/
                    // Unit) lower to non-`ptr` and are excluded by the
                    // `ptr` gate.
                    let is_ptr =
                        matches!(llvm_type(ail_ty).as_deref(), Ok("ptr"));
                    let is_str = matches!(
                        ail_ty,
                        Type::Con { name, .. } if name == "Str"
                    );
                    if matches!(self.alloc, AllocStrategy::Rc) && is_ptr && !is_str {

with:

                    // Bug #49: this `recur` REPLACES the binder's prior
                    // heap value with `arg_ssa`. Without a dec here the
                    // superseded value leaks every iteration (the
                    // scope-close path in drop.rs only ever sees the
                    // loop's FINAL result). Dec the prior value iff the
                    // binder lowers to an RC-heap `ptr`. mir.4 removed
                    // the former `!is_str` carve-out: lower_to_mir now
                    // promotes every loop-carried `Str` literal (seed +
                    // recur args) to `StrRep::Heap`, so a `Str` loop
                    // binder alloca always holds an owned heap slab with
                    // an `rc_header` — the dec is sound for `Str` exactly
                    // as for a boxed ADT. (drop.rs's loop-RESULT
                    // trackability gate keeps its own `Str` carve-out —
                    // see spec 0060 mir.4 refinement.) Primitives
                    // (Int/Bool/Float/Unit) lower to non-`ptr` and are
                    // excluded by the `ptr` gate.
                    let is_ptr =
                        matches!(llvm_type(ail_ty).as_deref(), Ok("ptr"));
                    if matches!(self.alloc, AllocStrategy::Rc) && is_ptr {

Implementer note: removing the is_str binding may make Type unused in this arm, but ail_ty: &Type is still consumed by llvm_type(ail_ty) and field_drop_call(ail_ty) just below, so the Type import stays. Do not remove it. The same-pointer guard, the do_dec/after blocks, field_drop_call, and the plain store (lines 2236-2268) are unchanged — they now run for Str binders too.

  • Step 4: Compile gate

Run: cargo build --workspace Expected: PASS — 0 errors (no unused-variable warning for is_str, which is now deleted). Runtime behaviour is verified in Task 3 / Task 4.


Task 3: Lift the #[ignore] on the #49 pin

Files:

  • Modify: crates/ail/tests/loop_recur_str_binder_no_leak_pin.rs:104-114

  • Step 1: Remove the #[ignore] and reword the doc to past tense

In crates/ail/tests/loop_recur_str_binder_no_leak_pin.rs, replace the doc-comment + attributes of the #49 test (lines 102-114):

/// The bug under test: a heap-Str loop binder replaced by `recur` leaks
/// every superseded `str_concat` slab. RED at HEAD (live=3).
///
/// Ignored pending the #49 representation spec: the fix is not a
/// mechanical patch — closing the leak for ALL Str loop-carried state
/// (the per-iteration intermediates, the loop result, and a phi-join of
/// static/heap Str) requires a deliberate "every Str in loop-carried
/// state is heap" representation decision, routed through `brainstorm`.
/// This `#[ignore]` keeps `main` green while that cycle runs; the GREEN
/// implementation removes it. The assertion (the contract) is unchanged.
#[test]
#[ignore = "RED until the #49 Str-loop-binder representation fix lands (see brainstorm spec)"]
fn alloc_rc_str_loop_binder_replaced_by_recur_does_not_leak() {

with:

/// The #49 fix (mir.4): a heap-Str loop binder replaced by `recur` no
/// longer leaks its superseded `str_concat` slabs. `lower_to_mir`
/// promotes the loop-carried Str seed to `StrRep::Heap` so codegen
/// `str_clone`s it into an owned heap slab, and the recur dec gate
/// (`crates/ailang-codegen/src/lib.rs`) dropped its `!is_str` carve-out
/// — so each superseded slab is RC-dec'd at the recur store. Was RED at
/// `live=3` before mir.4; now `live=0`.
#[test]
fn alloc_rc_str_loop_binder_replaced_by_recur_does_not_leak() {
  • Step 2: Run the #49 pin file

Run: cargo test -p ail --test loop_recur_str_binder_no_leak_pin Expected: PASS — both tests green: alloc_rc_str_loop_binder_replaced_by_recur_does_not_leak (now un-ignored: stdout xyyy, live=0, allocs==frees) and the GREEN guard alloc_rc_adt_loop_binder_replaced_by_recur_stays_leak_clean. 0 ignored in this file.


Task 4: Runtime leak pin for the recur-literal witness

Files:

  • Modify: crates/ail/tests/loop_recur_str_binder_no_leak_pin.rs (append a GREEN test, after the existing tests, before EOF)

  • Step 1: Append the recur-literal runtime pin

In crates/ail/tests/loop_recur_str_binder_no_leak_pin.rs, append at end of file (after alloc_rc_adt_loop_binder_replaced_by_recur_stays_leak_clean):

/// mir.4 soundness, recur-arg leg: a Str loop binder whose `recur`
/// rebinds it to a fresh static LITERAL each iteration. This is the case
/// the seed-only promotion would miss — the recur literal "reset" must
/// also be Heap-promoted so its supersede dec frees a real slab instead
/// of UB-dec'ing a header-less static. stdout `reset`, `live=0`.
#[test]
fn alloc_rc_str_loop_binder_recur_literal_does_not_leak() {
    let (allocs, frees, live, stdout) =
        build_run_stats("loop_str_recur_literal_no_leak_pin.ail");
    assert_eq!(
        stdout.trim_end(),
        "reset",
        "recur-literal loop result mis-built (allocs={allocs} frees={frees} live={live})"
    );
    assert_eq!(
        live, 0,
        "a Str loop binder recur'd with a static literal leaks {live} \
         superseded slab(s) (allocs={allocs} frees={frees}); the recur \
         literal must be Heap-promoted (StrRep::Heap) like the seed."
    );
    assert_eq!(
        allocs, frees,
        "recur-literal leg alloc/free mismatch (allocs={allocs} frees={frees} live={live})"
    );
}
  • Step 2: Run the recur-literal pin

Run: cargo test -p ail --test loop_recur_str_binder_no_leak_pin alloc_rc_str_loop_binder_recur_literal_does_not_leak Expected: PASS — stdout reset, live=0, allocs==frees.


Task 5: Full verification — no snapshot drift, whole suite green

Files: none (verification only).

  • Step 1: IR snapshots must not drift

Run: cargo test -p ail --test ir_snapshot Expected: PASS — no snapshot drift. The five snapshot programs (hello, list, max3, sum, ws_main) carry no loop-carried Str binder, so the Heap promotion does not reach them. If a snapshot does shift, STOP and investigate — a shift means the Heap leg leaked onto a non-loop-carried literal (an over-promotion bug in Task 1).

  • Step 2: The boxed-ADT recur-dec guards must stay green

Run: cargo test -p ail --test loop_recur_heap_binder_no_leak_pin Expected: PASS — alloc_rc_heap_loop_binder_replaced_by_recur_does_not_leak green (the f488d31 ADT leg the Str fix must not regress).

  • Step 3: Whole workspace suite

Run: cargo test --workspace Expected: PASS — every crate green. Per memory project_typed_mir_resynth_strictness, run the FULL workspace, not just e2e: a Str-representation change can surface in show_print_e2e, str_concat_e2e, print_no_leak_pin, or the prelude-driven lower_to_mir_ty walk. Confirm the previously-#[ignore]d #49 pin is no longer counted as ignored (the ignored count drops by 1 versus the mir.3b baseline: the two remaining ignores are doctests, not this test).