diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index 3b9fb70..7831ac2 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -1767,17 +1767,27 @@ fn alloc_rc_own_param_dec_at_fn_return() { let fn_body = &ir[fn_start..fn_start + fn_end_offset]; // The Own-param drop must fire on `%arg_xs` somewhere in - // head_or_zero's body. Either the per-type drop (empty - // moved_slots case) or the shallow `ailang_rc_dec` (non-empty - // moved_slots case) is acceptable. The canonical fixture here - // hits the latter: the Cons arm moves slot 1 into the arm-bound - // `t`, so moved_slots[xs] = {1} at fn return. + // head_or_zero's body. Three acceptable shapes: + // - per-type drop `@drop__IntList(ptr %arg_xs)` (empty + // moved_slots case), + // - tag-conditional partial-drop + // `@partial_drop__IntList(ptr %arg_xs, i64 )` + // (Iter 18g.tidy.fu2: non-empty moved_slots, dynamic-tag), + // - shallow `@ailang_rc_dec(ptr %arg_xs)` (legacy fallback; + // the typechecker accepts non-ADT param types but moved_slots + // cannot populate for those, so this branch is dead in + // practice). + // The canonical fixture here hits the partial-drop shape: the + // Cons arm moves slot 1 into the arm-bound `t`, so + // moved_slots[xs] = {1} at fn return → mask=2. let has_per_type_drop = fn_body.contains("call void @drop_rc_own_param_drop_IntList(ptr %arg_xs)"); + let has_partial_drop = fn_body + .contains("call void @partial_drop_rc_own_param_drop_IntList(ptr %arg_xs,"); let has_shallow_dec = fn_body.contains("call void @ailang_rc_dec(ptr %arg_xs)"); assert!( - has_per_type_drop || has_shallow_dec, - "head_or_zero's body must drop the Own-mode param `xs` (either via per-type drop or shallow dec) before the fn's `ret`. Body was:\n{fn_body}" + has_per_type_drop || has_partial_drop || has_shallow_dec, + "head_or_zero's body must drop the Own-mode param `xs` (per-type, partial-drop, or shallow dec) before the fn's `ret`. Body was:\n{fn_body}" ); // The drop must precede the fn's `ret`. Scope: any `ret i64 ...` @@ -1792,6 +1802,8 @@ fn alloc_rc_own_param_dec_at_fn_return() { let ret_idx = fn_body.find("\n ret i64").expect("head_or_zero has a ret i64"); let pre_ret = &fn_body[..ret_idx]; let drop_in_pre_ret = pre_ret.contains("call void @drop_rc_own_param_drop_IntList(ptr %arg_xs)") + || pre_ret + .contains("call void @partial_drop_rc_own_param_drop_IntList(ptr %arg_xs,") || pre_ret.contains("call void @ailang_rc_dec(ptr %arg_xs)"); assert!( drop_in_pre_ret, @@ -2239,3 +2251,124 @@ fn alloc_rc_let_alias_of_implicit_param_does_not_dec_borrowed_children() { "alloc=rc must match alloc=gc on rc_let_alias_implicit_param" ); } + +/// Iter 18g.tidy.fu2 regression — RED-then-GREEN for the +/// dynamic-tag partial-drop carve-out at fn-return. +/// +/// Three carve-out sites previously fell back to a shallow +/// `ailang_rc_dec` when a binder's `moved_slots` was non-empty +/// and the runtime ctor tag was dynamic (not statically known +/// from a `Term::Ctor` value). The shallow dec frees the outer +/// cell but does not walk the unmoved ptr fields — those leak +/// silently. +/// +/// **Site 1 (this fixture): Iter B Own-param dec at fn return +/// (lib.rs).** +/// +/// (fn use_first +/// (params (own (con Pair))) +/// (body +/// (match p +/// (case (pat-ctor MkPair a _) 1)))) +/// +/// `p` is consumed by the match. Its `consume_count == 0`, +/// `mode == Own`, and `moved_slots[p] = {0}` (slot 0 was bound, +/// slot 1 wildcarded). At fn-return, Iter B's dynamic-tag fallback +/// fired shallow `ailang_rc_dec(p)`, freeing p's outer Pair but +/// leaking the unbound second `MkCell` and its two `MkWrap` +/// children — three cells. +/// +/// fu2 routes the carve-out through `partial_drop__Pair(p, +/// mask)`, which dispatches on the runtime tag and dec's slot 1 +/// (the unmoved field) via `drop__Cell`, cascading correctly. +/// +/// Pre-fix: `live = 3`. Post-fix: `live = 0`. +#[test] +fn alloc_rc_partial_drop_at_fn_return_does_not_leak_unmoved_fields() { + let (stdout, allocs, frees, live) = + build_and_run_with_rc_stats("rc_own_param_partial_drop_leak.ail.json"); + assert_eq!( + stdout.trim(), + "1", + "use_first returns 1 regardless of leak status" + ); + assert_eq!( + live, 0, + "Own-param fn-return partial-drop carve-out leaked {live} cells \ + (allocs={allocs} frees={frees}); fu2 must route the dynamic-tag \ + fallback through `partial_drop__(p, mask)` so the unmoved \ + slots dec via the per-type cascade rather than being shallow-freed" + ); +} + +/// Iter 18g.tidy.fu2 regression — site 2: Iter A arm-close +/// pattern-binder dec (match_lower.rs). +/// +/// An outer match's arm-bound pattern-binder may itself be the +/// scrutinee of an inner match that moves out some of its fields. +/// At outer-arm close, `moved_slots[bname]` is non-empty and +/// `consume_count[bname] == 0` — the carve-out previously emitted +/// a shallow `ailang_rc_dec`, leaking the inner ctor's unbound +/// (wildcarded) ptr fields. +/// +/// (match p +/// (case (pat-ctor MkPair a b) +/// (match a +/// (case (pat-ctor MkCell w1 _) 1)))) +/// +/// `a` has `moves={0}` (w1 bound, slot 1 of MkCell wildcarded). +/// Pre-fix: shallow dec on `a` left slot 1's `MkWrap(2)` cell +/// allocated. +/// Post-fix: `partial_drop__Cell(a, mask=001)` dec's slot 1. +/// +/// Pre-fix: `live = 1`. Post-fix: `live = 0`. +#[test] +fn alloc_rc_partial_drop_at_arm_close_does_not_leak_unmoved_fields() { + let (stdout, allocs, frees, live) = + build_and_run_with_rc_stats("rc_match_arm_partial_drop_leak.ail.json"); + assert_eq!( + stdout.trim(), + "1", + "use_first returns 1 regardless of leak status" + ); + assert_eq!( + live, 0, + "match-arm pattern-binder partial-drop carve-out leaked {live} cells \ + (allocs={allocs} frees={frees}); fu2 must route arm-bound binders \ + with non-empty moved_slots through `partial_drop__(b, mask)`" + ); +} + +/// Iter 18g.tidy.fu2 regression — site 3: let-close for an +/// App-bound binder (drop.rs `emit_inlined_partial_drop` +/// non-Ctor branch). +/// +/// `emit_inlined_partial_drop` was keyed against `Term::Ctor` +/// values; for `Term::App` (Own-returning) values its fallback +/// shallow-dec'd the binder. With pattern-matching of the +/// App-bound binder (`(let p (app build_pair) (match p ...))`), +/// the body's `moved_slots[p]` is non-empty but the binder's +/// runtime tag is dynamic (unknown from the App's surface). +/// +/// fu2 derives the type from `synth_arg_type(value)` and routes +/// through `partial_drop__(p, mask)`. +/// +/// Pre-fix: `live = 3` (slot 1's whole MkCell + two MkWraps). +/// Post-fix: `live = 0`. +#[test] +fn alloc_rc_partial_drop_at_app_let_close_does_not_leak_unmoved_fields() { + let (stdout, allocs, frees, live) = + build_and_run_with_rc_stats("rc_app_let_partial_drop_leak.ail.json"); + assert_eq!( + stdout.trim(), + "1", + "use_cell returns 1 regardless of leak status" + ); + assert_eq!( + live, 0, + "App-bound let-close partial-drop carve-out leaked {live} cells \ + (allocs={allocs} frees={frees}); fu2 must derive the binder's \ + static type from synth_arg_type(value) and route through \ + `partial_drop__(p, mask)`" + ); +} diff --git a/crates/ailang-codegen/src/drop.rs b/crates/ailang-codegen/src/drop.rs index 6c99353..0716bfc 100644 --- a/crates/ailang-codegen/src/drop.rs +++ b/crates/ailang-codegen/src/drop.rs @@ -538,6 +538,193 @@ impl<'a> Emitter<'a> { } } + /// Iter 18g.tidy.fu2: tag-conditional partial-drop helper. + /// Emits `void @partial_drop__(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__(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 @(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__`. 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__` 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); + } + + /// Iter 18g.tidy.fu2: resolve the `partial_drop__` + /// 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 { + 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, + } + } + + /// Iter 18g.tidy.fu2: build the i64 moved-slots bitmask for a + /// `partial_drop__` 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) -> Option { + let mut mask: u64 = 0; + for &idx in moved { + if idx >= 64 { + return None; + } + mask |= 1u64 << idx; + } + Some(mask) + } + /// Iter 18d.3: emit a partial-drop sequence inline at a let-close /// site whose binder has moved-out pattern slots. Replaces the /// uniform `drop__(ptr)` call: load each pointer-typed @@ -548,15 +735,15 @@ impl<'a> Emitter<'a> { /// transferred to a pattern-bound binder that owns the dec /// for them. /// - /// Iter 18g.2 widens the input set: with `Term::App` (Own- - /// returning call) now also trackable, a let-binder whose value is - /// `Term::App` may accumulate moved slots if its body pattern- - /// matches the binder. The per-field static-ctor emission below - /// is not usable in that case (the runtime tag is dynamic, not - /// statically known from `value`'s shape); fall back to shallow - /// `ailang_rc_dec`. The non-moved field cells leak under that - /// path — same dynamic-tag partial-drop debt that 18d.4's match - /// arm already documents. + /// Iter 18g.2 widened the input set to `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. + /// Iter 18g.tidy.fu2 closes that path: 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, @@ -566,21 +753,29 @@ impl<'a> Emitter<'a> { let (type_name, ctor_name) = match value { Term::Ctor { type_name, ctor, .. } => (type_name.as_str(), ctor.as_str()), _ => { - // Iter 18g.2 fallback: dynamic-tag partial-drop debt. - // `value` is `Term::App` (Own-returning) or `Term::Lam` - // — `is_rc_heap_allocated` returns true for both, and a - // body that pattern-matches such a binder will populate - // `moved_slots`. We don't know the active ctor - // statically, so we shallow-dec the outer cell. Non- - // moved field cells leak under this path; closing the - // leak requires a tag-conditional partial-drop helper - // (analogous to the per-type drop fn but parameterised - // on the tag). Tracked as future work — same family as - // 18d.4's `(case (LCons h t) ...)` carve-out. - let _ = (val_ssa, moved); // keep compiler quiet about unused - self.body.push_str(&format!( - " call void @ailang_rc_dec(ptr {val_ssa})\n" - )); + // Iter 18g.tidy.fu2: 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(()); } }; diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index 9368b7f..1a40afb 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -800,6 +800,16 @@ impl<'a> Emitter<'a> { } else { self.emit_drop_fn_for_type(td); } + // Iter 18g.tidy.fu2: tag-conditional partial-drop + // helper alongside the recursive/iterative drop fn. + // Used at carve-out sites where a binder's runtime + // tag is dynamic and `moved_slots` is a strict + // subset of its ptr fields. Same shape regardless + // of `(drop-iterative)` (the helper is single-shot + // on the binder; field cascades go through their + // own drop fns which themselves choose recursive + // vs iterative). + self.emit_partial_drop_fn_for_type(td); } } } @@ -1085,14 +1095,30 @@ impl<'a> Emitter<'a> { " call void @{drop_call}(ptr {p_ssa})\n" )); } else { - // Iter 18d.4 debt: dynamic-tag partial-drop — - // see the symmetric arm-close branch in - // `lower_match` for the rationale. Falls back - // to shallow `ailang_rc_dec` of the outer - // cell. - self.body.push_str(&format!( - " call void @ailang_rc_dec(ptr {p_ssa})\n" - )); + // Iter 18g.tidy.fu2: dynamic-tag partial-drop + // via the per-type helper. The param's runtime + // tag is dynamic but its static type is known + // (`pty_ail`), so we route through + // `partial_drop__(p, mask)` which + // dispatches on the runtime tag and dec's only + // the unmoved fields. The fallback to shallow + // `ailang_rc_dec` only fires for non-ADT param + // types (Str, fn-typed, vars) — none of which + // can populate `moved_slots` in practice, so + // the fallback is dead under the typechecker. + let sym = Self::partial_drop_symbol_for_type( + self, pty_ail, + ); + let mask = Self::build_moved_mask(&moves); + if let (Some(sym), Some(mask)) = (sym, mask) { + self.body.push_str(&format!( + " call void @{sym}(ptr {p_ssa}, i64 {mask})\n" + )); + } else { + self.body.push_str(&format!( + " call void @ailang_rc_dec(ptr {p_ssa})\n" + )); + } } } } diff --git a/crates/ailang-codegen/src/match_lower.rs b/crates/ailang-codegen/src/match_lower.rs index 252df2a..2864c65 100644 --- a/crates/ailang-codegen/src/match_lower.rs +++ b/crates/ailang-codegen/src/match_lower.rs @@ -821,24 +821,28 @@ impl<'a> Emitter<'a> { " call void @{drop_call}(ptr {b_ssa})\n" )); } else { - // Iter 18d.4 debt: pattern-binder with - // statically-recorded moved slots requires - // tag-conditional partial-drop emission. The - // 18d.3 `emit_inlined_partial_drop` helper - // assumes a static ctor (it's keyed against a - // `Term::Ctor` node), but a pattern-bound - // binder's runtime tag is dynamic. Until a - // dynamic-tag-aware partial-drop lands, fall - // back to a shallow `ailang_rc_dec` of the - // outer cell. The non-moved fields of the - // active ctor (if any) leak under this path — - // the canonical 18d.4 fixtures avoid this - // case by construction (the regression `t` - // is never re-scrutinised inside the arm, - // so its `moved_slots` entry is empty). - self.body.push_str(&format!( - " call void @ailang_rc_dec(ptr {b_ssa})\n" - )); + // Iter 18g.tidy.fu2: pattern-binder with + // statically-recorded moved slots routes + // through the tag-conditional helper + // `partial_drop__(b, mask)`. The + // binder's runtime tag is dynamic but its + // static type (`b_ail`) selects the per-type + // helper, which dispatches on the runtime tag + // and dec's only the unmoved fields. Closes + // the 18d.4 carve-out: previously the shallow + // `ailang_rc_dec` leaked the unmoved fields of + // whichever ctor `b` happened to be at runtime. + let sym = self.partial_drop_symbol_for_type(b_ail); + let mask = Self::build_moved_mask(&moves); + if let (Some(sym), Some(mask)) = (sym, mask) { + self.body.push_str(&format!( + " call void @{sym}(ptr {b_ssa}, i64 {mask})\n" + )); + } else { + self.body.push_str(&format!( + " call void @ailang_rc_dec(ptr {b_ssa})\n" + )); + } } } } diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index 66a2b68..9c6bd64 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -8451,3 +8451,143 @@ queued iter required it. It is a tidy paid for navigability alone — the kind of work that compounds across future sessions without showing up in any single one's bench numbers. + +## 2026-05-08 — Iter 18g.tidy.fu2: tag-conditional partial-drop helper + +User feedback closed an open question. The 18g tidy entry had +queued the tag-conditional partial-drop helper as "stays queued; +will close with the next iter that surfaces the leak in a +benchmark or a real workload." The user pushed back: *"Du weisst, +dass es einen Bug gibt — Leaks sind Bugs."* CLAUDE.md's TDD-for- +bug-fixes rule is unambiguous on a known bug: write a RED test, +ship the GREEN, keep as regression. The "wait for organic +fixture" framing was avoidance disguised as discipline. Fixed. + +### Three carve-outs, three sites, one helper + +The pre-fu2 codebase had three sibling sites that fell back to +shallow `ailang_rc_dec` when a binder had a non-empty +`moved_slots` *and* a dynamic runtime ctor tag: + + 1. `lib.rs` Iter B Own-param dec at fn-return (`emit_fn`'s + pre-ret seam). + 2. `match_lower.rs` Iter A arm-close pattern-binder dec + (outer-arm close when an arm-bound binder was scrutinised + by an inner match that moved out fields). + 3. `drop.rs::emit_inlined_partial_drop` non-Ctor branch + (`Term::Let` whose value is a `Term::App` Own-returning + call, body pattern-matches the binder). + +All three share a structural shape: a binder whose static type is +known (an ADT), but whose runtime ctor tag is *not* statically +recoverable from the AST node at the drop emission site. The +existing per-type drop fn `drop__(ptr)` is built around +"emit the dec for *every* ptr field of the active ctor"; what was +missing was the variant "emit the dec for every ptr field of the +active ctor *whose slot index is not in `moved`*." + +### `partial_drop__(ptr %p, i64 %mask)` + +The fu2 helper (`drop.rs::emit_partial_drop_fn_for_type`) is a +straight parallel to `emit_drop_fn_for_type`: + +```text + define void @partial_drop__(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 at slot j of ctor i: + %b = and i64 %mask, (1 << j) + %s = icmp ne i64 %b, 0 + br i1 %s, label %after, label %do + do: + %v = load ptr, gep %p, (8 + 8*j) + call void @(ptr %v) + br label %after + after: + ; next ptr 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 in the module, emitted alongside +`drop__`. 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__` which itself decides recursive vs. iterative). + +The mask is `i64`, so ADTs with > 64 fields fall back to shallow +dec via the `build_moved_mask` cap. No language-level ADT has 64 +fields; the cap is load-bearing only as a defensive guard. + +### Three RED-then-GREEN fixtures + +Built before the helper to lock the carve-outs in regression +coverage. All three share the same `Wrap` / `Cell` / `Pair` shape +(Pair has 2 Cell slots, Cell has 2 Wrap slots) so the leak path +is observable through `AILANG_RC_STATS=1`'s +`allocs/frees/live` triple. + + - `examples/rc_own_param_partial_drop_leak.{ailx,ail.json}` — + site 1. `(fn use_first (params (own (con Pair))) ...)` matches + `p` as `MkPair(a, _)`. Pre-fu2: `live=3` (whole second Cell + + two Wraps leak through Iter B's shallow path). Post-fu2: + `live=0`. + - `examples/rc_match_arm_partial_drop_leak.{ailx,ail.json}` — + site 2. Outer match binds `a, b` from `p`; inner match + destructures `a` as `MkCell(w1, _)` (slot 1 wildcarded). + Pre-fu2: `live=1` (slot 1's MkWrap leaks through Iter A's + shallow path). Post-fu2: `live=0`. + - `examples/rc_app_let_partial_drop_leak.{ailx,ail.json}` — + site 3. `(let p (app build_pair 1) (match p ...))` with + `MkPair(a, _)`. Pre-fu2: `live=3` (slot 1's whole Cell + + Wraps leak through `emit_inlined_partial_drop`'s non-Ctor + fallback). Post-fu2: `live=0`. + +Each fixture has a corresponding e2e test in +`crates/ail/tests/e2e.rs`. e2e count: 66 → 69. + +### Routing the call sites + +Each of the three sites was rewritten symmetrically: resolve the +`partial_drop__` symbol from the binder's static type +(`partial_drop_symbol_for_type`), build the mask +(`build_moved_mask`), call. Falls back to the prior shallow +`ailang_rc_dec` only if the static type is non-ADT (Str, fn-typed, +type-var) — those shapes can't populate `moved_slots` in +practice, so the fallback is dead under the typechecker. + +The pre-existing test `alloc_rc_own_param_dec_at_fn_return` +asserted on the IR-level shape "either `drop__` or +`ailang_rc_dec` is called on `%arg_xs` before the ret"; widened +to also accept `partial_drop__(%arg_xs, i64 mask)` (the +canonical fu2 shape — the fixture's Cons arm moves slot 1 into +`t`, so `moved_slots[xs]={1}`). + +### What stays queued + +Carve-out diagnostic surface (item 5 from the 18g tidy entry): +the three carve-outs no longer leak, but they also don't surface +to the user when they fire. Closing this is a separate ergonomics +iter (structured diagnostics from codegen), not a correctness +fix. The fu2 helper makes the codegen-side leak path empty; +diagnostic surface is for the language-design layer. + +### Lesson recorded + +The "wait for organic fixture" stance from the 18g tidy entry is +now retracted: known leaks are bugs, and CLAUDE.md's TDD rule +covers them autonomously. Constructing a minimal fixture for a +known leak path is *not* "constructing fixtures to drive an +implementation" — it is the literal RED-step that the discipline +prescribes. The three fixtures shipped here are exactly that: +each pins one of the three carve-out sites, observable via the +RC stats line, kept as regression. JOURNAL queue: empty. diff --git a/examples/rc_app_let_partial_drop_leak.ail.json b/examples/rc_app_let_partial_drop_leak.ail.json new file mode 100644 index 0000000..36f084b --- /dev/null +++ b/examples/rc_app_let_partial_drop_leak.ail.json @@ -0,0 +1 @@ +{"defs":[{"ctors":[{"fields":[{"k":"con","name":"Int"}],"name":"MkWrap"}],"kind":"type","name":"Wrap"},{"ctors":[{"fields":[{"k":"con","name":"Wrap"},{"k":"con","name":"Wrap"}],"name":"MkCell"}],"kind":"type","name":"Cell"},{"ctors":[{"fields":[{"k":"con","name":"Cell"},{"k":"con","name":"Cell"}],"name":"MkPair"}],"kind":"type","name":"Pair"},{"body":{"args":[{"args":[{"args":[{"name":"n","t":"var"}],"ctor":"MkWrap","t":"ctor","type":"Wrap"},{"args":[{"lit":{"kind":"int","value":2},"t":"lit"}],"ctor":"MkWrap","t":"ctor","type":"Wrap"}],"ctor":"MkCell","t":"ctor","type":"Cell"},{"args":[{"args":[{"lit":{"kind":"int","value":3},"t":"lit"}],"ctor":"MkWrap","t":"ctor","type":"Wrap"},{"args":[{"lit":{"kind":"int","value":4},"t":"lit"}],"ctor":"MkWrap","t":"ctor","type":"Wrap"}],"ctor":"MkCell","t":"ctor","type":"Cell"}],"ctor":"MkPair","t":"ctor","type":"Pair"},"kind":"fn","name":"build_pair","params":["n"],"type":{"effects":[],"k":"fn","params":[{"k":"con","name":"Int"}],"ret":{"k":"con","name":"Pair"},"ret_mode":"own"}},{"body":{"arms":[{"body":{"lit":{"kind":"int","value":1},"t":"lit"},"pat":{"ctor":"MkCell","fields":[{"name":"w1","p":"var"},{"name":"w2","p":"var"}],"p":"ctor"}}],"scrutinee":{"name":"c","t":"var"},"t":"match"},"kind":"fn","name":"use_cell","params":["c"],"type":{"effects":[],"k":"fn","param_modes":["own"],"params":[{"k":"con","name":"Cell"}],"ret":{"k":"con","name":"Int"}}},{"body":{"body":{"args":[{"arms":[{"body":{"args":[{"name":"a","t":"var"}],"fn":{"name":"use_cell","t":"var"},"t":"app"},"pat":{"ctor":"MkPair","fields":[{"name":"a","p":"var"},{"p":"wild"}],"p":"ctor"}}],"scrutinee":{"name":"p","t":"var"},"t":"match"}],"op":"io/print_int","t":"do"},"name":"p","t":"let","value":{"args":[{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"build_pair","t":"var"},"t":"app"}},"kind":"fn","name":"main","params":[],"type":{"effects":["IO"],"k":"fn","params":[],"ret":{"k":"con","name":"Unit"}}}],"imports":[],"name":"rc_app_let_partial_drop_leak","schema":"ailang/v0"} \ No newline at end of file diff --git a/examples/rc_app_let_partial_drop_leak.ailx b/examples/rc_app_let_partial_drop_leak.ailx new file mode 100644 index 0000000..5580c0e --- /dev/null +++ b/examples/rc_app_let_partial_drop_leak.ailx @@ -0,0 +1,69 @@ +; Iter 18g.tidy.fu2 RED-test fixture 3/3: dynamic-tag partial-drop +; carve-out at let-close for an App-bound binder +; (drop.rs:580 fallback in `emit_inlined_partial_drop`). +; +; Shape: +; - Same `Pair`/`Cell`/`Wrap` types as fixtures 1 and 2. +; - `main` let-binds `p = (app build_pair)` — value is +; `Term::App` (Own-returning), `is_rc_heap_allocated` returns +; true → trackable. +; - Body matches `p` as `MkPair a _` — slot 0 bound, slot 1 wild. +; `moved_slots[p] = {0}`. +; - `a` is consumed by `use_cell` (Own param) → `consume_count[a] +; == 1`, arm-close drop skipped. `use_cell`'s body fully +; destructures `a` and returns Int. +; - At p's let-close: `consume_count[p] == 0`, `moves[p] = {0}` +; non-empty, `value` is `Term::App` (not `Term::Ctor`) → +; `emit_inlined_partial_drop` falls into the non-Ctor branch +; (drop.rs:580) → shallow `ailang_rc_dec(p)`. p's outer cell +; is freed; slot 1 (the second MkCell + its two MkWraps = +; 3 cells) leaks. +; +; Pre-fix: live = 3 (one MkCell + two MkWrap children in slot 1). +; Post-fix: live = 0. + +(module rc_app_let_partial_drop_leak + + (data Wrap + (ctor MkWrap (con Int))) + + (data Cell + (ctor MkCell (con Wrap) (con Wrap))) + + (data Pair + (ctor MkPair (con Cell) (con Cell))) + + (fn build_pair + (type + (fn-type + (params (con Int)) + (ret (own (con Pair))))) + (params n) + (body + (term-ctor Pair MkPair + (term-ctor Cell MkCell + (term-ctor Wrap MkWrap n) + (term-ctor Wrap MkWrap 2)) + (term-ctor Cell MkCell + (term-ctor Wrap MkWrap 3) + (term-ctor Wrap MkWrap 4))))) + + (fn use_cell + (type + (fn-type + (params (own (con Cell))) + (ret (con Int)))) + (params c) + (body + (match c + (case (pat-ctor MkCell w1 w2) 1)))) + + (fn main + (type (fn-type (params) (ret (con Unit)) (effects IO))) + (params) + (body + (let p (app build_pair 1) + (do io/print_int + (match p + (case (pat-ctor MkPair a _) + (app use_cell a)))))))) diff --git a/examples/rc_match_arm_partial_drop_leak.ail.json b/examples/rc_match_arm_partial_drop_leak.ail.json new file mode 100644 index 0000000..f3eb285 --- /dev/null +++ b/examples/rc_match_arm_partial_drop_leak.ail.json @@ -0,0 +1 @@ +{"defs":[{"ctors":[{"fields":[{"k":"con","name":"Int"}],"name":"MkWrap"}],"kind":"type","name":"Wrap"},{"ctors":[{"fields":[{"k":"con","name":"Wrap"},{"k":"con","name":"Wrap"}],"name":"MkCell"}],"kind":"type","name":"Cell"},{"ctors":[{"fields":[{"k":"con","name":"Cell"},{"k":"con","name":"Cell"}],"name":"MkPair"}],"kind":"type","name":"Pair"},{"body":{"args":[{"args":[{"args":[{"name":"n","t":"var"}],"ctor":"MkWrap","t":"ctor","type":"Wrap"},{"args":[{"lit":{"kind":"int","value":2},"t":"lit"}],"ctor":"MkWrap","t":"ctor","type":"Wrap"}],"ctor":"MkCell","t":"ctor","type":"Cell"},{"args":[{"args":[{"lit":{"kind":"int","value":3},"t":"lit"}],"ctor":"MkWrap","t":"ctor","type":"Wrap"},{"args":[{"lit":{"kind":"int","value":4},"t":"lit"}],"ctor":"MkWrap","t":"ctor","type":"Wrap"}],"ctor":"MkCell","t":"ctor","type":"Cell"}],"ctor":"MkPair","t":"ctor","type":"Pair"},"kind":"fn","name":"build_pair","params":["n"],"type":{"effects":[],"k":"fn","params":[{"k":"con","name":"Int"}],"ret":{"k":"con","name":"Pair"},"ret_mode":"own"}},{"body":{"arms":[{"body":{"arms":[{"body":{"lit":{"kind":"int","value":1},"t":"lit"},"pat":{"ctor":"MkCell","fields":[{"name":"w1","p":"var"},{"p":"wild"}],"p":"ctor"}}],"scrutinee":{"name":"a","t":"var"},"t":"match"},"pat":{"ctor":"MkPair","fields":[{"name":"a","p":"var"},{"name":"b","p":"var"}],"p":"ctor"}}],"scrutinee":{"name":"p","t":"var"},"t":"match"},"kind":"fn","name":"use_first","params":["p"],"type":{"effects":[],"k":"fn","param_modes":["own"],"params":[{"k":"con","name":"Pair"}],"ret":{"k":"con","name":"Int"}}},{"body":{"args":[{"args":[{"args":[{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"build_pair","t":"var"},"t":"app"}],"fn":{"name":"use_first","t":"var"},"t":"app"}],"op":"io/print_int","t":"do"},"kind":"fn","name":"main","params":[],"type":{"effects":["IO"],"k":"fn","params":[],"ret":{"k":"con","name":"Unit"}}}],"imports":[],"name":"rc_match_arm_partial_drop_leak","schema":"ailang/v0"} \ No newline at end of file diff --git a/examples/rc_match_arm_partial_drop_leak.ailx b/examples/rc_match_arm_partial_drop_leak.ailx new file mode 100644 index 0000000..2395757 --- /dev/null +++ b/examples/rc_match_arm_partial_drop_leak.ailx @@ -0,0 +1,71 @@ +; Iter 18g.tidy.fu2 RED-test fixture 2/3: dynamic-tag partial-drop +; carve-out at outer-arm-close pattern-binder dec +; (Iter A / match_lower.rs:839). +; +; Shape: +; - `Pair` carries two `Cell` fields; `Cell` carries two `Wrap` +; fields. (Same types as fixture 1.) +; - `use_first` takes `(own Pair)` and the OUTER match binds both +; slots: `MkPair a b`. +; - The arm body INNER-matches `a` as `MkCell w1 _` — slot 0 of +; a is bound, slot 1 is wildcarded. +; - Inner arm-close drops `w1` properly via `drop__Wrap`. +; Inner match adds slot 0 to `moved_slots[a]`. +; - Outer arm-close drops `a` and `b`. `a` has `moves={0}` +; (non-empty) and `consume_count==0` (only Borrow use as +; scrutinee of inner match) → Iter A's dynamic-tag carve-out +; fires: shallow `ailang_rc_dec(a)`. a's outer Cell is freed, +; but slot 1 (the unbound MkWrap(2) cell) leaks. +; - `b` has `moves={}` and `consume_count==0` → routes through +; `drop__Cell(b)` which cascades through both Wraps. No +; leak from b. +; - At fn return, `p`'s `moves={0,1}` (both slots bound) → Iter +; B carve-out fires too, but with all slots moved, the shallow +; dec is correct (no additional leak from p). +; +; Pre-fix: live = 1 (the unbound MkWrap(2) cell in slot 1 of a). +; Post-fix: live = 0. + +(module rc_match_arm_partial_drop_leak + + (data Wrap + (ctor MkWrap (con Int))) + + (data Cell + (ctor MkCell (con Wrap) (con Wrap))) + + (data Pair + (ctor MkPair (con Cell) (con Cell))) + + (fn build_pair + (type + (fn-type + (params (con Int)) + (ret (own (con Pair))))) + (params n) + (body + (term-ctor Pair MkPair + (term-ctor Cell MkCell + (term-ctor Wrap MkWrap n) + (term-ctor Wrap MkWrap 2)) + (term-ctor Cell MkCell + (term-ctor Wrap MkWrap 3) + (term-ctor Wrap MkWrap 4))))) + + (fn use_first + (type + (fn-type + (params (own (con Pair))) + (ret (con Int)))) + (params p) + (body + (match p + (case (pat-ctor MkPair a b) + (match a + (case (pat-ctor MkCell w1 _) 1)))))) + + (fn main + (type (fn-type (params) (ret (con Unit)) (effects IO))) + (params) + (body + (do io/print_int (app use_first (app build_pair 1)))))) diff --git a/examples/rc_own_param_partial_drop_leak.ail.json b/examples/rc_own_param_partial_drop_leak.ail.json new file mode 100644 index 0000000..0f9fc30 --- /dev/null +++ b/examples/rc_own_param_partial_drop_leak.ail.json @@ -0,0 +1 @@ +{"defs":[{"ctors":[{"fields":[{"k":"con","name":"Int"}],"name":"MkWrap"}],"kind":"type","name":"Wrap"},{"ctors":[{"fields":[{"k":"con","name":"Wrap"},{"k":"con","name":"Wrap"}],"name":"MkCell"}],"kind":"type","name":"Cell"},{"ctors":[{"fields":[{"k":"con","name":"Cell"},{"k":"con","name":"Cell"}],"name":"MkPair"}],"kind":"type","name":"Pair"},{"body":{"args":[{"args":[{"args":[{"name":"n","t":"var"}],"ctor":"MkWrap","t":"ctor","type":"Wrap"},{"args":[{"lit":{"kind":"int","value":2},"t":"lit"}],"ctor":"MkWrap","t":"ctor","type":"Wrap"}],"ctor":"MkCell","t":"ctor","type":"Cell"},{"args":[{"args":[{"lit":{"kind":"int","value":3},"t":"lit"}],"ctor":"MkWrap","t":"ctor","type":"Wrap"},{"args":[{"lit":{"kind":"int","value":4},"t":"lit"}],"ctor":"MkWrap","t":"ctor","type":"Wrap"}],"ctor":"MkCell","t":"ctor","type":"Cell"}],"ctor":"MkPair","t":"ctor","type":"Pair"},"kind":"fn","name":"build_pair","params":["n"],"type":{"effects":[],"k":"fn","params":[{"k":"con","name":"Int"}],"ret":{"k":"con","name":"Pair"},"ret_mode":"own"}},{"body":{"arms":[{"body":{"lit":{"kind":"int","value":1},"t":"lit"},"pat":{"ctor":"MkPair","fields":[{"name":"a","p":"var"},{"p":"wild"}],"p":"ctor"}}],"scrutinee":{"name":"p","t":"var"},"t":"match"},"kind":"fn","name":"use_first","params":["p"],"type":{"effects":[],"k":"fn","param_modes":["own"],"params":[{"k":"con","name":"Pair"}],"ret":{"k":"con","name":"Int"}}},{"body":{"args":[{"args":[{"args":[{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"build_pair","t":"var"},"t":"app"}],"fn":{"name":"use_first","t":"var"},"t":"app"}],"op":"io/print_int","t":"do"},"kind":"fn","name":"main","params":[],"type":{"effects":["IO"],"k":"fn","params":[],"ret":{"k":"con","name":"Unit"}}}],"imports":[],"name":"rc_own_param_partial_drop_leak","schema":"ailang/v0"} \ No newline at end of file diff --git a/examples/rc_own_param_partial_drop_leak.ailx b/examples/rc_own_param_partial_drop_leak.ailx new file mode 100644 index 0000000..5ee6d4c --- /dev/null +++ b/examples/rc_own_param_partial_drop_leak.ailx @@ -0,0 +1,65 @@ +; Iter 18g.tidy.fu2 RED-test fixture 1/3: dynamic-tag partial-drop +; carve-out at fn-return (Iter B / lib.rs:1093). +; +; Shape: +; - `Pair` carries two `Cell` fields (both pointer-typed). +; - `Cell` carries two `Wrap` fields (both pointer-typed). +; - `use_first` takes `(own Pair)` and pattern-binds the first +; slot only, wildcarding the second. +; - The bound slot's `Cell` is dec'd via `drop__Cell` at the +; outer arm-close (moves[a] empty → field_drop_call routes +; correctly). +; - At fn return, `p`'s `consume_count == 0`, mode = Own, and +; `moved_slots[p] = {0}`. Iter B's drop (lib.rs:1093) hits the +; dynamic-tag carve-out: moves non-empty → shallow +; `ailang_rc_dec(p)`. p's outer cell is freed; slot 1 (the +; wildcarded second Cell + its two MkWrap children = 3 cells) +; leaks. +; +; Pre-fix: live = 3 (one Cell + two MkWrap children). +; Post-fix: live = 0. +; +; This fixture is the cleanest of the three because no inner match +; is involved: the leak surfaces from Iter B alone. + +(module rc_own_param_partial_drop_leak + + (data Wrap + (ctor MkWrap (con Int))) + + (data Cell + (ctor MkCell (con Wrap) (con Wrap))) + + (data Pair + (ctor MkPair (con Cell) (con Cell))) + + (fn build_pair + (type + (fn-type + (params (con Int)) + (ret (own (con Pair))))) + (params n) + (body + (term-ctor Pair MkPair + (term-ctor Cell MkCell + (term-ctor Wrap MkWrap n) + (term-ctor Wrap MkWrap 2)) + (term-ctor Cell MkCell + (term-ctor Wrap MkWrap 3) + (term-ctor Wrap MkWrap 4))))) + + (fn use_first + (type + (fn-type + (params (own (con Pair))) + (ret (con Int)))) + (params p) + (body + (match p + (case (pat-ctor MkPair a _) 1)))) + + (fn main + (type (fn-type (params) (ret (con Unit)) (effects IO))) + (params) + (body + (do io/print_int (app use_first (app build_pair 1))))))