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:
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"iter_id": "mir.4",
|
||||
"date": "2026-06-01",
|
||||
"mode": "standard",
|
||||
"outcome": "DONE",
|
||||
"tasks_total": 5,
|
||||
"tasks_completed": 5,
|
||||
"reloops_per_task": { "1": 0, "2": 0, "3": 0, "4": 0, "5": 0 },
|
||||
"review_loops_spec": 0,
|
||||
"review_loops_quality": 0,
|
||||
"blocked_reason": null,
|
||||
"notes": "The implement-orchestrator agent completed Tasks 1-2 (producer Heap-promotion of seed + recur-arg literals, codegen Heap leg, recur dec-gate !is_str deletion) clean and correctly BLOCKED on Task 3, surfacing a genuine spec defect: the plan/spec scoped the !is_str deletion to the recur dec gate ONLY (drop.rs:493 'stays conservative'), but #49's live=0 is unreachable that way — the agent showed Tasks 1-2 took #49 from live=3 to live=1, the residual being the loop-RESULT slab. Orchestrator verified the diagnosis empirically: (a) deleting drop.rs:493's !is_str took #49 and the recur-literal fixture to live=0; (b) print is a BORROWING consumer (`(let s (show x) (do io/print_str s))`), so the loop result is freed at the caller's scope-close (drop.rs:493), not by print — the scoping premise ('consumed by print') was wrong; (c) deleting drop.rs:493 WITHOUT exit-arm promotion SIGSEGVs (exit 139) on a loop returning a static Str literal — confirming a third promotion position is needed. Orchestrator corrected the spec refinement note (both gates; three promotion positions: seed + recur-arg + exit-arm tail) and completed the loop-result leg inline (context already fully loaded from the BLOCK diagnosis + empirical verification, per CLAUDE.md 'already loaded context' carve-out): added promote_tail_str_literals (tail walk over If/Let/LetRec/Match/Seq/ReuseAs), gated on a Str-returning loop; deleted drop.rs:493 !is_str; created examples/loop_str_static_exit_no_leak_pin.ail; lifted the #49 #[ignore]; added recur-literal + static-exit runtime pins + an exit-arm producer pin. 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). All four leak fixtures live=0 (#49 xyyy, recur-literal reset, static-exit result, ADT guard 2); ir_snapshot no drift; no lockstep pair touched. Durable lesson: 'consumed by print' != 'freed by print' — print borrows; a loop result's owner is the caller's binder. The single-gate scoping was the planning-time error the agent's empirical BLOCK caught."
|
||||
}
|
||||
@@ -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})"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,6 +191,46 @@ fn classify_callee(ctx: &Ctx, callee: &Term) -> CalleeClass {
|
||||
CalleeClass::Indirect
|
||||
}
|
||||
|
||||
/// 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")
|
||||
}
|
||||
|
||||
/// Promote every `Str` literal in a **tail (result) position** of a
|
||||
/// `Str`-returning loop body to `StrRep::Heap`. The loop result flows to
|
||||
/// the caller's let-binder, whose scope-close dec (codegen's loop-result
|
||||
/// trackability path, `drop.rs`) frees it once the `!is_str` carve-out
|
||||
/// there is gone — and dec'ing a header-less static literal is UB. So a
|
||||
/// bare `Str` literal in an exit arm must become an owned heap slab,
|
||||
/// mirroring the seed / recur-arg promotion. `Recur` is not a result
|
||||
/// position (it loops back), so it is not descended; a nested `Loop`
|
||||
/// tail was already promoted when that inner loop was lowered. Every
|
||||
/// other tail leaf is already owned-heap (a promoted binder `Var`),
|
||||
/// heap-returning (`str_concat` / a call), or a non-`Str` value.
|
||||
fn promote_tail_str_literals(t: &mut MTerm) {
|
||||
match t {
|
||||
MTerm::Str { rep, .. } => *rep = StrRep::Heap,
|
||||
MTerm::If { then, else_, .. } => {
|
||||
promote_tail_str_literals(then);
|
||||
promote_tail_str_literals(else_);
|
||||
}
|
||||
MTerm::Let { body, .. } => promote_tail_str_literals(body),
|
||||
MTerm::LetRec { in_term, .. } => promote_tail_str_literals(in_term),
|
||||
MTerm::Match { arms, .. } => {
|
||||
for a in arms.iter_mut() {
|
||||
promote_tail_str_literals(&mut a.body);
|
||||
}
|
||||
}
|
||||
MTerm::Seq { rhs, .. } => promote_tail_str_literals(rhs),
|
||||
MTerm::ReuseAs { body, .. } => promote_tail_str_literals(body),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lower one term to `MTerm`, filling `ty` from `synth_pure`.
|
||||
fn lower_term(ctx: &mut Ctx, t: &Term) -> Result<MTerm> {
|
||||
let ty = ctx.synth_pure(t)?;
|
||||
@@ -367,10 +407,22 @@ fn lower_term(ctx: &mut Ctx, t: &Term) -> Result<MTerm> {
|
||||
Term::Loop { binders, body } => {
|
||||
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: lower_term(ctx, &b.init)?,
|
||||
init,
|
||||
});
|
||||
}
|
||||
let saved_locals = ctx.locals.clone();
|
||||
@@ -380,16 +432,46 @@ fn lower_term(ctx: &mut Ctx, t: &Term) -> Result<MTerm> {
|
||||
ctx.locals.insert(b.name.clone(), b.ty.clone());
|
||||
}
|
||||
ctx.loop_stack.push(frame);
|
||||
let m_body = Box::new(lower_term(ctx, body)?);
|
||||
let mut m_body = lower_term(ctx, body)?;
|
||||
ctx.loop_stack.pop();
|
||||
ctx.locals = saved_locals;
|
||||
MTerm::Loop { binders: m_binders, body: m_body, ty }
|
||||
// mir.4: a `Str` literal in a tail (result) position of the
|
||||
// loop body must be an owned heap slab too — the loop result
|
||||
// flows to the caller's let-binder whose scope-close dec
|
||||
// frees it (once codegen's loop-result `!is_str` carve-out is
|
||||
// gone). Only meaningful when the loop returns `Str`.
|
||||
if is_str_ty(&ty) {
|
||||
promote_tail_str_literals(&mut m_body);
|
||||
}
|
||||
MTerm::Loop { binders: m_binders, body: Box::new(m_body), ty }
|
||||
}
|
||||
|
||||
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()
|
||||
.map(|a| Ok(arg(lower_term(ctx, a)?)))
|
||||
.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 }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -478,23 +478,24 @@ impl<'a> Emitter<'a> {
|
||||
}
|
||||
MTerm::Loop { .. } => {
|
||||
// Loop result is owned-and-untracked (seeds are moved in).
|
||||
// Track iff its static type is boxed/heap (llvm `ptr`) AND not
|
||||
// Str. Str is excluded: a loop can return a *static* Str (a seed
|
||||
// literal threaded unchanged), and `ailang_rc_dec` on a static-Str
|
||||
// pointer is UB (see drop.rs Str-realisation note). An Own-App can
|
||||
// never return a static Str, which is why the App arm may track Str
|
||||
// but the Loop arm must not. Unboxed primitives (Int/Bool/Float/Unit)
|
||||
// lower to non-`ptr` and are correctly excluded by the `ptr` gate.
|
||||
// Track iff its static type is boxed/heap (llvm `ptr`).
|
||||
// mir.4 removed the former `Str` carve-out here: a loop
|
||||
// that returns `Str` now always returns an owned heap
|
||||
// slab, because `lower_to_mir` promotes every `Str`
|
||||
// literal in a loop-carried position — seed inits, recur
|
||||
// args, AND tail (exit-arm) result literals — to
|
||||
// `StrRep::Heap` (codegen `str_clone`s each into a fresh
|
||||
// `rc_header` slab). So `ailang_rc_dec` on a loop-`Str`
|
||||
// result is sound; the static-literal UB that motivated
|
||||
// the carve-out cannot reach a loop result. Unboxed
|
||||
// primitives (Int/Bool/Float/Unit) lower to non-`ptr` and
|
||||
// are correctly excluded by the `ptr` gate.
|
||||
let t = value.ty();
|
||||
let is_ptr = matches!(
|
||||
crate::synth::llvm_type(&t).as_deref(),
|
||||
Ok("ptr")
|
||||
);
|
||||
let is_str = matches!(
|
||||
&t,
|
||||
Type::Con { name, .. } if name == "Str"
|
||||
);
|
||||
is_ptr && !is_str
|
||||
is_ptr
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
use ailang_core::ast::*;
|
||||
use ailang_core::Workspace;
|
||||
use ailang_mir::{Callee, MArg, MTerm, MirDef, MirWorkspace, Mode};
|
||||
use ailang_mir::{Callee, MArg, MTerm, MirDef, MirWorkspace, Mode, StrRep};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
mod drop;
|
||||
@@ -1671,22 +1671,37 @@ impl<'a> Emitter<'a> {
|
||||
Literal::Unit => ("0".into(), "i8".into()),
|
||||
Literal::Float { bits } => (format!("0x{:016X}", bits), "double".into()),
|
||||
}),
|
||||
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).
|
||||
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
|
||||
Ok((
|
||||
format!(
|
||||
let gep = format!(
|
||||
"getelementptr inbounds (<{{ i64, [{total} x i8] }}>, ptr @{g}, i32 0, i32 0)",
|
||||
),
|
||||
"ptr".into(),
|
||||
))
|
||||
);
|
||||
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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
MTerm::Var { name, .. } => {
|
||||
// Floats iter 4.5: bare-value Float constants resolve
|
||||
@@ -2218,21 +2233,20 @@ impl<'a> Emitter<'a> {
|
||||
// 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.
|
||||
// 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"));
|
||||
let is_str = matches!(
|
||||
ail_ty,
|
||||
Type::Con { name, .. } if name == "Str"
|
||||
);
|
||||
if matches!(self.alloc, AllocStrategy::Rc) && is_ptr && !is_str {
|
||||
if matches!(self.alloc, AllocStrategy::Rc) && is_ptr {
|
||||
// Load the prior value sitting in the alloca and
|
||||
// guard against the own->own same-pointer case:
|
||||
// a `recur` that threads the SAME buffer back
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
(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)))))
|
||||
@@ -0,0 +1,19 @@
|
||||
(module loop_str_static_exit_no_leak_pin
|
||||
; mir.4 (loop-result leg, soundness witness): a loop that RETURNS a
|
||||
; static Str LITERAL from its exit arm. The result flows to the outer
|
||||
; let-binder `s`, freed at scope-close via codegen's loop-result drop
|
||||
; path. Before mir.4 that path excluded Str (the !is_str carve-out at
|
||||
; drop.rs); deleting the carve-out without promoting the exit literal
|
||||
; would `ailang_rc_dec` a header-less static — SIGSEGV. lower_to_mir
|
||||
; promotes the tail literal "result" to StrRep::Heap so codegen
|
||||
; str_clones it into an owned heap slab; scope-close then frees it.
|
||||
; Single Int binder (no Str binder to leak). allocs=1 frees=1 live=0.
|
||||
; Expected stdout: result.
|
||||
(fn main (type (fn-type (params) (ret (con Unit)) (effects IO))) (params)
|
||||
(body
|
||||
(let s
|
||||
(loop (i (con Int) 0)
|
||||
(if (app ge i 1)
|
||||
"result"
|
||||
(recur (app + i 1))))
|
||||
(app print s)))))
|
||||
Reference in New Issue
Block a user