Iter 18d.3: move-aware pattern bindings via codegen-side bookkeeping

Closes 18d.2's per-field-dec scope cut without mutating the
source. Codegen Emitter gains a per-fn-body side table:

  moved_slots: BTreeMap<binder_name, BTreeSet<field_idx>>

When lower_match destructures a pattern and binds a non-wildcard
pointer-typed slot, codegen records (scrutinee_binder, slot_idx)
in moved_slots — but does NOT mutate the source's slot. The
source box stays untouched; the bookkeeping carries the
move-out information through codegen.

Two consumer sites:

(1) Term::Let scope close. If moved_slots[binder] is non-empty,
codegen inlines a per-field dec sequence: load each pointer-
typed field whose index is NOT in moved_slots, dispatch through
field_drop_call, then ailang_rc_dec on the outer cell. If
moved_slots[binder] is empty (the common case for every shipping
fixture today), the call to drop_<m>_<T>(ptr) is unchanged from
18c.4 — no IR shape regression.

(2) lower_reuse_as_rc reuse arm. Per-field dec is now safe — the
moved-out slots are skipped via moved_slots lookup; non-moved
slots are dec'd via field_drop_call's null-guarded drop fns.
Replaces the 18d.2 punt rationale block with the new invariant.

drop_<m>_<T> itself is unchanged: it operates on a fully-owned
pointer (the cascade has no notion of "partially moved"). The
partial-move complexity is confined to top-level emission sites
that have the static info.

