Lifetime issues while wrapping a Future

Hi,

I am looking for help with a bit of code that baffles me (I am relatively new to Rust). Can't get it to compile, looks like some lifetime issue, but I can't figure out what and where to specify. This is dried out version of much bigger code (playground):

use tokio::task::spawn_local;
use futures::Future;

struct TaskCtx;

fn spawn_task<FN, F>(ctx: TaskCtx, fn_task: FN)
where
    FN: 'static + FnOnce(&TaskCtx) -> F,
    F: Future,
{
    _ = spawn_local(wrap_task(ctx, fn_task))
}

fn wrap_task<FN, F>(ctx: TaskCtx, fn_task: FN) -> impl Future + 'static
where
    FN: 'static + FnOnce(&TaskCtx) -> F,
    F: Future,
{
    async move {
        let inner = fn_task(&ctx);
        _ = inner.await
    }
}

// this version causes compiler to demand 'static bound on F for some reason... Why?
// async fn wrap_task<FN, F>(ctx: TaskCtx, fn_task: FN)
// where
//     FN: 'static + FnOnce(&TaskCtx) -> F 
//     F: Future,
// {
//     let inner = fn_task(&ctx);
//     _ = inner.await
// }

struct Test;
impl Test {
    async fn test(self, _ctx: &TaskCtx) {}
}

async fn foo1() {
    spawn_task(TaskCtx, |ctx| Test.test(ctx))
}


async fn tt(ctx: &TaskCtx) {
    Test.test(ctx).await;
}

async fn foo2() {
    spawn_task(TaskCtx, tt)
}

Right now it fails to compile and I can't figure out how to make compiler happy.

error: lifetime may not live long enough
  --> src/lib.rs:42:31
   |
42 |     spawn_task(TaskCtx, |ctx| Test.test(ctx))
   |                          ---- ^^^^^^^^^^^^^^ returning this value requires that `'1` must outlive `'2`
   |                          |  |
   |                          |  return type of closure `impl futures::Future<Output = ()>` contains a lifetime `'2`
   |                          has type `&'1 TaskCtx`

error[E0308]: mismatched types
  --> src/lib.rs:51:5
   |
51 |     spawn_task(TaskCtx, tt)
   |     ^^^^^^^^^^^^^^^^^^^^^^^ one type is more general than the other
   |
   = note: expected opaque type `impl for<'a> futures::Future<Output = ()>`
              found opaque type `impl futures::Future<Output = ()>`
   = help: consider `await`ing on both `Future`s
   = note: distinct uses of `impl Trait` result in different opaque types
note: the lifetime requirement is introduced here
  --> src/lib.rs:8:39
   |
8  |     FN: 'static + FnOnce(&TaskCtx) -> F,
   |                                       ^

Thank you.

fn spawn_task<FN, F>(ctx: TaskCtx, fn_task: FN)
where
    FN: 'static + FnOnce(&TaskCtx) -> F,
    F: Future,

Type parameters like F must resolve to a single type, and types which vary by lifetime are distinct types (even if they only vary by lifetime). Therefore it's impossible for a closure with a return type which captures the borrow of the &TaskCtx to meet this bound.

See also this recent thread. When @vague's suggestion is applied to your playground, it looks like so:

trait AsyncCallback<'a, T>: FnOnce(&'a TaskCtx) -> Self::Fut {
    type Fut: Future<Output = T>;
}

