fix: drop owned temp passed to a borrow slot at the call site (closes #43)

GREEN side of the owned-RawBuf drop-leak. RED test
raw_buf_owned_drop_balances_rc_stats (47efbdb) now passes:
allocs=2 frees=2 live=0, stdout still "10".

The leak. An owned temporary returned by an Own-ret call and then
passed only to a `borrow`-mode parameter is dead after that call but
was dropped by no one. In the repro, RawBuf.set returns (own RawBuf)
in-place (the intercept emits `ret ptr %b`, the same slab it received),
and that result is passed to RawBuf.get whose RawBuf param is `borrow`,
so get does not consume it and returns an i64. After get the owned
slab is dead. Codegen's two scope-close drop emitters cover only
Term::Let binders (the let gate) and Own fn-params; the general call
lowerer emit_call spliced args without inspecting per-arg modes, so an
owned arg landing in a borrow slot was never dec'd. The leak is
binder-independent — the fully inline form with no `let` at all leaks
byte-identically, which is why the fix lives at the call site, not at
the let gate.

The fix (emit_call, codegen-only, +50 lines, uniqueness.rs untouched).
After a non-tail call, for each argument that is itself an
is_rc_heap_allocated owned temporary (Own-ret call / fresh escaping
ctor / lambda — never a plain Var alias, whose owner is some other
binder dropped elsewhere) AND lands in a Borrow-mode param slot, emit
a drop of that argument's SSA through the same per-type drop symbol the
let gate uses (drop_symbol_for_binder). Per-param modes are read from
the Ail-level Type::Fn.param_modes in module_def_ail_types (which
survives monomorphisation: RawBuf_get__Int -> [Borrow, Implicit]);
note FnSig itself carries only LLVM type strings, not modes.

Why it cannot double-drop or drop a live value:
- set's buffer param is Own, not Borrow, so the rule does not fire for
  the new->set link; the single drop lands on the final temp after get.
  set is in-place (same pointer in and out), so that one drop frees the
  one slab exactly once.
- the rule excludes Var args, so it never races the let/Own-param
  emitters for a named binder.
- the dropped SSA is an input arg, always distinct from the fresh call
  result, and the rule is gated on !tail, so it can never dec a value
  that flows out as the surrounding fn's result.

Known limitation (advisory, no fixture exercises it, stated in a code
comment): a musttail call passing an owned temp into a borrow slot
still leaks — a tail call terminates the block, leaving no post-call
emission point.

Verified: full workspace green; ail e2e 95/95 incl. the 4 raw_buf
fixtures (60/4.0/42/reject) and the now-GREEN drop test; codegen
intercepts_bijection_with_intrinsic_markers green.
This commit is contained in:
2026-05-30 10:49:49 +02:00
parent 47efbdb5c0
commit 62cd4b4ee7
+50
View File
@@ -2585,6 +2585,56 @@ impl<'a> Emitter<'a> {
module = target_module,
name = target_def,
));
// owned-temp drop at the call site. An argument that is
// itself an Own-returning heap allocation (an anonymous owned
// temporary, e.g. the in-place result of `RawBuf.set`) and
// that lands in a `borrow`-mode parameter slot is borrowed,
// not consumed, by the callee. After the call returns it is
// dead and no binder owns it, so neither scope-close drop
// emitter (the `Term::Let` binder gate, the Own fn-param drop)
// covers it. Emit its drop here, routed through the same
// per-type drop symbol the let gate uses. Only in the non-tail
// path: a tail call terminates the block (the call result IS
// the fn result) and there is no post-call point to emit at.
//
// Scope of the rule (intentionally narrow, per the raw-buf.5
// steer — do NOT blanket-drop every owned arg):
// - the arg is `is_rc_heap_allocated` (Own-ret call / fresh
// escaping ctor / lambda); a plain `Term::Var` arg is an
// alias whose owner is some other binder and is dropped
// there, never here;
// - the matching callee param mode is `Borrow` — `Own` slots
// consume the arg (the callee dec's it), `Implicit` is the
// back-compat lane that carries no transfer signal;
// - the dropped SSA is never the call result `dst` (an input
// argument SSA is always distinct from the freshly-minted
// result SSA), so this can never dec a value that flows out
// as the surrounding fn's result.
if !tail {
if let Some(Type::Fn { param_modes, .. }) = self
.module_def_ail_types
.get(target_module)
.and_then(|m| m.get(target_def))
.cloned()
{
for (i, (arg, (arg_ssa, arg_ty))) in
args.iter().zip(compiled_args.iter()).enumerate()
{
let is_borrow_slot =
matches!(param_modes.get(i), Some(ParamMode::Borrow));
if is_borrow_slot
&& arg_ty == "ptr"
&& arg_ssa != &dst
&& self.is_rc_heap_allocated(arg)
{
let drop_sym = self.drop_symbol_for_binder(arg, arg_ssa);
self.body.push_str(&format!(
" call void @{drop_sym}(ptr {arg_ssa})\n"
));
}
}
}
}
if tail {
self.body
.push_str(&format!(" ret {ret} {dst}\n", ret = sig.ret));