From f7f4c3b2377a701e86fab373237b53c39b968cb7 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sat, 30 May 2026 10:58:07 +0200 Subject: [PATCH] test: RED shadow-rebind RawBuf drop-leak on shipped fixtures (refs #43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit raw_buf_{int,float,bool}_shadow_rebind_drop_balances_rc_stats guard the three shipped milestone fixtures (the LLM-natural shadow-rebind idiom `(let buf (new...) (let buf (set buf...) ...(get buf)))`) with a live==0 RC-stats assertion alongside their existing stdout check. This is a SECOND, distinct leak mechanism (A2), separate from the anonymous-temp path fixed in 62cd4b4 (A1). The A1 call-site drop does not cover it: here `get`'s arg is the BOUND `buf` (a Term::Var), not an anonymous temp, so the A1 rule (which excludes Var args) correctly does not fire, and the leak persists — allocs=2 frees=1 live=1. Cause (A2). The uniqueness pass runs post-monomorphisation (infer_module called from codegen lib.rs:993). It registers the intrinsic monomorph signatures in `globals` under their bare local name `RawBuf_get__Int` (uniqueness.rs:128), but callee_arg_modes (uniqueness.rs:390-410) looks them up by the cross-module-qualified callee-Var name `raw_buf.RawBuf_get__Int` -> MISS -> vec![] -> every arg defaults to Position::Consume (walk arm uniqueness.rs:240-242). The borrow-mode RawBuf param of `get`/`size` is thus mis-counted as a consume, inflating the final `buf` binder's consume_count from 0 to 1, which suppresses the Term::Let drop gate at codegen lib.rs:1795. The param_modes in the registered Type::Fn are correct ([Borrow, Implicit]); the only defect is the name-key mismatch that hides them from the lookup. NOT issue #42's "shadow-name collapse": #42's own evidence shows the distinct-name (b0/b1/...) variant leaks identically, so the defect is the borrow-counted-as-consume default, not name-keyed ownership. RED until callee_arg_modes resolves the qualified intrinsic name to the registered signature. The A1 test (raw_buf_owned_drop_balances_rc_stats) and the four stdout _e2e tests stay green. --- crates/ail/tests/e2e.rs | 68 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) 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).