BLOCKER: owned RawBuf never dropped — drop call not emitted at scope end (slab leak) #43

Closed
opened 2026-05-30 10:00:01 +02:00 by Brummel · 0 comments
Owner

An owned RawBuf whose lifetime ends inside a function body is never dropped: codegen emits @drop_raw_buf_RawBuf but emits no call to it at the owner's scope end. Every such buffer leaks its slab. Harmless for a one-shot program, accumulates in a loop. This is the true defect behind the #42 symptom — #42's stated root cause (uniqueness-table binder-name collapse under shadow-rebind) was disproven empirically (see the comment on #42).

Minimal reproducer

examples/raw_buf_drop_min.ail — a single owned 1-slot Int RawBuf, written once and read once:

(module raw_buf_drop_min
  (fn main
    (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))))))

Prints 10 correctly, but under AILANG_RC_STATS shows allocs=2 frees=1 live=1 — the slab leaks.

RED test (already in the working tree, uncommitted)

crates/ail/tests/e2e.rs::raw_buf_owned_drop_balances_rc_stats — builds the fixture with --alloc=rc, asserts stdout == "10" (green) and live == 0 (RED until fixed).

Evidence it is binder-independent (not #42's cause)

Measured under AILANG_RC_STATS:

Variant Binder situation Result
raw_buf_int.ail (3× let buf) colliding names live=1
distinct names b0/b1/b2/b3 no collision possible live=1
fully inline, no let at all no binder live=1

The fully inline form (app RawBuf.get (app RawBuf.set (new RawBuf (con Int) 1) 0 10) 0) produces byte-identical leaking IR. With no binder there is nothing for the (def_name, binder_name) key to collapse, so the leak cannot be a uniqueness-table name issue.

emit-ir shows main's body as:

%v1 = call ptr @ail_raw_buf_RawBuf_new__Int(i64 1)
%v2 = call ptr @ail_raw_buf_RawBuf_set__Int(ptr %v1, i64 0, i64 10)
%v3 = call i64 @ail_raw_buf_RawBuf_get__Int(ptr %v2, i64 0)
%v4 = call i8 @ail_prelude_print__Int(i64 %v3)
ret i8 %v4

@drop_raw_buf_RawBuf is defined but never called. The two allocs are the slab + the int_to_str string from print; the one free is the string's rc_dec.

Suspected area / next step

The owned-RawBuf drop-emission decision in crates/ailang-codegen (drop / let-close path). First suspect: the (intrinsic)/intercept interplay for RawBuf — the drop function is synthesised but its call site is not emitted for an owned RawBuf at scope end. Needs a debugger root-cause pass against the minimal reproducer before any fix is planned. Approach A from #42 is not the fix.

Relationship to #42

#42 should be reframed or closed in favour of this issue. The raw-buf.5 spec section (docs/specs/0054-raw-buf.md) is mis-scoped against both diagnoses and needs a fresh brainstorm. raw-buf.6 (kernel_stub retirement) remains after.

refs #7

An owned `RawBuf` whose lifetime ends inside a function body is never dropped: codegen emits `@drop_raw_buf_RawBuf` but emits **no call** to it at the owner's scope end. Every such buffer leaks its slab. Harmless for a one-shot program, accumulates in a loop. This is the true defect behind the #42 symptom — #42's stated root cause (uniqueness-table binder-name collapse under shadow-rebind) was disproven empirically (see the comment on #42). ## Minimal reproducer `examples/raw_buf_drop_min.ail` — a single owned 1-slot Int RawBuf, written once and read once: ``` (module raw_buf_drop_min (fn main (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)))))) ``` Prints `10` correctly, but under `AILANG_RC_STATS` shows `allocs=2 frees=1 live=1` — the slab leaks. ## RED test (already in the working tree, uncommitted) `crates/ail/tests/e2e.rs::raw_buf_owned_drop_balances_rc_stats` — builds the fixture with `--alloc=rc`, asserts `stdout == "10"` (green) and `live == 0` (RED until fixed). ## Evidence it is binder-independent (not #42's cause) Measured under `AILANG_RC_STATS`: | Variant | Binder situation | Result | |---|---|---| | `raw_buf_int.ail` (3× `let buf`) | colliding names | `live=1` | | distinct names `b0/b1/b2/b3` | no collision possible | `live=1` | | fully inline, no `let` at all | no binder | `live=1` | The fully inline form `(app RawBuf.get (app RawBuf.set (new RawBuf (con Int) 1) 0 10) 0)` produces byte-identical leaking IR. With no binder there is nothing for the `(def_name, binder_name)` key to collapse, so the leak cannot be a uniqueness-table name issue. `emit-ir` shows `main`'s body as: ``` %v1 = call ptr @ail_raw_buf_RawBuf_new__Int(i64 1) %v2 = call ptr @ail_raw_buf_RawBuf_set__Int(ptr %v1, i64 0, i64 10) %v3 = call i64 @ail_raw_buf_RawBuf_get__Int(ptr %v2, i64 0) %v4 = call i8 @ail_prelude_print__Int(i64 %v3) ret i8 %v4 ``` `@drop_raw_buf_RawBuf` is defined but never called. The two allocs are the slab + the `int_to_str` string from `print`; the one free is the string's `rc_dec`. ## Suspected area / next step The owned-RawBuf drop-emission decision in `crates/ailang-codegen` (drop / let-close path). First suspect: the `(intrinsic)`/intercept interplay for RawBuf — the drop function is synthesised but its call site is not emitted for an owned RawBuf at scope end. Needs a `debugger` root-cause pass against the minimal reproducer before any fix is planned. Approach A from #42 is **not** the fix. ## Relationship to #42 #42 should be reframed or closed in favour of this issue. The raw-buf.5 spec section (`docs/specs/0054-raw-buf.md`) is mis-scoped against both diagnoses and needs a fresh brainstorm. raw-buf.6 (kernel_stub retirement) remains after. refs #7
Brummel added this to the raw-buf milestone 2026-05-30 10:00:01 +02:00
Brummel added the bugBLOCKER labels 2026-05-30 10:00:01 +02:00
Sign in to join this conversation.