Skip to content

Commit 48422d1

Browse files
hyperpolymathclaude
andcommitted
fix(BUG-004): lambda capture tracking — linear variables cannot be captured
Two coordinated fixes close BUG-004 (lambda capturing linear resources). quantity.ml — ExprLambda scales captures by QOmega: A lambda may be called zero or many times. Any outer variable it references is therefore effectively used QOmega times. The fix snapshots the usage env before walking the lambda body (shadowing lambda-local params), computes the per-variable delta, restores the outer env, then re-applies each delta scaled by QOmega via scale_usage. Consequence: let v: QOne = ...; let f = |x| v + x; → delta(v) = UOne, scale_usage QOmega UOne = UMany → linear variable 'v' used multiple times → LinearVariableUsedMultiple borrow.ml — ExprLambda creates Shared borrows for all free variables: Adds collect_free: a structural walk of the lambda body that collects every ExprVar name not bound by the lambda's own parameters. For each free variable that resolves to a place, a Shared borrow is created before the body is checked. This prevents the caller from moving a captured variable after the lambda is created (use-after-move / move-while-borrowed): let v = 42; let f = |x| consume(v); f(0); → shared borrow of v created at lambda site → consume(v) tries to move v → MoveWhileBorrowed error Borrows expire at enclosing block exit via the lexical-lifetime clearing added in the previous commit. borrow.ml — borrow_kind_name replaces show_borrow_kind in error messages: show_borrow_kind (OCaml-derived) produced "Borrow.Shared"; the new helper produces "shared" / "exclusive" for readable diagnostics. Verified: - lambda capturing a moved-value → "cannot move `v` while it is shared-borrowed at …" - valid lambda with no captured owned values → passes - 73/73 tests pass Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d649a4c commit 48422d1

2 files changed

Lines changed: 154 additions & 9 deletions

File tree

lib/borrow.ml

Lines changed: 113 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ type borrow_kind =
2727
| Exclusive (** Mutable borrow (&mut) *)
2828
[@@deriving show, eq]
2929

