Fighting the borrow checker (as usual :)

Hello,

I stumbled on a curious case that I don't understand. I would appreciate it if you explain what is going on. .

I have the following code. The method called this_method_doesnt_build doesn't build. However, if I extract literally two lines of it into a separate function, it builds fine (like I did in this_method_builds)

The culprit is somewhere around the tokio::io::split of a reference.

use tokio::net::TcpStream;
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::io::{ReadHalf, WriteHalf};

trait AsyncReadWrite : AsyncRead + AsyncWrite + Unpin {
}

impl AsyncReadWrite for TcpStream {
    
}

async fn stream_data(_stream_rx: ReadHalf<&mut dyn AsyncReadWrite>, _stream_tx: WriteHalf<&mut dyn AsyncReadWrite>) {
    
}

// This method will fail to build (uncomment to experiment)

async fn this_mthod_doesnt_build(mut stream: Box<dyn AsyncReadWrite>)  {
    // The compiler will complain here that "^^^^^^^^^^^^^^^ borrowed value does not live long enough"
    // I believe somehow getting it through tokio::io::split() which underneath does Arc
    // and await causes the problem
    // However extracting two calls below (like in this_method_builds) will make it build
    let stream_ref = stream.as_mut();
    
    let (stream_rx, stream_tx) = tokio::io::split(stream_ref);
    
    stream_data(stream_rx, stream_tx).await;
} 

// These two method will build while being just extraction two lines into a separate method
async fn stream_data_wrapper(stream_ref: &mut dyn AsyncReadWrite) {
    let (stream_rx, stream_tx) = tokio::io::split(stream_ref);
    
    stream_data(stream_rx, stream_tx).await;
}

async fn this_method_builds(mut stream: Box<dyn AsyncReadWrite>)  {
    let stream_ref = stream.as_mut();
    
    stream_data_wrapper(stream_ref).await;
} 

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let stream : Box<dyn AsyncReadWrite> = Box::new(TcpStream::connect("www.google.com:443").await?);

    this_method_builds(stream).await;
    this_mthod_doesnt_build(stream).await;
    
    Ok(())
}

Regards,
Victor

1 Like

Hello!

I can get this_method_doesnt_build compiling on the 1.68 toolchain with an explicit type annotation, like so:

async fn this_method_doesnt_build(mut stream: Box<dyn AsyncReadWrite>)  {
    let stream_ref: &mut dyn AsyncReadWrite = stream.as_mut();
    //  NEW:      ^^^^^^^^^^^^^^^^^^^^^^^^^

    let (stream_rx, stream_tx) = tokio::io::split(stream_ref);

    stream_data(stream_rx, stream_tx).await;
}

I think this should do what you want, though I too am admittedly a little confused by the error. To be clear, stream.as_mut() is calling Box::as_mut (i.e., there aren't conflicting traits at play), and Box::as_mut should return &mut T where T is dyn AsyncReadWrite. I don't think this is a situation where lifetimes are being obscured, either.

I believe you're right that tokio::io::split is involved in the error, though it isn't obvious to me why adding a type annotation would resolve it. Hopefully, someone more knowledgeable than me can chime in.

Note: for anyone else that wants to test, here's a Cargo.toml containing the necessary Tokio feature flags:

[package]
name = "test"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { version = "1.24", features = ["io-util", "macros", "net", "rt-multi-thread"] }
2 Likes

Ooo that's nasty. It's an issue with the default lifetime bounds on trait objects, and I think also is interacting with lifetime variance since an &mut is involved.

When you split up the code between methods, you changed the default lifetime bound involved when you called split. If you explicitly annotate all of the trait objects behind references with + 'static it builds fine.

Playground

use tokio::io::{AsyncRead, AsyncWrite};
use tokio::io::{ReadHalf, WriteHalf};
use tokio::net::TcpStream;

trait AsyncReadWrite: AsyncRead + AsyncWrite + Unpin {}

impl AsyncReadWrite for TcpStream {}

async fn stream_data(
    _stream_rx: ReadHalf<&mut (dyn AsyncReadWrite + 'static)>,
    _stream_tx: WriteHalf<&mut (dyn AsyncReadWrite + 'static)>,
) {
}

// This method will fail to build (uncomment to experiment)

async fn this_method_doesnt_build(mut stream: Box<dyn AsyncReadWrite>) {
    let stream_ref = stream.as_mut();

    let (stream_rx, stream_tx) = tokio::io::split(stream_ref);

    stream_data(stream_rx, stream_tx).await;
}

// These two method will build while being just extraction two lines into a separate method
async fn stream_data_wrapper(stream_ref: &mut (dyn AsyncReadWrite + 'static)) {
    let (stream_rx, stream_tx) = tokio::io::split(stream_ref);

    stream_data(stream_rx, stream_tx).await;
}

async fn this_method_builds(mut stream: Box<dyn AsyncReadWrite>) {
    let stream_ref = stream.as_mut();

    stream_data_wrapper(stream_ref).await;
}

#[allow(dead_code)]
async fn builds() {
    async fn create_stream() -> Box<dyn AsyncReadWrite> {
        Box::new(TcpStream::connect("www.google.com:443").await.unwrap())
    }

    this_method_builds(create_stream().await).await;
    this_method_doesnt_build(create_stream().await).await;
}

This type annotation accomplishes the same thing that splitting the function did: It changes the default lifetime of the trait object involved from 'static to the lifetime of the reference.


Explaining the problem in more detail, you start with a Box<dyn AsyncReadWrite> which is actually Box<dyn AsyncReadWrite + 'static> if you include the lifetime bound the compiler assumes when you don't specify one.

You call as_mut() which gets you an &'a mut (dyn AsyncReadWrite + 'static). On it's own this isn't really a problem, but stream_data introduces some new constraints. As you originally defined it, stream_data introduces lifetime bounds approximately equivalent to

async fn stream_data<'r>(
    _stream_rx: ReadHalf<&'r mut (dyn AsyncReadWrite + 'r)>,
    _stream_tx: WriteHalf<&'r mut (dyn AsyncReadWrite + 'r)>,
)