impl<'a, T, Out, F> AsyncCallback<'a, T> for F
where
    Out: Future<Output = T>,
    F: FnOnce(&'a TaskCtx) -> Out,
{
    type Fut = Out;
}

fn spawn_task<FN, T>(ctx: TaskCtx, fn_task: FN)
where
    FN: 'static + for<'any> AsyncCallback<'any, T>,
{
    _ = spawn_local(wrap_task(ctx, fn_task))
}


fn wrap_task<FN, T>(ctx: TaskCtx, fn_task: FN) -> impl Future + 'static
where
    FN: 'static + for<'any> AsyncCallback<'any, T>,
{
    async move {
        let inner = fn_task(&ctx);
        _ = inner.await
    }
}

Unfortunately at that point, you next run into a closure inference problem which is probably this long-running issue.

One work around is to write a fn like in the playground.[1] Another (verbose, non-zero-cost) workaround is to box up the future so that you can nudge inference in the right direction.


  1. Or coerce to a fn pointer, but I don't think that's possible with the opaque return type. ↩︎

2 Likes

It's spawn_local that has a 'static bound which must be met. The notional desugaring of the async fn is something like

type Opaque<Fn, F> = impl Future<Output = ()>;
fn wrap_task<FN, F>(ctx: TaskCtx, fn_task: FN) -> Opaque<Fn, F> {
    async move {
        let (ctx, fn_task) = (ctx, fn_task); // Always move everything
        // ...
   }
}

Whereas the notional desugaring of the RPIT version is something like

type Opaque<Fn, F> = impl Future<Output = ()> + 'static;
fn wrap_task<FN, F>(ctx: TaskCtx, fn_task: FN) -> Opaque<Fn, F> {
    // ...
}

And the pertinent difference is

-type Opaque<Fn, F> = impl Future<Output = ()>;
+type Opaque<Fn, F> = impl Future<Output = ()> + 'static;

I.e. you guaranteed a 'static bound in the return type of the RPIT version which isn't present in the async fn version, and that satisfies the bounds on spawn_local.

2 Likes

Woah... How did I end up in such dark corner of the language? I'll need some time to process your answer (which I am very grateful for, btw)...

Couple of followup questions:

So, lifetime is part of the type and (when not specificed?) defaults to 'static, right?

I still don't get why |ctx: &_| -> impl Future<...> closure can't meet FnOnce(&TaskCtx) -> impl Future<...> bond. Don't we have default rules that cause output type to "inherit" lifetime of input parameter? I.e. that closure should end up like this: |ctx:&'a _| -> impl Future<...> + 'a, basically saying that returned future lifetime is same as input reference.

Why RPIT version has 'static in return type?
I've replaced wrap_task with async version in your playground -- note that compiler complains about bound in spawn_task -- how does it know that adding 'static to future returned by fn_task will make future returned by wrap_task 'static too? it doesn't know fn_task is invoked at all (or it's result stored across await-points) -- maybe it is invoked and result immediately dropped (i.e. instance of F is not a part of future returned by wrap_task).

that's correct, see also sub-typing and variance

not always, sometimes (e.g RPIT) it defaults to 'static, sometimes it could be an elided lifetime that can be inferred, but if the lifetime cannot be decided, you get a compile error.

by "inherit" rule I assume you are talking about the elided lifetime inference rule. it is for function signatures, it is unfortunately not the case for closures (yet, hopefully). currently, you cannot even make the following compile, (see also RFC3216).

let id = |x: &i32| -> &i32 { x }; //<-- compile error
let id: fn(&i32) -> &i32 = |x| x; //<-- compiles with explicit coercion

because the async function is just a constructor for some synthetic Future type, essentially, it has fn_task: FN as a field, and the compiler knows the definition of the type so it can figure out the 'static bound is satisfied.

Async Rust still has some rough edges, unfortunately. I'm going to answer your questions the best I can, but it's going to involve a lot of details one can often get away with not knowing / a lot of complexity.

Some structs have parameters, like Vec<T> has that T type parameter. Vec isn't a type, but Vec<String> is a type. Vec<_> is a type constructor which requires a (resolved) type for the parameter in order to construct a type.

Lifetime parameters work similarly. &str isn't a type, &'a str for some resolved lifetime is a type. So let's get away from async for a minute and consider this method:

pub fn trim(self: &str) -> &str { /* ... */ }

This can't compile:

fn example<F: Fn(&str) -> Out, Out>(_: F) {}

fn main() {
    example(str::trim);
}

Because str::trim doesn't have one return type across all &'input str inputs; there's a different output type for every input lifetime. But equating the return with the Out type parameter requires a single output type.

A type constructor can have multiple lifetime parameters, and a type (or type constructor) can have zero lifetime parameters. Having zero lifetime parameters doesn't mean you have an invisible 'static one. And it doesn't mean you can meet a 'static bound, either: Vec<T>: 'static only if T: 'static, for example, even though T is a type parameter and not a lifetime parameter.

The defaults for elided lifetimes are contextual.

One of the snags of the language is that closure lifetime elision is inference based and thus not the same as function signature lifetime elision. There are sometimes ways to work around that (forcing inference to go the correct way), but they aren't always applicable in the face of unnameable types like impl ... return types.


This is getting further into the weeds, but there's also a difference between impl ... lifetimes and lifetime parameters. The + separated fragments in impl Trait + Send + 'x and the like are all bounds on the opaque type. So when you have an impl ... return that captures an input lifetime / is parameterized by an input lifetime, it's not the same as this:

fn foo<'x, T>(&'x T) -> impl Trait + 'x { /* ... */  }

Because that forces the return type to meet an 'x bound:

Opaque<T>: 'x + Trait
// or maybe
Opaque<'x, T>: 'x + Trait

Which in turn requires T: 'x as well, for example. Whereas just being parameterized by 'x doesn't impose a restriction on the whole type (e.g. on T).

Sometimes impl ... lifetimes are implicitly captured, but implicitly adding + 'a bounds to the opaque type doesn't happen.

Because you wrote it into the API contract:

fn wrap_task<FN, T>(ctx: TaskCtx, fn_task: FN) -> impl Future + 'static
//                                                            ^^^^^^^^^

So the function body has to satisfy the bound and the caller can rely on the bound being satisfied.

Take off the + 'static and it will fail like the async fn version does.

It checks the body of the function to make sure the API is satisfied, which in this case includes the return type being 'static. I don't know enough gritty details of what goes into async state machine generation to give a detailed explanation of how it works in this particular case offhand, though.

3 Likes

quinedot and nerditation -- thank you for help, it is really appreciated.

All this reminds me pains C++ had (and still has a bit) before all template stuff was properly thought through and standardized. It all looked magical and logical, but as you keep pushing it -- it starts misbehaving or simply breaking... depending on your compiler. Luckily Rust has only one compiler implementation, so all programs are guaranteed to misbehave in same way... :smiley:

Huh... I think I got it. str::trim is not a function, it a (equivalent of C++) function template and (thanks to "elided lifetime inference rule"?) can be rewritten as:

pub fn trim<'a>(self: &'a str) -> &'a str { /* ... */ }

