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
@@ -1,5 +1,18 @@
# 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).
>
+42 -31
View File
@@ -352,40 +352,51 @@ class and deletes the matching codegen re-derivation.
> not mir.5 — the mir.5 row's "last re-derivation residue" shrinks to
> the element-type / `Term::New` (`New.elem`) work.
> mir.4 refinement (discovered in planning + recon, `docs/plans/0119-mir.4-strrep-loop-carried-heap.md`):
> The `!is_str` deletion is scoped to the **recur superseded-value dec
> gate** (`crates/ailang-codegen/src/lib.rs:2231`, the gate the row
> names by "recur dec gate"). 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 conservative** in mir.4: deleting it
> would require every `Str` a loop can *return* to be owned-heap, which
> a loop's non-`recur` exit arm (`(if … "done" (recur …))`, a static
> exit literal) does not guarantee — a strictly larger representation
> change with its own static-`Str`-dec UB hole, and not required by the
> #49 acceptance (the #49 loop result is consumed by `print`, so the
> result gate is never exercised).
> mir.4 refinement (plan `docs/plans/0119-mir.4-strrep-loop-carried-heap.md`):
> mir.4 deletes the `!is_str` carve-out from **both** codegen sites that
> carried it, because `#49`'s `live=0` requires both:
>
> - the **recur superseded-value dec gate**
> (`crates/ailang-codegen/src/lib.rs:2231`) — frees each *intermediate*
> loop-binder slab at the `recur` store; and
> - the **loop-result trackability gate** (the `MTerm::Loop` arm of
> `is_rc_heap_allocated`, `crates/ailang-codegen/src/drop.rs:493`) —
> frees the loop's *final* result at the **caller's** scope-close.
>
> The loop result needs the caller's scope-close because `print` is a
> *borrowing* consumer (`(let s (show x) (do io/print_str s))`, prelude
> — the eob.1 heap-`Str` discipline), so `(print s)` does not free `s`;
> the let-binder holding the loop result does, via `drop.rs:493`. With
> the carve-out there, a `Str` loop result was never tracked and leaked
> (`#49` reached `live=1` with only the recur gate fixed). Both gates
> are sound to delete because the producer now guarantees every
> loop-carried `Str` is owned-heap.
>
> **Mechanism (producer-side rep, codegen reads it — the milestone
> thesis applied):** `lower_to_mir` sets `rep = StrRep::Heap` on every
> `MTerm::Str` **literal that flows into a `Str` loop-binder alloca** —
> both the binder **seed** inits (the `Term::Loop` arm) AND the
> **recur args** at `Str`-binder positions (a per-loop `Str`-binder mask
> threaded on the lowering ctx, stacked for nested loops; the #49
> program has only the seed literal, but a literal `recur` arg —
> `(recur "reset" …)` — is well-typed and would be a static value under
> the now-unconditional dec, so it must be promoted too). codegen's
> `MTerm::Str` arm (`lib.rs:1674`, today `{ lit, .. }` ignoring `rep`)
> gains a `Heap` leg: emit the interned static GEP, then
> `call ptr @ailang_str_clone(ptr <gep>)` — a fresh heap slab with an
> `rc_header` at refcount 1 (`runtime/str.c:211`). **No** special-casing
> in the codegen Loop/Recur store arms: the `str_clone` is emitted
> automatically when the `MTerm::Str{Heap}` node is lowered. This
> establishes the invariant **every value in a `Str` loop-binder alloca
> is an owned heap slab**, making the unconditional superseded-value dec
> at the recur gate sound (the prior value is always RC-tracked). The
> same-pointer guard (`lib.rs:2248`) still elides the identity-thread
> `(recur acc …)` case.
> `MTerm::Str` **literal in a loop-carried position** — three positions,
> all in the `Term::Loop` / `Term::Recur` lowering:
> 1. binder **seed** inits (`is_str_ty(&b.ty)` in the `Term::Loop` arm);
> 2. **recur args** at `Str`-binder positions (read off the innermost
> `loop_stack` frame in the `Term::Recur` arm — `(recur "reset" …)`
> is well-typed and would otherwise be a static value under the
> now-unconditional recur dec);
> 3. **exit-arm (tail) result literals** (`promote_tail_str_literals`
> walks the loop body's tail positions when the loop returns `Str` —
> a loop whose exit arm is a bare literal, `(if … "result" (recur …))`,
> returns that literal as the loop result; without promotion the
> caller's scope-close would `ailang_rc_dec` a header-less static —
> a verified SIGSEGV).
>
> codegen's `MTerm::Str` arm gains a `Heap` leg: emit the interned static
> GEP, then `call ptr @ailang_str_clone(ptr <gep>)` — a fresh heap slab
> with an `rc_header` at refcount 1 (`runtime/str.c:211`). **No**
> special-casing in the codegen Loop/Recur/scope-close store arms: the
> `str_clone` is emitted automatically when the `MTerm::Str{Heap}` node
> is lowered. This establishes the invariant **every `Str` value a loop
> binder holds or a loop returns is an owned heap slab**, making both
> unconditional decs sound. The recur same-pointer guard (`lib.rs:2248`)
> still elides the identity-thread `(recur acc …)` case.
>
> **Boundary (out of scope, asserted ill-typed):** a **borrowed
> static-`Str` `Var`** seeded or recur'd into a *consumed* (dec'd) `Str`