Repository navigation
Explicit capability changes: action deltas, freezes, two-phase activation, joins as reached meets - #190
Draft
JonasAlaif wants to merge 15 commits into
Draft
Explicit capability changes: action deltas, freezes, two-phase activation, joins as reached meets#190JonasAlaif wants to merge 15 commits into
JonasAlaif wants to merge 15 commits into
Conversation
JonasAlaif
force-pushed
the
two-phase-reservation
branch
from
October 27, 2026 07:11
10fd9dd to
6c459f5
Compare
Merged
A shared or two-phase borrow of a place with E now emits a `Freeze` action (E to R). Unlike a weaken, the E is only suspended: it comes back through `Unfreeze` of the same place when no borrow of it is left, or is lent by the new `ActivateTwoPhaseBorrow` action. A two-phase reservation now obtains the place as a mutable borrow does (exclusive unpacking, siblings keep E) and then freezes it, instead of unpacking the whole path at R. It still unpacks at R if the place or an ancestor is R because of a live shared borrow. At activation, reads of the place are packed and the place goes from R to lent through `ActivateTwoPhaseBorrow`, instead of the upward weaken walk. The walk still runs for ancestors that hold R (an ancestor read during the reservation, or a reservation under a shared borrow). The restore of R leaves to E and the silent R to E of a collapsed local are now `Unfreeze` actions; the latter no longer fires while the local is borrowed. Written with Claude
Capability now never moves between E and R silently: - Unpacking a place with E for read (an owned expansion at R, a borrow expansion for read, or a dereference for read) first freezes it. Its fields are then read copies of the frozen place. - Reading an unpacked place (whose expansion cannot be packed because a descendant is borrowed) freezes it, which makes the descendants read copies. This replaces the weaken of E fields to R plus the restore of R to the place, and the R the post-condition of a shared borrow gave to an unpacked borrowed place. - `Unfreeze` of an unpacked place turns its read unpacking into an exclusive one. The new `Refreeze` does the same while moving the freeze to the descendants that are still borrowed. - Obtaining E for a read copy, and activating a two-phase borrow whose place is a read copy, end the freeze of the frozen ancestor with `Refreeze` (or `Unfreeze`) instead of the upward weaken walk, which weakened R ancestors and restored E to leaves that were never weakened. Read copies that cannot be packed are returned (weakened from R). - A frozen leaf is unfrozen as soon as it is no longer blocked. Written with Claude
`Restore` (and its owned twin `RegainLoanedCapability`) was always the second half of an edge removal: removing an unpacking edge (a borrow-PCG expansion or a dereference) dropped the children's capabilities and a separate `Restore` gave the base its capability back, and removing a mutable borrow (or a function call's abstraction edge) returned the lent capability with a separate `Restore`. In between, the capability existed nowhere. `RemoveEdge` now carries the capabilities it returns (`regained`) and applies them atomically, like `AddEdge` already lends or unpacks. `Restore`, `RestoreCapability`, `RepackOp::RegainLoanedCapability` and `RegainedCapability` are removed: - An assignment to a reference that is still frozen ends its freeze with `Unfreeze`/`Refreeze`, as obtaining E does. - The join of a frozen place with a moved-out one emits `Unfreeze` before the weaken; join actions are now `PcgAction`s. - A leaf whose future lifetime projection is a leaf but which has no capability is a validity error: the edge that blocked it would have returned its capability. Only its future labels are removed. - The owned leaves that labelling a place for a write unblocks get W through that `LabelPlace`, which now carries the capabilities it sets. Written with Claude
Unpacking a place and creating a mutable borrow now set their capabilities through the recorded AddEdge action instead of updating them silently. Written with Claude
A place that loses its capability at a join is lent to the conditional borrow of the other path (new Lend action), or weakened. A place frozen on the other path is frozen here with Freeze, and the other path's read unpackings below it are added with AddEdge instead of copying their capabilities. Borrow actions of the owned join are recorded. Written with Claude
A join unpacks a place that the other path has unpacked (freezing it first for a read unpacking), so that the capabilities below it survive instead of being dropped. Enum variant unpackings are not matched: the variant is unknown on this path, so the place is lent to the other path's variant unpacking. A read expansion on the other path freezes the whole expanded place. A local that is dead on the other path loses its capabilities with its StorageDead. Written with Claude
A raw pointer dereference does not borrow from the pointer. Once the pointer place is labelled, the dereference edge is removed so that it no longer keeps the borrows the pointer came from alive. Written with Claude
A borrow of the referent of a shared reference does not depend on the place holding the reference. When a place must become exclusive (an upgrade from R, a two-phase activation, a loop head) while such a referent in its read unpacking is still borrowed, the referent is labelled (LabelPlaceReason::DetachSharedReferent), so that the borrows keep the labelled referent, and the dereference edge is removed. The read unpacking is then packed with RemoveEdge instead of weakening the read copies and leaving the unpacking in the graph. Entering a loop replays the loop head's upgrade, so these actions appear on the entry edge. Written with Claude
Every PCG action (borrow and owned) now carries its capability changes:
the changes are computed by the action's rule when it is applied and
stored in the action, so consumers can read every capability change off
the actions.
- Weaken is a single borrow action that strictly lowers a capability to
a real one (no Weaken to None, no owned Weaken). RepackOp::Weaken and
RepackOp::IgnoreStorageDead are removed.
- Refreeze is merged into Unfreeze { place, refrozen }.
- LabelPlace states the capabilities it removes.
- A dead frozen reference is unfrozen before it is weakened, so there
are no R->W or W->W weakens.
- A capability lost at a join without a lend is a validity error.
- A place that is frozen and read-unpacked on one path and moved out on
the other gives up its value explicitly at the join (label, collapse,
Unfreeze, Weaken to W).
- The visualization shows the capability changes of each action.
Written with Claude
The joined state holds the meet of the predecessors' capabilities: the capability each path has or gets by unpacking, lowered to the smaller one (W where the paths disagree on how the value is held). Where the paths unpack different variants of an enum, the join holds the enum packed. Each predecessor reaches the joined state with explicit actions, computed by one procedure (`Pcg::reach`) that reuses the obtain machinery: it moves capabilities (freeze, unfreeze, weaken), unpacks and packs to the joined layout, and gives up the places the joined state does not hold (lend to the edge that blocks them on the other path, or detach their value with a label). The dataflow join, the normalization of the predecessors and the actions recorded on CFG edges, including loop entries and back edges, all use it. Into a loop head, the loans the loop abstraction stands for are removed. This replaces the owned join (owned_pcg/impl/local/join.rs, owned_pcg/impl/join/*), the separate unpacking, lending and weakening passes of `join_owned`, the loop-entry upgrade and expansion passes, and the silent meet of capabilities when merging the predecessors. The places a join gives up without a lend are no longer dropped: they are detached with `LabelPlaceReason::Detach` (formerly `DetachSharedReferent`), and `JoinOwnedReadAndWriteCapabilities` is gone. With every join capability change explicit, the following no longer pass silently: - a loop head holds the places that are not definitely initialized there with W, as a back edge may arrive with them moved out; - the imaginary target of a false edge gets no edge actions; - a shared borrow of a reference whose referent is reserved (e) freezes it; - a place still blocked in the PCG (e.g. through a raw pointer) is not reported as unblocked, and activating a two-phase borrow detaches the read copies that dead loans keep from being packed; - labelling one end of a borrow flow edge into the other collapses it. Joins also: - keep a reference read on one path and lent on the other with e; - lend a place whole when the other path lent it whole, packing the parts this path holds (a partially moved place regains W, not E); - keep the parts of a borrowed place frozen until the borrow ends; - treat raw pointer dereferences as unpackings, not loans; - copy the other path's edges for a Lend without this path's branch choice; - introduce a loop head's abstraction edges on the bridge only where the reached state lacks them. Loop heads weaken to W, with recorded obtain and Weaken actions, every owned place that is not definitely initialized there, and the local references held exclusively that are dead there: the dead-lifetime weakens of the loop body would otherwise bring them back with W. Written with Claude
A loan or freeze now records the capability it takes from a place, and the place gets back exactly that record when the loan or freeze ends. Before, the capability given back was recomputed from other state (the initialisation tree, whether the loop only reads the place, the place's type, or E by default), so a mistake in any of those could give a place more than it lent. The records (`PlaceCapabilities::suspended`) are set by: - a mutable borrow's `AddEdge`, which now takes the borrowed place's capability itself (the borrow's triple no longer removes it silently); - `Freeze`, for the frozen place (an unpacked one records what its parts hold, not counting what lies behind a shared reference) and its parts; - `Lend` at a join, recorded for the place the edge blocks within the lent value, at the meet with what is already recorded there; - the loop head, for the places its abstraction blocks or freezes. They are given back by `RemoveEdge` (a loan) and `Unfreeze`; removing an unpacking gives its base what its expansion holds. Records are dropped when the value is labelled, the local dies or a lent place is overwritten. At a join, the predecessors keep what all of them record, at the meet, and lower their own records with the new action `WeakenSuspended`. A loop head lowers the records of places that are not definitely initialized there to W. The case where one path partly moves a place that the other lends whole now gives back W by construction, so the special case in the initialisation tree is reverted. Frozen places hold at most R: `ActivateTwoPhaseBorrow` requires the place to be frozen itself, a raw pointer dereference leaves the pointer's capability unchanged, a collapse packs only its own expansion (two read expansions at different indices gave E without an unfreeze), and an unpacked shared reference that an unfreeze leaves at R stays frozen. Under validity checks, a place with a record holds no capability or R, and a frozen place has a record. The Polonius liveness query treated every region as dead when no loan was in scope; the frozen-place check exposed it. Written with Claude
A mutable reference held with E on one path and with e on the other (its referent lent there) now lends its referent, through the reference, to the edge that blocks it on the other path: the reference keeps e and the referent records E. Before, the join dereferenced the reference first, an unpacking this path never performed (and, for a dead reference, one whose referent the path no longer has). Written with Claude
Records (what a lent or frozen place gets back) are now joined the way capabilities are: at the finest places every path records or covers, reached through explicit record actions (UnpackSuspended, PackSuspended, WeakenSuspended), with the owned layout below lent places joined too. When a loan ends, each record holder in the value of what it blocked comes back once no loan blocks its value: its record is unpacked along its layout (through references' referents and only into the variants or unpackings a loan still needs; the others are forgotten), and the places that then record something get it back. While only shared borrows block it, it comes back frozen (R, record kept); while a mutable loan does, nothing comes back. A lent place, its parts and the places it is part of hold at most R while the loan lives; the validity check enforces it, and the lazy unfreezes respect it. At a join a frozen place's freeze moves to its read copies instead of ending, a place still blocked by this path's own loan stays with it, and each held part of a lent place is lent with what it holds. Written with Claude
JonasAlaif
force-pushed
the
two-phase-reservation
branch
from
November 12, 2026 17:55
6c459f5 to
f9dae19
Compare
The loop head obtained the places the loop uses with the capability the loop needs, and then capped only the used places that a loan borrows directly, by the loop's own usage: R if the loop only reads them, nothing otherwise. A place lent by a shared loan that ends inside the loop (`a = &x` before the loop, `a = &z; x.g = 1` in it) therefore had its parts at E while `Loop: x -> a` lent `x`, and a place borrowed shared but written later in the loop was taken as lent. The loop head now lowers its capabilities to what the loans of its graph leave, for each place in or above a lent place: a place that a mutable borrow in scope covers holds nothing; a place a mutable borrow takes a part of keeps the R of a read unpacking and holds nothing if it holds more; otherwise a place holds at most R. Each records what it held, so that the comeback gives it back when the loan ends in the loop. A two-phase borrow is mutable only once activated (its activation dominates the head or lies in the loop): `v.push(..)` whose argument loops over `v` keeps `v` frozen at the loop head. This replaces the abstraction's `capability_updates`. The loop head also does not unpack enum variants: not every path into it matched them. The loop's usages and blocked places in a variant are taken at the enum, so a loan of a variant field lends the enum whole. A blocker in a variant keeps the lifetime projection that holds the loan in the abstraction; the head holds the blocker at the enum. A root of the loop's blockers is lent to the loop abstraction only if a loan borrows the root or a place it is part of (`BorrowCheckerInterface::blocks_entirely`). Where the loans borrow only a part of it, which the loop lends apart, the root's lifetimes flow into the blocker and nothing more: `for .. in self.chars.by_ref()` lent all of `*self` (bstr's `FieldsWith::next`), so the loop could no longer call `self.f`. `ObtainType::LoopInvariant::is_blocked` was never read; it is removed. Written with Claude
A join of a path that borrows a variant field (`if let A(ref p, _) = x`)
with one that did not match the variant holds `x` frozen and packed. The
borrowing path keeps the layout `x -> x@A -> {0, 1}` below `x`, but its
read copies are gone: not every path knows the variant. Then:
- `join_lent_layout` refused to give the other path that layout because
`x` holds a capability. A place held frozen may have layout below it.
- Borrowing another variant mutably (`if let C(ref mut q) = x`, allowed
since variants are disjoint) or another field of the variant ended the
freeze of `x` from its read copies, found none borrowed, and unfroze
`x` whole while the loan of `x@A.0` lived.
- Ending the shared borrow of `x` whole lazily unfroze `x` for the same
reason, and the obtain's read collapse froze `x@A` out of nothing.
Ending a freeze now unpacks the read unpacking toward the place being
obtained and, at each place on the way, one level into the other
unpackings of the layout that hold a borrowed place. The freeze moves to
the topmost read copies that are borrowed or have a borrowed place in
their value that their own unpacking does not reach: `x@A` when `x@C.0`
is obtained, `x@A.0` when `x@A.1` is. Re-unpacking a place whose layout
below holds and records nothing gives the parts their share. The lazy
unfreeze of a place waits for every loan in its value, the read
collapse leaves alone the places that hold nothing below a holder, and
`packed_capability` counts a layout leaf as held when a place it is part
of holds a capability.
Written with Claude
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Capabilities change only through explicit actions, and every action records the capability changes it makes. Reading never weakens E: the place is frozen (the E is suspended) and comes back on the same place. A loan records what it takes and gives back exactly that. Joins are the meet of their predecessors, reached with the same explicit actions, for capabilities and for what is recorded.
Actions
Every
PcgAction(borrow and owned) carriescapabilities: the changes its rule computes when it is applied, stored in the action. Consumers can read every capability change off the actions.Weaken { place, from, to }RepackOp::Weaken,RepackOp::IgnoreStorageDead,RegainLoanedCapabilityand weakens toNoneare removed.Freeze { place }Unfreeze { place, refrozen }placeis left. Withrefrozennon-empty, those still-borrowed descendants stay frozen and the rest of the unpacking gets E (formerlyRefreeze).ActivateTwoPhaseBorrow { borrow }AddEdge/RemoveEdgeAdd Edge {p} -> {*p} setting p: e, *p: E,Remove Edge borrow: r = &mut x returning E to x).RemoveEdgeis applied once; replacesRestore.Lend { place, edge }placeis lent to an edge that exists only on another path (a conditional loan, a variant unpacking, a loop abstraction), or to a loan of this path that still blocks it. What it holds is recorded and comes back with the edge'sRemoveEdge; a frozen place keeps its record and lends its read copies with it.UnpackSuspended { place, parts }/PackSuspended { place }WeakenSuspended { place, from, to }LabelPlace { reason }Detach(formerlyDetachSharedReferent) gives up the value of a place at a join.RepackOp::StorageDeadA place with R whose parent has R is a read copy of a frozen ancestor; a place with R whose parent has no R is frozen. There are no R → W or W → W weakens: a dead frozen place is unfrozen first.
Records
PlaceCapabilitieskeeps, per lent or frozen place, the capability it gets back (its record):AddEdgeof a mutable borrow,FreezeandLendrecord what they take, andRemoveEdge/Unfreezegive back exactly that.UnpackSuspended/PackSuspended/WeakenSuspended. The owned layout that another path has below places nobody holds is added (it changes no capability).Joins
The joined state holds the meet of the predecessors' capabilities: for each place, the capability each path has or gets by unpacking (through derefs), lowered to the smaller one. R and e meet at e (a reference read on one path and with its referent lent on the other keeps its value); other incomparable pairs meet at W. Where one path holds the place frozen and a loan of either path blocks it or an ancestor, an e or W meet is none instead: the loan keeps the place frozen. Where the paths unpack different variants of an enum, the join holds the enum packed. E and W entries swallow their postfixes.
Each predecessor reaches the joined state with explicit actions, computed by one procedure (
Pcg::reachinsrc/pcg/domain/join.rs) that reuses the obtain machinery, in order:StorageDeadof locals the other path does not have;The dataflow join (meet, then every predecessor reaches it) and the actions on CFG edges (
Pcg::bridge) both use it; a lent or added edge drops this path's branch choice. An expansion that a path rebuilds to match the other path keeps that path's guide, so a labelled index guide (#187) keeps its label (expand_totakes the other path's graph as template). The loop head's abstraction edges are added on the bridge where the reached state lacks them.Loop heads are computed from the entry edge only, so
finish_joinweakens to W, with recorded obtain andWeakenactions, what a back edge may bring back weakened: every owned place that is not definitely initialized there, and (onmain, because of the dead-lifetime weakens of #147) the local references held E or e that are dead there.Loop heads (from #198)
At a loop head, the capabilities are what the loans of the head's graph leave. For each place in a place that a loan edge lends, or above one (short of a reference to it), looking at the mutable borrows in scope at the head that conflict with it:
A two-phase borrow counts as mutable only once activated (its activation dominates the head or lies in the loop). Each lowered place records what it held, so the comeback returns it when the loan ends inside the loop. The head does not unpack enum variants: not every path into the loop matched them. A root of the loop's blockers is lent to the abstraction only if a loan borrows the root or a place it is part of (
BorrowCheckerInterface::blocks_entirely):for .. in self.chars.by_ref()inbstr'sFieldsWith::nextlent all of*self. This replaces the abstraction'scapability_updates, which capped only the used places a loan borrows directly, by the loop's own usage:a = &xbefore a loop that writesx.gaftera = &zleftx.g: EwhileLoop(bb1): x -> alentx.Frozen enums with a borrowed variant field
A join of a path that borrows a variant field (
if let A(ref p, _) = x { a = p }) with one that did not match the variant holdsxfrozen and packed; the borrowing path keeps the layoutx -> x@A -> {0, 1}below it, without read copies. The other path gets that layout too (a frozen place may have layout below it). Ending the freeze ofxto borrow another variant mutably (allowed: rustc keeps variants apart under shared loans) or another field of the variant unpacks toward the obtained place and, at each place on the way, one level into the other unpackings that hold a borrowed place; the freeze moves to the topmost read copies that are borrowed or have a borrowed place in their value that their unpacking does not reach:Before,
xwas unfrozen whole whileplived (or the join failed: "the join unpacks x, which holds a capability"). The lazy unfreeze of a place also waits for every loan in its value, and the read collapse leaves alone places that hold nothing below a place that holds a capability.This replaces the owned join (
owned_pcg/impl/local/join.rs,owned_pcg/impl/join/*), the separate unpacking/lending/weakening passes ofjoin_owned, the loop-entry upgrade and expansion passes, the abstraction-edge pass of the results cursor,PlaceCapabilities::join, and the silent meet when merging predecessors (now a validity check that they agree).Other fixes the explicit changes exposed:
Diff
Rebased onto
main(now including #191, #192, #195, #196). #192's cut of delegation edges uses the newWeaken;174_linked_hashmap_refreshstays removed as onmain. With #192 the loan taken bymem::takein219ends before the two-phase activation, so that activation no longer detaches anything (annotation updated).Tests
On
main(this branch) and on the pin basee90a814(branchtwo-phase-reservation-e90a814, used by Prusti): the annotation check of everytest-filesfile withPCG_VALIDITY_CHECKS,cargo test --lib,pcg-tests(incl.selected_crates), clippy and fmt. Every commit builds.New cases:
211(partly moved vs lent whole; borrowed whole vs in part; and for records: frozen vs lent whole, whole vs part or every part lent, nested structs, reference fields whose referent is lent, enum variant fields per arm, shared and mutable fields of one variant, moved variant fields, arrays unpacked at different indices, borrowed*xwith the part's loan ending first; a variant field borrowed on one path, then another variant or field borrowed mutably, or the enum read whole),229(variant lend),230(lend without the branch choice),237(moved in a loop, frozen or lent on entry),239(raw pointer deref),240(main: deadselfacross nested loops; the loop-head cases of #198, includingbstr'sFieldsWith::nextand aserde_derive-shaped nested loop).main's245and246(deferred dead-lifetime weakens) pass unchanged.Annotation changes on
mainfrom the loop-head rule: in091,116,132,192a reference that is dead at a loop head while its referent is lent is labelled at the head (*y loop bb1,b_in: W). In240(consume,consume_after_branch) and241, the head no longer unpackslist.tailtoSome, so the sibling fields connect to their remote through the wholelist↓'rprojection (still only to their own remote).Four rounds of independent review on the explicit actions and the join, then five on the records (each finding confirmed with a probe and fixed; several also failed on the previous head).
Follow-ups
match x { A(ref p, _) => a = p, B(ref mut q) => *q += 1 }) fails the validity check: the head's abstraction lends the enum whole, so the body's mutable borrow ofx@B.0unfreezesxwhile it is lent. The head should block the variant field itself, withxfrozen and packed and the variant's layout below it, as the join now does.RemoveEdgecarriesreturningdepends on the order dead edges are removed in, which varies between runs.Expand/Collapsewith borrow-PCG expansions (separate PR).remove_edges_absent_fromandpack_read_unpackingrebuild the frozen graph after every removal; not measured.Prusti side: prusti/prusti#1609.