Skip to content

Commit 20b2131

Browse files
hyperpolymathclaude
andcommitted
fix(borrow): close #554 residuals (a) interprocedural + (c) reassign-old-loan
Follow-up hardening on the #554 return-borrow summaries, closing two of the three documented residuals. (a) interprocedural-through-call-result — compute_ret_borrow_params now resolves a returned call result through the callee's summary, and build_context drives all summaries to a monotone fixpoint (seed empty, recompute against callees until stable; origins only grow and are bounded by arity, so it terminates). So `fn wrap(ref x){ let t = pick(x); return t }` inherits pick's origin and `let r = wrap(a); consume(a); *r` is rejected — transitively through any wrapper depth, and across self- and mutual-recursion. (c) reassignment precision — `r = f(b)` reassigning an existing ref-binder now releases the binder's prior loan (ref-counted by b_id, so a borrow a ref-to-ref alias still holds is kept) and rebinds to the escaping call result, symmetric with the plain-& Slice-B reborrow. A later use of the old target is no longer spuriously rejected. The remaining residual (b) — the branch-merged / copy-out claim — is &-symmetric (a pre-existing lexical limitation inherited by the call-result path, not introduced by it) and is closed properly by Polonius (#553). Self-checked: ref-to-ref-alias-keeps-borrow, self/mutual recursion terminate, multi-arg interprocedural origin mapping, NLL precision preserved. Full suite 395/395 green; +2 regression fixtures. Refs #554. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 79f2f93 commit 20b2131

4 files changed

Lines changed: 208 additions & 36 deletions

File tree

lib/borrow.ml

