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:
@@ -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!(
|
||||
"getelementptr inbounds (<{{ i64, [{total} x i8] }}>, ptr @{g}, i32 0, i32 0)",
|
||||
),
|
||||
"ptr".into(),
|
||||
))
|
||||
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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user