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