Skip to content

Commit f45afa1

Browse files
feat(borrow): NLL last-use expiry for ref-bindings (CORE-01 pt3 Slice A, Refs #177) (#335)
## Summary CORE-01 Phase 3 **Slice A** — non-lexical lifetimes for ref-bindings. The lexical "borrow lives until block exit" rule is replaced by **last-use expiry**: a ref-binding's underlying borrow is released after the binder's last mentioning statement, not at block exit. - `compute_last_use_index` (new in `lib/borrow.ml`): forward AST pre-pass returning `sym_id → max statement index that mentions it`. Covers all 24 expr / 5 stmt / 5 unsafe_op variants; recurses through nested blocks, lambdas, handler arms, try/finally. - `check_block` now runs the pre-pass and expires ref-bindings introduced *in this block* once their binder's last use has passed (`end_borrow` + drop from `state.ref_bindings`). Outer-block bindings are preserved. **Unblocks** the canonical NLL patterns: ```affine let mut x = 1; let r = &x; let y = *r; x = 2; // previously MoveWhileBorrowed; now OK x + y ``` ```affine let mut x = 5; let m = &mut x; let y = *m; x + y // previously UseWhileExclusivelyBorrowed; now OK ``` **Soundness preserved**: anti-aliasing rules still fire against statements that read or assign the owner *before* the binder's last use, because the borrow is still on `state.borrows` at that point. ## Scope of this PR (Slice A of a multi-day plan) This is **Slice A** of CORE-01 Phase 3. Future slices, separate PRs: | Slice | Work | |---|---| | B | Flow-sensitive escape via `outer = &x` (assignment updates the borrow graph the way `let r = &x` already does) | | C | Origin/region variables (Polonius surface) + loan-live-at-point dataflow across CFG joins | | D | Tighter quantity-checker integration for captured linears | ## Tests (E2E Borrow Graph, +3) | Fixture | Direction | Expected | |---|---|---| | `borrow_nll_assign_after_last_use.affine` | positive (NLL win) | Ok | | `borrow_nll_read_after_mut_last_use.affine` | positive (NLL win, exclusive) | Ok | | `borrow_nll_still_rejects_live_borrow.affine` | anti-regression | `MoveWhileBorrowed` | Pre-existing fixtures audited end-to-end against the new flow and remain green by construction — the violations they pin are all detected *before* the binder's last-use index (return-escape runs during the `StmtExpr (ExprReturn ...)` check, before that statement's expire pass; block-tail `&local` uses `ref_target` not `ref_bindings`; the `&mut` conflict/use-while fixtures conflict at borrow creation, before any expire). ## Docs - `STATE.a2ml` `borrow-checker` → `phase-3-part-3-slice-A-landed` - `docs/CAPABILITY-MATRIX.adoc` borrow-checker row → "pts 1–3 Slice A" - `docs/TECH-DEBT.adoc` CORE-01 row → records Slice A landing, residual = Slices B–D ## Test plan - [ ] CI `Build` (opam exec -- dune build) green - [ ] CI `Run tests` (opam exec -- dune runtest) — `E2E Borrow Graph` count is +3 (the 3 new NLL cases), all green - [ ] CI `Run codegen WASM tests` green — the borrow checker runs in the standard CLI pipeline, so any over-rejection regression would surface here (full stdlib AOT exercises the corpus) - [ ] CI `Check formatting` (`dune build @fmt`) green **Local-build caveat**: the container this PR was authored in has no OCaml toolchain installed, so `dune build` / `dune runtest` were not run locally. CI is the source of truth. Refs #177 --- _Generated by [Claude Code](https://claude.ai/code/session_01TLERhquTknLCfyjv7eQU8b)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 99ae5e3 commit f45afa1

8 files changed

Lines changed: 281 additions & 18 deletions

.machine_readable/6a2/STATE.a2ml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ test-files = 54
6868
# the feature is not enforced on user programs through the CLI pipeline.
6969
affine-types = "wired-and-reachable (Track A Manhattan plan complete 2026-04-10. Quantity semiring in lib/quantity.ml; invoked from typecheck.ml:1206 inside the standard CLI pipeline. Surface syntax per ADR-007 hybrid: @linear/@erased/@unrestricted attributes (Option C primary) on let/stmt-let/param/lambda-param, AND :1/:0/:ω numeric sugar (Option B) on let/stmt-let. Scaled Let rule per ADR-002 implemented in lib/quantity.ml ExprLet/StmtLet — closes BUG-001 (ω-let smuggles linear values) and BUG-002 (zero-let erasure). Four regression fixtures in test/e2e/fixtures/ exercise both surface forms. Behavioural enforcement verified via E2E Quantity test suite — 4 new passing tests, 0 regressions.)"
7070
linear-arrows = "enforced (2026-04-11): Three-part fix landed. (1) typecheck.ml lambda synth: |@linear x: T| e now synthesises T -[1]-> U (was always QOmega). (2) typecheck.ml lambda check mode: explicit param quantity annotation validated against expected TArrow quantity; unannotated params inherit context quantity. (3) quantity.ml ExprLambda: added env.errors accumulator; annotated lambda params declared via env_declare so env_use tracks them; usage verified with check_quantity after body walk; violations pushed to env.errors and drained at end of check_function_quantities (step 4). Saved/restored env.quantities entries to prevent scope leakage. Two E2E fixtures + 2 passing tests. 75 tests total, 0 regressions. Commit d2f9b7b pushed."
71-
borrow-checker = "phase-3-part-1-landed (CORE-01, PR #240 Refs #177, 2026-05-19, gate 263/263): borrow-graph validation wired — BorrowOutlivesOwner emitted (&local escaping its block), shared-XOR-exclusive enforced at use sites (UseWhileExclusivelyBorrowed), ownership derived from param type TyOwn/TyRef/TyMut (owned/ref/mut discipline now enforced from real parsed source — closed a latent hole), call-arg borrows temporary, ref-binding graph tracked. Part 2+ deferred: NLL/region inference, flow-sensitive escape via `outer = &x`, tighter quantity integration. Authoritative: docs/CAPABILITY-MATRIX.adoc + docs/TECH-DEBT.adoc CORE-01."
71+
borrow-checker = "phase-3-part-3-slice-A-landed (CORE-01, Refs #177, 2026-05-24): pt1 (#240, gate 263/263) borrow-graph validation wired — BorrowOutlivesOwner emitted (&local escaping its block), shared-XOR-exclusive enforced at use sites (UseWhileExclusivelyBorrowed), ownership derived from param type TyOwn/TyRef/TyMut (owned/ref/mut discipline now enforced from real parsed source — closed a latent hole), call-arg borrows temporary, ref-binding graph tracked. pt2 (gate 271→274 and 278→281) return-escape (return-of-ref-rooted-at-callee-owned-binding caught) + &mut e parser surface (zero Menhir conflict delta — exclusive borrow finally expressible from real source). pt3 Slice A NLL last-use expiry: forward pre-pass compute_last_use_index maps each symbol to its greatest mentioning statement index; check_block expires ref-bindings introduced in-block once their binder is dead, releasing the underlying borrow. Unblocks `let r = &x; print(*r); x = 2` and `let m = &mut x; let y = *m; x` while still rejecting same-block live aliasing (2 positive + 1 anti-regression hermetic tests in E2E Borrow Graph). Residual (Slices B–D): flow-sensitive escape via `outer = &x`, origin/region variables (Polonius surface) + loan-live-at-point dataflow across CFG joins, tighter quantity integration. Authoritative: docs/CAPABILITY-MATRIX.adoc + docs/TECH-DEBT.adoc CORE-01."
7272
row-polymorphism = "60% (records + effects rows implemented in typecheck/unify; not fully exercised end-to-end)"
7373
effects = "interpreter-complete (handler dispatch, PerformEffect propagation, ExprResume, multi-arg ops all wired in interp.ml 2026-04-11). WasmGC: ops registered as unreachable stubs; ExprHandle/ExprResume reject with UnsupportedFeature — full WASM dispatch needs EH proposal or CPS transform."
7474
dependent-types = "parse-only (TRefined AST node exists and refinement predicates parse, but predicates do not reduce; no SMT/decision procedure wired in)"

docs/CAPABILITY-MATRIX.adoc

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,16 +84,26 @@ enforced. Handlers: *interpreter-complete*; WasmGC dispatch is
8484
`UnsupportedFeature` pending the EH proposal or the CPS transform (the #225
8585
CPS line is closing the async slice of this).
8686

87-
|Borrow checker |*partial — graph validation landed (CORE-01 pt 1)* |
87+
|Borrow checker |*partial — graph validation + NLL last-use landed
88+
(CORE-01 pts 1–3 Slice A)* |
8889
Place/borrow/move infra + use-after-move, conflicting-borrow,
8990
move-while-borrowed, lambda-capture. *CORE-01 part 1* (PR #240, Refs #177,
9091
gate 263/263): borrow-graph validation wired — `BorrowOutlivesOwner` now
9192
emitted (`&local` escaping its block); shared-XOR-exclusive enforced at
9293
*use* sites (`UseWhileExclusivelyBorrowed`); ownership now derived from the
9394
parameter *type* (`TyOwn/TyRef/TyMut`) so the owned/ref/mut discipline is
9495
actually enforced from real parsed source (closed a latent hole); call-arg
95-
borrows made temporary. *Deferred (CORE-01 pt 2+):* NLL/region inference,
96-
flow-sensitive escape via `outer = &x`, tighter quantity integration.
96+
borrows made temporary. *Part 2* (Refs #177): return-escape +
97+
`&mut e` parser surface (the exclusive borrow is finally expressible from
98+
source). *Part 3 Slice A* (Refs #177): non-lexical lifetimes —
99+
ref-bindings expire at their binder's *last use*, not at block exit
100+
(`compute_last_use_index` pre-pass + per-statement expiry in
101+
`check_block`). Patterns like `let r = &x; print(*r); x = 2` and
102+
`let m = &mut x; let y = *m; x` now type-check, while still-live borrows
103+
continue to block aliasing assignments. *Deferred (Slices B–D):*
104+
flow-sensitive escape via `outer = &x`; origin/region variables
105+
(Polonius surface) + loan-live-at-point dataflow across CFG joins;
106+
tighter quantity integration for captured linears.
97107

98108
|Quantity / affine types |*enforced* |QTT semiring in `lib/quantity.ml`,
99109
invoked inside the standard CLI pipeline. `@linear`/`@erased`/`@unrestricted`

docs/TECH-DEBT.adoc

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,12 +110,23 @@ typed/codegen'd like `&e` — exclusivity is a static borrow property).
110110
`&`-in-`#{` literals and bare block-statements already parse on `main`;
111111
`-> &T`/`&T` *type* sigil deliberately **not** added — `ref T`/`mut T`
112112
keyword types already express reference types; a `&T` sigil is duplicate
113-
surface (ADR territory, not a soundness gap). *Part 2 residual — now the
114-
analysis, not surface:* NLL/region inference (Polonius) + flow-sensitive
115-
escape via `outer = &x` are now *expressible* (surface unblocked); the
116-
remaining work is the dataflow analysis itself. |S1 |pt1 #240 + pt2
117-
return-escape + `&mut` surface DONE (Refs #177); residual = NLL analysis
118-
— issue #177
113+
surface (ADR territory, not a soundness gap). *Part 3 Slice A — NLL
114+
last-use expiry LANDED* (Refs #177): a forward pre-pass
115+
(`compute_last_use_index`) maps each symbol to the greatest statement
116+
index that mentions it; after each statement, `check_block` expires any
117+
ref-binding introduced in that block whose binder is now dead, releasing
118+
the underlying borrow. Unblocks the canonical NLL patterns
119+
(`let r = &x; print(*r); x = 2`; `let m = &mut x; let y = *m; x`) while
120+
preserving soundness — the anti-aliasing rules still fire against
121+
statements that *do* use the binder (3 hermetic tests in "E2E Borrow
122+
Graph", two positive + one anti-regression). *Slices B–D residual:*
123+
flow-sensitive escape via assignment to an outer mutable
124+
(`outer = &x`); origin/region variables (Polonius surface) + loan-live-
125+
at-point dataflow across CFG joins; tighter quantity integration for
126+
captured linears. |S1 |pt1 #240 + pt2 return-escape + `&mut` surface +
127+
pt3 Slice A NLL last-use DONE (Refs #177); residual = Slices B–D
128+
(flow-sensitive escape, origin variables, quantity integration) —
129+
issue #177
119130
|CORE-02 |Effect-handler dispatch on WasmGC (currently `UnsupportedFeature`;
120131
EH proposal or CPS). The #225 CPS line closes the async slice. |S2 |partial
121132
(#225 line CLOSED PR1..PR3d+PR4; #234 generalises the recogniser —

lib/borrow.ml

Lines changed: 160 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,113 @@ let check_return_escape (state : state) (symbols : Symbol.t)
562562
Error (BorrowOutlivesOwner (b, owner))
563563
| _ -> Ok ())
564564

565+
(** NLL last-use pre-pass (CORE-01 pt3 / #177 Slice A).
566+
567+
Returns a map [sym_id -> max statement index at which the symbol is
568+
mentioned anywhere inside [blk] (including nested blocks, lambda
569+
bodies, handler arms, etc.)]. Statement indices are 0-based; the
570+
block's tail expression, if any, is treated as the [n]-th "statement"
571+
(where [n = List.length blk.blk_stmts]).
572+
573+
Used by [check_block] to expire ref-bindings introduced in this block
574+
once their binder is dead, instead of holding them until lexical block
575+
exit. The expiry releases the underlying borrow, so subsequent
576+
statements may legally read or assign through the (no-longer-borrowed)
577+
owner — the canonical Rust-style "non-lexical lifetime" win.
578+
579+
A symbol that never appears is simply absent from the table; callers
580+
treat that as [-1] (last-use precedes the first statement → expire
581+
immediately after the declaration). *)
582+
let compute_last_use_index (symbols : Symbol.t) (blk : block)
583+
: (Symbol.symbol_id, int) Hashtbl.t =
584+
let tbl : (Symbol.symbol_id, int) Hashtbl.t = Hashtbl.create 8 in
585+
let bump (sym : Symbol.symbol_id) (idx : int) : unit =
586+
match Hashtbl.find_opt tbl sym with
587+
| Some cur when cur >= idx -> ()
588+
| _ -> Hashtbl.replace tbl sym idx
589+
in
590+
let mark_var (idx : int) (id : ident) : unit =
591+
match lookup_symbol_by_name symbols id.name with
592+
| Some s -> bump s.Symbol.sym_id idx
593+
| None -> ()
594+
in
595+
let rec visit_expr (idx : int) (e : expr) : unit =
596+
match e with
597+
| ExprVar id -> mark_var idx id
598+
| ExprLit _ | ExprVariant _ -> ()
599+
| ExprSpan (e, _) -> visit_expr idx e
600+
| ExprApp (f, args) ->
601+
visit_expr idx f; List.iter (visit_expr idx) args
602+
| ExprField (b, _) | ExprTupleIndex (b, _) -> visit_expr idx b
603+
| ExprIndex (b, i) -> visit_expr idx b; visit_expr idx i
604+
| ExprTuple es | ExprArray es -> List.iter (visit_expr idx) es
605+
| ExprRecord r ->
606+
List.iter (fun (_, eo) -> match eo with Some e -> visit_expr idx e | None -> ()) r.er_fields;
607+
(match r.er_spread with Some e -> visit_expr idx e | None -> ())
608+
| ExprRowRestrict (e, _) -> visit_expr idx e
609+
| ExprBinary (l, _, r) -> visit_expr idx l; visit_expr idx r
610+
| ExprUnary (_, e) -> visit_expr idx e
611+
| ExprLet lb ->
612+
visit_expr idx lb.el_value;
613+
(match lb.el_body with Some b -> visit_expr idx b | None -> ())
614+
| ExprIf ei ->
615+
visit_expr idx ei.ei_cond; visit_expr idx ei.ei_then;
616+
(match ei.ei_else with Some e -> visit_expr idx e | None -> ())
617+
| ExprMatch em ->
618+
visit_expr idx em.em_scrutinee;
619+
List.iter (fun arm ->
620+
(match arm.ma_guard with Some g -> visit_expr idx g | None -> ());
621+
visit_expr idx arm.ma_body
622+
) em.em_arms
623+
| ExprBlock blk -> visit_block idx blk
624+
| ExprLambda lam -> visit_expr idx lam.elam_body
625+
| ExprReturn (Some e) -> visit_expr idx e
626+
| ExprReturn None -> ()
627+
| ExprHandle eh ->
628+
visit_expr idx eh.eh_body;
629+
List.iter (fun arm ->
630+
match arm with
631+
| HandlerReturn (_, b) -> visit_expr idx b
632+
| HandlerOp (_, _, b) -> visit_expr idx b
633+
) eh.eh_handlers
634+
| ExprResume (Some e) -> visit_expr idx e
635+
| ExprResume None -> ()
636+
| ExprTry et ->
637+
visit_block idx et.et_body;
638+
(match et.et_catch with
639+
| Some arms ->
640+
List.iter (fun arm ->
641+
(match arm.ma_guard with Some g -> visit_expr idx g | None -> ());
642+
visit_expr idx arm.ma_body) arms
643+
| None -> ());
644+
(match et.et_finally with Some b -> visit_block idx b | None -> ())
645+
| ExprUnsafe ops ->
646+
List.iter (fun op ->
647+
match op with
648+
| UnsafeRead e -> visit_expr idx e
649+
| UnsafeWrite (a, b) -> visit_expr idx a; visit_expr idx b
650+
| UnsafeOffset (a, b) -> visit_expr idx a; visit_expr idx b
651+
| UnsafeTransmute (_, _, e) -> visit_expr idx e
652+
| UnsafeForget e -> visit_expr idx e
653+
) ops
654+
and visit_stmt (idx : int) (s : stmt) : unit =
655+
match s with
656+
| StmtLet sl -> visit_expr idx sl.sl_value
657+
| StmtExpr e -> visit_expr idx e
658+
| StmtAssign (lhs, _, rhs) -> visit_expr idx lhs; visit_expr idx rhs
659+
| StmtWhile (c, b) -> visit_expr idx c; visit_block idx b
660+
| StmtFor (_, it, b) -> visit_expr idx it; visit_block idx b
661+
and visit_block (idx : int) (b : block) : unit =
662+
List.iter (visit_stmt idx) b.blk_stmts;
663+
match b.blk_expr with Some e -> visit_expr idx e | None -> ()
664+
in
665+
List.iteri (fun i s -> visit_stmt i s) blk.blk_stmts;
666+
let tail_idx = List.length blk.blk_stmts in
667+
(match blk.blk_expr with
668+
| Some e -> visit_expr tail_idx e
669+
| None -> ());
670+
tbl
671+
565672
(** Check borrows in an expression *)
566673
let rec check_expr (ctx : context) (state : state) (symbols : Symbol.t) (expr : expr) : unit result =
567674
match expr with
@@ -1000,10 +1107,41 @@ and check_block (ctx : context) (state : state) (symbols : Symbol.t) (blk : bloc
10001107
ref-bindings introduced here do not leak past block exit. CORE-01. *)
10011108
let block_locals_at_entry = state.block_local_syms in
10021109
let ref_bindings_at_entry = state.ref_bindings in
1003-
let* () = List.fold_left (fun acc stmt ->
1004-
let* () = acc in
1005-
check_stmt ctx state symbols stmt
1006-
) (Ok ()) blk.blk_stmts in
1110+
(* NLL last-use pre-pass (CORE-01 pt3 / #177 Slice A): compute the last
1111+
statement index at which each symbol is mentioned. After each
1112+
statement we expire any ref-binding introduced in *this* block whose
1113+
binder's last use has already passed, releasing the underlying
1114+
borrow. This is the non-lexical-lifetimes win — patterns like
1115+
[let r = &x; print(*r); x = 2] now type-check, while the
1116+
anti-aliasing rules remain sound because the borrow is still live
1117+
across statements that *do* use the binder. *)
1118+
let last_use = compute_last_use_index symbols blk in
1119+
let last_use_of (sym : Symbol.symbol_id) : int =
1120+
match Hashtbl.find_opt last_use sym with
1121+
| Some i -> i
1122+
| None -> -1
1123+
in
1124+
let is_outer_binding (sym : Symbol.symbol_id) : bool =
1125+
List.exists (fun (sym', _) -> sym' = sym) ref_bindings_at_entry
1126+
in
1127+
let expire_dead_ref_bindings (stmt_idx : int) : unit =
1128+
let dying, still_live =
1129+
List.partition (fun (sym, _b) ->
1130+
(not (is_outer_binding sym)) && last_use_of sym <= stmt_idx
1131+
) state.ref_bindings
1132+
in
1133+
List.iter (fun (_sym, b) -> end_borrow state b) dying;
1134+
state.ref_bindings <- still_live
1135+
in
1136+
let* () =
1137+
let indexed = List.mapi (fun i s -> (i, s)) blk.blk_stmts in
1138+
List.fold_left (fun acc (i, stmt) ->
1139+
let* () = acc in
1140+
let* () = check_stmt ctx state symbols stmt in
1141+
expire_dead_ref_bindings i;
1142+
Ok ()
1143+
) (Ok ()) indexed
1144+
in
10071145
let* () = match blk.blk_expr with
10081146
| Some e -> check_expr ctx state symbols e
10091147
| None -> Ok ()
@@ -1190,10 +1328,24 @@ let check_program (symbols : Symbol.t) (program : program) : unit result =
11901328
bare block-statements already parsed; the `-> &T`/`&T` *type* sigil was
11911329
deliberately not added (`ref T`/`mut T` keyword types already exist).
11921330
1193-
Still deferred — now the *analysis*, no longer parser-gated:
1194-
- Non-lexical lifetimes with region inference (Polonius-style).
1331+
CORE-01 pt3 Slice A (NLL last-use): ref-bindings introduced in a
1332+
block now expire at the binder's *last use*, not at block exit. A
1333+
forward pre-pass (compute_last_use_index) maps each symbol to the
1334+
greatest statement index that mentions it (the tail expression
1335+
counts as the n-th statement); after each statement, check_block
1336+
releases the underlying borrow of any in-block ref-binding whose
1337+
binder is now dead. Unblocks the canonical NLL patterns
1338+
([let r = &x; print(*r); x = 2] and [let m = &mut x; let y = *m; x]),
1339+
while still rejecting real conflicts (the anti-aliasing rules fire
1340+
against statements that *do* use the binder, before expiry).
1341+
Outer-block ref-bindings are deliberately preserved — they expire
1342+
only at their own block's exit.
1343+
1344+
Still deferred — region-/flow-sensitive remainder:
11951345
- Flow-sensitive escape via assignment to an outer mutable
1196-
(`outer = &x`) — now *expressible* (`&mut`, assignment, blocks all
1197-
parse); the remaining work is the dataflow analysis itself.
1346+
(`outer = &x`): the assignment must update the borrow graph the
1347+
way [let r = &x] already does.
1348+
- Origin/region variables (Polonius surface) + loan-live-at-point
1349+
dataflow across CFG joins (Slice C).
11981350
- Tighter integration with the quantity checker for captured linears.
11991351
*)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Copyright (c) 2026 Jonathan D.A. Jewell
3+
//
4+
// CORE-01 pt3 Slice A / #177: NLL last-use expiry. Under purely lexical
5+
// lifetimes this program is rejected: the borrow held by `r` lives until
6+
// block exit, so the assignment `x = 2` triggers MoveWhileBorrowed. With
7+
// non-lexical lifetimes, `r` is dead after the `let y` statement (its
8+
// last mention), so the borrow on `x` has been released by the time the
9+
// assignment is checked. Expected: Ok.
10+
11+
module BorrowNllAssignAfterLastUse;
12+
13+
fn nll_assign() -> Int {
14+
let mut x = 1;
15+
let r = &x;
16+
let y = *r;
17+
x = 2;
18+
x + y
19+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Copyright (c) 2026 Jonathan D.A. Jewell
3+
//
4+
// CORE-01 pt3 Slice A / #177: NLL last-use expiry for *exclusive*
5+
// borrows. Under lexical lifetimes the tail `x` is rejected as
6+
// UseWhileExclusivelyBorrowed because `m`'s exclusive borrow on `x`
7+
// lives until block exit. With NLL, `m` is dead after `let y`, so the
8+
// tail read of `x` is legal. Expected: Ok.
9+
10+
module BorrowNllReadAfterMutLastUse;
11+
12+
fn nll_excl() -> Int {
13+
let mut x = 5;
14+
let m = &mut x;
15+
let y = *m;
16+
x + y
17+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Copyright (c) 2026 Jonathan D.A. Jewell
3+
//
4+
// CORE-01 pt3 Slice A / #177 anti-regression: NLL last-use must NOT
5+
// release a borrow that is *still* used later. Here `r` is mentioned
6+
// in the tail `*r`, so its last-use index is after the assignment.
7+
// The assignment to `x` while `r` is alive must still be rejected
8+
// (MoveWhileBorrowed). This guards against an over-eager expiry that
9+
// would silently allow real aliasing bugs.
10+
11+
module BorrowNllStillRejectsLiveBorrow;
12+
13+
fn bad() -> Int {
14+
let mut x = 1;
15+
let r = &x;
16+
x = 2;
17+
*r
18+
}

0 commit comments

Comments
 (0)