Files
AILang/crates/ailang-codegen/src/drop.rs
T
Brummel 8bcdae1b53 fix(codegen): drop a let-bound owned loop result at scope close
An owned heap value whose `let`-binding value is a `(loop ...)` result,
afterwards only borrow-read (or unused), was never dropped at scope close
— a memory leak (AILANG_RC_STATS live=1). Newly observable once #47 made
fill-loops build: rawbuf_2_running_max leaked one buffer per run.

Root cause: is_rc_heap_allocated — the predicate the let-lowering uses to
decide a scope-close `dec` — matched only Term::Ctor, Term::Lam and
Own-returning Term::App. A Term::Loop-valued binder fell through to false,
so it was never registered for drop. (Sibling of the #47 synth gap: the
Term::Loop arm under-handled in yet another pass.)

Fix: add a Term::Loop arm that tracks the binder iff the loop's static
result type lowers to llvm `ptr` (boxed/heap) AND is not Str — and widen
drop_symbol_for_binder's App arm to cover Term::Loop so it resolves the
per-type drop symbol. Two load-bearing safety gates:
  - the `ptr` gate excludes unboxed primitives (Int/Bool/Float/Unit), so
    an Int-returning loop is not handed to a drop fn;
  - Str is excluded because a loop can return a *static* Str (a seed
    literal threaded unchanged) and ailang_rc_dec on a static-Str pointer
    is UB; an Own-App can never return a static Str, which is why the App
    arm may track Str but the Loop arm must not.
The loop's seed binders are consumed (moved in), so nothing else tracks
the result; linearity guarantees no alias, so the new drop is single.

Verified leak-clean and double-free-free across the fieldtest RawBuf set,
the consumed case, the Int-gate case, and the escape case (a fn returning
a loop-built owned RawBuf to its caller).

The separate per-iteration leak of superseded heap loop-binder values
(e.g. a Str accumulator threaded through recur) is a distinct, broader
pre-existing bug, filed separately — not addressed here.

RED-first: raw_buf_loop_no_leak_pin.
2026-05-30 17:14:28 +02:00

995 lines
45 KiB
Rust

