Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Overview

The purpose of the PCG Analysis is to provide clients with the following:

  • The PCG data structure representing the state of ownership and borrowing of Rust places at arbitrary program points within a Rust function
  • For any pair of consecutive program points, an ordered list of actions that describe the transformation of the PCG of to the PCG of .

PCG Analysis Algorithm

Key Concepts:

  • The PCG Analysis Algorithm operates on the MIR Body of a Rust function and returns a PCG Analysis of the function.
  • A PCG Analysis contains PCG Data for every reachable1 MIR location in the Body2.
  • The PCG Data is a tuple of PCG States and PCG Action Data.

The PCG States of a MIR location is a list of the PCGs computed at that location, concretely:

The PCG Action Data of a MIR location contains a list of PCG Actions for each evaluation phase in the location (i.e. the actions performed at that phase).

The PCG Dataflow Analysis

The PCG Analysis Algorithm is implemented as a MIR dataflow analysis using PcgDomainData as the domain. PcgDomainData contains a PCGData value and other relevant metadata (e.g the associated basic block). Notably, the analysis only analyzes the statements in each basic block one time. Conceptually, this property is ensured because the final state at a loop head is computed upon entering it in the analysis (without having first seen the body).

We note that the behaviour of the join operation on PCGDomainData requires tracking of what blocks have been previously joined (this is basically a consequence of the interface of the MIR dataflow analysis). The PCGDomainData join operation joins the PCG of block into the PCG at block as follows:

  1. If no block has ever been joined into , then set =
  2. If the edge from to is not a back edge3 of a loop, then is joined into using the algorithm defined here

Because the join does not modify the PCG for back edges, the analysis can be completed without ever having to re-analyse the statements within a block.

Our implementation should also be checking that the PCG generated at the loop head is valid w.r.t the state at the back edge here, but this is not happening yet.

PCG Data Structure

The PCG data structure represents the state of ownership and borrowing of Rust places at an arbitrary program point.

It consists of three components:

  • The Owned PCG, which describes the state of owned places
  • The Borrow State, which describes borrowed memory (borrowed places and lifetime projections) and borrow relations, and also some auxillary data structures
  • The Place Capabilities which is a map from places to capabilities

Owned PCG

The Owned PCG is a forest, where the root of each tree in the forest is a MIR Local.

Each tree in the forest is defined by a set of place expansions, which describe how unpacked all of the owned places are. For each expansion in the set, the base is a node in the forest and are its children. We note that each expansion can be similarly interpreted as a hyperedge

Each local in the body of a MIR function is either unallocated or allocated in the Owned PCG. A local is allocated iff there exists a corresponding root node in the Owned PCG, otherwise it is unallocated.

The operation allocate in the Owned PCG requires that is not allocated, and adds a new root for . The deallocate operation removes the forest rooted at .

Borrow State

The Borrow State is a tuple containing a set of Validity Conditions that describes the set of paths leading to the current block and a Borrows Graph, a directed acyclic hypergraph which describes borrowed places, sets of borrows, and their relation to one another.

In our implementation the Borrows Graph is represented as a map from PCG hyperedges to validity conditions.

Because a borrow created within a block exists only for executions that visit that block, we label new borrows using the validity conditions of the block in which they were created.

Place Capabilities

The design for how place capabilities are represented and computed is being updated. In the new design, capabilities are computed from the initialisation state and borrow state rather than stored in an explicit map. See Computing Place Capabilities for details.

Place capabilities is a partial map from places to capabilities.


  1. A MIR location reachable iff its basic block is reachable from the start block in the CFG without traversing unwind or fake edges (the latter kind do not correspond to the actual control flow of the function). The original reason for only considering reachable edges was to improve performance; removing this constraint (and instead considering all locations) would be simple to change in the implementation.

  2. The PCG analysis algorithm is implemented as a MIR dataflow analysis defined by the rust compiler crate. In the implementation, the PCG Data is computed for every reachable MIR location during the algorithm execution itself, but only the PCG Data for the entry state of each basic block is stored. The PCG Data for an arbitrary location within a block is re-computed by applying the dataflow analysis transfer function from the entry state.

  3. In the join implementation, we an edge from to is a back edge if dominates in the CFG

Definitions

Types

Type Contains

A type contains a type , iff:

  1. , or
  2. is an ADT and contains a field and contains
  3. and contains

Types Containing Lifetimes

A type contains a lifetime iff contains the type for some type . A lifetime is nested in a type iff contains a type and contains . We extend these concept to places: a place contains a lifetime iff contains ; is nested in iff is nested in . A lifetime projection is nested if is nested in .


PCG Evaluation Phase

The PCG Evaluation Phases are:

  • PreOperands
  • PostOperands
  • PreMain
  • PostMain

For every MIR location, a seperate PCG is generated for each phase. They represent the following:

  • PreOperands - A reorganization of the initial state1 to prepare to apply the effects of evaluating the operands in the statement
  • PostOperands - The result of applying the operand effects on the PreOperands state
  • PreMain - A reorganization of the PostOperands state to prepare to apply any other effects of the statement
  • PostMain - The result of applying any other effects of the statement to the PreMain state.

Program Point

A program point represents a point within the execution of a Rust function in a way that is more fine-grained than a MIR location (each MIR location has multiple program points which to different stages of evaluation of a statement). Concretely, a program point is either:

  • The start of a basic block
  • A pair of a MIR location and a PCG evaluation phase

Borrows and Blocking

Blocking

A place blocks a place at a location if a usage of at would invalidate a live borrow contained in the origins of at .

Borrow Liveness

A borrow is live at location if a usage of at would invalidate the borrow.

Directly Borrowed

A place is directly borrowed by a borrow if is exactly the borrowed place (not e.g. a pre- or postfix of the place).


  1. The initial state is either the PostMain of the previous location within the basic block. Or if this is the first statement within the block, it is the entry state of the block (i.e. the result from joining the final states of incoming basic blocks).

Capabilities

A capability describes the actions that the program is permitted to perform on a place at a particular program point. There are three main capabilities:

Exclusive (E)

Places with this capability can be read from, written to, or mutably borrowed.

We do not differentiate between locals bound with let bindings and let mut bindings: a variable bound with let would still have this capability if it could be written to if it was mutably borrowed.

Read (R)

Places with this capability can be read from. Shared borrows can also be created to this place. Shared references with this capability can be dereferenced.

A place with capability E is downgraded to R if a shared borrow is created to a place that is a pre- or postfix of .

When a shared reference is dereferenced, the capability to is downgraded to R. Any place projecting from a shared references will have capability R.

Write (W)

The place can be written to.

When an exclusive reference is dereferenced, the capability to is downgraded to W.


In the implementation we define a fourth capability ShallowExclusive (e), which is used for a rather specific and uncommon situation. When converting a raw pointer *mut T into a Box<T>, there is an intermediate state where the memory for the box is allocated on the heap but the box does not yet hold a value. We use ShallowExclusive to represent this state.

In the implementation, writing to a Box-typed place p via e.g. *p = 5 requires that p have capability e.

PCG Nodes

We probably don't need so many label types, but we have them in the implementation currently.
In the implementation we currently refer to lifetime projections as "region projections" and labelled places as "old" places.

Associated Place

The associated place of a PCG node is defined by the partial function :

Where is the base of the lifetime projection as defined here.

Local PCG Nodes

A PCG node is a local node if it has an associated place .

PCG Hyperedges

A PCG Hyperedge is a directed edge of the form , where elements of and are PCG nodes. The set are referred to as the source nodes of the edge, and are the target nodes. Hyperedges in the PCG are labelled with validity conditions1.

We represent a PCG hyperedge as a tuple of an edge kind and validity conditions.

Edge Kinds

An edge kind is a description an edge, including its source and target nodes, as well as other associated metadata. The metadata is described by the type of the edge, the various types are presented below:

Unpack Edges

Unpack edges are used to represent the unpack of an owned place in order to access one of its fields. For example, writing to a field x.f requires expanding x.

An unpack edge connects an owned place to one of it's expansions . Each place in is guaranteed to be owned.

For example, if x is an owned place with fields x.f and x.g, the edge {x} -> {x.f, x.g} is a valid unpack edge.

Unpack edges are not generated for dereferences of reference-typed places. Borrow PCG Expansion Edges are used in such cases. Unpack edges are used in derefences of Box-typed places.

In the implementation, we don't have an explicit data type representing unpack edges. Rather, the unpack edges are conceptually represented as the interior edges in the Owned PCG.

Validity conditions are not currently associated with unpack edges in the implementation.

Borrow PCG Expansion Edge

Borrow PCG Expansion Edges conceptually take one of three forms:

  • Dereference Expansion: The dereference of a reference-typed place
  • Place Expansion: The expansion of a borrowed place to access one of its fields
  • Lifetime Projection Expansion: The expansion of the lifetime projections of an owned or borrowed place

Dereference Expansion

The source nodes of a dereference expansion consist of:

  • A maybe-labelled place , and:
  • A lifetime projection

Where and have the same associated place .

The target node of a dereference expansion is a maybe-labelled place with associated place .

Place Expansion

The source node of a place expansion is a maybe-labelled place with associated place , where is a borrowed place and is not a reference.

The target nodes of a place expansion is a set of maybe-labelled places where the associated places of are an expansion of .

Lifetime Projection Expansion

The source node of a lifetime projection expansion is a lifetime projection where is a maybe-labelled place with associated place of .

The target nodes of a lifetime projection expansion is a set of lifetime projections where the base of each place is a maybe-labelled place. The associated places of the bases of are an expansion of .

It might make sense to differentiate lifetime projection expansions of owned and borrowed places, since they differ in terms of how placeholder labels should be included. Namely, for owned places there is no need to connect the expansion nodes to the placeholder of the base node (the owned place may never be repacked, or could be repacked with entirely unrelated lifetime projections)

Borrow Edges

