diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index 72f50b6..93c21ad 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -2262,6 +2262,74 @@ fn raw_buf_owned_drop_balances_rc_stats() { ); } +/// Property (A2, refs #42 / #43): the shadow-rebind RawBuf idiom — +/// `(let buf (new …) (let buf (set buf …) … (get buf)))` — must drop +/// its owned slab at scope close. This is the LLM-natural shipped +/// form (`examples/raw_buf_int.ail`, the actual #42 symptom), not a +/// reduced repro: a 3-slot Int buffer filled then summed via three +/// borrowing `get`s. The final `buf` binder is dead after the last +/// `get` and must be dec'd once. +/// +/// This is a SEPARATE defect from the A1 anonymous-temp leak fixed in +/// 62cd4b4. Here the value reaching `get` is a bound `Term::Var`, not +/// an anonymous call temp, so A1's call-site rule correctly does not +/// fire. The leak is in uniqueness inference: `RawBuf.get`/`.size` +/// take their RawBuf param `borrow`, but their post-mono callee Var +/// (`raw_buf.RawBuf_get__Int`) is absent from the uniqueness pass's +/// `globals` map (registered only under the bare local name +/// `RawBuf_get__Int`), so `callee_arg_modes` misses, every arg +/// defaults to `Position::Consume`, and the borrowed `buf` is counted +/// as a consume. That inflates the binder's `consume_count` to 1, +/// suppressing the `Term::Let` scope-close drop gate +/// (`crates/ailang-codegen/src/lib.rs:1795`, `consume_count == 0`). +/// +/// NOT issue #42's "shadow-name collapse": #42's own evidence shows +/// the distinct-name variant (`b0`/`b1`/…) leaks identically, so the +/// defect is the borrow-counted-as-consume default, not name-keyed +/// ownership. RED until the borrow modes of the intrinsic ops are +/// visible to the consume-counter. +#[test] +fn raw_buf_int_shadow_rebind_drop_balances_rc_stats() { + let (stdout, allocs, frees, live) = + build_and_run_with_rc_stats("raw_buf_int.ail"); + assert_eq!(stdout.trim(), "60", "fixture must still sum to 60"); + assert_eq!( + live, 0, + "shadow-rebind RawBuf leaks {live} slab(s) (allocs={allocs} frees={frees}); \ + the final `buf` binder must be dropped at end of main" + ); +} + +/// A2 Float sibling: the shadow-rebind drop must balance for the +/// `double`-element shipped fixture too (`examples/raw_buf_float.ail`, +/// prints `4.0`). Same mechanism and RED state as the Int case. +#[test] +fn raw_buf_float_shadow_rebind_drop_balances_rc_stats() { + let (stdout, allocs, frees, live) = + build_and_run_with_rc_stats("raw_buf_float.ail"); + assert_eq!(stdout.trim(), "4.0", "fixture must still print 4.0"); + assert_eq!( + live, 0, + "shadow-rebind RawBuf leaks {live} slab(s) (allocs={allocs} frees={frees}); \ + the final `buf` binder must be dropped at end of main" + ); +} + +/// A2 Bool sibling: the shadow-rebind drop must balance for the +/// `i1`-element shipped fixture too (`examples/raw_buf_bool.ail`, +/// prints `42`). Same mechanism and RED state as the Int case. +#[test] +fn raw_buf_bool_shadow_rebind_drop_balances_rc_stats() { + let (stdout, allocs, frees, live) = + build_and_run_with_rc_stats("raw_buf_bool.ail"); + assert_eq!(stdout.trim(), "42", "fixture must still print 42"); + assert_eq!( + live, 0, + "shadow-rebind RawBuf leaks {live} slab(s) (allocs={allocs} frees={frees}); \ + the final `buf` binder must be dropped at end of main" + ); +} + /// raw-buf.4 Float variant: a 2-slot Float RawBuf (1.5 + 2.5), /// exercising the `double` load/store emits. Prints `4.0` (the `%g` /// whole-double `.0` fallback).