codegen rc: tag-conditional partial-drop helper closes 3 carve-outs
Three sibling sites previously fell back to shallow `ailang_rc_dec` when a binder had non-empty `moved_slots` and a dynamic runtime ctor tag: Iter B Own-param dec at fn-return (lib.rs), Iter A arm-close pattern-binder dec (match_lower.rs), and emit_inlined_partial_drop's non-Ctor branch (drop.rs). Shallow freed the outer cell but did not walk the unmoved ptr fields — silent leak. Adds `emit_partial_drop_fn_for_type`: a per-ADT helper structurally parallel to `emit_drop_fn_for_type` but taking an i64 moved-slots bitmask. Each ptr-field's dec is gated on the corresponding mask bit; the outer box is dec'd at join. Emitted alongside the per-type drop fn for every ADT under --alloc=rc. Three call sites rewritten to resolve the partial_drop symbol from the binder's static type and build the mask from `moved_slots`. Three RED-then-GREEN fixtures (rc_own_param_/rc_match_arm_/ rc_app_let_partial_drop_leak) pin the carve-outs in regression coverage; live cell counts go from 3/1/3 (pre-fix) to 0/0/0. e2e count: 66 → 69. The pre-existing `alloc_rc_own_param_dec_at_fn_return` IR-shape assertion is widened to accept `partial_drop_<m>_<T>(%arg_xs, i64 mask)` as a third valid drop shape (the canonical fu2 case). Closes the JOURNAL queue's only remaining iter; the "wait for organic fixture" stance from the 18g tidy is retracted — known leaks are bugs, CLAUDE.md's TDD rule covers them autonomously.
This commit is contained in:
+140
-7
@@ -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_<m>_IntList(ptr %arg_xs)` (empty
|
||||
// moved_slots case),
|
||||
// - tag-conditional partial-drop
|
||||
// `@partial_drop_<m>_IntList(ptr %arg_xs, i64 <mask>)`
|
||||
// (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_<m>_Pair(p,
|
||||
/// mask)`, which dispatches on the runtime tag and dec's slot 1
|
||||
/// (the unmoved field) via `drop_<m>_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_<m>_<T>(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_<m>_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_<m>_<T>(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_<m>_<T>(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_<m>_<T>(p, mask)`"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user