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:
@@ -37,14 +37,60 @@ fn body<'a>(mir: &'a MirWorkspace, module: &str, def: &str) -> &'a MTerm {
|
||||
}
|
||||
|
||||
#[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).
|
||||
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_static_str_seed(body(&mir, m, "main")),
|
||||
"loop seed \"x\" must lower to MTerm::Str {{ rep: Static }}"
|
||||
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 }}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loop_exit_arm_str_literal_lowers_to_heap_str_node() {
|
||||
// mir.4 loop-result leg: a Str literal in a loop exit (tail) arm is
|
||||
// promoted to rep = Heap, so the loop returns an owned heap slab the
|
||||
// caller's scope-close can safely free. `loop_str_static_exit` exits
|
||||
// with the literal "result"; it is the only Str literal in the body.
|
||||
let m = "loop_str_static_exit_no_leak_pin";
|
||||
let mir = elaborate_fixture(m);
|
||||
assert!(
|
||||
find_str_node_with_rep(body(&mir, m, "main"), StrRep::Heap),
|
||||
"the loop exit-arm literal \"result\" must lower to MTerm::Str {{ rep: Heap }}"
|
||||
);
|
||||
assert!(
|
||||
!find_str_node_with_rep(body(&mir, m, "main"), StrRep::Static),
|
||||
"no Str literal in the static-exit fixture should remain Static"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -214,18 +260,69 @@ fn find_app_with_fn<'a>(t: &'a MTerm, fn_name: &str) -> Option<&'a MTerm> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively search for a `MTerm::Str { rep: Static }` reachable
|
||||
/// from a loop binder init (the seed).
|
||||
fn find_static_str_seed(t: &MTerm) -> bool {
|
||||
/// 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| matches!(
|
||||
&b.init,
|
||||
MTerm::Str { rep: StrRep::Static, .. }
|
||||
)) || find_static_str_seed(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_static_str_seed(init) || find_static_str_seed(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)
|
||||
}
|
||||
MTerm::Loop { binders, body, .. } => {
|
||||
binders.iter().any(|b| find_str_node_with_rep(&b.init, want))
|
||||
|| find_str_node_with_rep(body, want)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user