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
structs only, infectious for
union) or is it always a single yes/no answer for the entire reborrowed range?
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
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), 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