Skip to content

Fix when_all and when_any completing twice under a racing stop request - #2358

Open
MaxFreedomPollard wants to merge 1 commit into
NVIDIA:mainfrom
MaxFreedomPollard:fix/when-all-stop-race
Open

MaxFreedomPollard wants to merge 1 commit into
NVIDIA:mainfrom
MaxFreedomPollard:fix/when-all-stop-race

Conversation

@MaxFreedomPollard

Copy link
Copy Markdown
Contributor

If a stop request from the receiver's environment races with the completion of the last child, when_all and exec::when_any can complete their receiver twice. With inplace_stop_source the usual symptom is a hang: the thread that completed the last child spins forever in ~inplace_stop_callback.

Reproduction

repro.cpp
#include <stdexec/execution.hpp>

#include <atomic>
#include <cstdio>
#include <functional>
#include <thread>

namespace ex = stdexec;

// A child that completes with set_value when the test calls `complete`.
struct manual_sender {
  using sender_concept = ex::sender_tag;
  using completion_signatures = ex::completion_signatures<ex::set_value_t(), ex::set_stopped_t()>;

  template <class Rcvr>
  struct op {
    Rcvr rcvr;
    std::function<void()>* complete;
    void start() & noexcept { *complete = [this] { ex::set_value(std::move(rcvr)); }; }
  };

  template <class Rcvr>
  auto connect(Rcvr rcvr) const -> op<Rcvr> { return {std::move(rcvr), complete}; }

  std::function<void()>* complete;
};

struct counting_receiver {
  using receiver_concept = ex::receiver_tag;
  void set_value() && noexcept { ++*count; }
  void set_stopped() && noexcept { ++*count; }
  auto get_env() const noexcept { return ex::prop{ex::get_stop_token, source->get_token()}; }

  std::atomic<int>* count;
  ex::inplace_stop_source* source;
};

int main() {
  for (int i = 0; i < 1'000'000; ++i) {
    ex::inplace_stop_source source;
    std::atomic<int> count{0};
    std::function<void()> complete;
    auto op = ex::connect(ex::when_all(ex::just(), manual_sender{&complete}),
                          counting_receiver{&count, &source});
    ex::start(op);
    std::thread requester{[&] { source.request_stop(); }};
    complete(); // the last child completes while the stop request may be running
    requester.join();
    if (count != 1)
      std::printf("iteration %d: %d completions\n", i, count.load());
  }
  std::puts("done");
}

On main (8398d5a, Apple clang 21, -O2) this hangs on every run; in five runs it got stuck between iterations 15,000 and 271,000. With a counter added to the spin loop in inplace_stop_source::__remove_callback_ (stop_token.hpp:468), the hung thread turns out to be waiting there for a callback that the other thread has already destroyed. With this PR the 1,000,000 iterations finish (about 25 s here) with one completion each. exec::when_any(manual_sender{&complete}) hangs the same way.

Cause

The callback that when_all registers on the receiver's stop token (__when_all.hpp:565-591, added in #1718) does:

  1. __count_.fetch_add(1)
  2. CAS __state_ from __started to __stopped, and __stop_source_.request_stop()
  3. __arrive(), i.e. if (1 == __count_.fetch_sub(1)) __complete();

Suppose the last child arrives on thread A while request_stop() on thread B has already taken the callback but has not reached step 1. A drops the count to 0 and calls __complete(), which destroys the callback (__on_stop_.reset(), line 620). B's step 1 raises the count from 0 to 1 and step 3 drops it back to 0, so B calls __complete() as well: the receiver is completed twice and the callback is destroyed twice. With inplace_stop_source, A is meanwhile inside ~inplace_stop_callback waiting for __callback_completed_, which is never set because B's own reset() marked the callback as removed while it was running, so A spins forever (reading freed memory once the receiver frees the operation state). exec::when_any adopted the same guard in #2115 (when_any.hpp:86-103) and has the same race.

The guard was a workaround for inplace_stop_source not tolerating its destruction from inside its own request_stop() (#1889). #1851 described 36a92fd as a misdiagnosis to be reverted once that was fixed, and #2303 fixed it.

Fix

Behavior change for when_all: when it uses a stop source, a stop request from the environment is now forwarded to the children and when_all completes with set_stopped only if a child does. Before, it completed with set_stopped whenever such a request arrived while it was running and some child could send set_stopped, even if every child then completed with a value. The new behavior is what [exec.when.all] specifies, and what when_all already did when it doesn't need a stop source.

Tests

  • test/test_common/stop_tokens.hpp adds racing_stop_source, whose request_stop() takes the registered callback and holds it until another thread is waiting in that callback's destructor, the way a thread can be preempted inside inplace_stop_source::request_stop(). That makes the interleaving above deterministic. Like any stop callback, its destructor blocks while the callback runs on another thread and doesn't block when the callback destroys itself.
  • when_all completes once when a stop request races with its last child and when_any completes once when a stop request races with its last child use it. On main both receivers get set_stopped twice. With the fix there is exactly one completion: set_value() for when_all, set_stopped() for when_any (which reports a stop requested by its receiver).
  • when_all leaves stop requests from the environment to its children requests stop before start(), with children that can complete with set_stopped in that environment but always complete with a value. On main when_all completes with set_stopped; with the fix, with set_value().
  • when_any handles stop requests from the environment correctly mirrors the existing when_all test from Fix: when_all cancelled from environment #1718 for the case fix(when_any): guard stop callback against inline completion destroying the opstate #2115 guarded against: both children complete inside the stop request and async_scope destroys the operation state before the request returns. It passes before and after this change. Under ASan it reports a heap-use-after-free if stop_token.hpp is taken from before fix use-after-free in inplace_stop_source::request_stop() #2303, and so does the existing when_all test.

All of this was tested with Apple clang 21 on macOS only: test.stdexec and test.exec (Debug) pass in full, and the two touched test files also pass under ASan+UBSan and TSan and compile cleanly as C++23 and C++26 with -Wall -Wextra -Wpedantic -Werror. I have not built with GCC, MSVC, nvc++ or CUDA.

🤖 Generated with Claude Code

The stop callbacks that when_all (since NVIDIA#1718) and exec::when_any (since
NVIDIA#2115) register on the receiver's stop token increment the count of
outstanding children, request stop, and then arrive. If the last child
arrives on another thread while such a callback is running, the count is
already zero, so the callback's arrival completes the operation a second
time. With inplace_stop_source the thread of the last child usually
spins forever in ~inplace_stop_callback instead, waiting for a callback
that the other thread has already destroyed.

The extra count only worked around inplace_stop_source not tolerating
its destruction from inside its own request_stop() (NVIDIA#1889), which NVIDIA#2303
fixed. Make both callbacks only forward the request to the internal stop
source, like on-stop-request in [exec.when.all]. For when_all this also
undoes the rest of the workaround from NVIDIA#1851: it completes with
set_stopped only when a child does, and it no longer records values once
a child has failed or been stopped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@copy-pr-bot

copy-pr-bot Bot commented Oct 22, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant