https://gcc.gnu.org/bugzilla/show_bug.cgi?id=127320

            Bug ID: 127320
           Summary: [15/16/17 Regression] coroutine frame refcount
                    (_Coro_frame_refcount) is updated non-atomically by
                    ramp and actor; races when the handle is resumed on
                    another thread from await_suspend
           Product: gcc
           Version: 15.3.0
            Status: UNCONFIRMED
          Keywords: wrong-code
          Severity: normal
          Priority: P3
         Component: c++
          Assignee: unassigned at gcc dot gnu.org
          Reporter: saxonp at nvidia dot com
  Target Milestone: ---
            Target: x86_64-linux-gnu

Created attachment 65558
  --> https://gcc.gnu.org/bugzilla/attachment.cgi?id=65558&action=edit
Recreate file

GCC 15.2 and later (including 16.x and trunk) give every coroutine frame a
16-bit _Coro_frame_refcount. The ramp and the actor both update this field
with plain, non-atomic read-modify-write operations. A conforming
callback-driven awaiter publishes its coroutine handle from await_suspend,
so the completing thread can resume the actor before the ramp has
returned. The resuming thread's decrement at the actor's
coro.delete.promise label then races the ramp's decrement on the same
two-byte field, with no happens-before edge between them: the user's
release-store of the handle is sequenced before the actor returns to the
ramp, so the resumer's acquire orders the actor's increment, but nothing
can order the ramp's later decrement.

This is not only a formal data race. If both sides read the same count and
write the same decremented value, one decrement is lost, the count never
reaches zero, and the frame leaks. Each side also re-reads the field for
its own "== 0" checks (frame delete, promise destructor, parameter-copy
destructors), so both threads can observe zero and both run the cleanups:
a double free. Both outcomes were observed without a sanitizer (see
"Consequence without a sanitizer" below). The same race is reachable when
another thread calls destroy() on the suspended handle, since destroy()
enters the same label.

AFFECTED VERSIONS

The field was introduced on trunk by r16-1564-g43e408f675f8e9 ("c++,
coroutines: CWG2563 promise lifetime extension [PR115908]", 2025-06-18,
also closing PR118074 and PR95615) and cherry-picked to the 15 branch as
b4da8ee3e613502b50501773f5ecb494d3a51c8b on 2025-07-29, so it first
shipped in 15.2.0. 15.1.0 does not have the field.

Known to fail: 15.2.0, 15.3.0, 16.1.0, 16.2.0. The refcount-touching
statements in gcc/cp/coroutines.cc are identical between releases/gcc-15
and master as of 2026-09-08, and the file uses no atomic built-in
anywhere. Two later changes touch the field without adding
synchronization: the PR121961 reordering (2026-04-15, in 15.3.0) and the
zero-initialisation of the ramp's counter for the promise-constructor-
throws path (trunk 2026-04-03, 15 branch 2026-07-10).

Known to work: 13.3.0, 14.4.0, 15.1.0 (checked with the reproducer below
on 2026-09-10: ThreadSanitizer clean in 3 of 3 runs, zero leaked frames,
no frame-offset-20 traffic in the generated code). Clang 17+ is also
clean.

WHERE IN THE SOURCE (releases/gcc-15 at 2026-09-08)

build_ramp_function: the ramp stores 1 into the field after the parameter
copies and the promise are constructed, calls the actor, and on return
runs scope cleanups: ~_Coro_gro, then "_Coro_frame_refcount =
_Coro_frame_refcount - 1", then three independently guarded cleanups
"if (_Coro_frame_refcount == 0)" for the promise destructor, each
parameter-copy destructor, and operator delete(frame), each re-loading the
field.

wrap_original_function_body: the actor increments the field before the
initial await.

build_actor_fn, label coro.delete.promise (reached when the final await
does not suspend, or from destroy() at any suspend point): "_Coro_frame_
refcount = _Coro_frame_refcount - 1; if (_Coro_frame_refcount != 0)
return;" followed by the promise/parameter destructors and the frame
delete.

All of these are ordinary MODIFY_EXPRs on short_unsigned_type_node built
with cp_build_modify_expr (PLUS_EXPR / MINUS_EXPR).

REPRODUCER

