When combining modern C++20 coroutines with std::jthread, you might encounter subtle lifetime and concurrency traps that manifest as mysterious runtime crashes. A common head-scratcher occurs when a std::jthread throws an exception during destruction right after calling coroutine_handle::resume()—specifically failing deep inside the runtime with errors like _Thrd_join(_Thr, nullptr) != _Thrd_result::_Success.

The Short Answer: The Thread Is Trying to Join Itself

The root cause of this failure is a thread self-join. In C++, a thread cannot call join() on itself; doing so triggers a deadlock detection failure and throws std::system_error (with an error code like resource_deadlock_would_occur).

Because h.resume() is called from within the background thread, the remainder of the coroutine—including the evaluation and destruction of temporary objects in the co_await expression—executes on that same background thread. When the std::jthread destructor runs, it attempts to join its own thread, causing MSVC's runtime check inside _Thrd_join to fail.

Tracing the Execution Step-by-Step

Let's break down what actually happens under the hood when this code runs:

  1. Coroutine suspends: test() hits co_await Awaitable{std::jthread{}};. The temporary std::jthread{} is constructed and bound to the reference member in Awaitable.
  2. New thread created: await_suspend() launches a new std::jthread and assigns it to thread.
  3. Resumption on background thread: The newly spawned thread begins executing its lambda and invokes h.resume().
  4. Execution switches contexts: h.resume() resumes test() right where it left off. The coroutine calls await_resume() and proceeds.
  5. End of full-expression cleanup: Because the coroutine finishes (or advances past the statement), the temporaries created for the co_await expression must now be destroyed. Since execution is currently running on the new thread, the destructor runs on that very same thread.
  6. Self-join crash: std::jthread::~jthread() automatically calls join() if joinable() is true. But because the thread executing the destructor is the thread represented by the std::jthread object, join() detects a self-join and aborts with an error.

Another Hidden Flaw: Rvalue Reference Member

Beyond the self-join issue, having an rvalue reference member in a struct is an anti-pattern:

struct Awaitable {
    // ...
    std::jthread&& thread; // Lifetime hazard!
};

In C++, temporary objects bound to reference members in aggregates do not have their lifetimes extended beyond the immediate expression. The temporary std::jthread{} passed to Awaitable{std::jthread{}} will expire at the end of the full-expression, leaving you with a dangling reference if accessed outside that window.

How to Fix the Issue

1. Detach the Thread if It Runs Independently

If you intentionally want the worker thread to manage its own lifecycle and not be joined, use std::thread with .detach() instead of std::jthread (which is designed specifically to enforce joining on destruction):

struct Awaitable {
    auto await_ready() noexcept { return false; }

    void await_suspend(std::coroutine_handle<> h) noexcept {
        std::thread([h] {
            h.resume();
        }).detach();
    }

    void await_resume() noexcept {}
};

2. Keep the Thread Ownership Outside the Coroutine

If the background work must be joined before program exit, transfer ownership of the std::jthread somewhere that outlives the coroutine invocation, ensuring the caller or an executor thread performs the join:

#include <coroutine>
#include <thread>
#include <iostream>

struct Resumer {
    std::jthread worker;

    auto operator co_await() {
        struct Awaiter {
            Resumer& parent;
            bool await_ready() noexcept { return false; }
            void await_suspend(std::coroutine_handle<> h) noexcept {
                parent.worker = std::jthread([h] {
                    h.resume();
                });
            }
            void await_resume() noexcept {}
        };
        return Awaiter{*this};
    }
};

In this pattern, parent.worker is destroyed from whichever thread owns the Resumer instance (for example, main()), not from inside the resumed coroutine itself.

Key Takeaway

In C++20 coroutines, calling coroutine_handle::resume() immediately transfers the coroutine's control flow to the calling thread. Never allow a thread to destroy synchronization primitives or std::jthread instances representing itself during the cleanup of resumed statements.