c5fd16a4eb
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
330 lines
14 KiB
Rust
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,
|
|
}
|
|
}
|