Borrow edges are used to represent direct borrows in the program. We define two types:

  • Local Borrow Edges: A borrow created in the MIR Body, e.g. from a statement let y = &mut x;
  • Remote Borrow Edges: A borrow created by the caller of this function where the assigned place of the borrow is an input to this function.

Remote Borrows are named as such because (unlike local borrows), the borrowed place does not have a name in the MIR body (since it was created by the caller).

Local Borrows

The source node of a local borrow is a maybe-labelled place . The target node of a local borrow is a lifetime projection where is a maybe-labelled place.

Remote Borrows

The source node of a remote borrow is a remote place . The target node of a remote borrow is a lifetime projection where is a maybe-labelled place.

Abstraction Edge

An abstraction edge represents the flow of borrows introduced due to a function call or loop.

Function Call Abstraction Edge

The source node of a function call abstraction edge is a lifetime projection where is a maybe-labelled place.

The target node of a function call abstraction edge is a lifetime projection where is a maybe-labelled place.

Loop Abstraction Edge

The source node of a loop abstraction edge is a PCG node.

The target node of a loop abstraction edge is a local PCG node.

Borrow-Flow Edge

A borrow-flow edge represents the flow of borrows between a lifetime projection and a local lifetime projection . This edge is used when the relationship between the blocked and target node is known concretely, but does not correspond to an expansion or a borrow.

Borrow-Flow Edges are labelled with a Borrow-Flow Edge Kind with associated metadata, enumerated as follows:

Aggregate

Metadata:

  • : Field Index
  • : Lifetime Projection Index

An aggregate kind is used for borrows flowing into an aggregate type (i.e. struct, tuple). The metadata indicates that the blocked lifetime projection flows into the lifetime projection of the field of the blocking lifetime projection.

Introduced in the following two cases:

  1. Collapsing an owned place:
    • edges flow from the lifetime projections of the labelled places of the base to the lifetime projections of the current base
  2. Assigning an aggregate (e.g. x = Aggregate(a, b)):
    • edges flow from the lifetime projections of the labelled places in the rvalue to the lifetime projections of x

is probably not necessary. We probably don't even need for case 1 (field index can be inferred from the expansion place), so perhaps a different edge kind could be used in that case.

Reference to Constant

Connects a region projection from a constant to some PCG node. For example let x: &'x C = c; where c is a constant of type &'c C, then an edge {c↓'c} -> {x↓'x} of this kind is created. This is called ConstRef in the implementation.

This seems quite similar to "Borrow Outlives", perhaps we should merge them?

Borrow Outlives

For a borrow e.g. let x: &mut y;, the PCG analysis inserts edges of this kind to connect the (potentially snapshotted) lifetime projections of y to the lifetime projections of x.

Initial Borrows

To construct the initial PCG state, the PCG analysis adds an edge of this kind between every lifetime projection in each remote place to the corresponding lifetime projection of its corresponding argument.

For example, if is the local corresponding to a function argument and contains a lifetime projection , an edge will appear in the graph.

Connects the lifetime projections of remote places to the lifetime projections of

Copy

For a copy let x = y;, the PCG analysis inserts edges of this kind to connect the lifetime projections of y to the lifetime projections of x.

In the implementation this is currently called CopyRef.

Move

For a move let x = move y;, the PCG analysis inserts edges of this kind to connect the (potentially snapshotted) lifetime projections of y to the lifetime projections of x.

Future

These edges are introduced to describe the flow of borrows to the lifetime projections of a place that is currently blocked. When they are created, the target node is a placeholder lifetime projection of a blocked place.

Perhaps this should be its own edge type?

  1. Currently not in the owned portion of the PCG, but this should happen eventually.

Types

A type is either:

  • A type parameter of the form
  • An alias type of the form
  • A type constructor application of the form
  • A box type of the form

Corresponding Regions

If is a region in , the corresponding region in a type is:

If and then

If and , iterate over , and if there exists an where in is the corresponding region of in , then .

Places

A place is a tuple of a local and a projection.

Place Expansion

This is missing some cases

A set of places is a place expansion iff there exists a base such that:

  • is an enum type and and is a variant of
  • is a struct or tuple type and is the set of places obtained by projecting with each of the fields in the type of
  • is a reference-typed field and
  • is an array or slice and (TODO: more cases)

If there is such a , then that is unique, and is an expansion of .

Owned Places

A place is owned iff it does not project from the dereference of a reference-typed place.

Place Liveness

A place is live at a location iff there exists a location and a control flow-path from to where a place conflicting with is used at and there are no assignments of any places conflicting with along c.

Place Prefix

A place is a prefix of a place iff:

  • and have the same local, and
  • The projection of is a prefix of the projection of

Note that is a prefix of itself.

A place is a strict prefix of iff is a prefix of and .

Regions

A region is one of:

  • A RegionVid (a region variable identifier)
  • The static region 'static
  • An early-bound region

TODO: Other cases

Lifetime Projections

Generalized Lifetimes

A generalized lifetime is either a region or , where is either:

  1. a type parameter, or
  2. a type alias that cannot be normalized

Generalized Lifetime Projections

A generalized lifetime projection takes the form where is a base having an associated type . The index of a lifetime projection is the index of the occurence of in the generalized lifetime list (the list of generalized lifetimes in , occurring in the order they appear in , and with duplicates removed).

A lifetime projection is a generalized lifetime projection of the form (that is, a generalized lifetime projection where the associated generalized lifetime is a region).

PCG Lifetime Projections

PCG lifetime projections take the following form

Lifetime Projection Lifetime

The lifetime of a lifetime projection is conceptually the lifetime on the right of the , i.e:

Lifetime Projection Base

The base of a lifetime projection is conceptually the part on the left of the , i.e. defined as follows:

Validity Conditions

We associate borrow PCG edges with validity conditions to identify the control-flow conditions under which they are valid. Consider the following program:

fn main(){
    // BB0
    let mut x = 1;
    let mut y = 2;
    let mut z = 3;
    let mut r = &mut x;
    if rand() {
        // BB1
        r = &mut y;
    }
    // BB2
    if rand2() {
        // BB3
        r = &mut z;
    }
    // BB4
    *r = 5;
}

We represent control flow using a primitive called a branch choice of the form . A branch choice represents the flow of control from one basic block to another. In the above code there are two possible branch choices from bb0: and and one branch choice from bb1 (to bb2).

For any given basic block in the program, an execution path leading to is an ordered list of blocks ending in . For example, one path of execution to bb4 can be described by: .

Validity conditions conceptually define a predicate on execution paths. For example, the validity conditions describing the control flow where r borrows from z at bb3 require that the path contain .

We represent validity conditions as a partial function mapping relevant blocks to sets of allowed target blocks.

An execution path is valid for validity conditions iff, for each , either:

  • is not a relevant block in , or
  • is in the set of allowed target blocks of in

Formal Definition

The representation of validity conditions in our implementations corresponds closely to the following description:

Validity conditions is a map describing control-flow conditions. For each block , is a subset of the real successors of .

The join of validity conditions and is defined as:

Validity conditions are valid for a path iff:

Correctness Theorem (Sketch)

For every borrow edge in the PCG at location and execution path to , the corresponding borrow is live at iff satisfies the validity conditions of .

Proof

We prove this for each location in the MIR by induction on the basic blocks of the graph via a reverse-postorder traversal. The inductive hypothesis is that the property holds for each ancestor block.

Having proved for the 1st location in a basic block, it is trivial to show for the remaining locations in the block, therefore we only concern ourselves with the basic blocks.

The case of bb0 is trivial.

We now concern ourselves with the validity conditions of an arbitrary edge at block , assuming via IH that it holds for all where . Let be the set of incoming nodes. We define our as the repeated join: .

We first show that the if direction holds: For every borrow edge in the PCG at location and execution path to , if the corresponding borrow is live at , then satisfies the validity conditions of .

Every has a prefix and ends with , by our IH we have satisfies . We need to then show that satisfies . Our proof is by contradiction.

Suppose did not satisfy ; then there must be a pair where . We know that the pair cannot be the final pair in (i.e. ) by the definition of join we have . Therefore the edge must exist in some strict prefix of . Then, there must be a nonempty set where (otherwise, the joined would be either or contain ). But then, because must contain , it would not satisfy the validity conditions of for any .

We now show the reverse: For every borrow live at for an execution path , its corresponding edge satisfies the validity conditions of .

Our proof relies on the monotonicity of joins: if then for any .

The borrow is created at a unique block . Then is definitely in at some index and the borrow is not killed in any block for all .

Our proof works by building longer prefixes of . The base case is when the borrow is created (and therefore becomes live) at , where the property holds trivially. Then, when going from to , we add to the validity conditions and join other incoming blocks. Because the prefix satisfies the prior conditions by induction, satisfies the additional condition, and monotonicity of join, the longer prefix also satisfies conditions. QED.

Functions

Generalized Types and Parameter Environments

A generalized type is either a type or a region

A param env is a list of constraints; where a constraint takes the form:

  • ( outlives )
  • (All regions in outlive )
  • ( implements )

Outlives Judgments

The base outlives relation holds iff it can be derived from the following rules:

  1. Direct:
  2. Reflexivity:
  3. Transitivity: and implies

That is, the base outlives relation is the transitive closure of the region-outlives and type-outlives facts in .

There also exists an outlives relation such that implies . This relation is computed by Rust's type system and additionally accounts for outlives constraints implied by trait membership constraints in .

Function Signatures

A function signature is a pair .

A defined function signature is a tuple .

An instantiation of is the tuple ; where is a list of early-bound parameters.

The identity instantiation of is obtained by applying the identity substitution . Defined function calls are applied to instantiations of a function.

The generalized lifetime projections of a function instantiation is defined as the set:

Function Calls

A function call target is either an instantiation or a closure / function pointer .

A function call takes the form , where is a MIR place, and is a sequence of MIR operands.

is the parameter environment of the function with respect to the call site.

The lifetime projections of a function call is the union of the lifetime projections in and the lifetime projections in .