Those constraints force the lifetime of our borrow of the trait object, and the lifetime bound on the trait object to be the same. Since this is an &mut the lifetimes inside the trait objects aren't covariant so the borrow checker can't artificially shorten the 'r inside the trait object. Instead the lifetime of the borrow of the box must be extended to match it as 'static.
That last part wasn't quite accurate, see quinedot's comment below

You can also fix the problem by only changing stream_data. This decouples that inner lifetime from the lifetime of the reference[1]

async fn stream_data<'a>(
    _stream_rx: ReadHalf<&mut (dyn AsyncReadWrite + 'a)>,
    _stream_tx: WriteHalf<&mut (dyn AsyncReadWrite + 'a)>,
) {
}

And of course we can also leak the box instead of using as_mut

let stream_ref = Box::leak(stream);

which will satisfy the borrow checker (even though you obviously shouldn't do that)


  1. though there are still constraints involved. 'a still can't be shorter than the lifetime of those &muts ↩︎

4 Likes

That's not quite right. The lifetime within the &mut is technically invariant. However, the trait object lifetime can still be shortened via unsizing coercion between dyn Trait + 'static and dyn Trait + 'short. This coercion can happen behind &mut.

Otherwise the annotation fix within this_method_doesnt_build wouldn't work.

    // The annotation fix in slow motion
    let stream_ref: &mut (dyn AsyncReadWrite + 'static) = stream.as_mut();
    let stream_ref: &mut dyn AsyncReadWrite = &mut *stream_ref;
    // The dyn lifetime is now non-'static

The bummer is that the compiler doesn't realize it should do this on its own.

Err, this should have been a reply to @semicoleon -- I just reused a draft window because they said everything else I was going to say in more detail.

7 Likes

Interesting! I originally didn't think the invariant-ness would necessarily apply to the trait object lifetime bound, but I couldn't find another explanation for why the compiler couldn't shorten it for the stream_data call

Thank you for detailed response.

If you don't mind me asking a follow up question (unfortunately, my Rust knowledge is intermediate at best, as result there were a couple of things that went over my hand).

Let me first try to repeat what you said and after ask a couple of questions. Based on what I understand

  • .as_mut() returned &'a mut (dyn AsyncReadWrite + 'static)
  • constraints on the function end up being &'r mut (dyn AsyncReadWrite + 'r)

This makes sense (you showed pretty much what compiled does under the hood for default lifetime bounds on trait objects + parameters for a function).

Three questions that I had (and I am sorry if they are a bit ignorant :slight_smile: )

  1. Why the complier decided on this "&'r mut (dyn AsyncReadWrite + 'r)". I totally understand that it needs to specify lifetime on the reference, but I don't understand why it decide to use it as a lifetime bound (why didn't it go with a default lifetime bound)? Is it to allow accepting as wide range of possible arguments as possible?

  2. I understood that there is a mismatch.. However, I didn't understand why this mismatch is the problem.

&'a mut (dyn AsyncReadWrite + 'static)
&'r mut (dyn AsyncReadWrite + 'r)

It looks like in this case 'r reference lifetime is shorter than 'a, and 'r trait lifetime bound is shorter than 'static. What does it complain then? What I am missing?

  1. Why does adding this helps?

