Files
AILang/crates/ailang-check/tests/lower_to_mir_ty.rs
T
Brummel d72fe0c2e6 fix(codegen): drop branch-consume-split owned params (#63 leg 3)
The last leg of the #63 drop-soundness cluster: an (own ...) heap param
consumed on one match-arm / if-branch but live on a sibling was never
dropped on the live path, leaking one slab per call. This was the
residual live=2 leak in series_sma that kept the series headline from
being leak-clean.

Root: the per-fn aggregate consume_count (the worst-case max over all
branches from uniqueness::merge_states) was codegen's only consume
signal, and every owned-param drop site gated on it. A param consumed
on SOME branch makes the aggregate >= 1, so every site skipped it —
including the branch where it stays live. match and if share this root
(if is first-class MTerm::If, not desugared to match).

Mechanism (spec 0068):
- CAPTURE: uniqueness retains the per-branch consume snapshots it
  already computes per branch and discarded at the max merge — new
  BranchConsume + infer_module_with_cross_branches (the aggregate-only
  infer_module_with_cross now delegates and drops the channel).
- CARRY: additive MArm.consume / MTerm::If.{then,else}_consume, attached
  by lower_to_mir via a traversal-order cursor (post-order pop, kind- and
  exhaustion-asserts make a desync a loud panic; neutral for const bodies
  which uniqueness does not walk). The AST carries no node id, so the
  correspondence is the structural pre-order both walks share — pinned by
  branch_consume_maps_attach_to_matching_arms.
- GATE: emit_leakclass_branch_param_drops fires a fall-through drop only
  for the leak class (branch_consume==0 AND aggregate>=1), in match arms
  + both if branches; the pre-tail-call dec switches its gate source from
  the aggregate to per-arm. The fn-return dec and arm-close pattern-binder
  dec are UNCHANGED.

Safety:
- Double-free: the drop sites partition by aggregate (==0 -> existing
  fn-return/pre-tail-call; >=1 -> new per-branch). Disjoint, so no param
  is dropped twice — no fn-return disable, no tail-position analysis.
- Use-after-free: the checker rejects use-after-consume, so an
  aggregate>=1 param is provably dead past the construct (path-terminal
  drop, tail position irrelevant).
- Type eligibility (found by the full-suite gate during implement; spec
  0068 refined): the new agg>=1 site is the FIRST drop site that can
  reach a STATIC closure-pair param (a top-level fn ref like inc in
  Either.either, consumed on one arm, live on the other) — a .rodata
  constant with no rc-header. field_drop_call routes Type::Fn / static-Str
  / Type::Var to the bare ailang_rc_dec, which underflowed on it. The
  helper now drops only params with a real per-type heap-ADT drop fn
  (field_drop_call != "ailang_rc_dec"). Sound (no underflow) and
  leak-correct (a static closure/Str allocates nothing). Witnessed green
  by std_either_demo / std_list_demo / poly_rec_capture_demo.

Verification: full workspace suite green (116 binaries); both new leak
pins (if + match) and series_sma_no_leak_pin green at live=0; legs 1/2
pins still green; INTERCEPTS<->(intrinsic) bijection intact; no
Pattern::Lit reject path added.

The leg-3 helper's type precondition was applied inline by the
orchestrator (a narrowing guard clause in an already-reviewed Task-4
helper, full context loaded) after the implement-orchestrator correctly
surfaced the spec gap rather than papering over the regression.

This clears the series_sma leak tail; the series milestone (#61) close
stays a separate deliberate step (its end-to-end milestone fieldtest).

closes #63
2026-06-02 15:47:01 +02:00

348 lines
14 KiB
Rust

//! mir.1a: `lower_to_mir` fills `ty` on every node from the canonical
//! `synth`, and routes string literals to `MTerm::Str { rep: Static }`.
//! Loads the boundary witnesses as workspace fixtures (prelude +
//! kernel injected by `load_workspace`) and elaborates the whole
//! workspace, so the walk is exercised over the full prelude. These
//! pins protect the producer; codegen consumption arrives in mir.1b.
use ailang_check::elaborate_workspace;
use ailang_mir::{Callee, MTerm, MirWorkspace, Mode, StrRep};
use ailang_surface::load_workspace;
use ailang_test_support::examples_dir;
/// Load a witness fixture (prelude injected) and elaborate it to MIR.
fn elaborate_fixture(module: &str) -> MirWorkspace {
let entry = examples_dir().join(format!("{module}.ail"));
let ws = load_workspace(&entry).expect("fixture loads (prelude injected)");
elaborate_workspace(&ws).expect("witness elaborates to MIR")
}
/// A def's lowered body in the elaborated workspace.
fn body<'a>(mir: &'a MirWorkspace, module: &str, def: &str) -> &'a MTerm {
&mir.modules[module]
.defs
.iter()
.find(|d| d.name == def)
.expect("def present")
.body
}
#[test]
fn loop_seed_str_literal_lowers_to_heap_str_node() {
// #49 witness: the loop seed "x" is a Str literal → MTerm::Str.
// mir.4: a loop-carried seed is promoted to rep = Heap so codegen
// emits the str_clone heap promotion and the recur superseded-value
// dec is sound (the prior alloca value is always an owned slab).
let m = "loop_recur_str_binder_no_leak_pin";
let mir = elaborate_fixture(m);
assert_eq!(
loop_seed_rep(body(&mir, m, "main")),
Some(StrRep::Heap),
"loop seed \"x\" must lower to MTerm::Str {{ rep: Heap }}"
);
}
#[test]
fn loop_recur_literal_arg_lowers_to_heap_str_node() {
// mir.4: a literal recur arg at a Str-binder position is promoted
// to rep = Heap, mirroring the seed promotion — `(recur "reset" …)`.
let m = "loop_str_recur_literal_no_leak_pin";
let mir = elaborate_fixture(m);
assert!(
find_recur_str_arg_with_rep(body(&mir, m, "main"), StrRep::Heap),
"the recur arg literal \"reset\" must lower to MTerm::Str {{ rep: Heap }}"
);
}
#[test]
fn non_loop_str_literal_stays_static() {
// Control: a Str literal NOT in a loop-carried position keeps
// rep = Static (no spurious heap promotion). `hello` prints the
// literal "Hello, AILang." via io/print_str.
let m = "hello";
let mir = elaborate_fixture(m);
assert!(
find_str_node_with_rep(body(&mir, m, "main"), StrRep::Static),
"a non-loop Str literal must stay MTerm::Str {{ rep: Static }}"
);
}
#[test]
fn loop_exit_arm_str_literal_lowers_to_heap_str_node() {
// mir.4 loop-result leg: a Str literal in a loop exit (tail) arm is
// promoted to rep = Heap, so the loop returns an owned heap slab the
// caller's scope-close can safely free. `loop_str_static_exit` exits
// with the literal "result"; it is the only Str literal in the body.
let m = "loop_str_static_exit_no_leak_pin";
let mir = elaborate_fixture(m);
assert!(
find_str_node_with_rep(body(&mir, m, "main"), StrRep::Heap),
"the loop exit-arm literal \"result\" must lower to MTerm::Str {{ rep: Heap }}"
);
assert!(
!find_str_node_with_rep(body(&mir, m, "main"), StrRep::Static),
"no Str literal in the static-exit fixture should remain Static"
);
}
#[test]
fn new_over_user_adt_carries_node_types() {
// #53 witness: every node has a filled `ty`; the lowered
// `(new Counter 42)` node's type is the user ADT `Counter`.
//
// A *monomorphic* `new` (no leading type-arg) is desugared to a
// type-scoped call `(app Counter.new 42)` before lower_to_mir runs
// (desugar.rs:1083-1098) — so the let-init lowers to `MTerm::App`,
// not `MTerm::New`. `MTerm::New` survives only for a *polymorphic*
// `new` carrying a written `NewArg::Type` (the #51 RawBuf case).
// What this pin protects is the ty-fill: the lowered init node
// carries the checker-proved result type `Counter`.
let m = "new_counter_user_adt";
let mir = elaborate_fixture(m);
let MTerm::Let { init, .. } = body(&mir, m, "main") else {
panic!("main body is a let");
};
let ty_str = ailang_core::pretty::type_to_string(&init.ty());
assert!(
ty_str.contains("Counter"),
"lowered (new Counter 42) init node ty is Counter, got {ty_str}"
);
// mir.2: the `(new Counter 42)` desugars to `App{callee:
// Var{"Counter.new"}}`; lower_to_mir must resolve it to a
// `Callee::Static` whose module is `Counter`'s home (its own
// module, spelled bare per the own-module-types-stay-bare rule),
// NOT leave it `Indirect` for codegen's deleted ladder to resolve.
let MTerm::App { callee, .. } = init.as_ref() else {
panic!("let init is the (new Counter 42) App node");
};
match callee {
ailang_mir::Callee::Static { module, fn_name, .. } => {
assert_eq!(module, "new_counter_user_adt", "Counter.new home module");
assert_eq!(fn_name, "new", "Counter.new fn_name");
}
other => panic!("expected Callee::Static for Counter.new, got {other:?}"),
}
}
#[test]
fn rawbuf_size_only_elaborates() {
// #51 witness: a RawBuf read only for size — must elaborate clean
// (the element type carried only by the author's annotation does
// not block lowering).
let mir = elaborate_fixture("new_rawbuf_size_only");
assert!(mir.modules.contains_key("new_rawbuf_size_only"));
}
#[test]
fn poly_free_fn_accumulating_into_own_adt_elaborates() {
// Producer pin (mono ↔ lower_to_mir boundary): a polymorphic free
// fn whose accumulator type-arg instantiates to the *defining
// module's own* ADT must elaborate. `std_list.fold_left` is
// `forall a b. ((b, a) -> b, b, List<a>) -> b`; `List_reverse`
// calls it with `b := List<Int>` — the accumulator is std_list's
// OWN `List`. mono normalises the observed type-arg to the
// registry-canonical qualified form (`std_list.List`) for dedup +
// symbol naming, but that qualified form must be localised back to
// the module's bare convention before it is substituted into the
// (own-bare) polymorphic signature and body. Without the
// localisation the synthesised specialisation is self-inconsistent
// — a qualified-`std_list.List` accumulator parameter against a
// bare-`List` list argument and Lam body — and the post-mono
// `synth` re-entry in `lower_to_mir` rejects it with
// `expected std_list.List<Int>, got List<Int>`.
let mir = elaborate_fixture("std_list_demo");
assert!(mir.modules.contains_key("std_list"));
}
#[test]
fn callee_classification_builtin_and_static() {
// mir.2: `lower_to_mir::classify_callee` resolves each App callee
// against check's own synth ladder, so codegen reads the resolved
// identity off MIR. This pins two of the three legs from the
// `classify_pin` witness: an arithmetic op (`+`) is a `Builtin`
// (no owning module), a bare same-module user fn (`bump`) is a
// `Static` carrying its own module name. The `Indirect` leg (a
// fn-typed local) is covered by the existing closure e2e tests.
let m = "classify_pin";
let mir = elaborate_fixture(m);
// `bump`'s body is `(app + n 1)` → the `+` callee is a Builtin.
match body(&mir, m, "bump") {
MTerm::App { callee: Callee::Builtin { name, .. }, .. } => {
assert_eq!(name, "+", "arithmetic op classified as Builtin");
}
other => panic!("expected Builtin `+`, got {other:?}"),
}
// `main`'s body is `(app bump 41)` → `bump` is a same-module user
// fn → Static{module: "classify_pin", fn_name: "bump"}.
match body(&mir, m, "main") {
MTerm::App { callee: Callee::Static { module, fn_name, .. }, .. } => {
assert_eq!(module, "classify_pin", "own-module callee module");
assert_eq!(fn_name, "bump", "own-module callee fn_name");
}
other => panic!("expected Static `bump`, got {other:?}"),
}
}
#[test]
fn mirdef_consume_is_populated_for_let_binder() {
// mir.3a: lower_to_mir runs the uniqueness pass and attaches the
// per-binder consume_count to MirDef.consume. `main`'s `(let c …)`
// binder must appear in the map (the exact count is validated
// end-to-end by the RC-stats leak pins; this pin guards that the
// producer fills the field at all, so codegen has it to read).
let ws = elaborate_fixture("new_counter_user_adt");
let m = ws.modules.get("new_counter_user_adt").expect("module");
let main = m.defs.iter().find(|d| d.name == "main").expect("main");
assert!(
main.consume.contains_key("c"),
"MirDef.consume must carry the `c` let-binder, got {:?}",
main.consume,
);
}
#[test]
fn app_arg_carries_callee_borrow_mode() {
// mir.3b: lower_to_mir fills MArg.mode from the callee's param_modes.
// In `(app RawBuf.get <set-result> 0)`, RawBuf.get's param 0 is a
// borrow receiver, so the outer App's arg 0 must be Mode::Borrow —
// the value codegen's anon-temp drop gate now reads off MIR.
let mir = elaborate_fixture("raw_buf_drop_min");
// main is `(app print (let buf (new RawBuf …)
// (app RawBuf.get (app RawBuf.set …) 0)))`, so the RawBuf.get App
// is nested under the print App's arg and the let body. Mono mangles
// `RawBuf.get` to `RawBuf_get__Int`.
let outer = find_app_with_fn(body(&mir, "raw_buf_drop_min", "main"), "RawBuf_get")
.expect("RawBuf.get App node");
let MTerm::App { args, .. } = outer else {
unreachable!("find_app_with_fn returns an App");
};
assert!(
matches!(args[0].mode, Mode::Borrow),
"RawBuf.get arg 0 (borrow receiver) must be Mode::Borrow, got {:?}",
args[0].mode,
);
}
/// First `MTerm::App` whose resolved callee (`Static`/`Builtin`) names a
/// fn equal to (or a monomorphised specialisation of) `fn_name`,
/// searching App args and Let init/body. The fixture's `RawBuf.get`
/// callee may be mangled by mono (e.g. `get__Int`), so the match is on
/// the fn-name stem.
fn find_app_with_fn<'a>(t: &'a MTerm, fn_name: &str) -> Option<&'a MTerm> {
let names = match t {
MTerm::App { callee: Callee::Static { fn_name: n, .. }, .. } => Some(n.as_str()),
MTerm::App { callee: Callee::Builtin { name: n, .. }, .. } => Some(n.as_str()),
_ => None,
};
if let Some(n) = names {
if n == fn_name || n.starts_with(&format!("{fn_name}__")) {
return Some(t);
}
}
match t {
MTerm::App { args, .. } => args.iter().find_map(|a| find_app_with_fn(&a.term, fn_name)),
MTerm::Let { init, body, .. } => {
find_app_with_fn(init, fn_name).or_else(|| find_app_with_fn(body, fn_name))
}
_ => None,
}
}
/// The rep of the first loop binder seed that is an `MTerm::Str`,
/// searching `Let`-init / `Let`-body / the loop's own binders.
fn loop_seed_rep(t: &MTerm) -> Option<StrRep> {
match t {
MTerm::Loop { binders, body, .. } => binders
.iter()
.find_map(|b| match &b.init {
MTerm::Str { rep, .. } => Some(*rep),
_ => None,
})
.or_else(|| loop_seed_rep(body)),
MTerm::Let { init, body, .. } => {
loop_seed_rep(init).or_else(|| loop_seed_rep(body))
}
_ => None,
}
}
/// True if any `Recur` arg in `t` is an `MTerm::Str` with the given rep.
fn find_recur_str_arg_with_rep(t: &MTerm, want: StrRep) -> bool {
match t {
MTerm::Recur { args, .. } => args.iter().any(|a| matches!(
&a.term, MTerm::Str { rep, .. } if *rep == want
)),
MTerm::Loop { binders, body, .. } => {
binders.iter().any(|b| find_recur_str_arg_with_rep(&b.init, want))
|| find_recur_str_arg_with_rep(body, want)
}
MTerm::Let { init, body, .. } => {
find_recur_str_arg_with_rep(init, want)
|| find_recur_str_arg_with_rep(body, want)
}
MTerm::If { cond, then, else_, .. } => {
find_recur_str_arg_with_rep(cond, want)
|| find_recur_str_arg_with_rep(then, want)
|| find_recur_str_arg_with_rep(else_, want)
}
_ => false,
}
}
/// True if any `MTerm::Str` reachable in `t` carries the given rep
/// (used by the non-loop control: a plain literal stays Static).
fn find_str_node_with_rep(t: &MTerm, want: StrRep) -> bool {
match t {
MTerm::Str { rep, .. } => *rep == want,
MTerm::Let { init, body, .. } => {
find_str_node_with_rep(init, want) || find_str_node_with_rep(body, want)
}
MTerm::App { args, .. } => {
args.iter().any(|a| find_str_node_with_rep(&a.term, want))
}
MTerm::Do { args, .. } => {
args.iter().any(|a| find_str_node_with_rep(&a.term, want))
}
MTerm::If { cond, then, else_, .. } => {
find_str_node_with_rep(cond, want)
|| find_str_node_with_rep(then, want)
|| find_str_node_with_rep(else_, want)
}
MTerm::Loop { binders, body, .. } => {
binders.iter().any(|b| find_str_node_with_rep(&b.init, want))
|| find_str_node_with_rep(body, want)
}
_ => false,
}
}
/// #63 leg 3 lock-step: the per-arm consume map attaches to the matching
/// MArm in traversal order. In the `match_arm_consume_split_no_leak_pin`
/// fixture's `run`, `b` is consumed on the `On` arm (forwarded into
/// `sink`) and live on the `Off` arm — so traversal-order attachment must
/// land MArm.consume[b] == 0 on `Off` (source-order arms[0]) and >= 1 on
/// `On` (arms[1]). A cursor desync would mis-attach and flip these.
#[test]
fn branch_consume_maps_attach_to_matching_arms() {
let m = "match_arm_consume_split_no_leak_pin";
let mir = elaborate_fixture(m);
let arms = match body(&mir, m, "run") {
MTerm::Match { arms, .. } => arms,
other => panic!("run body is not a Match: {other:?}"),
};
assert_eq!(
arms[0].consume.get("b").copied().unwrap_or(99),
0,
"Off arm (arms[0]) must leave `b` live (consume 0); got {:?}",
arms[0].consume.get("b")
);
assert!(
arms[1].consume.get("b").copied().unwrap_or(0) >= 1,
"On arm (arms[1]) must consume `b` (>= 1); got {:?}",
arms[1].consume.get("b")
);
}