I quite like this! I agree that this model more closely matches my model of the average developer's understanding of the borrow rules than SB, though I have a hard time putting into words exactly how or deriving implications more interesting than those spelled out by the OP.
I have a few clarifying questions for my own benefit.
Clarification: if you pass a reference to a function, the protector includes a reborrow and does a phantom read, throwing UB if the tag is disabled, correct?
Question: when you pass a mut reference to a function, do the protector semantics function in such a way that the tag already being Frozen is UB? I know they're leaving Reserved as-is until an actual write happens.
Clarification: shared mutability is handled via the unexplained ty_is_freeze
query: is this currently using the UnsafeCell
tracking which is field granular (for struct
s only, infectious for enum
/union
) or is it always a single yes/no answer for the entire reborrowed range?
Clarification: Unpin
isn't mentioned in the state automaton; is it correct then that Unpin
only impacts when a new borrow is derived, and not when read/writes are done?
Clarification: what interaction of rules make it so &mut impl !Unpin
doesn't get noalias
on function boundaries with protectors but &mut impl Unpin
does? This wasn't super clear to me on the first read through at least.
Question: how are Unpin
/Freeze
handled for the range of the allocation outside of the known range of the reborrowed type? (For extern type
, you at least have the extern type
to do type queries on, but for other cases you just don't know. Personally, I'm fine with strict borrow subranges even though it breaks *addr_of!(v[0])
, requiring the use of extern type
to get delayed retagging semantics. But will admit this can still cause some issues, e.g. with allocators storing meta before the returned pointer.)
Question: if a function takes a shared reference derived from a writable reference and writes to it, and that's the last access that ever happens, when/is that UB? For some reason I'm having trouble mentally tracking that at the moment, for TB or for SB. (Context derived from the previous: deallocating from a child provenance, would that be able to jump up to the root provenance or would it be UB because it's working at a derived provenance without write/dealloc permission? Potentially relevant for Box<T> -> &'static T -> Box<T>
.)
Interesting observation: because &mut
borrows start as the two-phase reserved stated, &mut
borrow splitting just falls out of the model without any special handling, even if it were to keep fully eager instead of delayed tag reborrowing. This is evident from the walkthrough of the disjoint fields example.
Interesting observation: this isn't what TB is doing. But if all retagging is delayed (i.e. the phantom read on reborrow doesn't undelay the retag), this alone makes the spurious speculative write an incorrect optimization (and also would for reads, which is bad). This suggests an interesting potential middle ground between full two-phase and no two-phase: non static two-phase mut borrows could do the phantom read (putting them in the Reserved state) and then make a delayed phantom write transition to Active. This would I believe allow moving writes before reads again but I think still retain some of the benefit of being two-phase (not actually requiring uniqueness unless written through). On the other hand doing so makes the state more complicated (it's basically another state between Reserved and Active; let's say Speculated) for speculative and likely quite minimal benefit. A more realistic middle ground would be to make function protectors activate