fix(codegen): dec superseded heap loop-binder values across recur (ADT leg)
A heap value threaded through a `(loop ...)`/`recur` as a loop binder
leaked: the `Term::Recur` arm stored each new arg into the binder's
loop-carried alloca with a plain `store` and never RC-dec'd the heap
value already in that slot, so every iteration's superseded value was
lost. The scope-close drop path (drop.rs, commit 8bcdae1) only ever
sees the loop's FINAL result, never the intermediates.
The recur store now decs the prior value before overwriting it, gated
by the SAME predicate the scope-close path uses — RC-heap AND lowers
to `ptr` AND not `Str`. The binder slot tuple is widened to carry the
binder's AILang type so the gate and the typed drop-symbol lookup
(`field_drop_call`) are available at the store. A same-pointer guard
(`icmp eq prior, new`) skips the dec when a `recur` threads the
identical pointer back — the own->own case (RawBuf fill loops, where
`RawBuf.set` mutates in place), so a retained buffer is never freed.
Scope: the boxed-ADT leg only. Every ADT value is heap-allocated, so
dec'ing a superseded ADT binder is unambiguously UB-free. A `Str`
accumulator is deliberately NOT covered: `Str` literals lower to
constexpr-GEPs into static globals (no rc_header), so a `Str` loop
seed may be static and dec'ing it would be UB. That is the same
static-vs-heap-`Str` obstacle behind 8bcdae1's deliberate `Str`
exclusion; the `Str` accumulator leak remains open in #49 pending a
runtime representation decision.
RED test in crates/ail/tests/loop_recur_heap_binder_no_leak_pin.rs
threads a boxed `Cell` through three recurs: pre-fix allocs=5 frees=2
live=3, post-fix live=0, stdout 2. Verified: zero-recur ADT control
live=0 (no spurious dec), Int loops (isqrt/collatz/gcd) live=0
(non-ptr, no dec), RawBuf fill loops live=0 out 35/16 (same-pointer
guard), Str accumulator unchanged (live=3, no crash).
refs #49
This commit is contained in:
@@ -884,13 +884,16 @@ struct Emitter<'a> {
|
||||
binder_allocas: BTreeMap<String, (String, Type)>,
|
||||
/// loop-recur iter 3: stack of enclosing `Term::Loop` codegen
|
||||
/// frames. Each frame is `(header_block_label, binder_slots)`
|
||||
/// where `binder_slots` is `(binder_name, alloca_ssa, llvm_ty)`
|
||||
/// in declaration order. Pushed on `Term::Loop` body entry,
|
||||
/// popped on exit; `Term::Recur` reads `.last()` for its target
|
||||
/// header + the binder allocas to store into. Saved/reset/
|
||||
/// restored at the lambda-lowering boundary alongside
|
||||
/// `binder_allocas` (a `recur` cannot cross a lambda boundary).
|
||||
loop_frames: Vec<(String, Vec<(String, String, String)>)>,
|
||||
/// where `binder_slots` is `(binder_name, alloca_ssa, llvm_ty,
|
||||
/// ail_ty)` in declaration order. Pushed on `Term::Loop` body
|
||||
/// entry, popped on exit; `Term::Recur` reads `.last()` for its
|
||||
/// target header + the binder allocas to store into. The `ail_ty`
|
||||
/// component (bug #49) lets the recur store dec the superseded
|
||||
/// prior value of an RC-heap-non-Str binder before overwriting
|
||||
/// it. Saved/reset/restored at the lambda-lowering boundary
|
||||
/// alongside `binder_allocas` (a `recur` cannot cross a lambda
|
||||
/// boundary).
|
||||
loop_frames: Vec<(String, Vec<(String, String, String, Type)>)>,
|
||||
/// Side buffer for `alloca` instructions emitted during body
|
||||
/// lowering but hoisted to the fn's entry block. Flushed once
|
||||
/// into `self.body` at `entry_block_end_marker` after
|
||||
@@ -2040,7 +2043,7 @@ impl<'a> Emitter<'a> {
|
||||
let id = self.fresh_id();
|
||||
let header = format!("loop.header.{id}");
|
||||
let mut saved: Vec<(String, Option<(String, Type)>)> = Vec::new();
|
||||
let mut frame_slots: Vec<(String, String, String)> = Vec::new();
|
||||
let mut frame_slots: Vec<(String, String, String, Type)> = Vec::new();
|
||||
for b in binders {
|
||||
let alloca_name =
|
||||
format!("%loopv_{}_{}", b.name, self.fresh_id());
|
||||
@@ -2061,7 +2064,7 @@ impl<'a> Emitter<'a> {
|
||||
.binder_allocas
|
||||
.insert(b.name.clone(), (alloca_name.clone(), b.ty.clone()));
|
||||
saved.push((b.name.clone(), prior));
|
||||
frame_slots.push((b.name.clone(), alloca_name, llvm_ty));
|
||||
frame_slots.push((b.name.clone(), alloca_name, llvm_ty, b.ty.clone()));
|
||||
}
|
||||
self.body.push_str(&format!(" br label %{header}\n"));
|
||||
self.loop_frames.push((header.clone(), frame_slots));
|
||||
@@ -2123,7 +2126,7 @@ impl<'a> Emitter<'a> {
|
||||
for a in args {
|
||||
lowered.push(self.lower_term(a)?);
|
||||
}
|
||||
for ((arg_ssa, arg_ty), (_name, alloca_name, llvm_ty)) in
|
||||
for ((arg_ssa, arg_ty), (_name, alloca_name, llvm_ty, ail_ty)) in
|
||||
lowered.into_iter().zip(slots.iter())
|
||||
{
|
||||
if &arg_ty != llvm_ty {
|
||||
@@ -2132,6 +2135,56 @@ impl<'a> Emitter<'a> {
|
||||
— typecheck should have rejected this earlier"
|
||||
)));
|
||||
}
|
||||
// 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 {
|
||||
// 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
|
||||
// (e.g. RawBuf fill loops, `RawBuf.set` mutating
|
||||
// in place) rebinds to an identical pointer, and
|
||||
// dec'ing it would free a live buffer. Skip the
|
||||
// dec exactly when prior == new; otherwise the
|
||||
// superseded distinct allocation drops once.
|
||||
let prior = self.fresh_ssa();
|
||||
self.body.push_str(&format!(
|
||||
" {prior} = load ptr, ptr {alloca_name}, align 8\n"
|
||||
));
|
||||
let same = self.fresh_ssa();
|
||||
self.body.push_str(&format!(
|
||||
" {same} = icmp eq ptr {prior}, {arg_ssa}\n"
|
||||
));
|
||||
let id = self.fresh_id();
|
||||
let do_dec = format!("recur.dec.{id}");
|
||||
let after = format!("recur.after.{id}");
|
||||
self.body.push_str(&format!(
|
||||
" br i1 {same}, label %{after}, label %{do_dec}\n"
|
||||
));
|
||||
self.start_block(&do_dec);
|
||||
let drop_call = self.field_drop_call(ail_ty);
|
||||
self.body.push_str(&format!(
|
||||
" call void @{drop_call}(ptr {prior})\n"
|
||||
));
|
||||
self.body.push_str(&format!(" br label %{after}\n"));
|
||||
self.start_block(&after);
|
||||
}
|
||||
self.body.push_str(&format!(
|
||||
" store {llvm_ty} {arg_ssa}, ptr {alloca_name}\n"
|
||||
));
|
||||
|
||||
Reference in New Issue
Block a user