let stream_ref: &mut dyn AsyncReadWrite = stream.as_mut();

I would assume that it should have used here also default trait lifetime bound ('static) and should have resulted in the same error. However, it looks like it figured that it should do something else.

All types have such bounds (but sometimes the compiler allows the programmer to elide it syntactically, for convenience). You can't make a reference &'r T or &'r mut T unless T: 'r. The compiler is merely being explicit here (since being implicit would be probably even more confusing, given that we are already in the context of a compiler error in the first place).

'r can't be shorter than 'static in order to satisfy &'a mut (dyn T + 'r). Mutable references are invariant in the lifetime of the referred type: unlike an immutable reference, a &mut T<'long> can't be coerced to a &mut T<'short>.

This is exactly because of mutability: if this were allowed, it would be trivially unsound, since you could start with a reference to a T<'long>, then write through the reference a T<'short> into its place, causing a dangling pointer or a use-after-free:

let mut supposed_to_be_long: T<'long> = T::<'long>::new();
let ref_to_long: &mut T<'long> = &mut supposed_to_be_long;
let ref_to_short: &mut T<'short> = ref_to_long; // wrong, disallowed
*ref_to_short = T::<'short>::new();
observe(supposed_to_be_long); // BOOM
2 Likes

I don't have time to write too detailed a response here at the moment, but damn, this is an annoying case. The subtle (and at times bad) interaction of type inference with coercions usually “only” results in type errors, but in this case it's actually resulting in more of a borrow checking error, which increases the potential for confusion a lot.

The way that invariance in mutable references as well as usize-coercion rules work out to still allow &'a mut dyn Trait + 'static to be coerced into &'a mut dyn Trait + 'a, but the same is no longer true behind the extra level of wrapper type introduced by tokio::io::split. The abovementioned interaction with type checking that makes the compiler disregard the earlier coercion site means it paints itself into a bad corner w. r. t. the lifetime argument being used, and thus gets an error. I believe, the compiler behavior for this code should be fixable eventually, I might even try to dig into this kind of stuff in the compiler myself eventually, who knows.

5 Likes

There are different defaults for the dyn Trait lifetime depending on the type and the context.

Outside of a function body,

  • Box<dyn Trait> is short for Box<dyn Trait + 'static>
    • And similarly for other non-lifetime-parameterized types like Arc<dyn Trait>
  • &dyn Trait is short for &'a (dyn Trait + 'a) for some anonymous lifetime parameter 'a
    • Or an error in contexts where anonymous lifetimes are not allowed (e.g. an associated type)
  • &'b dyn Trait is short for &'b (dyn Trait + 'b) for some named lifetime parameter 'b

But inside a function body,

  • Box<dyn Trait> is short for Box<dyn Trait + '_>
    • And similarly for other non-lifetime-parameterized types like Arc<dyn Trait>
  • &dyn Trait is short for &'a (dyn Trait + '_)

Where '_ means "try to infer a lifetime that works".

As for why are these the defaults, it's because the language designers went for a "usually what you want" approach. When it is what you want, your code looks simpler, but unfortunately when it is not what you want, it makes everything more complex.

In contexts where anonymous lifetime parameters are allowed, &dyn Trait being short for &'a1 (dyn Trait + 'a2) (distinct anonymous lifetime parameters) would be more flexible and is perhaps a future possibility.

1 Like

@quinedot @H2CO3 Thank you. I understand a bit more. However, still don't understand it fully. Please bare with me.

Again. Let me write down what I did understand.

In the code which doesn't work we have this reference &'a mut (dyn AsyncReadWrite + 'static) and try to pass to a method which requires &'r mut (dyn AsyncReadWrite + 'r). And I understand the idea of invariants (that &mut (dyn T + 'static) won't be the same as &mut (dyn T + 'r) ).

And now is the question. Why in such a case, this_method_builds works?

It looks like we do very-very similar thing:

  • We do .as_mut() so, stream_ref should be &'a mut (dyn AsyncReadWrite + 'static)
  • We pass it to a function strem_data_wrapper, that accepts &mut dyn AsyncReadWrite. And per my understanding it is equivalent of &'r mut (dyn AsyncReadWrite + 'r)

However, this should pretty much end up with exactly same issue as before. I don't see the difference.

BTW. I understand better why this solution works. As @semicoleon wrote " This decouples that inner lifetime from the lifetime of the reference".