Whenever you use std::trim you are asking compiler to figure out template parameter 'a (just like in C++) and once it figured it out, it instantiates your function template and you end up with a concrete function (that has concrete argument and return types). &str is not a type, but a "placeholder" that limits variability to "any type that looks like &'a str where only 'a can change".

Following this logic, example can be written like this:

fn example<F: for<'a, 'b> Fn(&'a str) -> Out), Out: 'b>(_: F) {} // rust doesn't take this syntax

i.e. to instantiate example template we need to pass (as argument) another template that "matches" Fn(&'a str) -> Out, where Out: 'b. Which is weird, because I don't see how this precludes case when 'a is same as 'b (as in case of std::trim).

And indeed, if we rewrite example in these terms -- it compiles just fine:

fn example<'a, 'b, F: Fn(&'a str) -> Out, Out: 'b>(_: F) {}

and the only difference is that 'a and 'b template parameters are on example template instead of on Fn(...) -> .... Looks like a defect in rust instantiation (inference?) logic or in my understanding of how it works. :slight_smile: Do you agree?

I have a feeling that if I express FnOnce(&'a TaskCtx) -> (impl Future + 'a) bound -- there is nothing (logically) that prevents inference from "approving" that closure. Simply following same "C++ instantiation" line of thinking.

Yes, "lifetime parameter" is a parameter that inference logic can substitute (to get a match) and impl ... lifetime is a bound for this process.

Oh, right. My bad, I was playing so much with that code -- I forgot. I assumed it is (a 'static bound) implied by some rule.

Now this bugs me quite a bit... Depending on how I call spawn_task -- program will compile or not. And all this because spawn_local requires argument to be 'static. This is very un-Rusty -- just because I forgot to specify a bound, my library can be useless for some callers, but fine -- for others. I could tell that compiler doesn't look into wrap_task (I don't even call fn_task here) -- i.e. it always wants 'static bound on F, but demands it only if F (after it is determined) is not 'static.

Ok, but how it figures out that spawn_task needs a 'static bound on F for wrap_task(ctx, fn_task) to become 'static? It doesn't even know what wrap_task is doing with fn_task...

1 Like

This is... pretty close to how things work, but just a little off. That is how things work for types which are parameterized with a lifetime, like &str. I would call these "type constructors"; in order to get an actual type, you need to resolve the parameters.

There is a caveat that lifetimes are erased at compile time. In the context of functions, you don't get a separate function per lifetime it was called with, for example. But you do get a separate function per type it was called with.[1]

On to the details about "a little off". Every function item in Rust has it's own function item type, similar to closures. This compiler-generated type may or may not be parameterized. Here are two possible ways the compiler could notionally generate the functionality of str::trim, for example.[2]

// Type constructor approach ("early bound")
struct fn#StrTrim<'a>(PhantomData<fn(&'a str) -> &'a str>);

impl<'a> FnOnce<(&'a str,)> for fn#StrTrim<'a> {
    type Out = &'a str;
    fn call_once(mut self, args: (&'a str,)) -> Self::Out { /* ... */ }
}

impl<'a> FnMut<(&'a str,)> for fn#StrTrim<'a> { /* ... */ }
impl<'a> Fn<(&'a str,)> for fn#StrTrim<'a> { /* ... */ }
// The other approach ("late bound")
struct fn#StrTrim;

impl<'a> FnOnce<(&'a str,)> for fn#StrTrim {
    type Out = &'a str;
    fn call_once(mut self, args: (&'a str,)) -> Self::Out { /* ... */ }
}

impl<'a> FnMut<(&'a str,)> for fn#StrTrim { /* ... */ }
impl<'a> Fn<(&'a str,)> for fn#StrTrim { /* ... */ }

So which is it? Lifetimes which have explicit bounds result in the first approach ("early bound"), whereas lifetimes which have no explicit bounds result in the second approach ("late bound"). str::trim has no explicit bound involving the lifetime, so it's the second version.

The main difference is that in the second version, the type satisfies the higher-ranked trait bound (HRTB):

fn#StrTrim: for<'any> Fn(&'any str) -> &'any str
// Same as 
fn#StrTrim: Fn(&str) -> &str

Whereas in the first version, there is no single (fully resolved) type that satisfies the bound. Instead. fn#StrTrim<'a> implements the trait for the single lifetime 'a, not all lifetimes.

You can also turbo-fish the first version to get at "each" parameter-resolved function, whereas you can't turbofish the second version.

Demonstration.

I think there's some desire or effort to reduce or eradicate the differences here, but I have no idea as to the state of that effort.[3]

The two functions accept a different set of types. Here's a portion of the language where we're not too far into the weeds -- something about parameters on functions that everyone should understand.

When you have lifetime or type parameters on functions, the caller determines how those parameters resolve. They have to choose a set of "values" that meet all the bounds on the function, and the function body has to similarly be valid for any set of "values" that meet the bounds.

It's impossible for a caller to choose a lifetime that doesn't last at least just-longer than the function body. So, for example, it is impossible to ever borrow a local variable for the length of a lifetime parameter.

If you need something to work with local borrows like this, you need the higher-ranked trait bound.

With that in mind, let's look at the modified example:

fn example<'a, 'b, F: Fn(&'a str) -> Out, Out: 'b>(_: F) {}

First of all, 'b isn't really doing anything here, it just says Out has to be valid for some region ('b). But that's trivially true, or you couldn't return one. So we can simplify this to:

fn example<'a, F: Fn(&'a str) -> Out, Out>(_: F) {}

To pass something to this version of example, you don't need a function/closure/whatever that can take a &str with any lifetime, you only need a function that can take a &str with one particular lifetime which you (the caller) also choose. But that also means that the function body can't call it with a lifetime shorter than the function body, like the borrow of a local variable. In fact in this case, the caller could choose 'static for the lifetime, so the function body can only pass &'static str (and this works when the caller chooses a non-'static lifetime due to variance -- you can coerce the &'static str to any shorter lifetime).

When you pass trim, the compiler just checks that there's some way to satisfy the constraints, which there is; so it compiles.[4] We can go back to the early/late bound examples to see that this version of example works with str_trim_two, even though that function does not satisfy the higher-ranked trait bound.

So this version of example isn't the same as the HRTB version.

There can't be version that's the same which also names the return type with a single type parameter like Out.

If I understand what you're saying correctly, I agree. There's just unfortunately not enough knobs to turn in this case, yet, or arguably the compiler just isn't smart enough to obviate the need for such knobs.[5]


  1. Also insert footnotes about function deduplication and other optimization yada yada. ↩︎

  2. I'm not cross-checking with the actual Fn traits so I may be a little off on the details, but it's not germane to the point. ↩︎

  3. It's hard to soundly achieve "'always' works (for<'any>) but with further requirements", I gather. ↩︎

  4. You could imagine it choosing 'static or choosing some lifetime that only lasts as long as the function call, but since lifetimes are erased during compilation, it doesn't matter which is chosen -- and in fact no specific lifetime need be chosen. All that needs proven is that a solution exists. It's a constraint satisfaction scenario. ↩︎

  5. I prefer the knobs because there's always some use case that gets left out. ↩︎

3 Likes

I think it clicked for me... In same C++ terms fn trim<'a>(_: &'a str) -> &'a str {...} is a template with one parameter 'a.

