Lifetimes in smol::Executor

I ran into an issue refactoring some code which uses smol::Executor. I condensed my original code into a minimal example. I want to run some async code which, in turn, spawns new async tasks on an executor. (Real-world use: accept incoming TCP connections, spawn 2 tasks per connection.)

use std::{future::Future, sync::Arc};
use smol::Executor;

fn main() {
    let outer = "outer".to_string();

    let executor = Executor::new();

    let fut = async {
        executor.spawn(async {
            println!("inner! {}", outer.as_str());
        }).detach();
    };

    smol::block_on(executor.run(fut));
}

fn main_broken() {
    let outer = "outer".to_string();
    let executor = Executor::new();
    let fut = run_broken(&executor, outer.as_str());
    smol::block_on(executor.run(fut));
}

fn run_broken<'a>(executor: &'a Executor<'a>, outer: &'a str) -> impl Future + 'a {
    async move {
        executor.spawn(async move {
            println!("inner! {}", outer);
        }).detach();
    }
}

fn main_fixed() {
    let outer = "outer".to_string();
    let executor = Arc::new(Executor::new());
    let fut = run_fixed(executor.clone(), outer.as_str());
    smol::block_on(executor.run(fut));
}

fn run_fixed<'ex, 'a: 'ex>(executor: Arc<Executor<'ex>>, outer: &'a str) -> impl Future + 'ex {
    async move {
        executor.spawn(async move {
            println!("inner! {}", outer);
        }).detach();
    }
}

My current code looks like main, but I would like to extract the async block into a separate function. I tried to do this with main_broken and run_broken, but I can't get it to compile. It seems to be related to drop check, but I don't really understand it despite having read the nomicon section. I tried fiddling with the lifetimes in run_broken, but to no avail.

When I wrap the executor in an Arc, I can get it to work, as in main_fixed. Note that the inner future still accesses data borrowed from the stack; it is not a 'static future. I'm not sure what it's actual lifetime is.

Is there a way to make run_broken work without the Arc? If not, is there a fundamental reason why it needs an Arc? Can you help me understand the constraints here?