//! Per-type drop-fn emission and per-let-close drop dispatch.
//!
//! All RC-allocator drop work lives here:
//! - `emit_drop_fn_for_type` / `emit_iterative_drop_fn_for_type`
//! emit `@drop_<m>_<T>(ptr)` per `Def::Type` under `--alloc=rc`.
//! The recursive variant cascades through `field_drop_call`; the
//! iterative variant uses an explicit worklist (Iter 18e) so
//! `(drop-iterative)` types can free chains of arbitrary length
//! without consuming proportional C stack.
//! - `field_is_same_type` / `field_drop_call` are the internal
//! dispatch helpers consulted by the per-type drop bodies and by
//! match-arm / reuse-as drop emission elsewhere.
//! - `is_rc_heap_allocated` / `synth_callee_ret_mode` /
//! `drop_symbol_for_binder` / `emit_inlined_partial_drop` are the
//! per-let-close cluster: codegen calls them from the
//! `Term::Let` lowering to decide whether to dec, what symbol to
//! dispatch to, and (when pattern destructuring transferred
//! fields out) how to emit a partial-drop sequence inline.
//! - `build_env_drop_fn` / `build_pair_drop_fn` build the per-
//! closure drop fns that `lower_lambda` defers into the IR
//! stream (Iter 18c.4 closure cleanup).
//!
//! Methods called from `lib.rs` are `pub(crate)`; helpers used only
//! among the drop-cluster methods stay private. Field access from
//! this submodule into the parent's private `Emitter` fields works
//! through the standard descendant-module privacy lane.
use ailang_core::ast::*;
use std::collections::BTreeSet;
use super::synth::llvm_type;
use super::{AllocStrategy, Emitter, FnSig, Result};
impl<'a> Emitter<'a> {
/// emit a `void @drop_<module>_<TypeName>(ptr %p)`
/// function that decrements the refcount of every pointer-typed
/// field of every ctor, then frees the outer box.
///
/// Shape:
/// ```text
/// define void @drop_<m>_<T>(ptr %p) {
/// %tag = load i64, ptr %p
/// switch i64 %tag, label %dflt [
/// i64 0, label %arm0
/// ...
/// ]
/// arm_i:
/// for each pointer-typed field f_j:
/// %addr = gep ptr %p, i64 (8 + 8*j)
/// %v = load ptr, ptr %addr
/// call void @drop_<owner>_<FieldT>(ptr %v) ; or @ailang_rc_dec
/// br label %join
/// dflt:
/// unreachable
/// join:
/// call void @ailang_rc_dec(ptr %p)
/// ret void
/// }
/// ```
///
/// For ADTs with no boxed children every arm is empty and falls
/// straight through to `join`, which is just the final dec — see
/// the assignment's "always emit drop_X for every ADT" decision
/// (`rc_box_drop`'s `MkBox(Int)` is the canonical example).
///
/// Recursion: when a ctor field's type is the same ADT (or any
/// ADT in the workspace), the emitted call to
/// `@drop_<owner>_<T>(field)` is recursive at the IR level and
/// will overflow the stack on long lists. The 18e
/// `(drop-iterative)` annotation routes such types through
/// [`Self::emit_iterative_drop_fn_for_type`] instead, which
/// replaces the recursive call with a worklist push. ADTs WITHOUT
/// the annotation continue to use this recursive form — the
/// orchestrator's choice: opt-in iterative drop where the depth
/// is known to grow, recursive cascade everywhere else (cheaper
/// IR, no worklist allocation).
pub(crate) fn emit_drop_fn_for_type(&mut self, td: &TypeDef) {
let m = self.module_name;
let tname = &td.name;
let mut out = String::new();
out.push_str(&format!("define void @drop_{m}_{tname}(ptr %p) {{\n"));
out.push_str("entry:\n");
// Null guard: a null payload is a no-op (matches
// `runtime/rc.c::ailang_rc_dec`'s null guard).
out.push_str(" %is_null = icmp eq ptr %p, null\n");
out.push_str(" br i1 %is_null, label %ret, label %live\n");
out.push_str("live:\n");
// FROZEN ABI (embedding boundary) — see design/contracts/0003-embedding-abi.md.
out.push_str(" %tag = load i64, ptr %p, align 8\n");
let n_ctors = td.ctors.len();
// Switch over the tag. Each ctor gets one arm.
out.push_str(" switch i64 %tag, label %dflt [\n");
for (i, _) in td.ctors.iter().enumerate() {
out.push_str(&format!(" i64 {i}, label %arm_{i}\n"));
}
out.push_str(" ]\n");
// Per-ctor arm: iterate fields, dec the boxed ones.
let mut local = 0u64;
for (i, ctor) in td.ctors.iter().enumerate() {
out.push_str(&format!("arm_{i}:\n"));
for (j, fty) in ctor.fields.iter().enumerate() {
// Decide what to call for this field. If the field
// lowers to `ptr` (boxed), we issue a `dec` call.
// For known ADT field types we route through that
// ADT's own drop fn so the recursion cascades; for
// anything else that lowers to `ptr` (Str, fn-typed,
// unresolved Var), fall back to plain `ailang_rc_dec`.
let lty = llvm_type(fty).unwrap_or_else(|_| "ptr".into());
if lty != "ptr" {
continue;
}
// FROZEN ABI (embedding boundary) — see design/contracts/0003-embedding-abi.md.
let off = 8 + (j as i64) * 8;
let addr_id = local;
local += 1;
let val_id = local;
local += 1;
out.push_str(&format!(
" %a{addr_id} = getelementptr inbounds i8, ptr %p, i64 {off}\n"
));
out.push_str(&format!(
" %v{val_id} = load ptr, ptr %a{addr_id}, align 8\n"
));
let drop_call = self.field_drop_call(fty);
// Recursive call into the field's drop fn. If the
// field's type is itself `(drop-iterative)`, that drop
// fn is the worklist variant — recursion stops at one
// level. Otherwise this is the unbounded recursive
// cascade; safe only on bounded-depth ADTs (the
// `(drop-iterative)` annotation exists for the
// unbounded ones).
out.push_str(&format!(
" call void @{drop_call}(ptr %v{val_id})\n"
));
}
out.push_str(" br label %join\n");
}
// Default arm: unreachable when the typechecker has accepted
// the input — every legal box has one of the ctor tags.
out.push_str("dflt:\n");
if n_ctors == 0 {
// No ctors at all: a Type with zero ctors cannot be
// instantiated; the drop fn is dead. Still emit a
// br-to-join for IR validity.
out.push_str(" br label %join\n");
} else {
out.push_str(" unreachable\n");
}
// Join: free the outer box.
out.push_str("join:\n");
out.push_str(" call void @ailang_rc_dec(ptr %p)\n");
out.push_str(" br label %ret\n");
out.push_str("ret:\n");
out.push_str(" ret void\n");
out.push_str("}\n\n");
self.body.push_str(&out);
}
/// emit `drop_<m>_<T>` for a `(drop-iterative)` type.
/// Replaces the recursive cascade in [`Self::emit_drop_fn_for_type`]
/// with an iterative-with-explicit-worklist body so cells of
/// arbitrary chain depth can free without consuming proportional
/// C stack.
///
/// IR shape:
/// ```text
/// define void @drop_<m>_<T>(ptr %p) {
/// entry:
/// %is_null = icmp eq ptr %p, null
/// br i1 %is_null, label %ret, label %init_wl
/// init_wl:
/// %wl = call ptr @ailang_drop_worklist_new()
/// call void @ailang_drop_worklist_push(ptr %wl, ptr %p)
/// br label %loop_head
/// loop_head:
/// %cur = call ptr @ailang_drop_worklist_pop(ptr %wl)
/// %done = icmp eq ptr %cur, null
/// br i1 %done, label %finish, label %dispatch
/// dispatch:
/// %tag = load i64, ptr %cur, align 8
/// switch i64 %tag, label %dflt [
/// i64 0, label %arm_0
/// ...
/// ]
/// arm_i:
/// for each pointer-typed field f_j of ctor i:
/// %addr = gep %cur, 8 + 8*j
/// %v = load ptr, ptr %addr
/// if field type is T (same as the type being dropped):
/// call void @ailang_drop_worklist_push(ptr %wl, ptr %v)
/// else:
/// call void @drop_<owner>_<F>(ptr %v) ; or @ailang_rc_dec
/// call void @ailang_rc_dec(ptr %cur)
/// br label %loop_head
/// dflt:
/// unreachable
/// finish:
/// call void @ailang_drop_worklist_free(ptr %wl)
/// br label %ret
/// ret:
/// ret void
/// }
/// ```
///
/// Mono-typed worklist. Every pointer pushed onto `%wl` is a `T`
/// (the type being dropped). For a field whose type is `T` itself
/// → push (continues the iterative cascade). For any other ADT
/// field type `T'` → call `drop_<m'>_<T'>` directly: if `T'` is
/// also `(drop-iterative)`, that fn allocates its own worklist
/// instance (no nesting); if `T'` is non-iterative, it recurses
/// stack-wise (depth bounded by the number of *distinct* nested
/// ADTs reachable from `T`, which is small in practice).
///
/// This interpretation of the assignment's "should also use the
/// worklist" clause was chosen because a heterogeneously-typed
/// worklist would require storing a (ptr, drop-handler) tuple per
/// entry plus a vtable dispatch on pop — significant complexity
/// for the case where two distinct ADTs are mutually recursive
/// AND both are drop-iterative AND the chain is millions deep.
/// That triple-conjunct is not on the 18-arc's critical path; if
/// it surfaces in practice, a follow-up iter can extend the
/// worklist entry shape. The mono-typed version captures the
/// stack-overflow-on-long-self-chains problem fully.
pub(crate) fn emit_iterative_drop_fn_for_type(&mut self, td: &TypeDef) {
let m = self.module_name;
let tname = &td.name;
let mut out = String::new();
out.push_str(&format!("define void @drop_{m}_{tname}(ptr %p) {{\n"));
out.push_str("entry:\n");
// Null guard — symmetric with the recursive variant. A null
// payload skips worklist allocation entirely.
out.push_str(" %is_null = icmp eq ptr %p, null\n");
out.push_str(" br i1 %is_null, label %ret, label %init_wl\n");
out.push_str("init_wl:\n");
out.push_str(" %wl = call ptr @ailang_drop_worklist_new()\n");
out.push_str(
" call void @ailang_drop_worklist_push(ptr %wl, ptr %p)\n",
);
out.push_str(" br label %loop_head\n");
out.push_str("loop_head:\n");
out.push_str(
" %cur = call ptr @ailang_drop_worklist_pop(ptr %wl)\n",
);
out.push_str(" %done = icmp eq ptr %cur, null\n");
out.push_str(" br i1 %done, label %finish, label %dispatch\n");
out.push_str("dispatch:\n");
out.push_str(" %tag = load i64, ptr %cur, align 8\n");
let n_ctors = td.ctors.len();
out.push_str(" switch i64 %tag, label %dflt [\n");
for (i, _) in td.ctors.iter().enumerate() {
out.push_str(&format!(" i64 {i}, label %arm_{i}\n"));
}
out.push_str(" ]\n");
// Per-ctor arms. For each pointer-typed field decide push vs
// direct call based on whether the field's type is the same
// as the type being dropped.
let mut local = 0u64;
for (i, ctor) in td.ctors.iter().enumerate() {
out.push_str(&format!("arm_{i}:\n"));
for (j, fty) in ctor.fields.iter().enumerate() {
let lty = llvm_type(fty).unwrap_or_else(|_| "ptr".into());
if lty != "ptr" {
continue;
}
let off = 8 + (j as i64) * 8;
let addr_id = local;
local += 1;
let val_id = local;
local += 1;
out.push_str(&format!(
" %a{addr_id} = getelementptr inbounds i8, ptr %cur, i64 {off}\n"
));
out.push_str(&format!(
" %v{val_id} = load ptr, ptr %a{addr_id}, align 8\n"
));
if self.field_is_same_type(fty, &td.name) {
// Same-type field: push onto the worklist —
// continues the iterative cascade. Null-guarding
// is handled inside `ailang_drop_worklist_push`
// itself (skips null payloads).
out.push_str(&format!(
" call void @ailang_drop_worklist_push(ptr %wl, ptr %v{val_id})\n"
));
} else {
// Different-type field: dispatch to that type's
// own drop fn (which itself decides recursive vs.
// iterative). `field_drop_call` resolves the
// symbol; its null-guard semantics are the same
// as the recursive variant.
let drop_call = self.field_drop_call(fty);
out.push_str(&format!(
" call void @{drop_call}(ptr %v{val_id})\n"
));
}
}
// Dec the outer cell. Worklist holds no other reference
// to this pointer (push happened exactly once on the
// parent's cascade, and pop just removed that entry), so
// the cell's refcount drops by exactly one here. Children
// pushed above keep their own refcounts pending until
// their loop iteration.
out.push_str(" call void @ailang_rc_dec(ptr %cur)\n");
out.push_str(" br label %loop_head\n");
}
// Default arm: unreachable when the typechecker has accepted
// the input. Same shape as the recursive variant.
out.push_str("dflt:\n");
if n_ctors == 0 {
out.push_str(" br label %finish\n");
} else {
out.push_str(" unreachable\n");
}
out.push_str("finish:\n");
out.push_str(" call void @ailang_drop_worklist_free(ptr %wl)\n");
out.push_str(" br label %ret\n");
out.push_str("ret:\n");
out.push_str(" ret void\n");
out.push_str("}\n\n");
self.body.push_str(&out);
}
/// is `fty` the same ADT as `td_name` in the
/// current module? Used by the iterative-drop body to decide
/// "push to worklist" (same type) vs. "call its drop fn directly"
/// (different type).
///
/// Returns `true` only when the field type is a `Type::Con`
/// referencing `td_name` AND the reference resolves to the
/// current module (bare or qualified-but-self). Qualified names
/// pointing at *other* modules are different types — even when
/// they spell the same suffix. Type-vars and fn-types are never
/// the same as the ADT being dropped (parametric self-recursion
/// could bind a var to T, but the bound is invisible at codegen
/// since we don't monomorphise drop fns).
fn field_is_same_type(&self, fty: &Type, td_name: &str) -> bool {
match fty {
Type::Con { name, .. } => {
if name.matches('.').count() == 1 {
let (prefix, suffix) = name.split_once('.').expect("checked");
if suffix != td_name {
return false;
}
// Resolve the prefix; same-module iff the resolved
// target equals self.module_name.
let target = self
.import_map
.get(prefix)
.map(|s| s.as_str())
.unwrap_or(prefix);
target == self.module_name
} else {
name == td_name
}
}
_ => false,
}
}
/// pick the drop-fn symbol to call for a single
/// pointer-typed field. Routes ADT fields to their own
/// `drop_<owner>_<T>` symbol so the recursion cascades through
/// recursive types (List, Tree). Falls back to `ailang_rc_dec`
/// for non-ADT pointer types (Str, fn-typed, unresolved Var) —
/// those are not user-defined ADTs and have no per-type drop fn.
pub(crate) fn field_drop_call(&self, fty: &Type) -> String {
match fty {
Type::Con { name, .. } => {
// Built-in pointer-typed cons: Str. No drop fn —
// shallow `ailang_rc_dec` is the right answer.
// Str has two realisations sharing the consumer
// ABI (len at offset 0, bytes at offset 8):
// - heap-Str: malloc'd slab with real rc_header
// at `payload - 8`; rc_dec is the correct
// refcount-and-free path.
// - static-Str: packed-struct LLVM global
// <{ i64, [N x i8] }> in .rodata, no rc_header
// slot; rc_dec would read undefined bytes at
// `payload - 8`. Codegen-level elision
// (`emit_inlined_partial_drop` move-tracking
// from iter 18d.3 + non-escape lowering from
// iter 18b) keeps static-Str pointers out of
// this call along every shipping execution
// path. The codegen-level invariant is the
// protection; no runtime guard backs it up.
if matches!(name.as_str(), "Str") {
return "ailang_rc_dec".to_string();
}
// Qualified `module.T` → drop fn lives in `module`.
if name.matches('.').count() == 1 {
let (prefix, suffix) = name.split_once('.').expect("checked");
if let Some(target) = self.import_map.get(prefix) {
return format!("drop_{target}_{suffix}");
}
// Fallback: treat the prefix itself as the owner
// module (typechecker would have rejected an
// unimported prefix earlier).
return format!("drop_{prefix}_{suffix}");
}
// Bare name: declared in the current module.
format!("drop_{m}_{name}", m = self.module_name)
}
// Type::Fn (closure-typed field) → no per-type drop fn
// exists for closures (each closure has its own per-pair
// drop fn keyed by the lam-id, not by type). Iter 18c.4
// shallow-frees the closure-pair; the env and any
// captured ADT fields leak. A future iter that types
// closure-typed fields with a runtime descriptor pointer
// would close this. For 18c.4's recursive-ADT story this
// is acceptable — the shipping fixtures don't store
// closures inside ADT fields.
Type::Fn { .. } => "ailang_rc_dec".to_string(),
// Type::Var (parameterised ADT field whose type-arg was
// not pinned at declaration): we don't know the field's
// concrete shape from the static decl alone, and the
// generated drop fn is a single symbol per ADT (no
// monomorphisation). Shallow free is the conservative
// choice — boxed children of polymorphic-typed fields
// leak. Iter 18d/18e will revisit this when reuse hints
// and the worklist allocator land.
Type::Var { .. } | Type::Forall { .. } => "ailang_rc_dec".to_string(),
}
}
/// predicate the `Term::Let` lowering uses to decide
/// whether a let-binder owns a fresh RC-heap allocation that
/// codegen should `dec` at scope close.
///
/// Returns `true` exactly when:
/// - the active allocator is `Rc`,
/// - `value` is a `Term::Ctor` or `Term::Lam` (the two AST shapes
/// that lower through the heap-allocation path), AND
/// - the term is *not* in the current fn's `non_escape` set —
/// escaping ctors/lambdas go through `ailang_rc_alloc`,
/// non-escaping ones become stack `alloca`s and must NOT be
/// `dec`'d (they are freed by LLVM at fn return).
///
/// The widened input set includes `Term::App` whose callee's
/// fn-type carries `ret_mode == Own`. The mode contract states that
/// the callee hands the returned cell's ownership to the caller's
/// frame; the let-scope close is the right place for the caller's
/// dec. Calls whose callee is `Borrow`/`Implicit`-returning are
/// still not trackable — they don't carry that signal.
///
/// Other value shapes (vars, literals, matches, …) return `false`
/// here. A `Term::Var` returning an RC-allocated box would already
/// be tracked by an earlier let-binder; tracking it again here
/// would double-dec.
pub(crate) fn is_rc_heap_allocated(&self, value: &Term) -> bool {
if !matches!(self.alloc, AllocStrategy::Rc) {
return false;
}
match value {
Term::Ctor { .. } | Term::Lam { .. } => {
let term_ptr = (value as *const Term) as usize;
!self.non_escape.contains(&term_ptr)
}
Term::App { callee, .. } => {
// a call whose callee carries
// `ret_mode == Own` hands a fresh heap allocation to
// the caller's frame. Trackable. `Borrow` and
// `Implicit` ret-modes do not carry that signal —
// returning by Borrow is a view into the callee's
// owned data (caller does not own it), and Implicit
// is the back-compat lane that 18c.3's debt covers.
self.synth_callee_ret_mode(callee)
.map(|m| matches!(m, ParamMode::Own))
.unwrap_or(false)
}
Term::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.
match self.synth_arg_type(value) {
Ok(t) => {
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
}
Err(_) => false,
}
}
_ => false,
}
}
/// lookup helper for a callee's `ret_mode`. Returns
/// `Some(mode)` when the callee resolves to a fn-typed term;
/// `None` for shapes whose type is not a `Type::Fn` (typechecker
/// would already have rejected an App on a non-fn, but the helper
/// is defensive). Used by [`Self::is_rc_heap_allocated`] and the
/// [`Self::drop_symbol_for_binder`] App-arm to decide both
/// trackability and the drop-fn symbol.
fn synth_callee_ret_mode(&self, callee: &Term) -> Option<ParamMode> {
let cty = self.synth_arg_type(callee).ok()?;
match cty {
Type::Fn { ret_mode, .. } => Some(ret_mode),
_ => None,
}
}
/// pick the drop-fn symbol to call at the close of a
/// trackable `Term::Let` scope. For a `Term::Ctor` binder the
/// symbol is `drop_<owner>_<TypeName>` — derived from the ctor's
/// `type_name` (which already encodes the owning module via the
/// `module.T` form when cross-module). For a `Term::Lam` binder
/// the symbol comes from `closure_drops`, populated by
/// [`Self::lower_lambda`] when it emitted the per-pair drop fn.
///
/// Falls back to `ailang_rc_dec` for any other shape — should be
/// unreachable since `is_rc_heap_allocated` only returns `true`
/// for `Term::Ctor` / `Term::Lam`, but a defensive fallback
/// keeps the IR well-formed even if the predicate ever widens.
pub(crate) fn drop_symbol_for_binder(&self, value: &Term, val_ssa: &str) -> String {
match value {
Term::Ctor { type_name, .. } => {
if type_name.matches('.').count() == 1 {
let (prefix, suffix) =
type_name.split_once('.').expect("checked");
if let Some(target) = self.import_map.get(prefix) {
return format!("drop_{target}_{suffix}");
}
return format!("drop_{prefix}_{suffix}");
}
format!("drop_{m}_{type_name}", m = self.module_name)
}
Term::Lam { .. } => self
.closure_drops
.get(val_ssa)
.cloned()
.unwrap_or_else(|| "ailang_rc_dec".to_string()),
// an Own-returning call hands a freshly heap-
// allocated cell whose static type is the callee's
// `ret`. Resolve the per-type drop fn from that ret-type
// so the cascade walks the cell's pointer-typed children
// (e.g. an Own-returned `Tree` fans out via
// `drop_<m>_<Tree>`). Falls back to `ailang_rc_dec` if
// the ret-type is not a `Type::Con` (e.g. a bare type
// var on an as-yet-unmonomorphised polymorphic call —
// the monomorphised copies will resolve correctly).
Term::App { .. } | Term::Loop { .. } => {
if let Ok(Type::Con { name, .. }) = self.synth_arg_type(value) {
// Symmetric to `field_drop_call`'s Str arm: Str is a
// built-in pointer type with no per-type drop fn. Both
// heap-Str (rc_header at payload-8) and static-Str
// (codegen-elision keeps static pointers out of this
// path) consume via `ailang_rc_dec`.
if name == "Str" {
return "ailang_rc_dec".to_string();
}
if name.matches('.').count() == 1 {
let (prefix, suffix) =
name.split_once('.').expect("checked");
if let Some(target) = self.import_map.get(prefix) {
return format!("drop_{target}_{suffix}");
}
return format!("drop_{prefix}_{suffix}");
}
return format!("drop_{m}_{name}", m = self.module_name);
}
"ailang_rc_dec".to_string()
}
_ => "ailang_rc_dec".to_string(),
}
}
/// tag-conditional partial-drop helper.
/// Emits `void @partial_drop_<m>_<T>(ptr %p, i64 %mask)` —
/// structurally parallel to [`Self::emit_drop_fn_for_type`] but
/// gates each ptr-field dec on a bit of `%mask`. If bit j of the
/// mask is set, slot j was moved out of the cell (its content
/// belongs to a downstream owner) and the dec is skipped; if the
/// bit is clear, the field is loaded and routed through
/// `field_drop_call` (same dispatch the recursive cascade uses).
/// The outer box is always dec'd at `join`.
///
/// Closes the three dynamic-tag carve-outs that previously
/// fell back to shallow `ailang_rc_dec`:
/// 1. `lib.rs` Iter B Own-param dec at fn-return when
/// `moved_slots[param]` is non-empty;
/// 2. `match_lower.rs` Iter A arm-close pattern-binder dec
/// when the binder was itself the scrutinee of an inner
/// match that moved out some fields;
/// 3. [`Self::emit_inlined_partial_drop`]'s non-Ctor branch
/// (let-binder whose value is `Term::App`).
///
/// All three share the same shape: a binder whose runtime ctor
/// tag is dynamic (not statically known from a `Term::Ctor`)
/// and whose `moved_slots` is a strict subset of its ptr fields.
/// Pre-fu2 the unmoved fields leaked silently; fu2 routes them
/// through this helper.
///
/// IR shape (analogous to `emit_drop_fn_for_type`):
/// ```text
/// define void @partial_drop_<m>_<T>(ptr %p, i64 %mask) {
/// %is_null = icmp eq ptr %p, null
/// br i1 %is_null, label %ret, label %live
/// live:
/// %tag = load i64, ptr %p
/// switch i64 %tag, label %dflt [...]
/// arm_i:
/// ; for each ptr field f_j of ctor i:
/// %b_j = and i64 %mask, (1 << j)
/// %s_j = icmp ne i64 %b_j, 0
/// br i1 %s_j, label %after_i_j, label %do_i_j
/// do_i_j:
/// %a = gep i8, ptr %p, (8 + 8*j)
/// %v = load ptr, ptr %a
/// call void @<field_drop_call>(ptr %v)
/// br label %after_i_j
/// after_i_j:
/// ; next field, or br label %join
/// dflt: unreachable
/// join:
/// call void @ailang_rc_dec(ptr %p)
/// br label %ret
/// ret:
/// ret void
/// }
/// ```
///
/// One helper per ADT, emitted alongside `drop_<m>_<T>`. We do
/// NOT emit a `(drop-iterative)` partial-drop variant: the
/// helper runs once on the binder (carve-out sites are not
/// cascade points — the unmoved fields go through their own
/// `drop_<m>_<F>` which itself decides recursive vs iterative).
pub(crate) fn emit_partial_drop_fn_for_type(&mut self, td: &TypeDef) {
let m = self.module_name;
let tname = &td.name;
let mut out = String::new();
out.push_str(&format!(
"define void @partial_drop_{m}_{tname}(ptr %p, i64 %mask) {{\n"
));
out.push_str("entry:\n");
out.push_str(" %is_null = icmp eq ptr %p, null\n");
out.push_str(" br i1 %is_null, label %ret, label %live\n");
out.push_str("live:\n");
out.push_str(" %tag = load i64, ptr %p, align 8\n");
let n_ctors = td.ctors.len();
out.push_str(" switch i64 %tag, label %dflt [\n");
for (i, _) in td.ctors.iter().enumerate() {
out.push_str(&format!(" i64 {i}, label %arm_{i}\n"));
}
out.push_str(" ]\n");
let mut local = 0u64;
for (i, ctor) in td.ctors.iter().enumerate() {
out.push_str(&format!("arm_{i}:\n"));
for (j, fty) in ctor.fields.iter().enumerate() {
let lty = llvm_type(fty).unwrap_or_else(|_| "ptr".into());
if lty != "ptr" {
continue;
}
let bit_id = local;
local += 1;
let set_id = local;
local += 1;
let bitmask: u64 = 1u64 << j;
out.push_str(&format!(
" %b{bit_id} = and i64 %mask, {bitmask}\n"
));
out.push_str(&format!(
" %s{set_id} = icmp ne i64 %b{bit_id}, 0\n"
));
out.push_str(&format!(
" br i1 %s{set_id}, label %after_{i}_{j}, label %do_{i}_{j}\n"
));
out.push_str(&format!("do_{i}_{j}:\n"));
let off = 8 + (j as i64) * 8;
let addr_id = local;
local += 1;
let val_id = local;
local += 1;
out.push_str(&format!(
" %a{addr_id} = getelementptr inbounds i8, ptr %p, i64 {off}\n"
));
out.push_str(&format!(
" %v{val_id} = load ptr, ptr %a{addr_id}, align 8\n"
));
let drop_call = self.field_drop_call(fty);
out.push_str(&format!(
" call void @{drop_call}(ptr %v{val_id})\n"
));
out.push_str(&format!(" br label %after_{i}_{j}\n"));
out.push_str(&format!("after_{i}_{j}:\n"));
}
out.push_str(" br label %join\n");
}
out.push_str("dflt:\n");
if n_ctors == 0 {
out.push_str(" br label %join\n");
} else {
out.push_str(" unreachable\n");
}
out.push_str("join:\n");
out.push_str(" call void @ailang_rc_dec(ptr %p)\n");
out.push_str(" br label %ret\n");
out.push_str("ret:\n");
out.push_str(" ret void\n");
out.push_str("}\n\n");
self.body.push_str(&out);
}
/// raw-buf.4: emit the flat `drop_<m>_<T>` for an
/// intrinsic-storage TypeDef (e.g. RawBuf). The slab is
/// `[size:i64][primitive elements]` with no tag at offset 0, so
/// the generic tag-switch drop is wrong; the elements are
/// primitives carrying no recursive drops. The flat drop is just
/// the null-guarded outer rc-dec — same `entry/live/ret` shape
/// and `@ailang_rc_dec` call form as the generic drop's `join`
/// tail, minus the tag-switch.
pub(crate) fn emit_flat_intrinsic_drop_fn(&mut self, td: &TypeDef) {
let m = self.module_name;
let tname = &td.name;
let mut out = String::new();
out.push_str(&format!("define void @drop_{m}_{tname}(ptr %p) {{\n"));
out.push_str("entry:\n");
out.push_str(" %is_null = icmp eq ptr %p, null\n");
out.push_str(" br i1 %is_null, label %ret, label %live\n");
out.push_str("live:\n");
out.push_str(" call void @ailang_rc_dec(ptr %p)\n");
out.push_str(" br label %ret\n");
out.push_str("ret:\n");
out.push_str(" ret void\n");
out.push_str("}\n\n");
self.body.push_str(&out);
}
/// raw-buf.4: emit the flat `partial_drop_<m>_<T>` for an
/// intrinsic-storage TypeDef. Emitted for symbol-resolution
/// parity with the generic path (every TypeDef gets both a drop
/// and a partial-drop). No carve-out site can actually call it —
/// the sole ctor field is a primitive, never a moved-out ptr
/// field — so the `%mask` arg is ignored and the body is the same
/// flat outer rc-dec as the drop fn.
pub(crate) fn emit_flat_intrinsic_partial_drop_fn(&mut self, td: &TypeDef) {
let m = self.module_name;
let tname = &td.name;
let mut out = String::new();
out.push_str(&format!(
"define void @partial_drop_{m}_{tname}(ptr %p, i64 %mask) {{\n"
));
out.push_str("entry:\n");
out.push_str(" %is_null = icmp eq ptr %p, null\n");
out.push_str(" br i1 %is_null, label %ret, label %live\n");
out.push_str("live:\n");
out.push_str(" call void @ailang_rc_dec(ptr %p)\n");
out.push_str(" br label %ret\n");
out.push_str("ret:\n");
out.push_str(" ret void\n");
out.push_str("}\n\n");
self.body.push_str(&out);
}
/// resolve the `partial_drop_<owner>_<T>`
/// symbol for a type, parallel to the existing per-type drop
/// dispatch in `field_drop_call`. Returns `None` for non-ADT
/// types (Str, fn-typed, type-vars) — callers fall back to
/// shallow `ailang_rc_dec` in those cases (no per-type drop
/// fn exists either, and `moved_slots` would not have been
/// populated for those shapes anyway).
pub(crate) fn partial_drop_symbol_for_type(&self, ty: &Type) -> Option<String> {
match ty {
Type::Con { name, .. } => {
if name == "Str" {
return None;
}
if name.matches('.').count() == 1 {
let (prefix, suffix) = name.split_once('.').expect("checked");
if let Some(target) = self.import_map.get(prefix) {
return Some(format!("partial_drop_{target}_{suffix}"));
}
return Some(format!("partial_drop_{prefix}_{suffix}"));
}
Some(format!(
"partial_drop_{m}_{name}",
m = self.module_name
))
}
_ => None,
}
}
/// build the i64 moved-slots bitmask for a
/// `partial_drop_<m>_<T>` call. Bit j is set iff slot j is in
/// `moved`. We reject slot indices ≥ 64 with `None` — no
/// language-level ADT has that many fields, but the cap is
/// load-bearing because the helper's mask is a single i64.
/// On overflow, callers fall back to shallow dec (correctness-
/// safe; carve-out leak path remains as before, but the cap is
/// effectively unreachable).
pub(crate) fn build_moved_mask(moved: &BTreeSet<usize>) -> Option<u64> {
let mut mask: u64 = 0;
for &idx in moved {
if idx >= 64 {
return None;
}
mask |= 1u64 << idx;
}
Some(mask)
}
/// emit a partial-drop sequence inline at a let-close
/// site whose binder has moved-out pattern slots. Replaces the
/// uniform `drop_<m>_<T>(ptr)` call: load each pointer-typed
/// field whose slot index is NOT in `moved`, dispatch through
/// `field_drop_call` (the same per-type or shallow drop the
/// recursive cascade picks), then `ailang_rc_dec` the outer
/// box. Skips slots in `moved` entirely — those values were
/// transferred to a pattern-bound binder that owns the dec
/// for them.
///
/// The widened input set includes `Term::App` (Own-returning
/// call). The static per-field emission below is keyed
/// against a `Term::Ctor`'s known ctor; a `Term::App` binder
/// whose body pattern-matches it has a *dynamic* runtime tag.
/// instead of falling back
/// to shallow `ailang_rc_dec` (which leaked the unmoved fields),
/// we route through the tag-conditional helper
/// [`Self::emit_partial_drop_fn_for_type`], which dispatches on
/// the runtime tag and dec's only the unmoved fields.
pub(crate) fn emit_inlined_partial_drop(
&mut self,
value: &Term,
val_ssa: &str,
moved: &BTreeSet<usize>,
) -> Result<()> {
let (type_name, ctor_name) = match value {
Term::Ctor { type_name, ctor, .. } => (type_name.as_str(), ctor.as_str()),
_ => {
// dynamic-tag partial-drop via the
// per-type helper. `value` is `Term::App` (Own-
// returning) — the binder's static type is the App's
// ret type, recovered through `synth_arg_type` /
// `partial_drop_symbol_for_type`. `Term::Lam` shapes
// never reach here with a non-empty `moved` (you can't
// pattern-match a closure-pair); the fallback below
// handles them defensively.
let sym = self
.synth_arg_type(value)
.ok()
.and_then(|ty| self.partial_drop_symbol_for_type(&ty));
if let (Some(sym), Some(mask)) =
(sym, Self::build_moved_mask(moved))
{
self.body.push_str(&format!(
" call void @{sym}(ptr {val_ssa}, i64 {mask})\n"
));
} else {
self.body.push_str(&format!(
" call void @ailang_rc_dec(ptr {val_ssa})\n"
));
}
return Ok(());
}
};
let cref = self.lookup_ctor_by_type(type_name, ctor_name)?;
// Per-field dec for non-moved pointer-typed slots. ail_fields
// are the AILang-level field types; field_drop_call resolves
// them to either `drop_<owner>_<T>` (ADTs cascade) or
// `ailang_rc_dec` (Str / closures / vars).
for (idx, fty_ail) in cref.ail_fields.iter().enumerate() {
let lty = llvm_type(fty_ail).unwrap_or_else(|_| "ptr".into());
if lty != "ptr" {
continue;
}
if moved.contains(&idx) {
continue;
}
let off = 8 + (idx as i64) * 8;
let addr = self.fresh_ssa();
self.body.push_str(&format!(
" {addr} = getelementptr inbounds i8, ptr {val_ssa}, i64 {off}\n"
));
let v = self.fresh_ssa();
self.body.push_str(&format!(
" {v} = load ptr, ptr {addr}, align 8\n"
));
let drop_call = self.field_drop_call(fty_ail);
self.body.push_str(&format!(
" call void @{drop_call}(ptr {v})\n"
));
}
// Finally dec the outer box. The per-type drop fn would have
// done this in its `join` block; we replicate it here.
self.body.push_str(&format!(
" call void @ailang_rc_dec(ptr {val_ssa})\n"
));
Ok(())
}
/// build the IR text for a closure env's drop fn.
/// Layout: 8 bytes per capture, in declaration order.
/// For each pointer-typed capture, emit a load + drop call;
/// finally `ailang_rc_dec` the env block.
pub(crate) fn build_env_drop_fn(
&self,
sym: &str,
cap_meta: &[(String, String, String, Type, Option<FnSig>)],
) -> String {
let mut out = String::new();
out.push_str(&format!("define void @{sym}(ptr %env) {{\nentry:\n"));
out.push_str(" %is_null = icmp eq ptr %env, null\n");
out.push_str(" br i1 %is_null, label %ret, label %live\n");
out.push_str("live:\n");
let mut local = 0u64;
for (i, (_cname, _outer_ssa, lty, ail_ty, _sig)) in cap_meta.iter().enumerate() {
if lty != "ptr" {
continue;
}
let off = (i as i64) * 8;
let addr_id = local;
local += 1;
let val_id = local;
local += 1;
out.push_str(&format!(
" %a{addr_id} = getelementptr inbounds i8, ptr %env, i64 {off}\n"
));
out.push_str(&format!(
" %v{val_id} = load ptr, ptr %a{addr_id}, align 8\n"
));
let drop_call = self.field_drop_call(ail_ty);
out.push_str(&format!(
" call void @{drop_call}(ptr %v{val_id})\n"
));
}
out.push_str(" call void @ailang_rc_dec(ptr %env)\n");
out.push_str(" br label %ret\n");
out.push_str("ret:\n");
out.push_str(" ret void\n}\n\n");
out
}
/// build the IR text for a closure pair's drop fn.
/// Layout: { ptr thunk, ptr env } — env at offset 8.
/// Loads env, calls the env drop, then decs the pair box.
pub(crate) fn build_pair_drop_fn(
&self,
sym: &str,
env_drop: &str,
has_env: bool,
) -> String {
let mut out = String::new();
out.push_str(&format!("define void @{sym}(ptr %p) {{\nentry:\n"));
out.push_str(" %is_null = icmp eq ptr %p, null\n");
out.push_str(" br i1 %is_null, label %ret, label %live\n");
out.push_str("live:\n");
if has_env {
// The pair layout is `{ ptr thunk, ptr env }`; env is the
// second field. Note the single braces — this is a plain
// `push_str`, not a `format!` call, so brace-escaping does
// not apply. (Iter 18c.4 originally shipped doubled braces
// here; surfaced when the rc backend became the corpus
// default and a closure-with-captures escaped.)
out.push_str(
" %ea = getelementptr inbounds { ptr, ptr }, ptr %p, i64 0, i32 1\n",
);
out.push_str(" %env = load ptr, ptr %ea, align 8\n");
out.push_str(&format!(" call void @{env_drop}(ptr %env)\n"));
} else {
// No env — the env-drop is still emitted (uniform shape)
// but is a no-op on null. Skip the load and call dec
// directly on the pair.
let _ = env_drop;
}
out.push_str(" call void @ailang_rc_dec(ptr %p)\n");
out.push_str(" br label %ret\n");
out.push_str("ret:\n");
out.push_str(" ret void\n}\n\n");
out
}
}