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
@@ -0,0 +1,13 @@
{
"iter_id": "mir.4",
"date": "2026-06-01",
"mode": "standard",
"outcome": "DONE",
"tasks_total": 5,
"tasks_completed": 5,
"reloops_per_task": { "1": 0, "2": 0, "3": 0, "4": 0, "5": 0 },
"review_loops_spec": 0,
"review_loops_quality": 0,
"blocked_reason": null,
"notes": "The implement-orchestrator agent completed Tasks 1-2 (producer Heap-promotion of seed + recur-arg literals, codegen Heap leg, recur dec-gate !is_str deletion) clean and correctly BLOCKED on Task 3, surfacing a genuine spec defect: the plan/spec scoped the !is_str deletion to the recur dec gate ONLY (drop.rs:493 'stays conservative'), but #49's live=0 is unreachable that way — the agent showed Tasks 1-2 took #49 from live=3 to live=1, the residual being the loop-RESULT slab. Orchestrator verified the diagnosis empirically: (a) deleting drop.rs:493's !is_str took #49 and the recur-literal fixture to live=0; (b) print is a BORROWING consumer (`(let s (show x) (do io/print_str s))`), so the loop result is freed at the caller's scope-close (drop.rs:493), not by print — the scoping premise ('consumed by print') was wrong; (c) deleting drop.rs:493 WITHOUT exit-arm promotion SIGSEGVs (exit 139) on a loop returning a static Str literal — confirming a third promotion position is needed. Orchestrator corrected the spec refinement note (both gates; three promotion positions: seed + recur-arg + exit-arm tail) and completed the loop-result leg inline (context already fully loaded from the BLOCK diagnosis + empirical verification, per CLAUDE.md 'already loaded context' carve-out): added promote_tail_str_literals (tail walk over If/Let/LetRec/Match/Seq/ReuseAs), gated on a Str-returning loop; deleted drop.rs:493 !is_str; created examples/loop_str_static_exit_no_leak_pin.ail; lifted the #49 #[ignore]; added recur-literal + static-exit runtime pins + an exit-arm producer pin. 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). All four leak fixtures live=0 (#49 xyyy, recur-literal reset, static-exit result, ADT guard 2); ir_snapshot no drift; no lockstep pair touched. Durable lesson: 'consumed by print' != 'freed by print' — print borrows; a loop result's owner is the caller's binder. The single-gate scoping was the planning-time error the agent's empirical BLOCK caught."
}