To invoke fn example<F: for<'a> Fn(&'a str) -> Out, Out>(f: F) {...} we need to select Out parameter and pass f argument who's type is bound by for<'a> Fn(&'a str) -> Out -- which is basically saying "example for an argument takes a template with two parameters -- 'a and 'b, these parameters will be substituted later when f is used inside of example". And that is why you can not pass trim as argument to example -- it wants template that is "more general". And compiler hints about this with "one type is more general than the other" error.

fn example<'a, F: Fn(&'a str) -> Out, Out>(f: F) {...} on the other hand wants f to be a template with only one (lifetime) parameter and trim can fit as long as Out is str and it's lifetime is 'a. Pretty sure it is not a correct logic, but it is close enough and is much easier to reason with. :slight_smile:

It also helps me understand how AsyncCallback trick works -- it allows us to have a bound with only one lifetime parameter, so that functions (with return type of same lifetime as input argument) can fit. And the reason they fit because they implement AsyncCallback trait that mentions return type (and it's lifetime) in associated type. I.e. lifetime of return type is no longer part of the bound and yet it is resolved when associated type is resolved.

I hope Rust gets necessary syntax updates that will allow specifying stricter bound in example. Smth like: F: for<'a> Fn(&'a str) -> Out<'a>.

Thank you for "late/early bounds" and other explanationw -- it took me at least 4 hours to process, but I think I got it. It is pretty complicated, but certainly worth knowing.

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.