landelare / ue5coro

A C++20 coroutine plugin offering seamless integration with Unreal Engine 5.
BSD 3-Clause Clear License
616 stars 57 forks source link

Ability for coroutine to await its own cancellation #28

Closed Acren closed 1 month ago

Acren commented 3 months ago

Hey there and thanks for the amazing plugin.

I'm looking for some certain functionality regarding cancellation - in particular, having a coroutine respect its own cancellation while awaiting another nested coroutine. I've read the cancellation documentation but can't find an example of this use case. Maybe this is already possible and I'm missing something.

Let's say I have a simple setup of one coroutine awaiting another:

TCoroutine<> Nested()
{
    co_await Latent::WaitSeconds(60.f);
}

TCoroutine<> Outer()
{
    co_await Nested();
}

And let's say that the Outer() coroutine is started and then has its Cancel() called while it's still running. In this case, it seems the cancellation will not actually stop the outer coroutine before the nested one is complete.

Suppose I actually want the Outer coroutine to pick up its cancellation immediately and run some cleanup logic, before Nested() completes which may be a long time later. (Maybe forward that cancellation on to a nested coroutine to interrupt it earlier) I feel like what I need is the ability to do something like this:

TCoroutine<> Outer()
{
    co_await WhenAny(Nested(), WaitForCancellation());
}

where WaitForCancellation() awaits and only resumes if Outer() is cancelled. This way Outer() can handle its cancellation immediately.

I thought FinishNowIfCanceled() looked promising, however I need it to suspend if it's not cancelled rather than resuming immediately.

I also tried Latent::Until([](){ IsCurrentCoroutineCanceled(); }); but of course it gets evaluated outside of the context of the coroutine itself so can't get the cancellation status.

Does this make sense? Is it possible in the plugin already?

Thanks a lot.

landelare commented 3 months ago

I agree with you that this is currently not directly supported on the public API. I don't know right this moment if this is feasible to support, I'll look into it for 2.0 or 2.1 and keep this issue open until a decision is made.

Until then, you can either change your Outer to run in latent mode (which will make co_await Nested(); do exactly what you want, see Private::TLatentCoroutineAwaiter), or do something like this:

for (auto NestedCoro = Nested(); !NestedCoro.IsDone();)
    co_await NextTick(); // This will repeatedly evaluate outer cancellation

PlatformSeconds(AnyThread) should also work instead of NextTick if that better fits your real usage.

Acren commented 3 months ago

Thanks for the tips, looking forward to seeing what 2.0 brings!

landelare commented 3 months ago

In the 2.0 preview, co_await Nested(); will do exactly what you want, regardless of the coroutine's execution mode.

Does this address your real use case?

This referred to async coroutines awaiting latent awaiters, which was improved in 2.0 to respond to cancellations within one tick, but it doesn't help here. Here, we have an async coroutine awaiting another async coroutine.

Acren commented 1 month ago

Sorry for the delay. I've recently been able to test this in 2.0 preview 2, but I'm still unclear on how to have the Outer coroutine detect it's own cancellation immediately, rather than waiting until after Nested finishes.

I've got the following test code, similar to before except added a new Manager coroutine to explicitly show how the cancellation is triggered.

    UE5Coro::TCoroutine<> Nested()
    {
        UE_LOGFMT(LogTemp, Log, "Nested started");

        co_await UE5Coro::Latent::Seconds(10.f);

        UE_LOGFMT(LogTemp, Log, "Nested finished");
    }

    UE5Coro::TCoroutine<> Outer()
    {
        UE_LOGFMT(LogTemp, Log, "Outer started");

        ON_SCOPE_EXIT
        {
            UE_LOGFMT(LogTemp, Log, "Outer scope exited");
        };

        UE5Coro::FOnCoroutineCanceled Cancelled([]
        {
            UE_LOGFMT(LogTemp, Log, "Outer cancelled");
        });

        co_await Nested();

        UE_LOGFMT(LogTemp, Log, "Outer finished");
    }

    UE5Coro::TCoroutine<> Manager()
    {
        UE_LOGFMT(LogTemp, Log, "Manager started");

        auto OuterTask = Outer();

        co_await UE5Coro::Latent::Seconds(5.f);

        UE_LOGFMT(LogTemp, Log, "Cancelling outer");

        OuterTask.Cancel();
    }

Now without Manager cancelling Outer, it works as you'd expect - Outer finishes naturally after Nested, and the scope exits.

[0:00]LogTemp: Manager started
[0:00]LogTemp: Outer started
[0:00]LogTemp: Nested started
[0:10]LogTemp: Nested finished
[0:10]LogTemp: Outer finished
[0:10]LogTemp: Outer scope exited

When the Manager does cancel Outer, it does prevent Outer resuming after Nested, and it does exit the scope and trigger the FOnCoroutineCanceled but it still only does so after Nested finishes.

[0:00]LogTemp: Manager started
[0:00]LogTemp: Outer started
[0:00]LogTemp: Nested started
[0:05]LogTemp: Cancelling outer
[0:10]LogTemp: Nested finished
[0:10]LogTemp: Outer cancelled
[0:10]LogTemp: Outer scope exited

Ideally, I'd have the scope exit and FOnCoroutineCanceled triggered immediately after the Cancel() call without having to wait for Nested to complete. What I'd expect in that case would be something like this:

[0:00]LogTemp: Manager started
[0:00]LogTemp: Outer started
[0:00]LogTemp: Nested started
[0:05]LogTemp: Cancelling outer
[0:05]LogTemp: Outer cancelled
[0:05]LogTemp: Outer scope exited
[0:10]LogTemp: Nested finished

The goal in this case being that Outer can execute some cleanup logic immediately upon cancellation, and possibly forward the cancellation onto other coroutines such as Nested in this case.

landelare commented 1 month ago

You're right, I confused exactly what was in latent mode when I wrote my above comment. I edited it for clarity. Nothing on the current public API will serve you, but I've added Latent::UntilCoroutine to the next branch, which is somewhat similar to UntilDelegate.

Using co_await UntilCoroutine(Nested()); should do what you want. Alternatively, you could make Outer latent, which will get you this behavior for free, even in 1.x.

Acren commented 1 month ago

Confirmed, that works great! Thanks for the new functionality and tips.