Repository navigation
Fix when_all and when_any completing twice under a racing stop request - #2358
Open
MaxFreedomPollard wants to merge 1 commit into
Open
MaxFreedomPollard wants to merge 1 commit into
MaxFreedomPollard wants to merge 1 commit into
Conversation
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>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
If a stop request from the receiver's environment races with the completion of the last child,
when_allandexec::when_anycan complete their receiver twice. Withinplace_stop_sourcethe usual symptom is a hang: the thread that completed the last child spins forever in~inplace_stop_callback.Reproduction
repro.cpp
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 ininplace_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_allregisters on the receiver's stop token (__when_all.hpp:565-591, added in #1718) does:__count_.fetch_add(1)__state_from__startedto__stopped, and__stop_source_.request_stop()__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. Withinplace_stop_source, A is meanwhile inside~inplace_stop_callbackwaiting for__callback_completed_, which is never set because B's ownreset()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_anyadopted the same guard in #2115 (when_any.hpp:86-103) and has the same race.The guard was a workaround for
inplace_stop_sourcenot tolerating its destruction from inside its ownrequest_stop()(#1889). #1851 described 36a92fd as a misdiagnosis to be reverted once that was fixed, and #2303 fixed it.Fix
when_all: register__forward_stop_request{__state.__stop_source_}, which only callsrequest_stop()on the internal stop source, likeon-stop-requestin [exec.snd.expos] that [exec.when.all] uses. With the callback no longer setting__stopped, also undo the rest of stdexec::when_all: Hang After Stop Request #1851's workaround:__stoppedcan again only come from a child'sset_stopped(so the!_SendsStoppedcase is unreachable, as before stdexec::when_all: Hang After Stop Request #1851), and values are recorded only while the state is__started, as inimpls-for<when_all_t>::complete.exec::when_any: the same callback it had before fix(when_any): guard stop callback against inline completion destroying the opstate #2115.Behavior change for
when_all: when it uses a stop source, a stop request from the environment is now forwarded to the children andwhen_allcompletes withset_stoppedonly if a child does. Before, it completed withset_stoppedwhenever such a request arrived while it was running and some child could sendset_stopped, even if every child then completed with a value. The new behavior is what [exec.when.all] specifies, and whatwhen_allalready did when it doesn't need a stop source.Tests
test/test_common/stop_tokens.hppaddsracing_stop_source, whoserequest_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 insideinplace_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 childandwhen_any completes once when a stop request races with its last childuse it. On main both receivers getset_stoppedtwice. With the fix there is exactly one completion:set_value()forwhen_all,set_stopped()forwhen_any(which reports a stop requested by its receiver).when_all leaves stop requests from the environment to its childrenrequests stop beforestart(), with children that can complete withset_stoppedin that environment but always complete with a value. On mainwhen_allcompletes withset_stopped; with the fix, withset_value().when_any handles stop requests from the environment correctlymirrors the existingwhen_alltest 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 andasync_scopedestroys the operation state before the request returns. It passes before and after this change. Under ASan it reports a heap-use-after-free ifstop_token.hppis taken from before fix use-after-free ininplace_stop_source::request_stop()#2303, and so does the existingwhen_alltest.All of this was tested with Apple clang 21 on macOS only:
test.stdexecandtest.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