c5fd16a4eb
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
668 lines
27 KiB
Markdown
668 lines
27 KiB
Markdown
# 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:
|
|
|
|
```rust
|
|
/// 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):
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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):
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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`:
|
|
|
|
```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):
|
|
|
|
```rust
|
|
#[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:
|
|
|
|
```rust
|
|
#[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):
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
/// 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):
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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):
|
|
|
|
```rust
|
|
// 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:
|
|
|
|
```rust
|
|
// 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):
|
|
|
|
```rust
|
|
/// 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:
|
|
|
|
```rust
|
|
/// 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`):
|
|
|
|
```rust
|
|
/// 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).
|