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.

Reply via email to