Lines changed: 111 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,8 @@ let param_ownership (p : param) : ownership option =
202202
so it cannot reintroduce a use-after-move false negative. Bodies of
203203
nested lambdas are skipped (a [return] there is the lambda's, not this
204204
function's). #554. *)
205-
let compute_ret_borrow_params (fd : fn_decl) : int list =
205+
let compute_ret_borrow_params (lookup : string -> int list option)
206+
(fd : fn_decl) : int list =
206207
let param_idx : (string, int) Hashtbl.t = Hashtbl.create 8 in
207208
let ref_param : (string, unit) Hashtbl.t = Hashtbl.create 8 in
208209
List.iteri (fun i (p : param) ->
@@ -229,7 +230,7 @@ let compute_ret_borrow_params (fd : fn_decl) : int list =
229230
(its entry, possibly [], was set by [record_let]) wins — otherwise a
230231
value-local shadowing a ref-param would be spuriously treated as a
231232
returned borrow of that param (false positive). *)
232-
let origins_of_ref_source (e : expr) : int list =
233+
let rec origins_of_ref_source (e : expr) : int list =
233234
match peel e with
234235
| ExprUnary ((OpRef | OpMutRef), inner) ->
235236
(match root_name inner with
@@ -246,6 +247,24 @@ let compute_ret_borrow_params (fd : fn_decl) : int list =
246247
if Hashtbl.mem ref_param id.name then
247248
(match Hashtbl.find_opt param_idx id.name with Some i -> [i] | None -> [])
248249
else [])
250+
| ExprApp (f, args) ->
251+
(* Interprocedural: a call whose result borrows the callee's parameter
252+
[i] makes OUR function borrow whatever argument [i] resolves to in our
253+
frame. [lookup] returns the callee's *current* return-borrow summary,
254+
driven to a fixpoint by [build_context], so a function that returns
255+
another ref-returning call's result (e.g.
256+
[wrap(ref x){ let t = pick(x); return t }]) inherits the origin.
257+
#554 residual (a). *)
258+
(match root_name f with
259+
| Some fname ->
260+
(match lookup fname with
261+
| Some callee_sum ->
262+
List.concat_map (fun i ->
263+
match List.nth_opt args i with
264+
| Some arg -> origins_of_ref_source arg
265+
| None -> []) callee_sum
266+
| None -> [])
267+
| None -> [])
249268
| _ -> []
250269
in
251270
(* Always record the binding (even to []) so a shadowing value-local masks
@@ -403,18 +422,51 @@ let add_fn_signature (ctx : context) (fd : fn_decl) : unit =
403422
let sig_ = {
404423
fn_name = fd.fd_name.name;
405424
fn_param_ownerships = List.map param_ownership fd.fd_params;
406-
fn_ret_borrow_params = compute_ret_borrow_params fd;
425+
(* Standalone use: intraprocedural only (no callee summaries available).
426+
[build_context] recomputes interprocedurally via a fixpoint. *)
427+
fn_ret_borrow_params = compute_ret_borrow_params (fun _ -> None) fd;
407428
} in
408429
Hashtbl.replace ctx.fn_sigs fd.fd_name.name sig_
409430

410-
(** Build context from program *)
431+
(** Build context from program.
432+
433+
Return-borrow summaries ([fn_ret_borrow_params]) are computed
434+
*interprocedurally* by a monotone fixpoint: each signature is seeded with
435+
an empty summary, then every function's summary is recomputed against the
436+
callees' current summaries until none change. [compute_ret_borrow_params]
437+
resolves a returned call result through the callee's summary, so a
438+
function that returns another ref-returning call's result eventually
439+
inherits the origin. Origins only grow and are bounded by arity, so the
440+
loop terminates. #554 (interprocedural residual (a)). *)
411441
let build_context (program : program) : context =
412442
let ctx = create_context () in
413-
List.iter (fun decl ->
414-
match decl with
415-
| TopFn fd -> add_fn_signature ctx fd
416-
| _ -> ()
417-
) program.prog_decls;
443+
let fds =
444+
List.filter_map (function TopFn fd -> Some fd | _ -> None) program.prog_decls
445+
in
446+
List.iter (fun (fd : fn_decl) ->
447+
Hashtbl.replace ctx.fn_sigs fd.fd_name.name {
448+
fn_name = fd.fd_name.name;
449+
fn_param_ownerships = List.map param_ownership fd.fd_params;
450+
fn_ret_borrow_params = [];
451+
}) fds;
452+
let lookup name =
453+
match Hashtbl.find_opt ctx.fn_sigs name with
454+
| Some s -> Some s.fn_ret_borrow_params
455+
| None -> None
456+
in
457+
let changed = ref true in
458+
while !changed do
459+
changed := false;
460+
List.iter (fun (fd : fn_decl) ->
461+
let new_sum = compute_ret_borrow_params lookup fd in
462+
match Hashtbl.find_opt ctx.fn_sigs fd.fd_name.name with
463+
| Some s when new_sum <> s.fn_ret_borrow_params ->
464+
Hashtbl.replace ctx.fn_sigs fd.fd_name.name
465+
{ s with fn_ret_borrow_params = new_sum };
466+
changed := true
467+
| _ -> ()
468+
) fds
469+
done;
418470
ctx
419471

420472
(** Generate a fresh borrow ID *)
@@ -1761,6 +1813,31 @@ and check_stmt (ctx : context) (state : state) (symbols : Symbol.t) (stmt : stmt
17611813
(binder_sym, new_b) :: state.ref_bindings
17621814
| None -> ())
17631815
| None -> ());
1816+
(* #554 residual (c): [r = f(b)] reassigning an existing ref-binder
1817+
to a call whose result borrows [b]. The `&p`/ref-var reborrow
1818+
path above does not fire ([is_reborrow_source] only matches those
1819+
shapes), so release the binder's OLD loan here (ref-counted by
1820+
[b_id] so a borrow another binder still aliases is kept) and
1821+
rebind it to the escaping call-result borrows — mirroring
1822+
[record_ref_binding]'s claim and the Slice-B `&` reborrow, so a
1823+
later use of the old target is no longer spuriously rejected. *)
1824+
(match root_var place with
1825+
| Some binder_sym ->
1826+
let rec peel = function ExprSpan (x, _) -> peel x | x -> x in
1827+
(match peel rhs with
1828+
| ExprApp _ when state.result_borrows <> [] ->
1829+
let old, remaining =
1830+
List.partition (fun (s, _) -> s = binder_sym) state.ref_bindings
1831+
in
1832+
List.iter (fun (_, ob) ->
1833+
if not (List.exists (fun (_, b') -> b'.b_id = ob.b_id) remaining)
1834+
then end_borrow state ob) old;
1835+
state.ref_bindings <-
1836+
List.fold_left (fun acc b -> (binder_sym, b) :: acc)
1837+
remaining state.result_borrows;
1838+
state.result_borrows <- []
1839+
| _ -> ())
1840+
| None -> ());
17641841
Ok ()
17651842
end
17661843
| None ->
@@ -2023,6 +2100,18 @@ let check_program (symbols : Symbol.t) (program : program) : unit result =
20232100
multi-arg, and aggregates (the aggregate + assign-path + branch-tail-
20242101
summary holes a first cut missed were all found and closed).
20252102
2103+
Follow-up (also #554) closed two of the three named residuals:
2104+
- *interprocedural-through-call-result* — [compute_ret_borrow_params] now
2105+
resolves a returned call result through the callee's summary, and
2106+
[build_context] drives all summaries to a monotone fixpoint, so
2107+
[fn wrap(x: ref Int) -> ref Int { let t = pick(x); return t; }] inherits
2108+
pick's origin and [let r = wrap(a); consume(a); *r] is rejected
2109+
(transitively, through any depth of wrappers).
2110+
- *reassignment precision* — [r = f(b)] now releases the binder's prior
2111+
loan (ref-counted by [b_id]) and rebinds to the escaping call result,
2112+
so a later use of the old target is no longer spuriously rejected —
2113+
symmetric with the plain-`&` Slice-B reborrow.
2114+
20262115
Still deferred:
20272116
- Origin/region variables (true Polonius surface) — a region
20282117
var on each [TyRef]/[TyMut] with subset constraints and a
@@ -2031,33 +2120,19 @@ let check_program (symbols : Symbol.t) (program : program) : unit result =
20312120
(docs/decisions/0022-polonius-origin-variables.adoc) for the
20322121
M1-M4 migration plan; lexical checker is the merge oracle
20332122
through M3.
2034-
- #554 residuals (all strictly smaller than the original hole; closed
2035-
properly by the Polonius origins above, #553):
2036-
(a) *interprocedural-through-call-result* — the summary is
2037-
intraprocedural and does not chase a returned borrow whose origin
2038-
is itself ANOTHER ref-returning call result bound to a local,
2039-
e.g. [fn wrap(x: ref Int) -> ref Int { let t = pick(x); return t; }];
2040-
so [let r = wrap(a); consume(a); *r] slips through. A summary
2041-
fixpoint over the call graph would close it.
2042-
(b) *branch-merged / copied-out claim* — a borrow threaded through an
2043-
[if]/[match] arm or block tail at the BIND site
2044-
([let r = if c { pick(a) } else { pick(b) }], [let r = { pick(a) }]),
2045-
or copied out of its binder into an aggregate before the binder's
2046-
last use ([let r = pick(a); let t = (r, 0); …]), is not protected,
2047-
because the arm-merge / block-exit drops the branch-local borrow
2048-
and NLL expires the binder at the copy site. This is NOT a new
2049-
asymmetry: the lexical checker treats the byte-identical plain-`&`
2050-
programs ([let r = if c { &a } else { &b }], [let t = (r, 0)] with
2051-
[r = &a]) identically (both accept) — it is the same pre-existing
2052-
through-branch / copy-out lexical limitation, inherited by the
2053-
call-result path, not introduced by it.
2054-
(c) *reassignment precision* (over-rejection, not a soundness hole):
2055-
[r = pick(b)] reassigning an existing ref-binder does not release
2056-
the binder's OLD loan (the [&p] reborrow path does, via
2057-
[is_reborrow_source]); a later use of the old target may be
2058-
spuriously rejected. Rare; conservative direction.
2059-
- Tighter integration with the quantity checker for captured
2060-
linears (Slice D).
2123+
- #554 remaining residual (closed properly by the Polonius origins
2124+
above, #553): *branch-merged / copied-out claim* — a borrow threaded
2125+
through an [if]/[match] arm or block tail at the BIND site
2126+
([let r = if c { pick(a) } else { pick(b) }], [let r = { pick(a) }]),
2127+
or copied out of its binder into an aggregate before the binder's last
2128+
use ([let r = pick(a); let t = (r, 0); …]), is not protected, because
2129+
the arm-merge / block-exit drops the branch-local borrow and NLL
2130+
expires the binder at the copy site. This is NOT a new asymmetry: the
2131+
byte-identical plain-`&` programs ([let r = if c { &a } else { &b }],
2132+
[let t = (r, 0)] with [r = &a]) behave identically (both accept) — the
2133+
same pre-existing through-branch / copy-out lexical limitation,
2134+
inherited by the call-result path, not introduced by it. A true
2135+
flow-sensitive borrow graph (Polonius) discharges it uniformly.
20612136
- Tighter integration with the quantity checker for captured
20622137
linears (Slice D).
20632138
*)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2026 hyperpolymath
3+
//
4+
// #554 residual (a) closed — interprocedural-through-call-result. `wrap`
5+
// returns the result of another ref-returning call (`pick(x)`) bound to a
6+
// local, so `wrap`'s return-borrow summary must transitively inherit the
7+
// origin (parameter 0). The summary is computed by a monotone fixpoint over
8+
// the call graph (build_context), so `let r = wrap(a); consume(a); *r` is a
9+
// use-after-move and must be rejected — not silently accepted as it was
10+
// before the fixpoint landed.
11+
12+
module BorrowCalleeReturnedBorrowInterproc;
13+
14+
fn pick(ref x: Int) -> ref Int {
15+
return &x;
16+
}
17+
18+
fn wrap(ref x: Int) -> ref Int {
19+
let t = pick(x);
20+
return t;
21+
}
22+
23+
fn consume(own v: Int) -> Int {
24+
return v;
25+
}
26+
27+
fn main() -> Int {
28+
let a: Int = 7;
29+
let r = wrap(a);
30+
let _gone = consume(a);
31+
let v = *r;
32+
return v;
33+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2026 hyperpolymath
3+
//
4+
// #554 residual (c) closed — reassignment releases the OLD loan. After
5+
// `r = other(b)` rebinds `r`, the previously-borrowed `a` (from the initial
6+
// `r = pick(a)`) is no longer held by `r`, so moving `a` must be ACCEPTED.
7+
// This mirrors the plain-`&` Slice-B behaviour (`r = &b` releases the old
8+
// borrow on `a`); before the fix the call-result path left the old loan
9+
// dangling and spuriously rejected the move.
10+
11+
module BorrowCalleeReturnedBorrowReassignOldOk;
12+
13+
fn pick(ref x: Int) -> ref Int {
14+
return &x;
15+
}
16+
17+
fn other(ref y: Int) -> ref Int {
18+
return &y;
19+
}
20+
21+
fn consume(own v: Int) -> Int {
22+
return v;
23+
}
24+
25+
fn main() -> Int {
26+
let a: Int = 1;
27+
let b: Int = 2;
28+
let mut r = pick(a);
29+
r = other(b);
30+
let _gone = consume(a);
31+
let v = *r;
32+
return v;
33+
}

test/test_e2e.ml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5250,6 +5250,33 @@ let test_borrow_callee_returned_borrow_return_stmt () =
52505250
a `return <branch>;` statement was not recorded in the \
52515251
return-borrow summary — use-after-move accepted"
52525252

5253+
(* #554 residual (a) closed — interprocedural-through-call-result. `wrap`
5254+
returns `pick(x)`'s result bound to a local; the summary fixpoint over the
5255+
call graph must give `wrap` pick's origin, so the use-after-move through
5256+
`wrap`'s result is rejected (transitively, at any wrapper depth). *)
5257+
let test_borrow_callee_returned_borrow_interproc () =
5258+
match borrow_result (fixture "borrow_callee_returned_borrow_interproc.affine") with
5259+
| Error (Borrow.MoveWhileBorrowed _) -> ()
5260+
| Error e ->
5261+
Alcotest.fail ("#554 interprocedural: expected MoveWhileBorrowed (the \
5262+
summary fixpoint must give `wrap` pick's return-borrow \
5263+
origin), got: " ^ Borrow.format_borrow_error e)
5264+
| Ok () ->
5265+
Alcotest.fail "#554 interprocedural regressed: a function returning \
5266+
another ref-returning call's result did not inherit the \
5267+
origin — use-after-move accepted"
5268+
5269+
(* #554 residual (c) closed — reassigning a ref-binder to a call result
5270+
releases the OLD loan, so the previously-borrowed target is movable again
5271+
(symmetric with the plain-`&` Slice-B reborrow). Must pass. *)
5272+
let test_borrow_callee_returned_borrow_reassign_old_ok () =
5273+
match borrow_result (fixture "borrow_callee_returned_borrow_reassign_old_ok.affine") with
5274+
| Ok () -> ()
5275+
| Error e ->
5276+
Alcotest.fail ("#554 reassign-old precision: after `r = other(b)` the old \
5277+
target `a` must be movable again, got: "
5278+
^ Borrow.format_borrow_error e)
5279+
52535280
let borrow_tests = [
52545281
Alcotest.test_case "BorrowOutlivesOwner: &local escapes its block"
52555282
`Quick test_borrow_outlives_owner;
@@ -5321,6 +5348,10 @@ let borrow_tests = [
53215348
`Quick test_borrow_callee_returned_borrow_match_arm;
53225349
Alcotest.test_case "#554 summary: `return if/match {..};` statement form is tracked"
53235350
`Quick test_borrow_callee_returned_borrow_return_stmt;
5351+
Alcotest.test_case "#554 (a): interprocedural-through-call-result via summary fixpoint"
5352+
`Quick test_borrow_callee_returned_borrow_interproc;
5353+
Alcotest.test_case "#554 (c): reassign to call result releases old loan (precision)"
5354+
`Quick test_borrow_callee_returned_borrow_reassign_old_ok;
53245355
]
53255356

53265357
(* ============================================================================

0 commit comments

Comments
 (0)