From 62cd4b4ee706d8f41e2b7c723c1acdd313853eac Mon Sep 17 00:00:00 2001 From: Brummel Date: Sat, 30 May 2026 10:49:49 +0200 Subject: [PATCH] fix: drop owned temp passed to a borrow slot at the call site (closes #43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/ailang-codegen/src/lib.rs | 50 ++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index 5955530..8d2802c 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -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));