async fn stream_data<'a>(
    _stream_rx: ReadHalf<&mut (dyn AsyncReadWrite + 'a)>,
    _stream_tx: WriteHalf<&mut (dyn AsyncReadWrite + 'a)>,
) {
}

BTW. I am not sure that understand why @norepimorphism solution works too

let stream_ref: &mut dyn AsyncReadWrite = stream.as_mut();

Ok. as_mut() should still return &'a mut (dyn AsyncReadWrite + 'static). I understand now that type definition &mut dyn AsyncReadWrite inside of a body function will become &mut dyn AsyncReadWrite + `_. However, taking into account that on the right side we have + 'static, I assume that on the left it should become also 'static and we are back to square one.

Both versions should work IMO, and I can't blame you for being surprised or confused. I didn't reply when I first read your OP because I didn't spot it until I read @norepimorphism's fix. I'll try and explain some more.


When you have a &mut T, T is invariant -- this means that if it has a lifetime, you generally can't coerce it to something shorter ('static to 'a). However, for a dyn Trait + '_, there's an out -- you can coerce dyn Trait + 'long to a dyn Trait + 'short even behind a &mut. It's the same coercion that goes from SomethingConcrete to dyn Trait (unsized coercion).

So you can coerce a &'x mut (dyn AsyncReadWrite + 'static) to a &'x mut (dyn AsyncReadWrite + 'x).

But this coercion is more restrictive than the normal variance of lifetimes -- it can only take place behind a single layer of indirection.

So you cannot coerce a ReadHalf<&'x mut (dyn AsyncReadWrite + 'static)> to a ReadHalf<&'x mut (dyn AsyncReadWrite + 'x)>.


Here:

async fn this_method_builds(mut stream: Box<dyn AsyncReadWrite>)  {
    let stream_ref: &mut (dyn AsyncReadWrite + 'static) = stream.as_mut();    
    stream_data_wrapper(stream_ref).await;
} 

Everything works because calling the wrapper induces the coercion due to the function signature. You're coercing the &mut directly.


Here:

async fn this_mthod_doesnt_build(mut stream: Box<dyn AsyncReadWrite>)  {
    let stream_ref = stream.as_mut();
    let (stream_rx, stream_tx) = tokio::io::split(stream_ref);
    
    stream_data(stream_rx, stream_tx).await;
} 

The compiler doesn't see any type mismatches that would imply a coercion may be in order until after it has decided that the generic tokio::io::split has returned a ReadHalf<&'_ mut (dyn AsyncReadWrite + 'static)>. The unsized coercion can't apply after split because there's too many layers of indirection now. Variance can't kick in because of the &mut (and that's what the error talks about).

It's just not currently smart enough to think "hmm, maybe I could have coerced this to something that works earlier on" (before the call to split).


With this fix:

async fn this_mthod_doesnt_build(mut stream: Box<dyn AsyncReadWrite>)  {
    let stream_ref: &mut AsyncReadWrite = stream.as_mut();
    let (stream_rx, stream_tx) = tokio::io::split(stream_ref);
    
    stream_data(stream_rx, stream_tx).await;
} 

The compiler sees an explicit ascription with some lifetimes it is going to have to figure out via inference:

    let stream_ref: &'_ mut (AsyncReadWrite + '_) = stream.as_mut();
    // '_ means "infer what I should be from the larger context"
    // (and not just from the right hand side)

And now the compiler no longer assumes you got a ReadHalf<&'_x mut (AsyncReadWrite + 'static)> back from split, but instead that you got a ReadHalf<&'_x mut (AsyncReadWrite + '_y)> back, where '_x and '_y match the inference lifetimes in the ascription.

Then when you call stream_data, it figures out that '_x must be the same as '_y. It connects this all the way back to the let stream_ref: assignment, and that's a valid coercion site, and the unsized coercion can happen because you still just have the &mut at that point.

1 Like

To he explitic about the problem (without explaining why any of these things are true):

Converting &'a mut dyn Trait + 'static to &'a mut dyn Trait + 'a works!

Converting WriteHalf<&'a mut dyn Trait + 'static> to WriteHalf<&'a mut dyn Trait + 'a>doesn't work! Similar for ReadHalf.

Type inference is a bitch and happens to inhibit the necessary &'a mut dyn Trait + 'static to &'a mut dyn Trait + 'a conversion from happening implicitly before the call to tokio::io::split. But adding a type annotation or splitting it up into two functions helps/avoids inference and re-enable the possibility for that coercion at that place.

In my opinion, the situation should eventually, hopefully, be improved and your original code should compile.

Here's one more workaround/fix:

    let stream_ref = stream.as_mut();
    let (stream_rx, stream_tx) = tokio::io::split(stream_ref as _);
    //                                                      ^^^^^
    stream_data(stream_rx, stream_tx).await;

This tells the compiler you want to coerce stream_ref to something before the call to split, and it figures out the correct "something" from the following line.

3 Likes

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.