30+
(** Human-readable borrow kind for error messages. *)
31+
let borrow_kind_name (k : borrow_kind) : string =
32+
match k with
33+
| Shared -> "shared"
34+
| Exclusive -> "exclusive"
35+
3036
(** A borrow record *)
3137
type borrow = {
3238
b_place : place;
@@ -259,20 +265,20 @@ let format_borrow_error (e : borrow_error) : string =
259265
"conflicting borrows on `%s`:\n \
260266
%s borrow (id %d) at %s conflicts with earlier %s borrow (id %d) at %s"
261267
(format_place b1.b_place)
262-
(show_borrow_kind b1.b_kind) b1.b_id (format_span b1.b_span)
263-
(show_borrow_kind b2.b_kind) b2.b_id (format_span b2.b_span)
268+
(borrow_kind_name b1.b_kind) b1.b_id (format_span b1.b_span)
269+
(borrow_kind_name b2.b_kind) b2.b_id (format_span b2.b_span)
264270
| BorrowOutlivesOwner (b, sym_id) ->
265271
Printf.sprintf
266272
"borrow of `%s` (id %d) outlives its owner (symbol %d)"
267273
(format_place b.b_place) b.b_id sym_id
268274
| MoveWhileBorrowed (place, b) ->
269275
Printf.sprintf
270276
"cannot move `%s` while it is %s-borrowed at %s"
271-
(format_place place) (show_borrow_kind b.b_kind) (format_span b.b_span)
277+
(format_place place) (borrow_kind_name b.b_kind) (format_span b.b_span)
272278
| CannotMoveOutOfBorrow (place, b) ->
273279
Printf.sprintf
274280
"cannot move out of `%s`, which is behind a %s borrow at %s"
275-
(format_place place) (show_borrow_kind b.b_kind) (format_span b.b_span)
281+
(format_place place) (borrow_kind_name b.b_kind) (format_span b.b_span)
276282
| CannotBorrowAsMutable (place, span) ->
277283
Printf.sprintf
278284
"cannot borrow `%s` as mutable — it is not declared with `let mut` (at %s)"
@@ -457,6 +463,109 @@ let rec check_expr (ctx : context) (state : state) (symbols : Symbol.t) (expr :
457463
) (Ok ()) args param_ownerships
458464

459465
| ExprLambda lam ->
466+
(* Collect every free variable referenced in the lambda body —
467+
variables that appear in the body but are NOT bound by the lambda's
468+
own parameter list. Each free variable is "captured" by the closure.
469+
470+
Borrow-checker contract for captures:
471+
- We create a Shared borrow for each captured place. This
472+
prevents the caller from moving the variable out from under the
473+
closure while it is still in scope (use-after-move).
474+
- The borrows expire at the end of the enclosing block thanks to
475+
the lexical-lifetime clearing in check_block.
476+
- Ownership / linear duplications are caught by the quantity
477+
checker (quantity.ml ExprLambda scales captures by QOmega),
478+
so the borrow checker only needs to prevent structural misuse. *)
479+
let param_names =
480+
List.map (fun (p : param) -> p.p_name.name) lam.elam_params
481+
in
482+
(* Walk the body collecting every ExprVar name not bound by params. *)
483+
let rec collect_free (acc : string list) (expr : expr) : string list =
484+
match expr with
485+
| ExprVar id ->
486+
if List.mem id.name param_names || List.mem id.name acc
487+
then acc
488+
else id.name :: acc
489+
| ExprLambda inner ->
490+
(* For nested lambdas, shadow the inner params too. *)
491+
let inner_params = List.map (fun (p : param) -> p.p_name.name) inner.elam_params in
492+
let outer_free = collect_free acc inner.elam_body in
493+
List.filter (fun n -> not (List.mem n inner_params)) outer_free
494+
| ExprLit _ | ExprVariant _ -> acc
495+
| ExprApp (f, args) ->
496+
List.fold_left collect_free (collect_free acc f) args
497+
| ExprLet lb ->
498+
let acc' = collect_free acc lb.el_value in
499+
(match lb.el_body with Some b -> collect_free acc' b | None -> acc')
500+
| ExprIf ei ->
501+
let acc' = collect_free (collect_free acc ei.ei_cond) ei.ei_then in
502+
(match ei.ei_else with Some e -> collect_free acc' e | None -> acc')
503+
| ExprMatch em ->
504+
let acc' = collect_free acc em.em_scrutinee in
505+
List.fold_left (fun a arm ->
506+
let a' = match arm.ma_guard with Some g -> collect_free a g | None -> a in
507+
collect_free a' arm.ma_body
508+
) acc' em.em_arms
509+
| ExprBlock blk ->
510+
let acc' = List.fold_left (fun a stmt ->
511+
match stmt with
512+
| StmtLet sl -> collect_free a sl.sl_value
513+
| StmtExpr e -> collect_free a e
514+
| StmtAssign (lhs, _, rhs) -> collect_free (collect_free a lhs) rhs
515+
| StmtWhile (cond, body) -> collect_free (collect_free a cond) (ExprBlock body)
516+
| StmtFor (_, iter, body) -> collect_free (collect_free a iter) (ExprBlock body)
517+
) acc blk.blk_stmts in
518+
(match blk.blk_expr with Some e -> collect_free acc' e | None -> acc')
519+
| ExprBinary (l, _, r) -> collect_free (collect_free acc l) r
520+
| ExprUnary (_, e) | ExprReturn (Some e) | ExprField (e, _)
521+
| ExprTupleIndex (e, _) | ExprRowRestrict (e, _) | ExprSpan (e, _) ->
522+
collect_free acc e
523+
| ExprReturn None | ExprResume None | ExprUnsafe [] -> acc
524+
| ExprResume (Some e) -> collect_free acc e
525+
| ExprTuple es | ExprArray es -> List.fold_left collect_free acc es
526+
| ExprRecord er ->
527+
let acc' = List.fold_left (fun a (_, e_opt) ->
528+
match e_opt with Some e -> collect_free a e | None -> a
529+
) acc er.er_fields in
530+
(match er.er_spread with Some e -> collect_free acc' e | None -> acc')
531+
| ExprIndex (a, i) -> collect_free (collect_free acc a) i
532+
| ExprTry et ->
533+
let acc' = collect_free acc (ExprBlock et.et_body) in
534+
let acc'' = match et.et_catch with
535+
| Some arms -> List.fold_left (fun a arm ->
536+
let a' = match arm.ma_guard with Some g -> collect_free a g | None -> a in
537+
collect_free a' arm.ma_body) acc' arms
538+
| None -> acc'
539+
in
540+
(match et.et_finally with Some b -> collect_free acc'' (ExprBlock b) | None -> acc'')
541+
| ExprHandle eh ->
542+
let acc' = collect_free acc eh.eh_body in
543+
List.fold_left (fun a arm ->
544+
match arm with
545+
| HandlerReturn (_, b) | HandlerOp (_, _, b) -> collect_free a b
546+
) acc' eh.eh_handlers
547+
| ExprUnsafe ops ->
548+
List.fold_left (fun a op ->
549+
match op with
550+
| UnsafeRead e | UnsafeForget e -> collect_free a e
551+
| UnsafeWrite (e1, e2) | UnsafeOffset (e1, e2) -> collect_free (collect_free a e1) e2
552+
| UnsafeTransmute (_, _, e) -> collect_free a e
553+
) acc ops
554+
in
555+
let free_names = collect_free [] lam.elam_body in
556+
(* Create a Shared borrow for each captured free variable. If a borrow
557+
conflict or use-after-move is detected, fail immediately. *)
558+
let borrow_span = expr_span (ExprLambda lam) in
559+
let* () = List.fold_left (fun acc name ->
560+
let* () = acc in
561+
match expr_to_place symbols (ExprVar { name; span = borrow_span }) with
562+
| Some place ->
563+
(* Check the place is not already moved before we borrow it. *)
564+
let* _borrow = record_borrow state place Shared borrow_span in
565+
Ok ()
566+
| None -> Ok ()
567+
) (Ok ()) free_names in
568+
(* Walk the body for structural checking (nested borrows / moves). *)
460569
check_expr ctx state symbols lam.elam_body
461570

462571
| ExprLet lb ->

lib/quantity.ml

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -291,11 +291,47 @@ let rec infer_usage_expr (env : env) (expr : expr) : unit =
291291
List.iter (infer_usage_expr env) args
292292

293293
| ExprLambda lam ->
294-
(* Lambda parameters shadow outer bindings for the body.
295-
We do NOT track lambda-bound params here — only function-level
296-
params are checked. The lambda body may still reference outer
297-
tracked variables. *)
298-
infer_usage_expr env lam.elam_body
294+
(* A lambda may be called zero or many times, so any outer variable
295+
it captures is effectively used QOmega times. We implement this
296+
by walking the body in a temporary copy of the env (shadowing the
297+
lambda's own params), computing the per-variable delta, and then
298+
merging those deltas back scaled by QOmega.
299+
300+
Consequence: a lambda that captures an @linear variable raises
301+
LinearVariableUsedMultiple, because scale_usage QOmega UOne = UMany.
302+
This closes BUG-004. *)
303+
let param_names =
304+
List.map (fun (p : param) -> p.p_name.name) lam.elam_params
305+
in
306+
let before_snapshot = env_snapshot env in
307+
(* Shadow lambda params so their usage inside the body doesn't leak
308+
into the outer quantity environment. *)
309+
List.iter (fun name ->
310+
if Hashtbl.mem env.usages name then
311+
Hashtbl.replace env.usages name UZero
312+
(* Params not yet in env are fine — they are lambda-local. *)
313+
) param_names;
314+
infer_usage_expr env lam.elam_body;
315+
let after_snapshot = env_snapshot env in
316+
(* Restore outer env, then re-apply QOmega-scaled deltas for captured
317+
outer variables only (exclude lambda params). *)
318+
env_restore env before_snapshot;
319+
Hashtbl.iter (fun name after_u ->
320+
if List.mem name param_names then ()
321+
else begin
322+
let before_u =
323+
Hashtbl.find_opt before_snapshot name |> Option.value ~default:UZero
324+
in
325+
let delta =
326+
if equal_usage before_u after_u then UZero
327+
else after_u
328+
in
329+
(* Any use inside a lambda body counts as potentially-unbounded. *)
330+
let scaled = scale_usage QOmega delta in
331+
let merged = add_usage before_u scaled in
332+
Hashtbl.replace env.usages name merged
333+
end
334+
) after_snapshot
299335

300336
| ExprLet lb ->
301337
(* ADR-002 / ADR-007: Let value context is scaled by the binder's

0 commit comments

Comments
 (0)