Description
Spawned off of discussion with @ecstatic-morse and their work on PR #61787
There is a dataflow subroutine for Borrows
, kill_borrows_on_place
, added in PR #56649 to resolve #46589. kill_borrows_on_place
reads from the on_entry
field of BlockSets
(even though I am pretty sure no one was ever supposed to do so; its inclusion in this part of the API was a wart):
rust/src/librustc_mir/dataflow/impls/borrows.rs
Lines 205 to 214 in f0c2bdf
I think this was written with the assumption that sets.on_entry.clone().union(sets.gen_set)
(aka on-entry U gen-set) would represent an upper-bound on the things that should be in the kill set. (And then the subsequent loop determines which of those things to actually put into the kill set.)
The problem with that is that when we are computing the transfer-function initially, on_entry
is going to be the empty-set, at least for almost all basic blocks. That all you have to work with in dataflow. So you end up with a kill-set here that is an under-approximation to what it should actually be, if you are assuming that we're supposed to start with the complete (post dataflow) on-entry and combine that with gen-set to get the upper bound on what to use for the kill-set.
Why hasn't this been a problem for us so far? Well, here's my theory:
-
With respect to soundness: if the kill-set for
Borrows
is a subset of what it should "truly be", then I think the compiler ends up treating borrows as living longer than they should, and so it ends up detecting conflicts that are not actually there, and so the compiler rejects code that you might have other expected it to accept. -
With respect to expressiveness: We don't actually get into the problematic part of
kill_borrows_on_place
as often as you might think, because there's a special dodge at the start of the function:
rust/src/librustc_mir/dataflow/impls/borrows.rs
Lines 196 to 203 in f0c2bdf
You combine the two points made above, and you end up with things like the following example: https://play.rust-lang.org/?version=nightly&mode=debug&edition=2018&gist=3d29327853729ec280799760d5e58003
Click to see the example Rust code
struct List<T> {
value: T,
next: Option<Box<List<T>>>,
}
fn to_refs1<T>(mut list: &mut List<T>) -> Vec<&mut T> {
let mut result = vec![];
loop {
result.push(&mut list.value);
if let Some(n) = list.next.as_mut() {
list = n;
} else {
return result;
}
}
}
fn to_refs2<T>(mut list: (&mut List<T>,)) -> Vec<&mut T> {
let mut result = vec![];
loop {
result.push(&mut (list.0).value);
if let Some(n) = (list.0).next.as_mut() {
list.0 = n;
} else {
return result;
}
}
}
(In that example, to_refs2
is basically the same as to_refs1
, except that its wrapped in a unary tuple (T,)
. I think the intention is that if to_refs1
is accepted, then so should to_refs2
. But NLL currently accepts to_refs1
and )