(Just using an Arc is not really a performance hit, but I feel like I'm programming in the dark again. I would really like to understand this.)

Smol isn't available on the playground so I can't really try it out easily, but I'm pretty sure that you need to detach some of the lifetimes from each other. The reference to outer is spawned on executor, so its lifetime must be at least the lifetime annotated on the executor, but the reference to the executor does not get spawned, so that reference should be shorter.

Thank you for looking over this! I really appreciate the help.
I tried to follow your suggestion. I also removed outer, since the problem seems hard enough without it.

fn main_broken() {
    let executor = Executor::new();
    let fut = run_broken(&executor);
    smol::block_on(executor.run(fut));
}

fn run_broken<'a, 'ex: 'a>(executor: &'a Executor<'ex>) -> impl Future + 'a {
    async move {
        executor.spawn(async move {
            println!("inner!");
        }).detach();
    }
}

I tried to give the reference to the executor a shorter lifetime. I now get another error, which is also quite puzzling:

error[E0700]: hidden type for `impl Trait` captures lifetime that does not appear in bounds
  --> src\main.rs:24:60
   |
24 | fn run_broken<'a, 'ex: 'a>(executor: &'a Executor<'ex>) -> impl Future + 'a {
   |                                                            ^^^^^^^^^^^^^^^^
   |
note: hidden type `impl Future` captures the lifetime `'ex` as defined on the function body at 24:19
  --> src\main.rs:24:19
   |
24 | fn run_broken<'a, 'ex: 'a>(executor: &'a Executor<'ex>) -> impl Future + 'a {
   |                   ^^^

I don't really see where the problem is. My future captures an Executor<'ex>, but 'ex outlives 'a, so the future should still be able to have lifetime 'a.

When I write the return type as impl Future + 'a + 'ex, I get the following error instead:

error[E0623]: lifetime mismatch
  --> src\main.rs:24:60
   |
24 | fn run_broken<'a, 'ex: 'a>(executor: &'a Executor<'ex>) -> impl Future + 'a + 'ex {
   |                                      -----------------     ^^^^^^^^^^^^^^^^^^^^^^ ...but data from `executor` flows into `executor` here
   |                                      |
   |                                      these two types are declared with different lifetimes...

which I don't understand either.

I managed to put this on the playground by copying the method signatures from smol. I stubbed out the methods, so it won't run, but the compiler errors should be the same.

https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=225509af2459bc1ce87e2c0d0a1840f6

Thanks for the playground. Here is a solution:

fn run_broken<'a: 'ex, 'ex>(executor: &'a Executor<'ex>) -> impl Future + 'a {
    async move {
        executor.spawn(async move {
            println!("inner!");
        }).detach();
    }
}
2 Likes

Well, I made a mistake. I forgot the impl Drop for Executor in my playground stub. The issue is indeed related to drop checking, so when Executor doesn't need drop, it works. But the actual Executor from smol needs Drop, so it doesn't work. I fixed the playground code: Rust Playground

Sorry for the confusion.

I don't think it is fixable then.

The following signature works (Playground):

/// The "`Captures<'_>` hack" to work around `impl` _vs._ `dyn` inconsistency
trait Captures<'__> {}
impl<T : ?Sized> Captures<'_> for T {}

fn run_broken<'a, 'ex : 'a> (executor: &'a Executor<'ex>)
  -> impl Future + 'a + Captures<'ex>
{
    async move {
        executor.spawn(async move {
            println!("inner!");
        }).detach();
    }
}

Explanation

  1. I've removed the incorrect 'a ≥ 'ex bound: &'a Executor<'ex> implies that 'ex ≥ 'a, so if we had that extra 'a ≥ 'ex bound, we'd end up with 'a == 'ex and the classic horrible "nested borrow with same lifetime" hell. Made the 'ex ≥ 'a bound explicit, although that is not necessary (I'd even recommend that it be removed to avoid the danger of getting the bound wrong): it is just here to teach the notion better.

  2. At that point, the returned async move { … } (outer) future is capturing a type that mentions 'a and 'ex, thus its area / span / region of owned-usability (the lifetime appearing in the impl signature) is the intersection / minimum of 'a and 'ex. Since 'ex ≥ 'a, it is 'a == min('a, 'ex).

  3. That's when we hit the infamous:

    error[E0700]: hidden type for `impl Trait` captures lifetime that does not appear in bounds
      --> src/main.rs:26:61
       |
    26 | fn run_broken<'a, 'ex : 'a>(executor: &'a Executor<'ex>) -> impl Future + 'a { // + Captures<'ex> {
       |                                                             ^^^^^^^^^^^^^^^^
       |
    note: hidden type `impl Future` captures the lifetime `'ex` as defined on the function body at 26:19
      --> src/main.rs:26:19
    
    Detailed explanation

    This is basically what I personally consider to be a bug, and is, at the very least, a big inconsistency with the dyn counterpart. That is, if this were (pin-)boxed, returning a Box<dyn 'a + Future…> would work at this point. This is because once a type is (dyn-)erased, the only lifetimes that matter are those appearing on traits (here, none), and the lifetime of owned-usability. If the type initially had an internal non-'static lifetime (necessarily bigger than that of its owned-usability), then if such lifetime does not appear in the visible trait bounds of the dyn object, then it can't play any role anymore.

    Sadly, this can't be the case for impl Trait…, since the -> impl… mechanic is just a "mild" / "fake" type erasure; the compiler still sees the real type behind it and may thus interact with more lifetimes than the ones visible. So it can't erase such an inner special lifetime (here, 'ex). And indeed, with the initial designs of -> impl Trait, it didn't do so: a lifetime such as 'ex would still be present in the returned type, hidden, even if it wouldn't appear in the public API (and that is sound, as we will see with the workaround at the end of this post).

    What's the problem then? Well, Rust further developed a currently unstable / nightly feature: type_alias_impl_trait (you can actually experiment with min_type_alias_impl_trait already, the soon to be stabilized subset of it). And they decided, legitimately, to reduce special casing and increase consistency by making -> impl Trait… use a hidden / compiler-generated type_alias_impl_trait under the hood.

    And the catch is that, when using type_alias_impl_trait, one needs to explicitly list the generic (type and lifetime (and const?)) parameters that the existential type is allowed to depend on. And when they implemented this shift, they changed the logic of -> impl Trait w.r.t. captured generic parameters (for no good reason imho): the returned type will only be generic over the parameters appearing in the return type (rather than being generic over all the parameters present in scope).


    TL,DR

    That means that when writing -> impl 'a + Future…, Rust is, under the hood, writing:

    type HiddenReturnedFut<'a> = impl 'a + Future…;
    

    and then trying to unify:

    HiddenReturnedFut<'a> := CompilerGeneratedFuture<'a, 'ex> {
        capture_0: &'a Executor<'ex>,
    }
    

    , which it can't do because of that 'ex lifetime parameter which may (and often will) be strictly bigger than 'a, and because Executor<'ex> is not covariant in 'ex (this is the reason why often this limitation is dodged; very often the inner lifetime is in covariant position and Rust can thus then shrink the lifetime 'ex down to 'a).

    The workaround

    Until the compiler / lang team decides to acknowledge this is a bug (it looks like the status quo is to say this is "suprisingly" Working As Intended™), and then fix it (that is, make the compiler-defined HiddenReturnedFut be generic over an 'ex parameter too), the only solution / workaround at the moment is to make the 'ex parameter explicitly appear in the returned type impl … signature, by using a helper dummy trait to be infected with that lifetime parameter: hence the dummy trait Captures<'__> {}, the dummy blanket impl (so that adding it to a signature does not really add any constraints), and then the actual addition of + Captures<'ex>.

4 Likes

I'm impressed. Not only does my code now work without using Arc, I also think I understand why it didn't work before and I learned about an obscure compiler quirk. This is perfect, thank you so much.

2 Likes

FWIW,

  • since this issue / question comes back from time to time,

  • since there is a "trivial" mechanical resolution to it (which, to me, is an argument in the direction of saying this limitation is a bug)

  • and since fully explaining the subtleties at play, here is non-trivial,

I've finally committed to writing a helper attribute that automagically solves the compilation error for you, with the option to show you the fix if you don't want to keep a proc-macro dependency around:

asciicast

1 Like