Why static tracking, not source-mutation: the previous attempt
(null-out the source's slot at pattern-bind point) ran into two
structural issues: (i) borrowed scrutinees must not be mutated —
null-out violates 18a's borrow contract; (ii) Desugarer creates
chains that re-scrutinise the same scrutinee across arms, and
mutation in arm 1 makes arm 2's re-load see garbage. Static
tracking sidesteps both: the source box stays bit-identical
across the entire match.

Tests:
- new fixture examples/pat_extract_partial_drop — Pair(IntList,
  IntList) destructured (a, _) — exercises moved + wildcard
  pointer slots.
- new e2e alloc_rc_partial_drop_skips_moved_keeps_wildcarded
  asserts stdout 6 under both gc and rc, plus IR shape: main's
  let-close inlines drop on the wildcarded slot but NOT on the
  moved slot.
- updated reuse_as_demo_under_rc_uses_inplace_rewrite IR-shape
  assertion — the canonical fixture moves the only pointer
  slot, so the reuse arm should contain neither @drop_ nor
  @ailang_rc_dec for fields.

Test deltas: e2e 56 -> 57 (+1). All other buckets unchanged.
cargo test --workspace green.

Known regression — narrow, deliberate, queued for 18d.4:

The prior alloc_rc_borrow_only_recursive_list_drop fixture from
18c.4 had a pattern (Cons h t) where t was bound but never
consumed downstream. Under 18c.4, drop_<m>_<IntList>(xs) at
let-close cascaded through xs.tail and freed the entire 5-cell
chain. Under 18d.3 with strategy (b), xs.tail is in
moved_slots, so the cascade is interrupted there — but t is
never consumed, so its 4-cell tail now leaks. The fixture
stdout is unchanged (still "11"), but memory hygiene under
--alloc=rc is strictly worse.

The fix is symmetric to 18c.4's known fn-parameter-dec debt:
pattern-bound binders whose consume_count is 0 at arm close
should also get a drop call emitted. Same emission seam as
let-close-drop. Queued as 18d.4. Until 18d.4 ships, fixtures
with pattern-extracted unused pointer binders leak under
--alloc=rc; this is documented and bounded (shipping fixtures
beyond the test suite generally consume every binder they
bind).
This commit is contained in:
2026-05-08 12:18:12 +02:00
parent 509c9700bf
commit c96940ea96
4 changed files with 416 additions and 28 deletions
+107
View File
@@ -1423,6 +1423,23 @@ fn reuse_as_demo_under_rc_uses_inplace_rewrite() {
!reuse_arm.contains("@ailang_rc_alloc"),
"reuse arm must not call @ailang_rc_alloc (the slot is reused in place). Reuse arm IR was:\n{reuse_arm}"
);
// Iter 18d.3: in this canonical fixture the pattern `(Cons h t)`
// moves the only pointer-typed slot of the source's old ctor
// (slot 1, the tail; slot 0 is Int and never a dec candidate).
// The reuse arm therefore must emit NEITHER a per-field drop
// call nor an outer `ailang_rc_dec` against the source — only
// the unconditional tag store and field stores. The 18d.2 leak
// (full slot leaking under the reuse arm regardless of moves)
// is closed: the precise-skip behaviour matches what 18d.3's
// moved_slots side table records for `xs` here.
assert!(
!reuse_arm.contains("@drop_"),
"reuse arm must not call any `@drop_<m>_<T>` — both pointer slots are moved in this fixture. Reuse arm IR was:\n{reuse_arm}"
);
assert!(
!reuse_arm.contains("@ailang_rc_dec"),
"reuse arm must not call `@ailang_rc_dec` against the source (refcount-1 path keeps the slot alive in place). Reuse arm IR was:\n{reuse_arm}"
);
}
/// Iter 18b: extends `alloc_rc_produces_same_stdout_as_gc` to a larger
@@ -1534,3 +1551,93 @@ fn alloc_rc_borrow_only_recursive_list_drop() {
"alloc=rc must match alloc=gc on rc_list_drop_borrow"
);
}
/// Iter 18d.3: move-aware pattern bindings — let-close inlines a
/// per-field dec sequence that skips moved slots and dec's non-moved
/// (e.g. wildcarded) pointer-typed slots.
///
/// The fixture defines a `Pair (IntList, IntList)` and pattern-matches
/// it as `(Pair a _)` — the first slot is moved into `a` (consumed by
/// `sum_list a`), the second slot is wildcarded and remains owned by
/// the Pair box. At p's let-close, codegen replaces the uniform
/// `drop_<m>_Pair` call with an inline sequence:
///
/// 1. NO load+drop for slot 0 (offset 8) — it is in
/// `moved_slots["p"]` → skipped.
/// 2. YES load+drop for slot 1 (offset 16) via
/// `@drop_<m>_IntList(<slot1_v>)` — it is non-moved.
/// 3. YES `@ailang_rc_dec(<p>)` for the outer Pair box.
///
/// Properties guarded:
/// 1. The binary runs to completion under both `--alloc=gc` and
/// `--alloc=rc`.
/// 2. Stdout matches `--alloc=gc` byte-for-byte (`6` — the sum of
/// the first list, [1,2,3]).
/// 3. The IR at p's let-close contains an inlined per-field drop
/// for slot 1 (the wildcarded IntList) but NOT the uniform
/// `drop_<m>_Pair` call against `p`.
#[test]
fn alloc_rc_partial_drop_skips_moved_keeps_wildcarded() {
let example = "pat_extract_partial_drop.ail.json";
let stdout_gc = build_and_run_with_alloc(example, "gc");
let stdout_rc = build_and_run_with_alloc(example, "rc");
assert_eq!(stdout_gc.trim(), "6");
assert_eq!(stdout_rc.trim(), "6");
assert_eq!(
stdout_gc, stdout_rc,
"alloc=rc must match alloc=gc on pat_extract_partial_drop"
);
// Re-lower under rc to inspect the IR shape directly.
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let json_path = workspace.join("examples").join(example);
let ws = ailang_core::load_workspace(&json_path).expect("load workspace");
let mut lifted_modules = std::collections::BTreeMap::new();
for (mname, m) in &ws.modules {
let desugared = ailang_core::desugar::desugar_module(m);
let lifted = ailang_check::lift_letrecs(&desugared)
.unwrap_or_else(|e| panic!("lift_letrecs in module `{mname}`: {e:?}"));
lifted_modules.insert(mname.clone(), lifted);
}
let lifted_ws = ailang_core::Workspace {
entry: ws.entry.clone(),
modules: lifted_modules,
root_dir: ws.root_dir.clone(),
};
let ir = ailang_codegen::lower_workspace_with_alloc(
&lifted_ws,
ailang_codegen::AllocStrategy::Rc,
)
.expect("lower workspace under rc");
// The inline partial drop must dispatch through the IntList drop
// fn for the wildcarded slot 1. The presence of THIS specific
// call — `@drop_pat_extract_partial_drop_IntList(ptr ...)` — in
// main's body proves a non-moved pointer slot was dec'd inline.
assert!(
ir.contains("call void @drop_pat_extract_partial_drop_IntList(ptr "),
"expected an inline `@drop_pat_extract_partial_drop_IntList(ptr ...)` for the wildcarded slot 1 of `p`. IR was:\n{ir}"
);
// Conversely, the uniform Pair drop fn (which would cascade
// through BOTH slots — the wrong shape because slot 0 was
// moved) must NOT be called at p's let-close. The Pair drop
// fn is still EMITTED as a per-type definition (used by any
// potential ADT-recursive cascade), but the call site at
// let-close must be the inlined sequence.
let main_start = ir
.find("define i8 @ail_pat_extract_partial_drop_main(")
.expect("main definition present");
let main_end_offset = ir[main_start..]
.find("\n}\n")
.expect("main definition ends with `}`");
let main_body = &ir[main_start..main_start + main_end_offset];
assert!(
!main_body.contains("@drop_pat_extract_partial_drop_Pair("),
"main's body must not call the uniform `@drop_pat_extract_partial_drop_Pair(...)` — at let-close, the moved-slot-aware inline sequence replaces it. main body was:\n{main_body}"
);
assert!(
main_body.contains("call void @ailang_rc_dec(ptr "),
"main's body must call `@ailang_rc_dec(ptr ...)` for the outer Pair box at let-close. main body was:\n{main_body}"
);
}