https://gcc.gnu.org/bugzilla/show_bug.cgi?id=124584
--- Comment #3 from Michael Vandeberg <michael at cppalliance dot org> --- The trigger condition described orignally is narrower than the actual bug. The repro says "structured binding from co_await", but co_await is not required at the binding site, the hidden binding variable just needs to live in the coroutine frame. A user-reported instance (https://github.com/cppalliance/capy/issues/277) hit this bug with the binding sourced from a plain function call. The structured binding declaration contains no co_await; the binding is simply followed by a co_await in the same coroutine, so its hidden variable is captured into the frame. The destructor is still skipped. Minimal reproducer (no co_await on the binding RHS): #include <coroutine> #include <cstdio> #include <tuple> #include <utility> static int g_dtor_count = 0; struct guard { bool live_ = true; ~guard() { if (live_) ++g_dtor_count; } guard() = default; guard(guard&& o) noexcept : live_(std::exchange(o.live_, false)) {} }; static std::coroutine_handle<> g_pending; struct yield_aw { bool await_ready() const noexcept { return false; } void await_suspend(std::coroutine_handle<> h) noexcept { g_pending = h; } void await_resume() const noexcept {} }; struct task { struct promise_type { task get_return_object() noexcept { return {std::coroutine_handle< promise_type>::from_promise(*this)}; } std::suspend_never initial_suspend() noexcept { return {}; } std::suspend_always final_suspend() noexcept { return {}; } void return_void() noexcept {} void unhandled_exception() { std::abort(); } }; std::coroutine_handle<promise_type> h_; ~task() { if (h_) h_.destroy(); } }; std::tuple<guard> make_guarded_tuple() { return {guard{}}; } task test_fn() { auto [g] = make_guarded_tuple(); // not co_await co_await yield_aw{}; // binding crosses suspend (void)g; } int main() { auto t = test_fn(); std::exchange(g_pending, nullptr).resume(); std::fprintf(stdout, "dtor_count = %d %s\n", g_dtor_count, g_dtor_count > 0 ? "OK" : "BUG"); return g_dtor_count > 0 ? 0 : 1; } Results on my system: GCC 15.2.1 -O0 OK GCC 15.2.1 -O0 -fsanitize=address BUG GCC 15.2.1 -O1 / -O2 / -O3 (+/- asan) BUG GCC 14.3.0 (built from source) -O0 / -O1 / -O2 / -O3 OK clang 22.1.2 all -O levels (+/- asan) OK I ran a removal-test matrix on GCC 15.2.1 to identify which conditions are actually required versus incidental: 1. Tuple-protocol structured binding (member or non-member get() plus std::tuple_size / std::tuple_element; std::tuple qualifies). Replacing with an aggregate binding makes the bug go away. 2. The hidden binding variable's lifetime crosses a suspension point, so it is captured into the coroutine frame. Moving the binding to after the only co_await -- or removing the coroutine altogether -- makes the bug go away. 3. A bound member with a non-trivial destructor. Replacing with a trivially destructible type makes the program appear OK, but this is trivially unobservable: there is no destructor call to skip, so this criterion is required to *see* the bug, not necessarily required to *cause* it. 4. GCC 15+, at -O1+, or at -O0 with -fsanitize=address. GCC 14.3.0 and clang 22 work at all -O levels. So a more accurate one-line summary is roughly: "Destructor of tuple-protocol structured binding captured into a coroutine frame is skipped at -O1+ (or -O0 with -fsanitize=address)" rather than "...from co_await...". The original co_await formulation is just one way to capture the binding into the frame. Practical impact: a std::unique_ptr<int> returned in a std::tuple from a regular helper was leaked because the caller happened to be a coroutine that suspended later in the same scope. Skipping the destructor of an std::tuple captured into the coroutine frame is going to bite any code that uses RAII types alongside coroutines, not only code that binds directly to co_await results.
