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:
@@ -99,18 +99,16 @@ fn build_run_stats(fixture: &str) -> (u64, u64, i64, String) {
|
||||
)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// The #49 fix (mir.4): a heap-Str loop binder replaced by `recur` no
|
||||
/// longer leaks. `lower_to_mir` promotes every loop-carried Str literal
|
||||
/// (seed, recur args, and exit-arm result literals) to `StrRep::Heap`,
|
||||
/// so codegen `str_clone`s each into an owned heap slab; the recur dec
|
||||
/// gate AND the loop-result scope-close drop both dropped their `!is_str`
|
||||
/// carve-out, so every superseded slab is freed at the recur store and
|
||||
/// the final result is freed at the caller's scope-close. Was RED at
|
||||
/// `live=3` before mir.4 (the seed plus two intermediate `str_concat`
|
||||
/// slabs); now `live=0`.
|
||||
#[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() {
|
||||
let (allocs, frees, live, stdout) =
|
||||
build_run_stats("loop_recur_str_binder_no_leak_pin.ail");
|
||||
@@ -121,11 +119,11 @@ fn alloc_rc_str_loop_binder_replaced_by_recur_does_not_leak() {
|
||||
);
|
||||
assert_eq!(
|
||||
live, 0,
|
||||
"a heap-Str loop binder replaced by `recur` leaks {live} \
|
||||
superseded slab(s) (allocs={allocs} frees={frees}); the \
|
||||
Term::Recur arm in crates/ailang-codegen/src/lib.rs gates its \
|
||||
superseded-value dec behind `!is_str`, so the prior str_concat \
|
||||
heap slab is overwritten by a plain store without RC-dec."
|
||||
"a heap-Str loop binder replaced by `recur` leaks {live} slab(s) \
|
||||
(allocs={allocs} frees={frees}); mir.4 must promote the seed and \
|
||||
every recur arg to StrRep::Heap and drop the `!is_str` carve-out \
|
||||
on both the recur dec gate (lib.rs) and the loop-result \
|
||||
scope-close drop (drop.rs)."
|
||||
);
|
||||
assert_eq!(
|
||||
allocs, frees,
|
||||
@@ -154,3 +152,58 @@ fn alloc_rc_adt_loop_binder_replaced_by_recur_stays_leak_clean() {
|
||||
"ADT leg alloc/free mismatch (allocs={allocs} frees={frees} live={live})"
|
||||
);
|
||||
}
|
||||
|
||||
/// mir.4 soundness, recur-arg leg: a Str loop binder whose `recur`
|
||||
/// rebinds it to a fresh static LITERAL each iteration. The seed-only
|
||||
/// promotion would miss this — 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} \
|
||||
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})"
|
||||
);
|
||||
}
|
||||
|
||||
/// mir.4 soundness, loop-result leg: a loop that RETURNS a static Str
|
||||
/// LITERAL from its exit arm. The result is freed at the caller's
|
||||
/// scope-close, which (after mir.4 deletes the `!is_str` carve-out on
|
||||
/// the loop-result drop path) `ailang_rc_dec`s it — so the exit-arm
|
||||
/// literal "result" must be Heap-promoted, or the program SIGSEGVs on a
|
||||
/// header-less static. This pin would crash (non-zero exit) if the
|
||||
/// exit-arm tail promotion regressed. stdout `result`, `live=0`.
|
||||
#[test]
|
||||
fn alloc_rc_str_loop_static_exit_literal_does_not_leak() {
|
||||
let (allocs, frees, live, stdout) =
|
||||
build_run_stats("loop_str_static_exit_no_leak_pin.ail");
|
||||
assert_eq!(
|
||||
stdout.trim_end(),
|
||||
"result",
|
||||
"static-exit loop result mis-built (allocs={allocs} frees={frees} live={live})"
|
||||
);
|
||||
assert_eq!(
|
||||
live, 0,
|
||||
"a loop returning a static Str literal leaks {live} slab(s) \
|
||||
(allocs={allocs} frees={frees}); the exit-arm literal must be \
|
||||
Heap-promoted (StrRep::Heap) so the scope-close dec frees a real \
|
||||
slab rather than UB-dec'ing a header-less static."
|
||||
);
|
||||
assert_eq!(
|
||||
allocs, frees,
|
||||
"static-exit leg alloc/free mismatch (allocs={allocs} frees={frees} live={live})"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user