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:
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user