https://gcc.gnu.org/bugzilla/show_bug.cgi?id=124584
Bug ID: 124584
Summary: Destructor of tuple-protocol structured binding from
co_await skipped at -O1+
Product: gcc
Version: 15.1.0
Status: UNCONFIRMED
Severity: normal
Priority: P3
Component: c++
Assignee: unassigned at gcc dot gnu.org
Reporter: michael at cppalliance dot org
Target Milestone: ---
Title: [C++20 Coroutines] Destructor of tuple-protocol structured binding from
co_await skipped at -O1+ (regression in 15)
Component: c++
Version: 15.1.0, 15.2.0, 15.2.1 20260209, 16.0.1 20260315 (trunk)
Regression from GCC 14. Also present on trunk.
System
------
$ gcc -v
Using built-in specs.
COLLECT_GCC=gcc
COLLECT_LTO_WRAPPER=/usr/lib/gcc/x86_64-pc-linux-gnu/15.2.1/lto-wrapper
Target: x86_64-pc-linux-gnu
Configured with: /build/gcc/src/gcc/configure
--enable-languages=ada,c,c++,d,fortran,go,lto,m2,objc,obj-c++,rust,cobol
--enable-bootstrap --prefix=/usr --libdir=/usr/lib --libexecdir=/usr/lib
--mandir=/usr/share/man --infodir=/usr/share/info
--with-bugurl=https://gitlab.archlinux.org/archlinux/packaging/packages/gcc/-/issues
--with-build-config=bootstrap-lto --with-linker-hash-style=gnu
--with-system-zlib --enable-__cxa_atexit --enable-cet=auto
--enable-checking=release --enable-clocale=gnu --enable-default-pie
--enable-default-ssp --enable-gnu-indirect-function --enable-gnu-unique-object
--enable-libstdcxx-backtrace --enable-link-serialization=1
--enable-linker-build-id --enable-lto --enable-multilib --enable-plugin
--enable-shared --enable-threads=posix --disable-libssp --disable-libstdcxx-pch
--disable-werror
Thread model: posix
Supported LTO compression algorithms: zlib zstd
gcc version 15.2.1 20260209 (GCC)
$ uname -a
Linux workstation 6.18.9-arch1-2 #1 SMP PREEMPT_DYNAMIC Mon, 09 Feb 2026
17:16:33 +0000 x86_64 GNU/Linux
Summary
-------
When a coroutine uses "auto [a, b] = co_await expr;" where the result type
uses the tuple protocol (get() + std::tuple_size / std::tuple_element),
and one of the bound members has a non-trivial destructor, GCC 15 at -O1
and above fails to call the destructor of the hidden binding variable at
scope exit. At -O0, the identical source compiles and runs correctly.
This is a regression: GCC 14.2.0 and 14.3.0 compile and run the reproducer
correctly at all optimization levels with -Wall -Werror.
Tested versions:
GCC version -O0 -Wall -Werror -O1 -Wall -Werror -O1 runtime
--------------- ------------------- ---------------------------
------------
14.2.0 Clean compile, OK Clean compile, OK dtor runs
14.3.0 Clean compile, OK Clean compile, OK dtor runs
15.1.0 Clean compile, OK False -Wmaybe-uninitialized DTOR
SKIPPED
15.2.0 Clean compile, OK False -Wmaybe-uninitialized DTOR
SKIPPED
15.2.1 Clean compile, OK False -Wmaybe-uninitialized DTOR
SKIPPED
16.0.1 20260315 Clean compile, OK False -Wmaybe-uninitialized DTOR
SKIPPED
Both symptoms -- the false warning and the missing destructor -- point to
the same root cause: an optimization pass losing track of the hidden binding
variable's lifetime in the coroutine frame.
Steps to Reproduce
------------------
Save the program below as repro.cpp.
At -O0 -- compiles cleanly, runs correctly (all GCC versions):
$ g++ -std=c++20 -fcoroutines -O0 -Wall -Werror -o repro repro.cpp
$ ./repro
dtor_count = 1 OK
At -O1 -- GCC 14 compiles cleanly and runs correctly; GCC 15 does not:
$ g++-14 -std=c++20 -fcoroutines -O1 -Wall -Werror -o repro repro.cpp
$ ./repro
dtor_count = 1 OK
$ g++-15 -std=c++20 -fcoroutines -O1 -Wall -Werror -o repro repro.cpp
repro.cpp: In function 'void test_fn(...)':
repro.cpp:85:5: error: '<anonymous>' may be used uninitialized
[-Werror=maybe-uninitialized]
85 | } // guard should be destroyed here
| ^
repro.cpp:82:41: note: '<anonymous>' was declared here
82 | auto [ec, g] = co_await lock_aw{};
| ^
$ g++-15 -std=c++20 -fcoroutines -O1 -o repro repro.cpp # without -Werror
$ ./repro
dtor_count = 0 BUG
repro.cpp
---------
#include <coroutine>
#include <cstdio>
#include <cstdlib>
#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)) {}
};
struct result {
int ec;
guard g;
template<std::size_t I>
decltype(auto) get() & noexcept
{
if constexpr (I == 0) return (ec);
else return (g);
}
template<std::size_t I>
decltype(auto) get() && noexcept
{
if constexpr (I == 0) return std::move(ec);
else return std::move(g);
}
};
template<std::size_t I>
decltype(auto) get(result& r) noexcept { return r.get<I>(); }
template<std::size_t I>
decltype(auto) get(result&& r) noexcept
{
return std::move(r).template get<I>();
}
namespace std {
template<> struct tuple_size<result>
: integral_constant<size_t, 2> {};
template<> struct tuple_element<0, result>
{ using type = int; };
template<> struct tuple_element<1, result>
{ using type = guard; };
}
static std::coroutine_handle<> g_pending;
struct lock_aw {
bool await_ready() const noexcept { return true; }
void await_suspend(std::coroutine_handle<>) noexcept {}
result await_resume() noexcept { return {0, guard{}}; }
};
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(); }
};
task test_fn()
{
{
auto [ec, g] = co_await lock_aw{};
(void)ec;
co_await yield_aw{};
} // guard should be destroyed here
}
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;
}
Expected Result
---------------
At all optimization levels:
- Clean compilation with -Wall -Werror (no warnings).
- "dtor_count = 1 OK" -- the hidden binding variable's destructor runs at
scope exit, destroying the guard.
This is what GCC 14 does, and what GCC 15 does at -O0.
Actual Result (GCC 15, at -O1 and above)
-----------------------------------------
- False -Wmaybe-uninitialized warning on the hidden binding variable.
The variable is initialized by the co_await expression; the warning
does not appear at -O0 for the same source, nor with GCC 14 at any
optimization level.
- "dtor_count = 0 BUG" -- the destructor of the hidden binding variable
is never called. The guard object leaks.
Analysis
--------
The bug appears to be a regression in GCC 15's handling of tuple-protocol
structured bindings inside coroutine frames under optimization. At -O0, GCC 15
correctly:
- Initializes the hidden binding variable from the co_await result.
- Destroys it when the scope exits.
- Emits no warnings.
At -O1+, an optimization pass loses track of the hidden variable's
lifetime in the coroutine frame. This produces both a false uninitialized
warning (GCC can no longer prove the variable is initialized) and a missing
destructor call (GCC no longer emits cleanup code for it).
Trigger conditions (all required):
- Structured binding from co_await using the tuple protocol (member
get() + std::tuple_size / std::tuple_element specializations).
- The result type contains a member with a non-trivial destructor.
- Optimization level -O1 or higher.
- GCC 15 or later (not present in GCC 14; still present on trunk 16.0.1).
Not affected:
- Aggregate structured bindings (auto [a, b] = co_await expr; where
the result is a plain aggregate without get() / tuple_size).
- Plain "auto x = co_await expr;" (no structured binding).
- Any optimization level with GCC 14.
- -O0 with GCC 15.
Practical Impact
----------------
This bug causes deadlocks in real-world async code. An async mutex's
scoped_lock() returning a tuple-protocol result containing a lock guard
causes the guard's destructor to be skipped, leaving the mutex permanently
locked.