diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index 3143b89..72f50b6 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -2234,6 +2234,34 @@ fn raw_buf_int_e2e() { assert_eq!(out.trim(), "60"); } +/// Property (refs #42): an owned RawBuf that is no longer referenced +/// at the end of `main` must be dropped — every slab alloc is matched +/// by a free, so `live == 0` at exit. The flat drop function +/// `@drop_raw_buf_RawBuf` is emitted (raw-buf.4), but no call to it is +/// emitted in `main`'s body, so the slab leaks (`allocs=2 frees=1`, +/// the lone free being the `int_to_str` string from `print`). RED +/// until the drop call fires. +/// +/// NOTE: the leak is independent of binder names. The fully inline +/// form — `(app RawBuf.get (app RawBuf.set (new RawBuf (con Int) 1) +/// 0 10) 0)`, no `let` at all — produces byte-identical leaking IR, +/// as does a distinct-name (`b0`/`b1`) variant. This contradicts the +/// root-cause stated in issue #42 (uniqueness-table binder-name +/// collapse under shadow-rebind): with no binder there is nothing to +/// collapse, yet the drop call is still absent. The defect is in the +/// owned-RawBuf drop-emission decision, not in name-keyed ownership. +#[test] +fn raw_buf_owned_drop_balances_rc_stats() { + let (stdout, allocs, frees, live) = + build_and_run_with_rc_stats("raw_buf_drop_min.ail"); + assert_eq!(stdout.trim(), "10", "fixture must still read back the stored value"); + assert_eq!( + live, 0, + "owned RawBuf leaks {live} slab(s) (allocs={allocs} frees={frees}); \ + the drop call for the owned buffer must fire 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). diff --git a/examples/raw_buf_drop_min.ail b/examples/raw_buf_drop_min.ail new file mode 100644 index 0000000..716e69e --- /dev/null +++ b/examples/raw_buf_drop_min.ail @@ -0,0 +1,9 @@ +(module raw_buf_drop_min + (fn main + (doc "Minimal RawBuf drop-leak reproducer (refs #42): a single owned 1-slot Int RawBuf, written once and read once, then dropped at the end of main. Prints 10. Under AILANG_RC_STATS the owned buffer's drop call must fire, so allocs are balanced by frees (live == 0). The leak is independent of binder names — the fully inline form (app RawBuf.get (app RawBuf.set (new RawBuf (con Int) 1) 0 10) 0) with no let-binding at all leaks identically.") + (type (fn-type (params) (ret (con Unit)) (effects IO))) + (params) + (body + (app print + (let buf (new RawBuf (con Int) 1) + (app RawBuf.get (app RawBuf.set buf 0 10) 0))))))