A function call is valid iff it satisfies the unique region property: each region in the lifetime projections of is unique.

We assume that function calls generated by directly extracting the result place and operands from a MIR body are valid. We note that converting the places to PCG places (which use the type derived from their local), does not necessarily maintain the validity of a function call.

MIR Definitions

Here we describe definitions of MIR concepts that are relevant to the PCG.

It's possible that these definitions will become outdated as the MIR is not stable. If there is any discrepency between the descriptions here and those from official Rust sources (e.g. the dev guide), this page should be updated accordingly.

MIR Dataflow Analysis

At a high level, a MIR dataflow analysis is defined by the following elements:

  • A domain
  • A join operation
  • An empty state
  • A transfer function

Performing the dataflow analysis on a MIR body computes a value of type for every location in . The analysis is performed (conceptually) as follows1:

  • The analysis defines a map that maps locations in to elements of .
  • Each location in is initialized to
  • The operation analyze(b) updates as follows:
    • For
  • The analysis defines a worklist
  • While is not empty:
    • Pop from
    • Perform
    • Let be the entry of the last location in in
    • For each successor of :
      • Let
      • Let
      • If :
        • Add to
  • is the result

I'm not sure of the order things are popped from . Any ordering should yield the same but some blocks may be analyzed more frequently than necessary. We should check the rustc implementation.


  1. The current analysis implementation (defined in the rust compiler) is more efficient than what we describe because it tracks state per basic block rather than per-location, as the states for any location in a block can be computed by repeated application of the transfer function to the entry state.

Borrow-Checker Interface

The PCG Analysis requires an implementation of the borrow-checker interface providing the following:

  • A predicate , which holds iff the borrow extents in PCG node are all in scope at MIR location
  • A predicate , which holds iff place is directly blocked at MIR location
  • A predicate which holds iff mutating at would cause to be dead
  • A predicate which holds iff must outlive at
  • A function which returns the set of borrows that would be invalidated if was modified at
  • A function which returns the set of two-phase borrows that are activated at .

Analysing Statements

The PCG Analysis computes four states for each MIR statement, corresponding to the PCG evaluation phases:

  • PreOperands
  • PostOperands
  • PreMain
  • PostMain

The analysis for each statement proceeds in two steps:

  • Step 1: Place Conditions are computed for each statement phase
  • Step 2: PCG Actions are performed for each statement phase

Determining Place Conditions

A place condition is a predicate on the PCG related to a particular MIR place.

We define the following place conditions:

  • Capability: Place must have capability
  • RemoveCapability: Capability for place should be removed1
  • AllocateOrDeallocate: Storage for local is allocated (e.g. via the StorageLive instruction)
  • Unalloc: Storage for local is not allocated (e.g. via the StorageDead instruction)
  • ExpandTwoPhase: Place is the borrowed place of a two-phase borrow
  • Return: The RETURN place has Exclusive capability

ExpandTwoPhase may not be necessary. AllocateOrDeallocate is a confusing name, also it's not clear if it's any different than just having Write permission.

During this step of the analysis, place conditions are computed for each phase. The determination of place conditions is based on the MIR statement; the state of the PCG is not relevant.

The conditions computed for each phase are as follows:

  • PreOperands: Pre-conditions on the PCG for the operands in the statement to be evaluated
  • PostOperands: Post-conditions on the PCG after the operands in the statement has been evaluated
  • PreMain: Pre-conditions on the PCG for the main effect of the statement to be applied
  • PostMain: Post-conditions on the PCG after the main effect of the statement has been applied

As an example, the MIR statement: let y = move x would have the following place conditions:

  • PreOperands: {x: E}
  • PostOperands: {x: W}
  • PreMain: {y: W}
  • PostMain: {y: E}

The rules describing how these place conditions are generated for a statement are described here.

Performing PCG Actions

After the place conditions for each phase are computed, the analyses proceeds by performing the actions for each phase. At a high-level, this proceeds as follows:

PreOperands

  1. The Borrow PCG graph is minimized by repeatedly removing every effective leaf edge2 for which their target PCG nodes of are not live at the current MIR location. A Borrow PCG RemoveEdge action is generated for each removed edge. See more details here.

TODO: Precisely define liveness.

  1. The place capabilities required by the PreOperand place conditions are obtained.

PostOperands

No actions occur at this stage. Capabilities for every moved-out operand are set to Write.

PreMain

The place capabilities required by the PreMain place conditions are obtained.

Then, the behaviour depends on the type of statement:

  1. StorageDead(v)
    1. The analysis performs the MakePlaceOld(v, StorageDead) action.
  2. Assign(p r)
    1. If p is a reference-typed value and contained in the borrows graph and the capability for p is R:
      1. The analysis performs the RestoreCapability(p, E) action
    2. If :
      1. The analysis performs the action
    3. All edges in the borrow PCG blocked by any of the lifetime projections in p are removed

PostMain

  1. For every operand move p in the statement:
    1. The analysis performs the MakePlaceOld(p, MoveOut) action.
  2. If the statement is a function call p = call f(..):
    1. Function call abstraction edges are inserted using the rules defined here
  3. If the statement takes the form Assign(p, r):
    1. p is given exclusive permission
    2. If takes the form Aggregate(o_1, ..., o_n):
      1. For every
        1. Let be the associated place of
        2. For all
          1. If outlives :
            1. Add an Aggregate BorrowFlow edge , with associated field index and lifetime projection index .
    3. If takes the form use c, c is a reference-typed constant with lifetime , and is a reference-typed place with lifetime , then:
      1. Create a new ConstRef borrowedge of the form
    4. If takes the form move p_f or cast(_, move p_f):
      1. For all :
        1. Let be the i'th lifetime projection in p
        2. Let be the i'th lifetime projection in p_f
        3. Let be the snapshot location
        4. Add a Move edge
    5. If takes the form copy p_f or cast(_, copy p_f):
      1. For all :
        1. Let be the i'th lifetime projection in p
        2. Let be the i'th lifetime projection in p_f
        3. Add a CopyRef edge
    6. If takes the form &p or &mut p:
      1. Handle the borrow as described here

  1. This is only used for mutably borrowed places

  2. The set of effective leaf edges are the leaf edges in the graph obtained by removing all edges to placeholder lifetime projections.

Removing Edges for out-of-scope Borrow Extents

At the beginning of each statement

Rules for Determining Place Conditions

Place conditions are computed in terms of triples, where a triple is a pair (pre, post) where pre is a place condition and post is either a place condition or None.

The place conditions for each phase are determined by two sets of triples: the operand triples and the main triples. The place conditions for the PreOperands phase is the set of conditions in the pres of the operand triples. The PostOperands phase is the set of conditions in the posts of the operand triples. PreMain and PostMain are defined accordingly.

Determining Operand Triples for a Statement

  1. For each operand in the statement:
    1. If takes the form copy p:
      • Add (p: R, None) to the operand triples
    2. If takes the form move p:
      • Add (p: E, p: W) to the operand triples
  2. For each rvalue in the statement:
    1. If takes the form &p:
      • Add (p: R, p: R) to the operand triples
    2. If takes the form &mut p:
      • If the borrow is a two-phase borrow:
        • Add (ExpandTwoPhase p, p: R) to the operand triples
      • Otherwise, add (p: E, RemoveCapability p) to the operand triples
    3. If takes the form *mut p:
      • Add (p: E, None) to the operand triples
    4. If takes the form *const p:
      • Add (p: R, None) to the operand triples
    5. If takes the form len(p), discriminant(p) or CopyForDeref(p):
      • Add (p: R, None) to the operand triples

Determining Main Triples for a Statement

The rule depends on the statement type:

  1. Assign(p, r)
    1. If r takes the form &fake q:
      • Add (p: W, None) to the main triples
    2. If r takes the form ShallowInitBox o t
      • Add (p: W, p: e) to the main triples
    3. Otherwise, add (p: W, p: E) to the main triples
  2. FakeRead(_, p)
    1. Add (p: R, None) to the main triples
  3. SetDiscriminant(p, ..)
    1. Add (p: E, None) to the main triples
  4. Deinit(p)
    1. Add (p: E, p: w) to the main triples
  5. StorageLive(v)
    1. Add (Unalloc v, AllocateOrDeallocate v) to the main triples
  6. StorageDead(v)
    1. Add (AllocateOrDeallocate v, Unalloc v) to the main triples
  7. Retag(_, p)
    1. Add (p: E, None) to the main triples

Determining Main Triples for a Terminator

The rule depends on the terminator type:

  1. Return
    1. Add (Return, _0: w) to the main triples
  2. Drop(p)
    1. Add (p: W, None) to the main triples
  3. Call(p, _)
    1. Add (p: W, p: E) to the main triples
  4. Yield(p, _)
    1. Add (p: W, p: E) to the main triples

Rules for the Creation of Borrows

Mutable Borrows

Consider the stmt p = &mut q, at a program point , where has type , and has type , and is a type containing lifetimes .

At the end of the PreOperands phase, the PCG is guaranteed to be in a state where, for each the lifetime projection is in the graph. During the Operands phase, each lifetime projection is labelled with the current program point to become . At the end of the PreMain phase, for each , the lifetime projection is guaranteed not to be in the graph. During the Main phase, these projections are added.

Subsequently, the labelled lifetime projections under are connected with BorrowFlow edges to the new lifetime projections under . Namely, for each if outlives , then a BorrowFlow edge is added.

Subsequently, we introduce Future nodes and edges to account for nested references as follows. For each :

  1. Insert the node into the graph
  2. If any Future edges originate from the labelled projection , redirect them such that they originate from .
  3. Insert a Future edge
  4. Insert a Future edge

Owned PCG Operations

Collapse

The operation modifies place expansions and set of place capabilities such that becomes a leaf in the forest corresponding to . Stated more formally it modifies to ensure that contains an expansion containing , and is not the base of any expansion in . Capabilities in are updated to account for the removal of expansions from .

