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.

Reply via email to