Skip to content

Commit 79813d9

Browse files
fix(borrow): CORE-01 pt2 — return-escape of borrow rooted at callee-owned (Refs #177) (#254)
CORE-01 pt1 (#240) only inspected *block tails* for escape, so a `return r` where `r = &local` / `r = &by-value-param` slipped through: `fn esc(x: Int) -> ref Int { let r = &x; return r; }` type-checked OK while the returned `ref` dangles once the callee frame is gone — a reachable unsoundness (verified: oracle accepted it pre-fix). Fix (bounded, mirrors pt1's BorrowOutlivesOwner rationale): - state.callee_owned_params: sym-ids of params with effective ownership None (by-value) or Some Own. ref/mut params are caller-owned referents and stay out (returning a borrow of them is sound). - returned_borrow: the borrow a returned expr denotes — a direct &place/&mut place, or a ref binder `r` resolved in the live state.ref_bindings graph (None for a returned *value*, incl. *r). - check_return_escape: if that borrow's root is a callee-owned param OR a function local (block_local_syms) -> BorrowOutlivesOwner. Wired at ExprReturn(Some e) (graph live during traversal — catches `return r`), FnExpr tail, and FnBlock direct-`&param` implicit tail (pt1 covers the block-local implicit tail). Verified (oracle): `return &x`/`return r`(r=&x) for by-value param AND local now ERROR; `fn ok(x: ref Int) -> ref Int { return x; }` still passes (no over-rejection of the caller-owned case); `*r`/value returns and local-only borrows still pass; #240 block-escape + move-twice regressions intact. Full gate 271 -> 274 (+3 hermetic "E2E Borrow Graph" return-escape tests + 3 fixtures); ALL 20 stdlib AOT green — zero corpus over-rejection. Honest scope (bidirectional-evidence finding): CORE-01 pt2's residual items — NLL/region inference (Polonius), flow-sensitive escape via `outer = &x`, tighter quantity integration — are *parser-gated*, not reachable unsoundnesses today: the surface to express them does not parse (`&mut e`, `-> &T`, `&`-in-literal, bare block-statements). The next move there is the parser surface, then the analysis. Ledger + in-code comment truthed. Refs #177 (not Closes — pt2 residual remains, parser-gated). Co-authored-by: hyperpolymath <hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1ce5ff5 commit 79813d9

6 files changed

Lines changed: 182 additions & 15 deletions

File tree

docs/TECH-DEBT.adoc

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,19 @@ shared-XOR-exclusive enforced at use sites
8787
(`UseWhileExclusivelyBorrowed`); ownership derived from the param *type*
8888
(`TyOwn/TyRef/TyMut`) — owned/ref/mut discipline now enforced from real
8989
parsed source (closed a latent hole); call-arg borrows temporary;
90-
ref-binding graph tracked. *Part 2+ deferred:* NLL/region inference
91-
(Polonius), flow-sensitive escape via `outer = &x`, tighter quantity
92-
integration. |S1 |pt1 in PR #240 (open); pt2+ open — issue #177
90+
ref-binding graph tracked. *Part 2 — return-escape LANDED* (Refs #177,
91+
gate 271→274): `return e` (or fn-tail) whose value is a reference rooted
92+
at a *callee-owned* binding (local or by-value/`own` param) is now
93+
`BorrowOutlivesOwner` (pt1 only saw block tails — `return r` slipped
94+
through); `ref`/`mut` params are caller-owned and not flagged (no
95+
over-rejection; full stdlib AOT green). 3 hermetic tests in "E2E Borrow
96+
Graph". *Part 2 residual — parser-gated (honest finding):* NLL/region
97+
inference (Polonius); flow-sensitive escape via `outer = &x`; tighter
98+
quantity integration. These are *not reachable unsoundnesses today* — the
99+
surface to express them does not parse (`&mut e`, `-> &T`, `&`-in-literal,
100+
bare block-statements), so the next move is the parser surface, then the
101+
analysis. |S1 |pt1 #240 + pt2 return-escape DONE (Refs #177); pt2 residual
102+
parser-gated — issue #177
93103
|CORE-02 |Effect-handler dispatch on WasmGC (currently `UnsupportedFeature`;
94104
EH proposal or CPS). The #225 CPS line closes the async slice. |S2 |partial
95105
(PR3a/b/c merged; #234 generalises the recogniser)

lib/borrow.ml

Lines changed: 101 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@ type state = {
8686
Used to decide whether a borrowed owner is block-local (so a
8787
reference that escapes the block outlives its owner). *)
8888
mutable block_local_syms : Symbol.symbol_id list;
89+
90+
(** Sym-ids of the current function's *callee-owned* parameters: those
91+
with effective ownership [None] (by-value) or [Some Own]. A reference
92+
rooted at one of these escapes the callee frame if it is *returned*,
93+
exactly like a borrow of a block-local. [ref]/[mut] params are
94+
*caller-owned* referents and are deliberately absent here — returning
95+
a borrow of them is sound. CORE-01 pt2 / #177 (return-escape). *)
96+
mutable callee_owned_params : Symbol.symbol_id list;
8997
}
9098

9199
(** Borrow checker errors *)
@@ -143,6 +151,7 @@ let create () : state =
143151
mutable_bindings = [];
144152
ref_bindings = [];
145153
block_local_syms = [];
154+
callee_owned_params = [];
146155
}
147156

148157
(** Add a function signature to context *)
@@ -509,6 +518,50 @@ let record_ref_binding (state : state) (symbols : Symbol.t)
509518
end
510519
| _ -> ()
511520

521+
(** The borrow a *returned* expression denotes, if any: either a direct
522+
[&place] / [&mut place], or a reference binder [r] (from [let r = &p])
523+
looked up in the live borrow-graph. Returning a *value* (incl. [*r]) is
524+
not a borrow and yields [None]. CORE-01 pt2 / #177 (return-escape). *)
525+
let returned_borrow (state : state) (symbols : Symbol.t)
526+
(e : expr) : borrow option =
527+
let rec peel = function ExprSpan (x, _) -> peel x | x -> x in
528+
match ref_target symbols e with
529+
| Some target ->
530+
Some (match List.find_opt (fun b ->
531+
places_overlap b.b_place target) state.borrows with
532+
| Some b -> b
533+
| None -> { b_place = target; b_kind = Shared;
534+
b_span = expr_span e; b_id = -1 })
535+
| None ->
536+
(match peel e with
537+
| ExprVar id ->
538+
(match lookup_symbol_by_name symbols id.name with
539+
| Some sym ->
540+
List.assoc_opt sym.Symbol.sym_id state.ref_bindings
541+
| None -> None)
542+
| _ -> None)
543+
544+
(** Return-escape (CORE-01 pt2 / #177): a [return e] (or fn-tail) whose
545+
value is a reference rooted at a *callee-owned* binding — a function
546+
local or a by-value/[own] parameter — dangles once the callee frame is
547+
gone. Mirrors [BorrowOutlivesOwner]'s block-escape rationale, extended
548+
to the return position (the let-only graph in pt1 only saw block tails,
549+
so [return r] slipped through). Sound and non-over-rejecting: returning
550+
a borrow of a dying callee binding is always wrong; valid code returns
551+
the value, or a borrow of a [ref]/[mut] (caller-owned) parameter — the
552+
latter has no callee-owned root and is intentionally not flagged. *)
553+
let check_return_escape (state : state) (symbols : Symbol.t)
554+
(e : expr) : unit result =
555+
match returned_borrow state symbols e with
556+
| None -> Ok ()
557+
| Some b ->
558+
(match root_var b.b_place with
559+
| Some owner
560+
when List.mem owner state.callee_owned_params
561+
|| List.mem owner state.block_local_syms ->
562+
Error (BorrowOutlivesOwner (b, owner))
563+
| _ -> Ok ())
564+
512565
(** Check borrows in an expression *)
513566
let rec check_expr (ctx : context) (state : state) (symbols : Symbol.t) (expr : expr) : unit result =
514567
match expr with
@@ -870,7 +923,13 @@ let rec check_expr (ctx : context) (state : state) (symbols : Symbol.t) (expr :
870923

871924
| ExprReturn e_opt ->
872925
begin match e_opt with
873-
| Some e -> check_expr ctx state symbols e
926+
| Some e ->
927+
let* () = check_expr ctx state symbols e in
928+
(* CORE-01 pt2 / #177: a returned reference rooted at a callee-owned
929+
binding (local or by-value/own param) escapes the frame. The
930+
let-graph is live here, so `return r` (r = &local/&byval-param)
931+
is caught — the pt1 tail-only check missed it. *)
932+
check_return_escape state symbols e
874933
| None -> Ok ()
875934
end
876935

@@ -1057,16 +1116,33 @@ and check_stmt (ctx : context) (state : state) (symbols : Symbol.t) (stmt : stmt
10571116
let check_function (ctx : context) (symbols : Symbol.t) (fd : fn_decl) : unit result =
10581117
let state = create () in
10591118
List.iter (fun (p : param) ->
1060-
if param_ownership p = Some Mut then
1061-
match lookup_symbol_by_name symbols p.p_name.name with
1062-
| Some sym ->
1063-
let place = PlaceVar (p.p_name.name, sym.sym_id) in
1064-
state.mutable_bindings <- place :: state.mutable_bindings
1065-
| None -> ()
1119+
let own = param_ownership p in
1120+
(match lookup_symbol_by_name symbols p.p_name.name with
1121+
| Some sym ->
1122+
if own = Some Mut then
1123+
state.mutable_bindings <-
1124+
PlaceVar (p.p_name.name, sym.sym_id) :: state.mutable_bindings;
1125+
(* Callee-owned params (by-value [None] or [Some Own]); [ref]/[mut]
1126+
are caller-owned referents and stay out. CORE-01 pt2 / #177. *)
1127+
(match own with
1128+
| None | Some Own ->
1129+
state.callee_owned_params <-
1130+
sym.sym_id :: state.callee_owned_params
1131+
| Some Ref | Some Mut -> ())
1132+
| None -> ())
10661133
) fd.fd_params;
10671134
match fd.fd_body with
1068-
| FnBlock blk -> check_block ctx state symbols blk
1069-
| FnExpr e -> check_expr ctx state symbols e
1135+
| FnBlock blk ->
1136+
let* () = check_block ctx state symbols blk in
1137+
(* Implicit-tail return of a *direct* `&param` (no `let`, no `return`):
1138+
the pt1 block-tail check only knew block-locals, not by-value params.
1139+
`return`-statement escapes are caught inline in ExprReturn. *)
1140+
(match blk.blk_expr with
1141+
| Some tail -> check_return_escape state symbols tail
1142+
| None -> Ok ())
1143+
| FnExpr e ->
1144+
let* () = check_expr ctx state symbols e in
1145+
check_return_escape state symbols e
10701146
| FnExtern -> Ok () (* No body to borrow-check *)
10711147

10721148
(** Check a program *)
@@ -1094,9 +1170,22 @@ let check_program (symbols : Symbol.t) (program : program) : unit result =
10941170
reference to an owner declared inside that block is a dangling borrow.
10951171
record_move / record_borrow / end_borrow are all live now.
10961172
1097-
Deferred to a later CORE-01 part (tracked in docs/TECH-DEBT.adoc):
1173+
CORE-01 pt2 (2026-05-19): return-escape. A `return e` (or fn-tail)
1174+
whose value is a reference rooted at a callee-owned binding — a function
1175+
local or a by-value/[own] parameter — is now [BorrowOutlivesOwner]
1176+
(check_return_escape, state.callee_owned_params). pt1 only inspected
1177+
block tails, so `return r` (r = &local/&by-value-param) slipped through.
1178+
[ref]/[mut] params are caller-owned referents and are intentionally not
1179+
flagged (probed: `fn ok(x: ref Int) -> ref Int { return x; }` passes).
1180+
Sound + non-over-rejecting (full stdlib AOT + borrow suite green).
1181+
1182+
Still deferred to a later CORE-01 part (docs/TECH-DEBT.adoc) — and note
1183+
these are *parser-gated*: the surface to express them does not parse
1184+
today (`&mut e`, `-> &T`, `&`-in-literal, bare block-statements), so
1185+
they are not reachable unsoundnesses until the surface lands:
10981186
- Non-lexical lifetimes with region inference (Polonius-style).
1099-
- Dataflow analysis for flow-sensitive reference-escape via assignment
1100-
to an outer mutable (the let-only graph does not yet cover `outer = &x`).
1187+
- Flow-sensitive escape via assignment to an outer mutable
1188+
(`outer = &x`) — blocked on assignment-of-borrow + inner-block-stmt
1189+
surface, not just the analysis.
11011190
- Tighter integration with the quantity checker for captured linears.
11021191
*)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Copyright (c) 2026 Jonathan D.A. Jewell
3+
//
4+
// CORE-01 pt2 / #177 return-escape: returning a reference rooted at a
5+
// function-local also dangles. `return`-statement form (not a block tail).
6+
7+
module BorrowRetEscapeLocal;
8+
9+
fn esc() -> ref Int {
10+
let x = 5;
11+
let r = &x;
12+
return r;
13+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Copyright (c) 2026 Jonathan D.A. Jewell
3+
//
4+
// CORE-01 pt2 / #177 return-escape: returning a reference rooted at a
5+
// by-value (callee-owned) parameter dangles once the callee frame is gone.
6+
// pt1 only saw block tails, so `return r` (r = &x) slipped through.
7+
8+
module BorrowRetEscapeParam;
9+
10+
fn esc(x: Int) -> ref Int {
11+
let r = &x;
12+
return r;
13+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Copyright (c) 2026 Jonathan D.A. Jewell
3+
//
4+
// CORE-01 pt2 / #177 non-over-rejection guard: returning a borrow of a
5+
// `ref` (caller-owned) parameter is SOUND and must still pass — the
6+
// returned reference has no callee-owned root.
7+
8+
module BorrowRetRefParamOk;
9+
10+
fn ok(x: ref Int) -> ref Int {
11+
return x;
12+
}

test/test_e2e.ml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3664,13 +3664,43 @@ let test_borrow_call_arg_then_use () =
36643664
Alcotest.fail ("valid call-arg-then-use spuriously rejected: "
36653665
^ Borrow.format_borrow_error e)
36663666

3667+
(* CORE-01 pt2 / #177 — return-escape. *)
3668+
let test_borrow_return_escape_param () =
3669+
match borrow_result (fixture "borrow_return_escape_param.affine") with
3670+
| Error (Borrow.BorrowOutlivesOwner _) -> ()
3671+
| Error e ->
3672+
Alcotest.fail ("expected BorrowOutlivesOwner (return &by-value-param), got: "
3673+
^ Borrow.format_borrow_error e)
3674+
| Ok () -> Alcotest.fail "return &(by-value param) escaped uncaught"
3675+
3676+
let test_borrow_return_escape_local () =
3677+
match borrow_result (fixture "borrow_return_escape_local.affine") with
3678+
| Error (Borrow.BorrowOutlivesOwner _) -> ()
3679+
| Error e ->
3680+
Alcotest.fail ("expected BorrowOutlivesOwner (return &local), got: "
3681+
^ Borrow.format_borrow_error e)
3682+
| Ok () -> Alcotest.fail "return &local escaped uncaught"
3683+
3684+
let test_borrow_return_refparam_ok () =
3685+
match borrow_result (fixture "borrow_return_refparam_ok.affine") with
3686+
| Ok () -> ()
3687+
| Error e ->
3688+
Alcotest.fail ("sound `return ref-param` spuriously rejected: "
3689+
^ Borrow.format_borrow_error e)
3690+
36673691
let borrow_tests = [
36683692
Alcotest.test_case "BorrowOutlivesOwner: &local escapes its block"
36693693
`Quick test_borrow_outlives_owner;
36703694
Alcotest.test_case "UseWhileExclusivelyBorrowed: mut+read in one call"
36713695
`Quick test_borrow_use_while_excl;
36723696
Alcotest.test_case "temporary call-arg borrow released (no over-reject)"
36733697
`Quick test_borrow_call_arg_then_use;
3698+
Alcotest.test_case "return-escape: return &(by-value param) rejected (#177 pt2)"
3699+
`Quick test_borrow_return_escape_param;
3700+
Alcotest.test_case "return-escape: return &(local) rejected (#177 pt2)"
3701+
`Quick test_borrow_return_escape_local;
3702+
Alcotest.test_case "return ref-param sound — not over-rejected (#177 pt2)"
3703+
`Quick test_borrow_return_refparam_ok;
36743704
]
36753705

36763706
(* ============================================================================

0 commit comments

Comments
 (0)