collapse returns the set of Owned PCG Actions corresponding to the removed expansions.

This logic is very similar to the collapse defined on the (combined) PCG defined here. This is used in contexts where the Borrow PCG is not available (such as the join on owned PCGs).

We should investigate making a common operation.

The algorithm is implemented as follows:

  1. Let be the subset of place expansions in such that for each in , the base place is a prefix of .
  2. Let be an ordered list of the expansions in sorted in order of descending length of the projections of their base place
  3. Let be the list of operations to return
  4. For each in
    1. Let be the capabilities of in
    2. Let be the minimum common capabiility of .
    3. Let be the base of
    4. Remove capabilities to the places in from
    5. Assign capability to in
    6. Remove from
    7. Add to
  5. return

Join Operation

The join algorithm on PCGs takes as input PCGs and and mutates to join in .

We define the join in this manner because this is similar to how the implementation works.

The algorithm proceeds in the follow steps:

  1. The Owned PCG of is joined into the Owned PCG of (this may also change the capabilities of )
  2. The capabilities of are joined into the capabilities of .
  3. The Borrow State of is joined into the Borrow State of

We now describe each step in detail:

Owned PCG Join

Let be the owned PCG of and the PCG of .

  1. For each local in the MIR body:
    1. If is allocated in both and :
      1. Join the place expansions rooted at into
    2. Otherwise, if is allocated in , it should be deallocated in

Joining Local Expansions (Algorithm Overview)

The algorithm for joining local expansions also involves modifying capabilities (including to borrowed places), and also may cause some places to be labelled. We "emulate" a modification of the block to be joined in, so that we can use the resulting modified capabilities and borrows graph in the remainder of the join. We implement the join by defining a "one-way" join operation , and then performing a join and subsequently . The owned PCGs of and (including the associated capabilities) should be the same after the join. Formally, we could imagine a partial order where iff the owned PCG of can be transformed to the owned PCG of by a sequence of repack operations on its owned PCG. The join identifies the maximum graph (w.r.t ) where and .

Before we present the full algorithm (that takes into account borrows), we first describe a version that would work in the absence of borrows and therefore only considers capabilities W and E. The key point of the join is that all places/capabilities in the resulting joined PCGs should be obtainable from the originals (i.e. via a sequence of PCG repacks). The join starts from the root of both trees, and navigate downwards. For expansions that are the same in both, nothing needs to happen. If the other tree is expanded more than us, we continue expanding ours: the only reason it would be expanded is that part of it is moved out, so we can expand and weaken the LHS to match the RHS. For example, if the LHS is {x: E}, and the RHS is {x.f: E, x.g: W}, we'd want to expand x in the LHS (note that the subsequent capability join will properly account for capabilities).

If we're at a place where the expansions differ in both trees (e.g. x -> {x@Some} vs x -> {x@None}), we collapse both to the place (e.g x) and set capability to the place to W. Neither of the expansion places are accessible by both, so the "most" capability we could obtain is the W permission to the base.

More formally, we define the one-way join as the traversal where:

  1. If we're at a leaf in that is not a leaf in (having children ), then:
    1. Expand to in
  2. If were at a node in where the expansion is not the same as the expansion in :
    1. Collapse both and to for capability

Now, we extend the join to work with borrows and the read capability . Borrows complicate the story because it enables mutually exclusive (at runtime) places to appear in the PCG. For example:

#![allow(unused)]
fn main() {
fn f(x: Either<String, String>) -> &String; {
    let result = match x {
        Left(l) => &l,
        Right(r) => &r
    };
    result
}
}

After the assignment, we should have capability to both x@Left and x@Right because they are borrowed into result. In the case of exclusive borrows, such places would not have capability but still present in the owned PCG (again, to reflect the target of result). Therefore, the collapse and expand rule previously shown are no longer sufficient.

  1. If we're at a place that's a leaf in both and :
    1. If has capability of at least in and has capability exactly in :
      1. Then, after the join, the place can have at most capability . Its also possible that we can now have incompatible different owned expansions of in the joined PCG, that have been borrowed under mutually exclusive paths.
TODO: continue. (An overly formal version is below)

Local Expansions Join:

The algorithm joins a set of place expansions into a set of place expansions , and makes corresponding changes to borrows and capabilities .

  1. If either or have any expansions:
    1. Perform
    2. Perform
  2. Otherwise:
    1. Let be the local
    2. Perform
    3. Perform

We define as:

  1. Let
  2. For each expansion (shortest first):
    1. Let be the base of
    2. If there exists a where is a prefix of , then ignore this expansion
    3. Otherwise, let by the set of (direct) expansions from in
    4. If :
      1. Perform
    5. Otherwise, if is empty and :
      1. If :
        1. Perform
      2. Otherwise, perform a normal expansion operation of in
    6. Otherwise, perform
      1. If the result of the join is to collapse to , then add to

We define as:

  1. If
    1. Add expansion to
    2. Perform
  2. Otherwise, if :
    1. Let be the base of
    2. Perform
  3. Otherwise do nothing

Expansion Edge One-Way Join

We define as:

  1. For each place in the expansion of :
    1. Perform

Expansion Edge Two-Way Join

We define as:

  1. Let be the base of
  2. If and :
    1. Perform a regular expand of
  3. Otherwise, if there exists a descendant of in that does not have a capability:
    1. Insert into (do not change capabilities)
  4. Otherwise:
    1. Collapse and to

Place Join

We define as:

  1. If is not a leaf in or is not a leaf in or or :
    1. Abort
  2. Otherwise, let ,
  3. If and :
    1. Perform
  4. If and :
    1. Perform
  5. If and :
    1. Perform

Leaf RW Join

We define as:

  1. Label all postfixes places of in
TODO: Actually it seems like shared references should maintain E capability even if they're dereferenced

Place Capabilitiies Join

The algorithm join() is defined as:

  1. For each p: c in :
    1. If :
      1. Remove capability to in
    2. Otherwise:
      1. If is defined:
        1. Assign capability to in
      2. Otherwise, remove capability to in

Borrow State Join

  1. The borrow graphs are joined
  2. The validity conditions are joined

Borrow PCG Join

Joining into , where is the block for and is the block for .

Update the validity conditions for each edge in to require the branch condition .

Update the validity

  1. If is a loop head perform the loop join algorithm as described here.
  2. Otherwise:
    1. For each edge in :
      1. If there exists an edge of the same kind in
        1. Join the validity conditions associated with in to the validity conditions associated with in
      2. Otherwise, add to
    2. For all placeholder lifetime projections in :
      1. Label all lifetime projection nodes of the form in with

Loops

The loop head is the basic block with an incoming back edge. We define as the location of the first statement of the loop head.

The pre-loop block is the block prior to the loop head. We assume that there is always a unique pre-loop block. The pre-loop state is the state after evaluating the terminator of the pre-loop block.

The following operations are performed when we join the pre-loop block with the loop head. Note that at this point we've already computed .

We construct the state for the loop head as follows:

Step 1 - Identify Relevant Loop Places

We identify the following sets of places:

  • : the places used inside the loop that are live and initialized at .

TODO: Doesn't liveness imply initialization?

  • : the subset of that are directly borrowed by a borrow live at

  • : the subset of that contain lifetime projections

  • : Places used in the loop that may be relevant for the invariant:

is the union of and the associated lifetime projections of .

are the associated lifetime projections of .

Step 2 - Obtain Relevant Loop Nodes in Pre-state Graph

