Have I found a borrow checker bug? - a weird lifetime related error

So I have a fair bit of rust experience and I am not stranger to dealing with lifetime issues, but for the first time in a while, I have a found a problem neither I or ChatGPT seems to be able to understand. And I think I may have found a weird inconsistancy in the borrow checker, but I figure I should come and ask here to see if anyone else know what's going on here before I make a bug report.

This code compiles:

fn update_reads(&mut self) {
let io = IoTaskPool::get();

    let errors = io.scope(|s| {
        for entry in self.streams.iter_mut() {
            s.spawn(async {
                match entry.1.update_read_buffer().await {
                    Err(e) => Some((*entry.0, e)),
                    _ => None,
                }
            })
        }
    });

    self.handle_io_errors(errors);
}

but I am doing a refactor since I am using the pattern of spawning scoped futures a lot and have this as a result (also on a refactored struct btw):

fn for_each_async<F, T, Fut>(&mut self, function: F, io: &TaskPool) -> Vec
where F: Fn(&u64, &mut V) -> Fut,
Fut: Future<Output = T>, T: Send + 'static {

    io.scope(|s| {
        for (key, value) in self.map.iter_mut() {
            s.spawn(function(key, value))
        }
    })
}

This is ment to make it easy to perform async operations on mass, such as read or writing from internal buffers to os sockets. But I am new to async code and I am awhere it can break in ways sync code can't so I desided to test it like this:

#[test]
fn test_for_each_async() {
let pool = TaskPool::new();

let mut map = SocketMapAsync::new();

let k1 = map.insert(50);
let k2 = map.insert(100);
let k3 = map.insert(150);

map.for_each_async(|key, value| async {
    *value = *value / 2;
}, &pool);

assert!(*map.get(&k1).unwrap() == 25 && *map.get(&k2).unwrap() == 50 && *map.get(&k3).unwrap() == 150)

}

But this doesn't compile, the error I get is:

error: lifetime may not live long enough
--> crates\bevy_net\src\easy_sockets\mod.rs:69:37
|
69 | map.for_each_async(|key, value| async {
| ________________________------^
| | | |
| | | return type of closure {async block@crates\bevy_net\src\easy_sockets\mod.rs:69:37: 71:6} contains a lifetime '2
| | has type &'1 mut i32
70 | | *value = *value / 2;
71 | | }, &pool);
| |
^ returning this value requires that '1 must outlive '2

(Sorry the compiler error came out a bit wonky but it's just saying that the futrue returned by the async block outlives the lifetime of value, and is therefore illegal)

Even though the other example does. And the thing is, I think I can prove the borrow check wrong only using life times, and it goes like this:

  • self outlives scope (i.e io.scope)

  • scope outlives the tasks spawned in it using s.spawn() (this is enforced by explicit lifetimes on the scope and spawn methods)

*the lifetime of value is tied to the lifetime of self since self owns value

*so therefore: self: value: scope: spawned futures

And so everybody should be happy right? I assume thats why the genric code compiles, but for some reason when acctually used in the test it sudderley doesn't work.

I would greatley appreciate any help, thanks.

Please format the code and the error message. This is unreadable.


Apart from that, you are extremely unlikely to have found a bug in the borrow checker. A bug would be code that compiles but it shouldn't.

You may have found a limitation – a sound system necessarily has to reject some "obviously" valid (or at least, valid) programs.

nevermind I fixed, for anyone else having a similar problem, the fix was:

"fn for_each_async<'a, F, T, Fut>(&'a mut self, function: F, io: &TaskPool) -> Vec
where F: Fn(&'a u64, &'a mut V) -> Fut,
Fut: Future<Output = T>, T: Send + 'static {

    //let io = IoTaskPool::get();
    
    io.scope(|s| {
        for (key, value) in self.map.iter_mut() {
            s.spawn(function(key, value))
        }
    })
}"

this explicatley spesifies that the lifetimes of the funtion inputs are tied to the lifetime of self, but I thought the compiler could have figured that out itself.

It couldn't, that would have been an incorrect assumption. Fn(&T) is actually a HRTB, for<'lt> Fn(&'lt T).

Learn to format the code in your posts.


With this bound:

F: Fn(&u64, &mut V) -> Fut,
// same as
F: for<'a, 'b> Fn(&'a u64, &'b mut V) -> Fut

it's impossible for Fut to borrow from the inputs, because the inputs could have any lifetimes 'a and 'b, but generic type variables like Fut must resolve to a single type (and types that vary only by lifetime are still distinct types).

If the compiler had allowed it, it would mean that the inputs could become unborrowed while the Fut was still in use, which would be unsound if not instant UB.

When there's only one input lifetime (i.e. your fix), it's possible for the Fut to capture that lifetime and still be a single type, and the compiler recognizes this.

1 Like

Technically the complier could infer a sub-lifetime of all inputs, but that's even more annoying than no lifetimes at all.

Perhaps simply requiring all result lifetimes to be explicit would ease a lot of confusion though...

That's what -> impl Future does in traits / edition 2024+,[1] roughly speaking.[2] But it can't be expressed with a type variable like Fut unless we get "generic type constructor variables" or the like.

F: for<'a, 'b> Fn(&'a u64, &'b mut V) -> Fut<'a, 'b>,
// or
F: for<'a: 'c, 'b: 'c, 'c> Fn(&'a u64, &'b mut V) -> Fut<'c>,
// or maybe
F: Fn(&u64, &mut V) -> Fut<'..>,

We'll probably get -> impl Trait in bounds instead, if I had to guess.

F: Fn(&u64, &mut V) -> impl Future<Output: Send + 'static>

Sadly -> impl Trait went the opposite direction by default. Maybe we'll get a lint requiring precise capturing :crossed_fingers:... but elided_lifetimes_in_paths isn't even warn yet.


  1. and without precise capturing annotations ↩ī¸Ž

  2. Or I misunderstood what you meant (but I can't think of any single lifetime that makes sense to be the default for the bound in question). ↩ī¸Ž

1 Like

Pretty sure that's the only #[warn(rust_2018_idioms)] I still got continuously. Now we have cargo lint settings, maybe the new project template could start adding some of these?

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.