Skip to content

[coroutines] Clang generates incorrect code for throwing promise.unhandled_exception() #61900

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
lewissbaker opened this issue Apr 2, 2023 · 7 comments
Assignees
Labels

Comments

@lewissbaker
Copy link

The wording for coroutines in [dcl.fct.def.coroutine] for unhandled_exception() says that:

If the evaluation of the expression promise.unhandled_exception() exits via an exception, the coroutine is considered suspended at the final suspend-point and the exception propagates to the caller or resumer.

However, it seems that the current codegen for Clang does not set the suspend point to be at a final suspend point in this case. Instead, if a coroutine exits the call to unhandled_exception() with an exception, which you then catch and then destroy the coroutine, the coroutine behaves as if the coroutine was still suspended at the last suspend-point it suspended at. i.e. it does not seem to be writing a new suspend-point index to the coroutine-state if unhandled_exception() exits with an exception, instead it leaves the last-written suspend-point index value unchanged.

This can lead to unexpected behaviour. e.g. double-destruction of variables in-scope at the last suspend-point.

For example: Compile the following code with Clang 16.0 and the compiler args -std=c++20 -stdlib=libc++ -O2

#include <coroutine>
#include <cstdio>

struct generator {
    struct promise_type {
        generator get_return_object() noexcept { return generator{std::coroutine_handle<promise_type>::from_promise(*this)}; }
        void unhandled_exception() { throw; }
        void return_void() noexcept {}
        std::suspend_always yield_value(int) noexcept { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        std::suspend_always initial_suspend() noexcept { return {}; }
    };

    explicit generator(std::coroutine_handle<promise_type> h) noexcept
    : coro(h)
    {}

    generator(generator&& g) noexcept
    : coro(g.coro)
    {
        g.coro = {};
    }

    ~generator() {
        if (coro)
            coro.destroy();
    }

    std::coroutine_handle<promise_type> coro;
};

struct RAII {
    __attribute__((noinline)) RAII() noexcept { std::puts("ctor"); }
    __attribute__((noinline)) ~RAII() { std::puts("dtor"); }
};

struct X {};

static generator example() {
    RAII raii;
    co_yield 0;
    throw X{};
}

int main() {
    generator g = example();
    g.coro.resume();
    try {
        g.coro.resume();
    } catch (X) {
        std::puts("caught X");
    }
}

Compiler-Explorer: https://godbolt.org/z/xqY4bbPTW

The expected output is:

ctor
dtor
caught X

The observed output under Clang 16 is:

ctor
dtor
caught X
dtor
@cor3ntin cor3ntin added the coroutines C++20 coroutines label Apr 2, 2023
@llvmbot
Copy link
Member

llvmbot commented Apr 2, 2023

@llvm/issue-subscribers-coroutines

@ChuanqiXu9
Copy link
Member

ChuanqiXu9 commented Jun 20, 2023

If I call coro.done() after try-catch statement, it will return true as expected, which implies that the coroutine is suspended at the final suspend point actually. https://godbolt.org/z/rbK8Wd8dT. But it looks indeed the state of coroutines are broken.

@ChuanqiXu9 ChuanqiXu9 self-assigned this Jun 20, 2023
@ChuanqiXu9
Copy link
Member

Update to myself: although I solved the O0 case, the optimized case is more complex than I thought. Because:

The coroutine will be transformed to (roughly):


try {
  coro-body;
} catch(...) {
  unhandled_exception();
}

final_suspend:
   ...

In the reproducer, the optimizer found that the unhandled_exception() here will always throw after inlining. So the final suspend won't be reached. So the optimizer removes the final_suspend part in the early places. Maybe I need to find something tricky to solve this.

ChuanqiXu9 added a commit that referenced this issue Jun 20, 2023
… path

Try to address part of
#61900.

It is not completely addressed since the original reproducer is not
fixed due to the final suspend point is optimized out in its special
case. But that is a relatively independent issue.
@ChuanqiXu9
Copy link
Member

Note to my self: given the transformation from http://eel.is/c++draft/dcl.fct.def.coroutine, the destroy function from the final suspend point should destroy the same thing with the initial suspend point. This may help us to handle the case that the final suspend is optimized out. However, it is problematic since the optimizier don't have knowledge about first suspend and final suspend point.

So the best method may still be to add a coro intrinsic to teach the middle end about the semantics of final suspend to not optimize it out.

@lewissbaker
Copy link
Author

lewissbaker commented Aug 22, 2023

Note that destroying at the final suspend point may have different behaviour compared to the initial suspend point depending on the awaitable object. The initial suspend point may have one temporary awaiter returned from operator co_await() (or indeed a temporary returned from initial_suspend() itself) and the final-suspend point may have a different type of temporary awaitable/awaiter used in the co_await promise.final_suspend() expression. And this may be different again from the final suspend point that is reached when unhandled_exception() throws, where there will be no such temporary awaitable/awaiter.

The destroy() behaviour from all of these suspend points will have commonalities - they all end up eventually executing the sequence of promise destruction, parameter destruction and frame deallocation.

@ChuanqiXu9
Copy link
Member

Thanks for correcting me.

While this is relatively easy to fix: we can fix it by making unhandled_exception() noinline, or we can add intrinsics to make the final_suspend to not be removed, etc... the reason why I put this so long is that the condition to trigger the bug is super critical and I don't like to add costs for a corner case.

The condition to trigger the bug including:

  • The function body must be always-throw statically.
  • The unhandled_exception() function need to be always-throw statically too.

Then the optimizer found the final_suspend part is never executed then it got removed. Then, I'd like to see this is really a corner case. https://godbolt.org/z/Tsj7Th41s

So I am still trying to find a light-weight solution to users so that the users who don't use such pattern won't get worse code than before.

@ChuanqiXu9
Copy link
Member

Inspired by #64945, the best solution may be to introduce something like @llvm.coro.unhandled_exception(%promise, @unhandled_exception), and if:

  • the @llvm.coro.unhandled_exception(...) call dominates all the terminators of the function. e.g., in another word, we know the unhandled_exception must be executed in the middle end.
  • the @unhandled_exception may throw exceptions.

then we will add special information for the final suspend to make it not to be optimized out. Otherwise, we will convert the call to @llvm.coro.unhandled_exception(...) to a call to @unhandled_exception directly in the CoroEarly phase. Then everything will be fine. This may be the most promising solution we got now.

veselypeta pushed a commit to veselypeta/cherillvm that referenced this issue Aug 29, 2024
… path

Try to address part of
llvm/llvm-project#61900.

It is not completely addressed since the original reproducer is not
fixed due to the final suspend point is optimized out in its special
case. But that is a relatively independent issue.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
Status: No status
Development

No branches or pull requests

5 participants