The nodes in will need to appear in but may not be present in (for example, it's possible that the loop could borrow from a subplace that requires unpacking). We construct a graph by performing the obtain operation on each place in .

Step 3 - Identify Borrow Roots

The borrow roots of a place are the most immediate places that could be borrowing from and later become accessible, and excluding places already in

We defined the borrow roots using the function :

  • Initialize a list to contain all lifetime projections in
  • while is not empty:
    • Pop from
    • For each edge blocked by in :
      • If the edge is an unpack edge, add all of its blocked nodes to
      • Otherwise, for each blocked node in :
        • If or , do nothing
        • If is live at , add to
        • If is a root in , add to
        • Otherwise, add to
  • The resulting roots are the associated places of

We then identify , the most immediate nodes that could be borrowing from and later become accessible (excluding nodes already in ). is the union of the roots for each place in .

Step 4 - Construct Abstraction Graph And Compute Blocked Lifetime Projections

We construct an abstraction graph that describes the blocking relations potentially introduced in the loop from places in to nodes in and from nodes in to nodes in .

connect() function:

We begin by define a helper function which adds edges to based on being blocked by in the loop:

  • Identify : the top-level lifetime projection in
    • Insert a loop abstraction edge into
  • For each :
    • Identify the lifetime projections in that may mutate borrows in
      • If is nonempty:
        • Introduce a placeholder node
        • Add a borrowflow hyperedge
        • Add a future hyperedges:
    • Identify the lifetime projections in that borrows in may flow into
      • If is nonempty:
        • Add a borrowflow hyperedge

Algorithm:

  • For each blocker place :

    • For each :
      • If is a remote node, or if blocks at :
        • Perform
  • Subsequently, ensure that is well-formed by adding unpack edges where appropriate. For example, if (*x).f is in the graph, there should also be an expansion edge from *x to (*x).f.

  • We identify the set of lifetime projections that will need to be renamed (indicating they will be expanded in the loop and remain non-accessible at the loop head). is the set of non-leaf lifetime projection nodes in (leaf nodes are accessible at the head).

  • Label all lifetime projections in with location , add connections to their Future nodes as necessary.

Step 5 - Label Blocked Lifetime Projections in Pre-State

The resulting graph for the loop head will require new labels on lifetime projections modified in the loop. We begin by constructing an intermediate graph by labelling each lifetime projection in with and remove capabilities to all places in in .

Step 6 - Identify Pre-State Subgraph to Replace With Abstraction Graph

We then identify the subgraph that will be removed from and replaced with the loop abstraction .

  • Let = .
  • We construct by including all nodes in and all edges where there exists where is on a path between and in .

Step 7 - Replace Pre-State Subgraph with Abstraction Graph

The graph for the loop head is defined as

Step 8 (Optional) - Confirm Invariant is Correct

To confirm that the resulting graph is correct, for any back edge into the state at with state , performing the loop join operation on and should yield .

Function Shapes

Background

The PCG generates a "shape" for a function call - a bipartite graph indicating where borrows could flow as a result of the call. In particular this shape represents (1) reborrows that are returned from the function (w.r.t borrows passed in the arguments), and (2) the effects of the nested borrows passed in the operands.

We model the first case by introducing abstraction edges between lifetime projections in the operands and those in the result place: each lifetime projection in the operands is connected to the corresponding lifetime projections that it outlives in the result place. The (compiler-checked) outlives constraint captures whether borrows could be assigned in this way, according to the type system.

For (2) To directly model the potentially changed sets of borrows relevant to these concerns, our analysis of function calls introduces lifetime projections to represent the post-state of each lifetime projection in the operands. Each lifetime projection in the operands is connected with abstraction edges to its corresponding post-state projection as well as the post-state nested lifetime projections that it outlives (analogously to sets of borrows explicitly returned).

Creating Function Shapes

Function Shapes

A function shape source base takes the form . A function shape target base is either or .

A function shape source node is a pair where is the region index of the node. Function shape target nodes are defined analogously.

A function shape edge is a pair , and a function shape is a set of edges.

A shape permits more borrowing than a shape iff ; likewise permits less borrowing than iff .

Signature Shape

The corresponding node of a generalized lifetime projection is .

The corresponding generalized lifetime projection of a node is the generalized lifetime projection such that .

A generalized lifetime outlives a generalized lifetime in the signature of (denoted iff:

Note that represents all regions that could occur in . More generally, outlives when all regions in outlive all corresponding regions in . The current implementation handles the case where (reflexivity); other cases may be added in the future.

The signature shape for a function instantiation is defined as follows:

For each then add to if both:

  1. , and
  2. is , or is a region that is invariant in .

Call Shape

The corresponding node of a lifetime projection is .

The corresponding node of a lifetime projection is .

The call shape for a function call is defined as follows:

For each then add to if both:

  1. at according to the borrow checker, and
  2. is , or is invariant in .

Type Aliases and Normalization

An alias type is a type of the form where is a type constructor. The function returns a type where alias types in may possibly be replaced with other types. This normalisation is idempotent, e.g. .

Signature-Derived Call Shape

For a call at ), the signature-derived call shape is obtained as follows:

Let be the normalized signature shape, e.g the one obtained by replacing each in with .

If is the operand in , the corresponding normalized type is the type of the argument in . Likewise, if , then is the output type of. Then, the corresponding normalized region of a lifetime projection is the region in that corresponds to in .

For each :

  • Let , be the corresponding lifetime projections
  • Then, let and be the corresponding normalized regions of and respectively.
  • If outlives in , then add to

Using shapes for function calls in the PCG

If the call is to a defined function, then the signature-derived call shape is used. Otherwise, the call shape is used.

More Background

For a function , there are three types of shapes to consider:

  • Signature Shapes: The shape of an instantiation of generated from its signature
  • Call Shapes: A shape used to represent call to an instantiation of
  • Implementation Shapes: A shape representing 's body, which connects remote lifetime projection nodes to the the result.

These different shape types are relevant for Prusti, as:

  • Signature shapes define the shape of a magic wand
  • Call shapes are used for magic wands that will be applied
  • Implementation shapes define magic wands that will be packaged

The call shape and implementation shapes are necessarily related to the signature shape; the former can contain more edges while the latter can contain less.

The shape for a function call must necessarily have corresponding edges that appear in the shape for the function signature. The reverse is not necessarily the case. For example, consider the following:

#![allow(unused)]
fn main() {
fn caller<'e, 'f: 'e>(x: T<'e>, y: U<'e>, z: T<'f>) {
    let r: W<'e> = f(x, y, z);
}
}

Using the borrow-checker to generate the shape of the call will result in an edge from z|'f to r|'e: the borrow-checker reflects that 'f: 'e. However, the definition of f do not allow borrows to flow from z to r (which is reflected in the function call shape).

