diff --git a/lib/borrow.ml b/lib/borrow.ml index 005d0022..e830f9a8 100644 --- a/lib/borrow.ml +++ b/lib/borrow.ml @@ -1392,10 +1392,29 @@ and check_stmt (ctx : context) (state : state) (symbols : Symbol.t) (stmt : stmt assignment path so the NLL last-use + return-escape analyses see the *current* referent after re-assignment, not the stale original. *) + (* Self-assign `r = r` guard (#177 follow-up to #395): without + this, [is_reborrow_source] reports true for the ref-binder + LHS=RHS case, pre_release ends `r`'s borrow and removes the + binding, then post-rebind calls [ref_source_borrow] which + now finds `r` unbound and returns None — net effect is `r` + silently stripped from the borrow-graph. *) + let rhs_is_self_binder = + match root_var place with + | Some binder_sym -> + let rec peel = function ExprSpan (x, _) -> peel x | x -> x in + (match peel rhs with + | ExprVar id -> + (match lookup_symbol_by_name symbols id.name with + | Some sym -> sym.Symbol.sym_id = binder_sym + | None -> false) + | _ -> false) + | None -> false + in let pre_release = match root_var place with | Some binder_sym - when is_reborrow_source state symbols rhs + when not rhs_is_self_binder + && is_reborrow_source state symbols rhs && List.mem_assoc binder_sym state.ref_bindings -> let old_borrow = List.assoc binder_sym state.ref_bindings in end_borrow state old_borrow; diff --git a/test/e2e/fixtures/ref_to_ref_return_escape.affine b/test/e2e/fixtures/ref_to_ref_return_escape.affine new file mode 100644 index 00000000..8066713a --- /dev/null +++ b/test/e2e/fixtures/ref_to_ref_return_escape.affine @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +// +// CORE-01 pt3 ref-to-ref / #177 (follow-up to #395): the strongest +// proof that the let-graph propagation works is `return r2` where +// `r2 = r1 = &local` — pre-fix this slipped past `returned_borrow`'s +// `ref_bindings` lookup. With the let-graph alias landed by #395 +// (`record_ref_binding` delegating to `ref_source_borrow`), `r2` +// inherits `r1`'s borrow on the function-local `x` and the +// return-escape check sees `root_var = x` is block-local. +// Expected: BorrowOutlivesOwner. + +module RefToRefReturnEscape; + +fn esc_via_indir() -> ref Int { + let x = 5; + let r1 = &x; + let r2 = r1; + return r2; +} diff --git a/test/test_e2e.ml b/test/test_e2e.ml index 0e5648a4..49718acb 100644 --- a/test/test_e2e.ml +++ b/test/test_e2e.ml @@ -4454,6 +4454,23 @@ let test_ref_to_ref_assign_aliases () = the subsequent write to `x` was spuriously \ rejected: " ^ Borrow.format_borrow_error e) +(* CORE-01 pt3 ref-to-ref / #177 follow-up: return-escape via + indirection. Pre-fix `let r2 = r1` did not propagate `r1`'s + ref-binding, so `returned_borrow` for `return r2` silently + returned None — the escape was missed. Post-fix `r2` inherits + `r1`'s borrow on a function-local, and the escape check fires. *) +let test_ref_to_ref_return_escape () = + match borrow_result (fixture "ref_to_ref_return_escape.affine") with + | Error (Borrow.BorrowOutlivesOwner _) -> () + | Error e -> + Alcotest.fail ("ref-to-ref return-escape: expected \ + BorrowOutlivesOwner on `return r2` where r2 = r1 = \ + &local, got: " ^ Borrow.format_borrow_error e) + | Ok () -> + Alcotest.fail "ref-to-ref return-escape regressed: the indirection \ + chain was not detected by `returned_borrow`'s \ + ref_bindings lookup — escape silently accepted" + (* CORE-01 pt3 / #177 deferred-items: assignment-clears-move. Whole-place write `x = …` after a prior move on `x` revives the place; the move-record is dropped after the RHS lands so the @@ -4510,6 +4527,8 @@ let borrow_tests = [ `Quick test_ref_to_ref_protects_owner; Alcotest.test_case "ref-to-ref assign-path: `r = s` releases + aliases (#177 pt3)" `Quick test_ref_to_ref_assign_aliases; + Alcotest.test_case "ref-to-ref return-escape via indirection (#177 pt3 follow-up)" + `Quick test_ref_to_ref_return_escape; Alcotest.test_case "assignment-clears-move: whole-place write revives moved place (#177)" `Quick test_borrow_assign_clears_move; ]