Attached: coroutine_frame_refcount_race.cpp. Standalone translation unit,
no external headers. It runs two loops through the same worker thread and
the same release-store/acquire-load pair. The control loop publishes its
handle only after the ramp has returned and is clean everywhere. The
second loop publishes from await_suspend and fails on affected compilers.

  g++ -std=c++20 -O1 -g -pthread -fsanitize=thread -fno-omit-frame-pointer \
      coroutine_frame_refcount_race.cpp -o probe
  TSAN_OPTIONS=halt_on_error=1:exitcode=1:abort_on_error=0 ./probe

(If the ThreadSanitizer binary dies with a segmentation fault or "FATAL:
ThreadSanitizer: unexpected memory mapping" before printing anything, that
is a TSan/ASLR incompatibility of the host, seen in Docker and WSL2, not
this bug. Run it under "setarch x86_64 -R".)

EXPECTED

Both loops complete and the process exits 0, as it does with 13.3.0,
14.4.0, 15.1.0 and Clang 17+. Output ends with:

  coroutine_frame_refcount_race: control (publish after the ramp) ok
  coroutine_frame_refcount_race: ok

OBSERVED (g++ (GCC) 15.3.0, x86_64, docker.io/library/gcc:15; 3 of 3 runs)

  coroutine_frame_refcount_race: control (publish after the ramp) ok
  WARNING: ThreadSanitizer: data race (pid=15)
    Read of size 2 at 0x7208000017f4 by thread T1:
      #0 publishes_during_the_ramp coroutine_frame_refcount_race.cpp:121
      #1 std::__n4861::coroutine_handle<void>::resume() const
         /usr/local/include/c++/15.3.0/coroutine:142
      #2 worker coroutine_frame_refcount_race.cpp:163
    Previous write of size 2 at 0x7208000017f4 by main thread:
      #0 publishes_during_the_ramp coroutine_frame_refcount_race.cpp:117
      #1 main coroutine_frame_refcount_race.cpp:255
    Location is heap block of size 32 at 0x7208000017e0 allocated by main
    thread
  SUMMARY: ThreadSanitizer: data race coroutine_frame_refcount_race.cpp:121
    in publishes_during_the_ramp

The racing address is the frame heap block plus 20. DWARF for this frame
at -O1 -g: +16 _Coro_promise (2), +18 _Coro_resume_index (2),
+20 _Coro_frame_refcount (2), +22 _Coro_frame_needs_free (1),
+23 _Coro_initial_await_resume_called (1). The same was originally
observed with c++ (Ubuntu 15.2.0-16ubuntu1) 15.2.0.

GENERATED CODE

g++ -std=c++20 -O1 -pthread -S (15.3.0, x86_64) emits the refcount traffic
in both the ramp and the actor as plain loads and stores at frame offset
20, with no lock prefix or fence anywhere near them:

  movzwl  20(%rdi), %eax
  ...
  movw    %ax, 20(%rdi)
  addw    $1, 20(%rdi)

The only locked instruction in the object is the program's own
std::atomic counter.

CONSEQUENCE WITHOUT A SANITIZER

Attached: refcount_leak_counter.cpp counts frame allocations against frees
through promise_type::operator new/delete, using the racy loop only.

  g++ -std=c++20 -O0 -g -pthread refcount_leak_counter.cpp -o leak
  ./leak 200000

With 15.3.0 at -O0, three runs gave:
  iterations=200000 frame_allocs=200000 frame_frees=199999 leaked=1
  double free or corruption (fasttop)   (then Aborted)
  iterations=200000 frame_allocs=200000 frame_frees=200000 leaked=0

At -O1 the decremented value is kept in a register for the zero tests, so
the double-free mode disappears on that build and only the lost-update
leak remains possible (five runs of 200000 happened to show none). That is
an optimisation artefact, not a guarantee. 14.4.0 and 15.1.0 report
leaked=0 in every run at both levels.

NOTES

Searched before filing. These are related but not this bug: PR121961
(await_suspend destroys the just-suspended coroutine; deterministic,
single-threaded ordering, fixed 2026-04-15 without adding
synchronization), PR121219 (operator new heap-use-after-free, fixed
2025-07-25), PR115908 / PR118074 / PR95615 (the lifetime bugs the refcount
was introduced to fix), PR124594 (await_suspend resumes then throws),
PR125210 / PR125213 / PR126884 (promise lifetime when unhandled_exception
rethrows). No report describes the cross-thread race on the refcount
itself, and no patch touching it exists on trunk as of 2026-09-08.

A fix presumably needs the increments and decrements to be atomic
read-modify-writes (release on decrement, acquire before running the
cleanups), with the cleanups keyed off the returned pre-decrement value
rather than re-loading the field.

Reply via email to