Therefore, to build more precise graphs at function call sites. We want to use the shape of the function to determine the shape of the call. The procedure is as follows:

  1. Generate the shape of the function.
  2. Build a map from Def Projections to Call Projections by comparing the types of the arg places and formal args (analogously to the results). By construction, will contain all projections in the function def (even if they don't appear in the shape).

Here's an example of how its computed:

If the type of the 'th place is &'?1 mut U<'?2> and the type of the 'th formal arg is &'a mut U<'b>, then the visitor will first compare at the top level, add arg_i |'a -> p_i '?1 to the map and continue by comparing U<'?2> to U<'b>

  1. Then, for each edge in the fn shape, the edge is replaced with the corresponding projections of the call. If the fn shape has an edge arg 1|'a -> result|'b for example, then for the call shape it will lookup corresponding call projections and add an edge between them.

Reasoning about Associated Types

Consider the following code:

#![allow(unused)]
fn main() {
trait MyTrait<'a> {
    type Assoc<'b> where 'a: 'b;

    fn get_assoc<'slf, 'b>(&'slf mut self) -> Self::Assoc<'b> {
      todo!()
    }
}
}

The full signature for get_assoc is:

#![allow(unused)]
fn main() {
fn(&'slf mut Self) -> <Self as MyTrait<'a>>::Assoc<'b>
}

we observe that the fn sig has the following lifetime projections:

  • argidx 0|'slf
  • result 0|'a
  • result 0|'b

And the shape for get_assoc contains the single edge:

  • argidx 0|'slf -> result|'b

For the body, self has type &'8 mut Self/#0 and result has Alias(Projection, AliasTy { args: [Self/#0, '?6, '?7], def_id: DefId(0:5 ~ input[9b88]::MyTrait::Assoc), .. }),

So we unify &'slf mut Self with &'?8 mut Self adding argidx 0|'slf -> _1|'?8 to

We unify <Self as MyTrait<'a>::Assoc<'b> with <Self as MyTrait<'?6>::Assoc<'?7> adding

  • result|'a -> result|'?6 to
  • result|'b -> result|'?7 to

Then applying the substitutions our shape is _1|'?8 -> result|'?7

Function Calls

Consider a function call at location . Each is an operand of the form move p | copy p | const c. Let be the set of moved operand places.

The function call abstraction is created in the PostMain evalution stage, after all of the operands have been evaluated. Therefore, all places in are labelled, as they have been moved-out by this point.

Algorithm

Let be the set of lifetime projections in (that is, obtained from places in , regardless of whether they are already in the graph). For any place , we define as the place .

We define the set of lifetime projections of the moved-out operands as .

We define the "pre-call states" of as .

We define the "post-call states" of as

The definitions of sets , , , and do not depend on the nodes present in the PCG. In general, we postulate that following invariants should always hold:

  • Only the nodes in are in the graph at the end of stage
  • Only the nodes in are in the graph at the end of the stage
  • Only the nodes in are in the graph at the end of the stage

1. Redirect Future Nodes

This follows the standard process, e.g. similar to borrows and borrow PCG expansions

  1. For each Future edge where the source node is in :
    1. Change the source node from to

2. Label Lifetime Projections for Pre-State

Label projections in to become in the graph:

  1. For each :
    1. Label the with

3. Add Abstraction Edges

At a high level we construct by connecting lifetime projections to the lifetime projections in and the lifetime projections in , where connections are made based on outlives constraints. Concretely:

  1. Let be union of and the lifetime projections in
  2. For each in :
    1. Let contain each lifetime projection where outlives the lifetime of
    2. If is not empty, add the abstraction edge to the PCG

Generating Annotations

Generating Annotations Between Statements

The annotations for transitioning from statement to statement are the recorded PCG actions performed during its analysis.

Generating Annotations Between Basic Blocks

We generate the annotations to get from the state of to its successor . The state of may be to the join of multiple basic blocks (including ).

We generate annotations by performing a join that joins into a copy of and recording the actions that occur. Note that this is the reverse of the join that occurs during the analysis where is joined into . Conceptually, the actions performed by the join are the procedure that weakens state to obtain state .

Generating Annotations for Magic Wands

Clients like Prusti want to generate magic wands from function bodies.

The PCG facilitates this via the UnblockGraph interface. The interface takes a PCG and a PCG node , and returns an ordered list of annotations that describe how to unblock in . Prusti can consume these annotations to generate magic wands.

The interface could trivially be extended to unblock multiple nodes simultaneously

The annotations generated from the UnblockGraph are Borrow PCG edges.

At a high level, the resulting annotations are a topological sort of the edges in the subgraph of that contains and all of its ancestors. Concretely, the procedure to generate the list of annotations is as follows:

  1. Let be the subgraph containing and its ancestors in
  2. While has at least one edge:
    1. Let be the set of leaf edges in .
    2. If is empty fail
    3. Append to
    4. Remove from

We note that the above procedure could fail, for example, could contain a cycle. We expect that in practice this is quite rare. Furthermore, we believe the following property should hold:

For any list of PCG edges forming a cycle, there does not exist any execution path that call satisfy the validity conditions of every edge in the cycle.

Therefore, it should be possible to modify the implementation to e.g. produce multiple lists for distinct paths.

Misc (To Update)

Unpacking Places

An unpack operation introduces an Unpack edge into the graph, and is associated with a source place and an expansion . An unpack operation can either be mutable or read-only.

An unpack operation is a dereference unpack for lifetime iff the type of the source place p is &'r mut p or &'r p.

Applying an unpack operation introduces an Unpack edge as follows:

  1. If the unpack operation is a dereference operation, the edge is added to the graph.
  2. Otherwise, the edge is added to the graph.

A mutable unpack operation for capabiliy requires that the source place have capability . When the operation is a applied, the capability for is removed and the capability of all places in is set to .

A read-only unpack operation requires that have capability and assigns capability to all places in .

Updating Lifetime Projections

Assume the source place has type with lifetimes . If the unpack operation is mutable, then we label each lifetime projection with location .

The source lifetime projection for a lifetime is if the unpack is mutable, and otherwise. The target lifetime projections is the set .

For each lifetime , if the set of target lifetime projections associated with is nonempty:

  1. For each in the set of target projections, add a BorrowFlow edge where is the source projection1.
  1. If the unpack is mutable and the source place of the expansion is either a reference or a borrowed place: a. Add a Future edge b. For each in the set of target projections, add a Future edge c. If any Future edges originate from the source projection , redirect them such that they originate from .

Origin Containg Loan

An origin contains a loan created at location iff:

Polonius: Read directly from output facts

NLL: is live at and outlives


  1. TODO: Currently we actually introduce an unpack edge in the implementation, but we should change this.

PCG Operations

Obtaining Capability to a Place

The operation to obtain capability to a place in a PCG proceeds as follows.

When performing this operation to satisfy the place capability required for a statement, the analysis guarantees that no live mutable borrows conflicting with 1. At a high level, capability to is obtained by first collapsing the owned places in to

Step 1 - Label dereferences of shared borrows stored in

Reborrows of shared references derived from (i.e. from any postfix of ) will survive even if is moved or mutated. Therefore, if permits the function to mutate (i.e. ), then the analysis labels all places that project from shared references derived from .

Formally:

If , then for each place in where:

  • is a strict postfix of , and
  • is a shared reference, and
  • is in

The analysis labels every postfix of in .

Step 2 - Collapse

Implementation

This operation is implemented as PlaceObtainer::obtain

The obtain(p, o) operation reorganizes a PCG state to a new state in where the PCG node for a place is present in the graph. The parameter o is an Obtain Type which describes the reason for the expansion. The reason is either:

  • To obtain a specified capability to the place
  • To access the place because it is the borrowed place of a two-phase borrow

An Obtain Type is associated with a result capability which is either the specified capability (in the first case), or Read (in the case of access for two-phase borrows).

The "two-phase" borrow case is likely unnecessary: we can use the borrow-checker to detect if the place is also the borrowed place of a two-phase borrow reserved at the current location. In fact the current implementation make similar queries as part of the expand step.

Note that a place node for is not necessarily present in the graph before this occurs.

This proceeds as follows:

Step 1 - Label dereferences of shared borrows stored in

(Same as high-level description)

Step 2 - Upgrading Closest Ancestor From R to E

This step is included to handle a relatively uncommon case (see the Rationale section below).

If the obtain operation is called with permission or and the closest ancestor to , that is, the longest prefix of for which there exists a node in the graph, has capability, we upgrade 's capability to in exchange for removing capability to all pre- and postfix places of in the graph (excluding itself).

This is sound because if we need to obtain non-read capability to place, and there are any ancestors of place in the graph with R capability, one such ancestor originally had E capability was subsequently downgraded. This function finds such an ancestor (if one exists), and performs the capability exchange.

Perhaps it would be better to explicitly track downgrades in the analysis so that they can be upgraded later? This will make the soundness argument more convincing.

Rationale

It's possible that we want to obtain exclusive or write permission to a field that we currently only have read access for. For example, consider the following case:

  • There is an existing shared borrow of (*c).f1
  • Therefore we have read permission to *c, (*c).f1, and (*c).f2
  • Then, we want to create a mutable borrow of (*c).f2
  • This requires obtaining exclusive permission to (*c).f2

We can upgrade capability of (*c).f2 from R to E by downgrading all other pre-and postfix places of (*c).f2 to None (in this case c and *c). In the example, (*c).f2 is actually the closest read ancestor, but this is not always the case (e.g. if we wanted to obtain (*c).f2.f3 instead)

Step 3 - Collapse

Then, if a node for exists in the graph and 's capability is not at least as strong as , collapse the subgraph rooted at (and obtain capability for ) by performing the collapse(p, c) operation.

collapse

The collapse(p) operation is implemented as follows:

  • For each such that is a prefix of (from longest to shortest) and there is a node for in the graph:
    • perform the Collapse Repack Operation on .
    • For each lifetime in the type of :
      • Create a new lifetime projection node
      • For each lifetime projection node where is an expansion of :
        • Label
        • Create a new BorrowFlow edge

Step 4 - Labelling

At this point, if is , we know that a subsequent operation will mutate . As a result, if there exists a lifetime projection node for (for example, if stores a borrow that has since been reborrowed), it will no longer be tied to the current value of . So, we label with reason ReAssign.

Step 5 - Expand

At this point there should be a node for some prefix of in the graph such that .

We expand the graph to (and obtain the capability for ) by performing the expandTo(p, o) operation.

expandTo

The expandTo operation is implemented as follows:

  • For each strict prefix of (from shortest to longest):
    • If expanding one level adds new edges to the graph, then
      • We expand the lifetime projections of one level

The operation to expand a place one level is the expandPlaceOneLevel operation, and the operation to expand the lifetime projections one level is expandLifetimeProjectionsOneLevel.

expandLifetimeProjectionsOneLevel

expandLifetimeProjectionsOneLevel is defined with three parameters:

  • : The place to expand
  • : The target expansion of
  • : The Obtain Type

The operation is implemented as follows:

  • Let be the expansion of using
  • For each lifetime projection of :
    • Let be the set of lifetime projections in with lifetime
    • If is nonempty2:
      • We identify the base lifetime projection as follows:
        • Let be the current snapshot location
        • If is not an obtain for capability R:
        • Otherwise, if is blocked by a two-phase borrow live at :
          • Let be the reservation location of the conflicting borrow
        • Otherwise,
      • Create a new Borrow PCG Expansion hyperedge

  1. If the program requires capability to the place to do some action, then the place cannot be borrowed mutably. This invariant does not hold when we are obtaining capability to the place in order to construct a loop abstraction.

  2. This could happen e.g. expanding an x : Option<&'a T> to a x@None

Collapsing Owned Places

At the outset of each program point, the collapse_owned_places operation eagerly collapses the Owned PCG.

This operation is implemented as PcgVisitor::collapse_owned_places (see https://github.com/viperproject/pcg/blob/main/src/pcg/visitor/pack.rs

It is implemented as follows:

  • For each place for which there exists a place node (from longest to shortest):
    • If no expansion of is blocked by a borrow and every expansion of has the same capability:
      • perform the collapse(p) operation
      • if has no projection and has capability, upgrade 's capability to

Activating Two-Phase Borrows

activateTwophaseBorrowCreatedAt

reserveLocation is a function from borrow edges to the MIR location at which the borrow edge was created.

The activateTwophaseBorrowCreatedAt operation takes a single parameter:

  • , a MIR location

The operation is implemented as follows:

  • If there exists a borrow edge in the graph such that l = reserveLocation(e):
    • If there exists a place node for in the graph:
      • Restore capability to
    • If is not owned:
      • Downgrade the capability of every ancestor of to None
  • TODO Logic is bad

Packing Old and Dead Borrow Leaves

The packOldAndDeadBorrowLeaves operation removes leaf nodes in the Borrow PCG that are old or dead (according to the borrow checker).

  • : the current MIR location

When analysing a particular location, this operation is performed before the effect of the statement.

Note that the liveness calculation is performed based on what happened at the end of the previous statement.

For example when evaluating:

bb0[1]: let x = &mut y;
bb0[2]: *x = 2;
bb0[3]: ... // x is dead

we do not remove the *x -> y edge until bb0[3]. This ensures that the edge appears in the graph at the end of bb0[2] (rather than never at all).

This operation is implemented as PcgVisitor::pack_old_and_dead_borrow_leaves (see https://github.com/viperproject/pcg/blob/main/src/pcg/visitor/pack.rs

We must first introduce some auxiliary operations:

isDead

isDead(n, l) is true if and only if the borrow checker considers the node to be dead at MIR location

removeEdgeAndPerformAssociatedStateUpdates

removeEdgeAndPerformAssociatedStateUpdates is defined with one parameter:

  • : the edge to remove

It proceeds as follows:

  • For each current place node that would be unblocked by removing :
    • If does not have capability, and is mutable:
      • Update to map to where is the current MIR location
  • Remove from the graph
  • For each current place node that is unblocked by removing :
    • Let be if projects a shared reference and otherwise
    • If has no capability or capability, upgrade its capability to
    • Unlabel each region projection of
  • If is a Borrow PCG Expansion edge:
    • If is a Dereference Expansion where is a current place with no lifetime projections:
      • unlabel
    • If has a source node where is a current place:
      • For each place node in the expansion of , label each region projection of with , where is the current MIR location
  • If is a Borrow edge; where is the current MIR location; the target of the borrow is a current place ; and has non-zero capability:
    • weaken 's capability to

Main Loop

packOldAndDeadBorrowLeaves proceeds as follows:

  • Until the PCG remains unchanged across the following steps:
    • Let be be the set of edges such that either:
      • is an Borrow PCG Expansion edge and either:
        • for each , either:
          • , where is the current MIR location
          • or is old
        • or , any pair of place nodes in have the same capability, and for all such that , has the same label as and is an exact prefix of
        • or and for all such that , is an exact prefix of ; and have the same label; and and have the same label.
      • or for each , where or , either:
        • is old
        • or 's associated place is not a function argument and either:
          • has a non-empty projection and is not blocked by an edge
          • or , where is the current MIR location
    • For each in :
      • perform removeEdgeAndPerformAssociatedStateUpdates(e)

Repack Operations

Repack operations describe actions on owned places.

RegainLoanedCapability

Fields:

  • - Place
  • - Capability

This operation is used to indicate that is no longer borrowed, and can therefore be restored to capability .

In principle I think should always be exclusive capability

Applying RegainLoanedCapability

The PCG applies this operation by setting the capability of to .

DerefShallowInit

Fields:

  • - From Place
  • - To Place

This operation is used to indicate that a (which is a shallow-initialized box) was dereferenced.

Applying DerefShallowInit

Let be the expansion of obtained by expanding towards .

The PCG applies this operation by adding read capability to the places in

Why do we use this logic?

Collapse

Fields:

  • - Place
  • - Collapse Guide
  • - Capability

This operation indicates that the expansion of should be packed (using guide ) with resulting capability .

Applying Collapse

Let be the expansion of towards guide place .

Preconditions:

  • Each place in has a capability

Let be the minimum capability of the places in .

  • Capability for each place in is removed.
  • Capability for is set to .
  • The Unpack edge from is removed

The current implementation guarantees there is only one unpack edge from . In the future this may change.

Expand

Fields:

  • - Place
  • - Expand Guide
  • - Capability

This operation indicates that should be expanded (using guide ) such that each place in the expansion has capability .

Applying Expand

Let be the expansion of towards guide place .

Preconditions:

  • has capability
  • The unpack edge is added
  • Capability for every place in is set to
  • If is Read capability, the capability of is set to Read
  • Otherwise, if is not Read, capability of is removed

Note that reference-typed places will never be expanded.

Borrow PCG Actions

LabelLifetimeProjection

Fields

  • predicate - A predicate describing lifetime projections that should be labelled
  • label - The label to apply (current, FUTURE or a label )

Applying LabelLifetimeProjection

Replaces the label associated with lifetime projections in the borrow PCG matching predicate. If label is current, then the label of each matching lifetime projection is removed.

Weaken

Fields:

  • - Place
  • - From capability
  • - (Optional) To capability

Used to reduce the capability of a place. In general the is Exclusive, for example in the following cases:

  • Before writing to , capability should be reduced to Write
  • When a two-phase borrow is activated, capabilities to places conflicting with the borrowed place should be removed

Applying Weaken

If is defined, the capability of is set to . Otherwise, capability to is removed.

RestoreCapability

Fields:

  • - Place
  • - Capability

Instructs that the capability to the place should be restored to the given capability, e.g. after a borrow expires, the borrowed place should be restored to exclusive capability.

Applying RestoreCapability

The capability of is set to .

LabelPlace

Fields

  • - Place
  • - The snapshot location to use
  • reason - Why the place is to be made labelled

The purpose of this action is to label current versions of (and potentially prefixes and postfixes of ) with the label corresponding to the last time they were updated.

There are six reasons defined:

  • StorageDead
  • MoveOut
  • ReAssign
  • LabelSharedDerefProjections
  • Collapse

Applying LabelPlace

The behaviour of this action depends on reason:

ReAssign, StorageDead, MoveOut

The places to be labelled are:

  • All postfixes of
  • All prefixes of prior to the first dereference of a reference.

For example, if is (*x).f, then *((*x).f), (*x).f, and *x will be labelled.

Collapse

The place is labelled (but none of its prefixes or postfixes).

LabelSharedDerefProjections

All strict postfixes of are labelled.

RemoveEdge

Removes an edge from the graph. If the removal of the edge causes any place nodes to be removed from the graph, the capability of those places are removed.

AddEdge

Inserts an edge into the graph. This does not change the capabilities.

Coupling (WIP)

The PCG tracks ownership and borrowing at a fine-grained level, and in some cases this granularity cannot be "observed" by the type system. For example, lifetime projection nodes can represent a notion of reborrowing that is more precise than Rust's borrow-checker itself. For example, consider the choose function:

#![allow(unused)]
fn main() {
fn choose<'a, T>(choice: bool, lhs: &'a mut T, rhs: &'a mut T) -> &'a mut T {
    if choice {
        lhs
    } else {
        rhs
    }
}
}

The the PCG shape of a call choose(x, y) function: consists of two edges and . However, because the compiler only tracks lifetimes, the borrows of x and y will always expire at the same time. Accordingly, the two edges corresponding to the call will always be removed from the graph at the same time. These edges are therefore coupled, because the Rust type system forces the PCG to remove them at the same time.

Motivation

Because the type systems forces a set of coupled edges to be removed "all-at-once", edges that are known to be coupled could be treated as a single hyperedge.

The primary reason for doing so is to provide more information analysis tools. For example, Prusti uses coupling information to generate the shape of magic wands: in the choose, the coupled hyperedge provides precisely the shape of magic wand that Prusti encodes (although this is not always the case).

Another benefit is that coupling can reduce the size of the graphs.

Formal Definitions

Hyperedge

A hyperedge is an object with an associated set of source nodes and target nodes. The functions and denote the source and target nodes respectively.

Coupled Edges

A coupled edge is a hyperedge defined by a set of underlying hyperedges, where the sources and targets are defined as follows:

Let be the union of the sources of and be the union of the targets of . Then and .

Hypergraph

A hypergraph is a tuple where is a set of nodes and is a set of hyperedges. Functions and return the sets of nodes and hyperedges respectively.

Blocked Nodes

A node is blocked in iff and is not a leaf in .

Descendant Relation

We define the descendant relation as

Frontier

A set of nodes is a frontier of a hypergraph (denoted ) iff and is closed under .

If is a frontier of , it defines a valid expiry. The valid expiry is the subgraph of obtained by removing all nodes in and all edges containing sources or targets in . The expired edges of a valid

Reachable Subgraph

A graph is a reachable subgraph of a graph iff there exists a frontier such that

Desired Properties of Coupled Edges

Edges that will always be removed from the graph at the same time should definitely be coupled. Formally:

A set of edges expire together on a graph iff for all reachable subgraphs , either contains all edges in or none of them, i.e.:

  1. , or

If a set of edges expire together on a graph , then there must exist a set of edges that are coupled for .

Note that we could in principle define coupling as such, but we could also consider a stronger definition we describe below:

Definition Based on Unblocking Frontier Expiries

Our stronger definition is based on the following two observations:

First, if removing a frontier does not unblock any node in a graph, there is no way for the removal to be observed in the program. Therefore, such frontiers do not need to be considered for the purpose of asserting properties about a place once it becomes accessible. We can instead define coupling via notion of unblockings of a graph, where an unblocking is an ordered list of the sets of nodes that become available by repeated removal of frontiers.

Unblockings

An unblocking of a graph is an ordered partitioning of the non-root nodes of into non-empty subsets , satisfying the property that there exists a frontier of with an expiry that unblocks all nodes in , and is an unblocking of . The function denotes the set of all unblockings of .

Correspondingly, we can define edges as coupled if they always observably expire together, i.e. at all points when a node becomes accessible, they are either all in the graph or none of them are.

Reachable Subgraphs

Formally, for a graph and an unblocking of , the reachable subgraphs of an unblocking is the list of graphs where . The function is the lifting of to sets of unblockings:

Therefore, edges should be coupled if they are either all present or all absent for each graph in .

The second observation is that this set can be computed by considering only a subset of the unblockings in . This is because an unblocking can subsume an unblocking in the sense that the reachable subgraphs of are a superset of the reachable subgraphs of .

Subsumption

An unblocking is immediately subsumed by an unblocking (denoted ) iff there exists an such that and and .

subsumes (denoted ) iff is in the transitive closure of .

Theorem (Subsumption): If , then

Distinct Unblockings

The distinct unblockings of a graph (denoted ) is the subset of unblockings obtained by removing all non-minimal elements w.r.t .

Theorem (Distinct Unblockings): For all graphs ,

Effective and Maximal Coupling

A set of edges are effectively coupled for a graph iff for all reachable subgraphs in the distinct unblockings of , contains either all edges in or none of them. A set of edges is maximally coupled if it is effectively coupled and not a subset of an effectively coupled set.

Theorem (Correctness)

If a set of edges expire together on a graph , then there exists a set of edges that are maximally coupled on .

Proof

Recall that a set of edges expire together on if every reachable subgraph either contains all edges in or none of them, and that a set of edges are definitely coupled if for all reachable subgraph in the distinct unblockings of contains either all edges in or none of them.

Therefore it is sufficient to show that the reachable subgraphs in the distinct unblockings of is a subset of the reachable subgraphs of . The proof makes use of the following lemma:

Lemma: Valid Expiry on Unions of Frontier Nodes

If is a frontier of and is a frontier of , then is a frontier of and:

Proof is TODO

Then, let be an arbitrary unblocking of , it follows by induction on the list of frontiers corresponding to the nodes in , that any for every reachable subgraph of , there exists a frontier of such that . Therefore, for all unblockings of , the reachable subgraphs of are a subset of the reachable subgraphs of .

Test Graphs

m function

#![allow(unused)]
fn main() {
fn m<'a: 'c, 'b: 'e, 'c, 'd, 'e, T>(
    x: &'a mut T,
    y: &'b mut T,
) -> (&'c mut T, &'d mut T, &'e mut T)
    where 'a: 'd, 'b: 'd {
         unimplemented!()
}
}

w function

#![allow(unused)]
fn main() {
fn w<'a: 'd, 'b: 'd, 'c: 'e, 'd, 'e T>(
    x: &'a mut T,
    y: &'b mut T,
    z: &'c mut T,
) -> (&'d mut T, &'e mut T) where 'b: 'e {
         unimplemented!()
}
}

Previous Example

Additional Examples from HackMD

One Lifetime Reborrower Function

#![allow(unused)]
fn main() {
fn f<'a>(x: &'a mut T) -> &'a mut T {
    x
}
}

Possible Outlives Reborrower Function

#![allow(unused)]
fn main() {
fn f<'a, 'b: 'a>(x: &'a mut T, y: &'b mut T) -> &'a mut T {
    todo!()
}
}

Non-Bipartite Graph Example

Owned State

This section describes a design that is currently being implemented and is subject to change.

The Owned State describes the state of owned places at a program point. It consists of two layers:

  1. The Initialisation State, which tracks which owned places are initialised, uninitialised, or shallowly initialised.
  2. Materialised extensions, which extend the leaves of the initialisation state to reach the roots of borrows in the borrow PCG.

Place capabilities are computed from the owned state and the borrow state (see Computing Place Capabilities), rather than being stored and updated directly.

The key motivation for this design is that the initialisation state does not depend on what places are borrowed. This independence simplifies the join algorithm significantly, because borrows no longer force the owned state to be unpacked in borrow-dependent ways.

Initialisation State

Initialisation Capabilities

Each leaf node in the initialisation state carries one of three initialisation capabilities, ordered as :

Deep (D)

The place is fully initialised. All memory reachable from this place (including through dereferences) is valid and accessible. This is the state of a place after it has been assigned a value.

Shallow (S)

The place is shallowly initialised: the place itself holds a valid value, but memory behind a dereference may not be initialised. This state arises only for Box-typed places, where the heap allocation exists but no value has been written through the pointer yet.

Uninit (U)

The place is uninitialised or has been moved out of. No reads are permitted; only writes (to re-initialise the place) are allowed.

Tree Structure

The initialisation state is a forest of trees, one per allocated MIR local. The root of each tree is a local variable, and internal nodes correspond to place expansions (unpacking a struct or tuple into its fields).

The key structural properties are:

  • Leaf nodes carry an initialisation capability (D, S, or U).
  • Internal nodes have no explicit capability; their capability is derived from their children. An internal node exists only because one or more of its descendants has a different initialisation status than its siblings.
  • Invariant: if a tree is expanded (i.e. is not a single leaf node), then at least one of its leaves must be U or S. Otherwise, the tree would be collapsed to a single D leaf.

For example, after executing consume(pair.0) on a pair: (String, String):

pair
├── .0: U
└── .1: D

The tree is expanded because pair.0 has been moved out while pair.1 remains initialised.

In contrast, a fully initialised pair is represented as a single leaf:

pair: D

Join Algorithm

The join algorithm on the initialisation state operates pointwise on the tree structure. Because the initialisation state is independent of borrows, the join does not need to consult the borrow state.

The algorithm is defined recursively:

The intuition behind these cases:

  • Two leaves: take the minimum capability. If either side is uninitialised, the join must conservatively assume the place may be uninitialised.
  • Leaf U or S vs internal: the leaf dominates because if the place is (at best) uninitialised or shallowly initialised, the detailed expansion on the other side is irrelevant.
  • Leaf D vs internal: the internal node's structure is preserved, because a deeply initialised place is compatible with any expansion of that place.
  • Two internals: join children pointwise.

Example

Consider the following program:

#![allow(unused)]
fn main() {
type Pair = (String, String);

fn f(choice: bool) {
    let mut pair0 = (String::new(), String::new());
    let mut pair1 = (String::new(), String::new());
    let mut pair2: Pair;
    let mut rx: String;
    if choice {
        rx = pair0.0;
        pair2 = pair1; // {pair0: {.0: U, .1: D}, pair1: U}
    } else {
        rx = pair1.0;
        pair2 = pair0; // {pair0: U, pair1: {.0: U, .1: D}}
    }
    // join: {pair0: U, pair1: U}
}
}

At the join point, pair0 has state {.0: U, .1: D} on one branch and U on the other. Applying the rule , the result is pair0: U. Symmetrically, pair1: U.

Materialised Extensions

The leaves of the initialisation state serve as the roots of materialised extensions. A materialised extension is an additional subtree that grows off a leaf of the initialisation state to reach places that are targets of borrows in the borrow PCG.

Materialised extensions exist because borrowing a sub-place does not change the initialisation state (e.g. &mut x.f does not expand x in the init state), but the owned state still needs to represent the expanded structure so that the borrow target is a node in the graph.

Construction

For each leaf of the initialisation state with place , if there exist places in the borrow PCG that are strict descendants of (i.e. is a strict prefix of each ), then a materialised extension tree is constructed by expanding toward each . The materialised extension tree uses the same expansion structure as owned place expansions. Leaves of the materialised extension carry no additional data.

Example

Suppose x.0 is moved out and x.1 is D, and there is a borrow targeting x.1.h:

Initialisation state:
  x
  ├── .0: U
  └── .1: D

Materialised extension off x.1:
  x.1
  └── .h  (materialised)

The full owned state combines both: the init state provides the tree x -> {x.0, x.1}, and the materialised extension provides the edge x.1 -> {x.1.h}.

A simpler example where the init state is not expanded:

#![allow(unused)]
fn main() {
let mut pair = (String::new(), String::new());
let rx = &mut pair.0;
// init state: {pair: D}
// materialised: pair -> {pair.0, pair.1}
}

Here the initialisation state is a single leaf {pair: D} (borrowing does not change initialisation), but the materialised extension expands pair so that pair.0 (the borrow target) is a node in the owned state.

Edge Capabilities

This section describes a design that is currently being implemented and is subject to change.

In the updated design, edges in the Owned PCG (specifically, unpack hyperedges) carry an edge capability that describes whether the expansion is mutable or immutable. This is used to generate upgrade/downgrade annotations when the mutability of an edge changes between PCG states.

Edge Capability Values

An edge capability is one of:

  • Immutable (I): the expansion is under a shared borrow. The parent place has capability R, and the children inherit R.
  • Mutable (M): the expansion is not constrained by a shared borrow. Children may have capabilities E, W, or e depending on their initialisation state.

Computing Edge Capabilities

Edge capabilities are computed, not tracked explicitly. An edge's capability is determined by the borrow state: if any place in the subtree rooted at the parent of the edge is blocked by a shared (immutable) borrow, the edge is I; otherwise it is M.

Annotations from Edge Capability Changes

When the edge capability of an unpack hyperedge changes between two consecutive PCG states (e.g. from I to M because a shared borrow expired), an upgrade annotation is emitted. Conversely, when an edge transitions from M to I (e.g. because a shared borrow is created), a downgrade annotation is emitted.

These edge-oriented annotations replace the previous per-place capability update annotations (such as "Restore pair.1 to E"). The advantage is that edge annotations make explicit where a capability change originates, rather than describing isolated per-place updates whose source is implicit.

Example

Consider the following program:

#![allow(unused)]
fn main() {
fn shared_borrow() {
    let mut pair = (String::new(), String::new());
    // {pair: D} -> {pair: E}

    let r0 = &pair.0;
    // edge {pair} -> {.0, .1}: I
    // {r0: E, pair: R, pair.0: R, pair.1: R}

    let p1 = pair.1;
    // edge {pair} -> {.0, .1}: M   (upgrade emitted)
    // {r0: E, pair: ∅, pair.0: R, pair.1: W}
}
}

When pair.1 is moved, the shared borrow on pair.0 no longer covers pair as a whole, so the edge {pair} -> {.0, .1} is upgraded from I to M, and an upgrade {pair} -> {.0, .1} annotation is emitted.

Computing Place Capabilities

This section describes a design that is currently being implemented and is subject to change.

In the updated design, place capabilities are computed from the owned state and the borrow state, rather than being tracked and updated via explicit rules. This eliminates a class of soundness issues (see below) and simplifies the capability accounting logic.

Motivation

The previous design maintained a map from places to capabilities that was updated by three mechanisms: statement evaluation, borrow expiry/activation, and control-flow joins. This led to three problems:

  1. Unsoundness: the rule "when a mutable borrow expires, restore the place's capability to E" is unsound when the place has been conditionally moved out. After a join, the move-out information is lost, and the borrow expiry incorrectly restores E to a potentially uninitialised place.

  2. Insufficient annotations: per-place capability update annotations (e.g. "Restore pair.1 to E") do not explain the source of the capability. Edge-oriented annotations (see Edge Capabilities) address this.

  3. Complex rules: the rules for updating capabilities were numerous and difficult to justify, complicating an eventual soundness proof.

Computing Capabilities for Owned Places

The capability of an owned place is determined as follows:

  1. Look up in the initialisation state. If is within a materialised extension, use the initialisation capability of the ancestor leaf from which the extension grows.

  2. Based on the initialisation capability:

    • U (uninitialised): the place capability is W (write-only).
    • S (shallow): the place capability is e (shallow exclusive).
    • Internal node: the place capability is (none), because the place is only partially initialised.
    • D (deep): proceed to check the borrow state below.
  3. If the place is fully initialised (D), consult the borrow state:

    • If the type of contains a region but the lifetime projection does not exist in the borrow state: the capability is e (shallow exclusive).
    • If or any of its sub-places is blocked by a mutable borrow: the capability is (none).
    • If or any of its sub-places is blocked by a shared borrow: the capability is R (read).
    • Otherwise: the capability is E (exclusive).

Computing Capabilities for Borrowed Places

For borrowed places, the capability is determined by the borrow PCG:

  • If the place projects a shared borrow (e.g. *r where r: &T): R (read).
  • Otherwise, if the place is a leaf in the borrow PCG: E (exclusive).
  • Otherwise: (none).

Example: Soundness Fix

The following example demonstrates how the computed capability approach avoids the unsoundness of the previous design:

#![allow(unused)]
fn main() {
fn conditional_move(choice: bool) {
    let mut p = String::new();
    let mut p2 = String::new();
    let mut rp: &mut String;

    // BB0: init {p: D, p2: D, rp: U} -> cap {p: E, p2: E, rp: W}
    if true {
        consume(p);
        rp = &mut p2;
        // BB1: init {p: U, p2: D, rp: D} -> cap {p: W, p2: ∅, rp: E}
    } else {
        rp = &mut p;
        // BB2: init {p: D, p2: D, rp: D} -> cap {p: ∅, p2: E, rp: E}
    }

    // BB3: join -> init {p: U, p2: D, rp: D}
    //           -> cap  {p: ∅, p2: ∅, rp: E}

    *rp = String::from("updated");

    // After borrow expiry:
    // init {p: U, p2: D, rp: D} -> cap {p: W, p2: E, rp: e}
}

In the previous design, borrow expiry would have incorrectly restored p to E. In the computed design, p remains W after expiry because the initialisation state still records p: U.