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
@@ -99,18 +99,16 @@ fn build_run_stats(fixture: &str) -> (u64, u64, i64, String) {
)
}
/// 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.
/// The #49 fix (mir.4): a heap-Str loop binder replaced by `recur` no
/// longer leaks. `lower_to_mir` promotes every loop-carried Str literal
/// (seed, recur args, and exit-arm result literals) to `StrRep::Heap`,
/// so codegen `str_clone`s each into an owned heap slab; the recur dec
/// gate AND the loop-result scope-close drop both dropped their `!is_str`
/// carve-out, so every superseded slab is freed at the recur store and
/// the final result is freed at the caller's scope-close. Was RED at
/// `live=3` before mir.4 (the seed plus two intermediate `str_concat`
/// slabs); now `live=0`.
#[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() {
let (allocs, frees, live, stdout) =
build_run_stats("loop_recur_str_binder_no_leak_pin.ail");
@@ -121,11 +119,11 @@ fn alloc_rc_str_loop_binder_replaced_by_recur_does_not_leak() {
);
assert_eq!(
live, 0,
"a heap-Str loop binder replaced by `recur` leaks {live} \
superseded slab(s) (allocs={allocs} frees={frees}); the \
Term::Recur arm in crates/ailang-codegen/src/lib.rs gates its \
superseded-value dec behind `!is_str`, so the prior str_concat \
heap slab is overwritten by a plain store without RC-dec."
"a heap-Str loop binder replaced by `recur` leaks {live} slab(s) \
(allocs={allocs} frees={frees}); mir.4 must promote the seed and \
every recur arg to StrRep::Heap and drop the `!is_str` carve-out \
on both the recur dec gate (lib.rs) and the loop-result \
scope-close drop (drop.rs)."
);
assert_eq!(
allocs, frees,
@@ -154,3 +152,58 @@ fn alloc_rc_adt_loop_binder_replaced_by_recur_stays_leak_clean() {
"ADT leg alloc/free mismatch (allocs={allocs} frees={frees} live={live})"
);
}
/// mir.4 soundness, recur-arg leg: a Str loop binder whose `recur`
/// rebinds it to a fresh static LITERAL each iteration. The seed-only
/// promotion would miss this — 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} \
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})"
);
}
/// mir.4 soundness, loop-result leg: a loop that RETURNS a static Str
/// LITERAL from its exit arm. The result is freed at the caller's
/// scope-close, which (after mir.4 deletes the `!is_str` carve-out on
/// the loop-result drop path) `ailang_rc_dec`s it — so the exit-arm
/// literal "result" must be Heap-promoted, or the program SIGSEGVs on a
/// header-less static. This pin would crash (non-zero exit) if the
/// exit-arm tail promotion regressed. stdout `result`, `live=0`.
#[test]
fn alloc_rc_str_loop_static_exit_literal_does_not_leak() {
let (allocs, frees, live, stdout) =
build_run_stats("loop_str_static_exit_no_leak_pin.ail");
assert_eq!(
stdout.trim_end(),
"result",
"static-exit loop result mis-built (allocs={allocs} frees={frees} live={live})"
);
assert_eq!(
live, 0,
"a loop returning a static Str literal leaks {live} slab(s) \
(allocs={allocs} frees={frees}); the exit-arm literal must be \
Heap-promoted (StrRep::Heap) so the scope-close dec frees a real \
slab rather than UB-dec'ing a header-less static."
);
assert_eq!(
allocs, frees,
"static-exit leg alloc/free mismatch (allocs={allocs} frees={frees} live={live})"
);
}