Files
AILang/crates/ailang-check/tests/lower_to_mir_ty.rs
T
Brummel c5fd16a4eb feat(codegen): StrRep loop-carried Str ⇒ Heap, close the #49 recur leak
mir.4 closes bug #49 (a heap-Str loop binder replaced across `recur`
leaks every superseded slab; the loop result leaks too) structurally,
by making every Str a loop binder holds OR a loop returns an owned heap
slab — then deleting the `!is_str` carve-out from BOTH codegen gates
that carried it. The leg-by-leg `!is_str` patches are gone.

Mechanism (the milestone thesis: representation in MIR, codegen reads
it). lower_to_mir sets rep = StrRep::Heap on every Str literal in a
loop-carried position; codegen's MTerm::Str arm promotes a Heap literal
via ailang_str_clone (a fresh ailang_rc_alloc slab, rc_header refcount
1). Three promotion positions, all in the Loop/Recur lowering:
  1. binder seed inits (is_str_ty on the binder type);
  2. recur args at Str-binder positions (read off the innermost
     loop_stack frame);
  3. exit-arm tail result literals (promote_tail_str_literals walks the
     loop body's tail positions when the loop returns Str).
With every loop-carried Str now owned-heap, both gates lose !is_str:
the recur superseded-value dec (lib.rs) frees each intermediate slab;
the loop-result trackability gate (drop.rs:493) lets the caller's
scope-close free the final slab.

Why both gates, and the planning-time error this corrects. The original
plan/spec scoped the deletion to the recur dec gate only, reasoning the
#49 loop result is "consumed by print". The implement-orchestrator
landed that scope (Tasks 1-2) clean and correctly BLOCKED: it took #49
from live=3 to live=1, the residual being the loop RESULT. I verified
the diagnosis empirically:
  - print BORROWS its arg (prelude: `(let s (show x) (do io/print_str
    s))`, the eob.1 heap-Str discipline), so `(print s)` does not free
    the loop result; the caller's let-binder does, via drop.rs:493.
    "consumed by print" != "freed by print" — that was the error.
  - Deleting drop.rs:493's !is_str takes #49 and the recur-literal
    fixture to live=0.
  - Deleting drop.rs:493 WITHOUT exit-arm promotion SIGSEGVs (exit 139)
    on a loop returning a static Str literal — hence the third
    promotion position and the static-exit witness.
The agent surfaced a real spec defect via an empirical BLOCK rather
than guessing; I corrected the spec refinement note (both gates, three
positions) and completed the loop-result leg inline, the context
already loaded from verifying the BLOCK (CLAUDE.md "already loaded
context" carve-out). drop.rs:493's Str carve-out is now sound to delete.

Boundary (asserted ill-typed, not constructible): a borrowed static-Str
Var seeded/recur'd into a consumed Str loop binder — rejected upstream
by uniqueness (a consumed binder position is owned).

Verification (orchestrator-run): all four leak fixtures reach live=0
under AILANG_RC_STATS — #49 (xyyy), recur-literal (reset), static-exit
(result), and the f488d31 boxed-ADT guard (2, no regression). The #49
#[ignore] is lifted. Full workspace: 708 passed / 0 failed / 2 ignored
(mir.3b baseline 702/0/3; +6 passed, -1 ignored = #49 lifted; the 2
remaining ignores are doctests). ir_snapshot: no drift (the Heap
promotion does not reach non-loop-carried literals — the
non_loop_str_literal_stays_static pin confirms at the unit level).
Neither CLAUDE.md lockstep pair touches Str representation.

New witness fixtures: examples/loop_str_recur_literal_no_leak_pin.ail
(recur-arg leg) and examples/loop_str_static_exit_no_leak_pin.ail
(loop-result leg / static-exit soundness). New pins: lower_to_mir_ty
exit-arm producer pin + the flipped seed/recur-arg pins; two runtime
leak pins alongside the lifted #49.

closes #49
2026-06-01 00:54:44 +02:00

330 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 std::path::{Path, PathBuf};
/// `<repo>/examples` — resolved from this crate's manifest dir,
/// cwd-independent (same pattern as `method_collision_pin.rs`).
fn examples_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent().expect("crates/ailang-check has parent crates/")
.parent().expect("crates/ has parent repo root")
.join("examples")
}
/// 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,
}
}