Skip to content

Explicit capability changes: action deltas, freezes, two-phase activation, joins as reached meets - #190

Draft
JonasAlaif wants to merge 15 commits into
mainfrom
two-phase-reservation
Draft

JonasAlaif wants to merge 15 commits into
mainfrom
two-phase-reservation

Conversation

@JonasAlaif

@JonasAlaif JonasAlaif commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

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) carries capabilities: the changes its rule computes when it is applied, stored in the action. Consumers can read every capability change off the actions.

Action Effect
Weaken { place, from, to } Strictly lowers a capability to a real one (E/e → W, E → e). The only weaken: RepackOp::Weaken, RepackOp::IgnoreStorageDead, RegainLoanedCapability and weakens to None are removed.
Freeze { place } E/e → R. Nothing is given up. An unpacked place takes over the R of its descendants, which become read copies under it.
Unfreeze { place, refrozen } R → the suspended E, once no borrow of place is left. With refrozen non-empty, those still-borrowed descendants stay frozen and the rest of the unpacking gets E (formerly Refreeze).
ActivateTwoPhaseBorrow { borrow } The reserved place goes R → lent.
AddEdge / RemoveEdge Carry what the edge takes and gives back (Add Edge {p} -> {*p} setting p: e, *p: E, Remove Edge borrow: r = &mut x returning E to x). RemoveEdge is applied once; replaces Restore.
Lend { place, edge } At a join: place is 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's RemoveEdge; a frozen place keeps its record and lends its read copies with it.
UnpackSuspended { place, parts } / PackSuspended { place } Move a record one level, as a capability would be unpacked or packed: parts get the same, the referent of a mutable reference E with the reference keeping e; a part that records less keeps that. Used at joins and when a loan ends.
WeakenSuspended { place, from, to } Lowers (or drops) a record: at a join to the meet of the paths, at a loop head to W.
LabelPlace { reason } States the capabilities it removes. Detach (formerly DetachSharedReferent) gives up the value of a place at a join.
RepackOp::StorageDead Applied: removes the local's capabilities.

A 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

PlaceCapabilities keeps, per lent or frozen place, the capability it gets back (its record): AddEdge of a mutable borrow, Freeze and Lend record what they take, and RemoveEdge/Unfreeze give back exactly that.

  • Invariant. A lent place, its parts, and the places it is part of (short of a reference to it) hold at most R while the loan lives; the validity check enforces it. Unfreezes skip places within a lent place, and the lazy unfreeze of leaves runs after the dead edges are removed.
  • 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 (owned expansions, borrow-PCG expansions, the referent of a reference), entering an enum variant or one of several unpackings only where a remaining loan needs it (the others, holding nothing, 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. Removing an unpacking whose parts record something first packs those records into the base.
  • At a join, records are joined like capabilities: at the finest places every path records or covers (including the enum above diverging variants), the meet of what each path has, reached with 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::reach in src/pcg/domain/join.rs) that reuses the obtain machinery, in order:

  1. StorageDead of locals the other path does not have;
  2. move capabilities (freeze, unfreeze, weaken; a place the target unpacks exclusively is unfrozen, one whose parts the target only reads has its freeze moved to them, a place read in the target is frozen);
  3. unpack to the joined layout (into a loop head, as the loop invariant unpacks);
  4. pack what the joined state holds packed;
  5. give up the places the joined state does not hold: lend each, with what it holds, to the edge of the other path that blocks it or an ancestor; else to the loan of this path that blocks it, an ancestor, or another part of the value the join gives up; lend the place itself for an unmatched variant, or unpack toward a part the other path lent (moving a freeze to the read copies); otherwise detach its value with a label.

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_to takes 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_join weakens to W, with recorded obtain and Weaken actions, what a back edge may bring back weakened: every owned place that is not definitely initialized there, and (on main, 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:

  • if one covers it (borrows it or a place it is part of), it holds nothing;
  • if one takes a part of it, it keeps the R of a read unpacking, and holds nothing if it holds more;
  • otherwise it holds at most R.

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() in bstr's FieldsWith::next lent all of *self. This replaces the abstraction's capability_updates, which capped only the used places a loan borrows directly, by the loop's own usage: a = &x before a loop that writes x.g after a = &z left x.g: E while Loop(bb1): x -> a lent x.

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 holds x frozen and packed; the borrowing path keeps the layout x -> 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 of x to 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:

if let AC::A(ref p, _) = x { a = p; }
if let AC::C(ref mut q) = x { *q = 1; }   // bb6[1]: unpack x@C with capability R
                                          //         Refreeze x to x@A

Before, x was unfrozen whole while p lived (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 of join_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:

  • 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 through a raw pointer is not reported unblocked; raw pointer dereferences are unpackings, not loans;
  • activating a two-phase borrow detaches the read copies that dead loans keep from being packed;
  • a lent place that was partly moved on one path regains W for the moved part and E for the rest;
  • an owned place weakened to W is uninitialised in the initialisation tree;
  • the Polonius liveness check no longer tests origin liveness inside the loop over loans;
  • the parts of a place under a live borrow stay frozen, and an unpacked place regains nothing itself when its borrow ends;
  • labelling one end of a borrow flow edge into the other collapses it.

Diff

Rebased onto main (now including #191, #192, #195, #196). #192's cut of delegation edges uses the new Weaken; 174_linked_hashmap_refresh stays removed as on main. With #192 the loan taken by mem::take in 219 ends before the two-phase activation, so that activation no longer detaches anything (annotation updated).

Tests

On main (this branch) and on the pin base e90a814 (branch two-phase-reservation-e90a814, used by Prusti): the annotation check of every test-files file with PCG_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 *x with 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: dead self across nested loops; the loop-head cases of #198, including bstr's FieldsWith::next and a serde_derive-shaped nested loop). main's 245 and 246 (deferred dead-lifetime weakens) pass unchanged.

Annotation changes on main from the loop-head rule: in 091, 116, 132, 192 a reference that is dead at a loop head while its referent is lent is labelled at the head (*y loop bb1, b_in: W). In 240 (consume, consume_after_branch) and 241, the head no longer unpacks list.tail to Some, so the sibling fields connect to their remote through the whole list↓'r projection (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

  • A loop that borrows a variant field across the back edge and another variant mutably in the body (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 of x@B.0 unfreezes x while it is lent. The head should block the variant field itself, with x frozen and packed and the variant's layout below it, as the join now does.
  • Which RemoveEdge carries returning depends on the order dead edges are removed in, which varies between runs.
  • Obtaining through a reference that holds e gives its referent E without checking the referent's record.
  • Merge owned Expand/Collapse with borrow-PCG expansions (separate PR).
  • remove_edges_absent_from and pack_read_unpacking rebuild the frozen graph after every removal; not measured.

Prusti side: prusti/prusti#1609.

@JonasAlaif JonasAlaif changed the title Freeze/Unfreeze/Refreeze capabilities; explicit two-phase borrow activation Explicit capability changes: freezes, two-phase activation, edge capabilities, joins Oct 26, 2026
@JonasAlaif JonasAlaif changed the title Explicit capability changes: freezes, two-phase activation, edge capabilities, joins Explicit capability changes: action deltas, freezes, two-phase activation, joins as reached meets Oct 27, 2026
@JonasAlaif
JonasAlaif force-pushed the two-phase-reservation branch from 10fd9dd to 6c459f5 Compare October 27, 2026 07:11
@JonasAlaif JonasAlaif mentioned this pull request Oct 30, 2026
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
JonasAlaif force-pushed the two-phase-reservation branch from 6c459f5 to f9dae19 Compare November 12, 2026 17:55
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

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant