test: RED shadow-rebind RawBuf drop-leak on shipped fixtures (refs #43)

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.
This commit is contained in:
2026-05-30 10:58:07 +02:00
parent 62cd4b4ee7
commit f7f4c3b237
+68
View File
@@ -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), /// 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` /// exercising the `double` load/store emits. Prints `4.0` (the `%g`
/// whole-double `.0` fallback). /// whole-double `.0` fallback).