Repository navigation
Description
Summary
Using co_await (ex::get_scheduler() | ex::let_value([](auto sch) { return ex::schedule(sch); })); in a task leads to a premature exit of sync_wait and a subsequent use-after-free of the coroutine.
This is a regression introduced with 482c260 #2078
Tested with Clang and GCC on x86_64
Workaround
auto sch = co_await ex::get_scheduler();
co_await (ex::just(sch) | ex::let_value([](auto sch) { return ex::schedule(sch); }));Reproducer
https://godbolt.org/z/WE9fs5WPv
#include <coroutine>
#include <stdexec/execution.hpp>
#include <exec/task.hpp>
namespace ex = STDEXEC;
void foo()
{
int order[2] = {0, 0};
size_t idx = 0;
ex::sync_wait(
[&]() -> ex::task<void> /* or exec::task<void> */ {
order[idx++] = 1;
co_await (ex::get_scheduler() | ex::let_value([](auto sch) { return ex::schedule(sch); }));
order[idx++] = 2;
}());
}
void foo_workaround()
{
int order[2] = {0, 0};
size_t idx = 0;
ex::sync_wait(
[&]() -> ex::task<void> /* or exec::task<void> */ {
order[idx++] = 1;
auto sch = co_await ex::get_scheduler();
co_await (ex::just(sch) | ex::let_value([](auto sch) { return ex::schedule(sch); }));
order[idx++] = 2;
}());
}
int main()
{
foo(); // crashes with SIGSEGV probaly use after free
foo_workaround();
}
/* my usage:
namespace Tools
{
// Give control back to the scheduler.
struct Yield : ex::sender_adaptor_closure<Yield>
{
auto operator()() const
{
return ex::get_scheduler() | ex::let_value([](auto sch) { return ex::schedule(sch); });
}
template<class Sndr>
auto operator()(Sndr sndr) const;
};
inline constexpr Yield yield{};
template<class Sndr>
auto Yield::operator()(Sndr sndr) const
{
return std::move(sndr) | ex::let_value([](auto... value) {
return ex::when_all(ex::just(std::move(value)...), Tools::yield());
});
}
}
void foo_intended_use()
{
int order[4] = {0, 0, 0, 0};
size_t idx = 0;
ex::sync_wait(
ex::when_all(
[&]() -> ex::task<void> /* or exec::task<void> */ {
order[idx++] = 1;
auto sch = co_await ex::get_scheduler();
co_await Tools::yield();
order[idx++] = 2;
}(),
[&]() -> ex::task<void> /* or exec::task<void> */ {
order[idx++] = 3;
auto sch = co_await ex::get_scheduler();
co_await Tools::yield();
order[idx++] = 4;
}()));
assert(order[0] == 1);
assert(order[1] == 3);
assert(order[2] == 2);
assert(order[3] == 4);
}
*/Analysis
We analyzed this on 307b83c5689ea7c2e5b31561cdc428697705333e and fee4d651494014610a277540f209cae56011e47f with our intended code, but without the when_all.
We debugged it and it seems, that after the co_await Tools::yield() the sync_wait returns, because __coroutine_unhandled_stopped is called. This leads then to the destruction of the coroutine handle and then a use-after-free once the run_loop wants to execute the rest of the task:
include/stdexec/__detail/__as_awaitable.hpp
// When the sender is known to complete inline, we can connect and start the operation
// in await_suspend.
template <class _Promise, sender_in<env_of_t<_Promise&>> _Sender>
requires __completes_inline<_Sender, env_of_t<_Promise&>>
struct __sender_awaiter<_Promise, _Sender>
: __sender_awaiter_base<__value_t<_Sender, _Promise>, true>
{
...
auto await_suspend([[maybe_unused]] __std::coroutine_handle<> __continuation)
-> __std::coroutine_handle<>
{
STDEXEC_ASSERT(this->__continuation_.handle() == __continuation);
{
auto __opstate = STDEXEC::connect(static_cast<_Sender&&>(__sndr_), __receiver_t(*this));
// The following call to start will complete synchronously, writing its result
// into the __result_ variant.
STDEXEC::start(__opstate); // yields continuation was added here
}
return this->__get_continuation(); // sync_wait will return after this.
}
...
};
...
[[nodiscard]]
constexpr auto __get_continuation() const noexcept -> __std::coroutine_handle<>
{
// If the operation was stopped (__result_ is valueless), we should use the
// unhandled_stopped() continuation. Otherwise, should resume the __continuation_
// as normal.
if (__result_.__is_valueless()) // after Tools::yield() __result_ is considered valueless because set_value was not called
{
return STDEXEC::__coroutine_unhandled_stopped(__continuation_); // sync_wait returns because of this
}
else
{
return __continuation_.handle();
}
}edit: added variant with exec::task<void> instead of stdexec::task<void>
Activity
Cra3z commentedon Jun 2, 2026
There is a
stack-use-after-return, see https://godbolt.org/z/Taqqc8hjT.Note:
stdexec/include/stdexec/__detail/__let.hpp
Lines 655 to 659 in 02d671d
I think this is the same underlying issue that #2077 is addressing.
The failure does not appear to be caused directly by
sync_waitor by the stopped path. The earlier problem is that, before #2077, thelet_*sender's attributes effectively forwarded the predecessor sender's attrs. For example:Here the predecessor is
read_env(get_scheduler), whosecompletion behavioris always__completion_behavior::__inline_completion. Sincelet_valueforwarded that metadata, the whole composed sender could be classified as satisfying__as_awaitable::__completes_inline.That classification is incorrect: the secondary sender returned by the
let_valuefunction isschedule(sch), which may complete asynchronously.See:
stdexec/include/stdexec/__detail/__as_awaitable.hpp
Lines 369 to 381 in 02d671d
Once
as_awaitabletakes the inline-specialized path, it creates the operation state for the whole composed sender as a local variable inawait_suspend().When that composed operation is started,
let_valuefirst completes the predecessor inline and then starts the secondary sender,schedule(sch). The operation state for that secondary schedule operation is stored as a subobject inside the composedlet_valueoperation state.See:
stdexec/include/stdexec/__detail/__run_loop.hpp
Lines 148 to 154 in 02d671d
The schedule operation can then enqueue a pointer to its own nested operation state into the
run_loopused bysync_wait. Ifawait_suspend()returns before the queued operation completes, the outer local operation state is destroyed, and the queued scheduler node may point to a subobject that has already gone out of scope.The approach in #2077 looks like the right fix to me: compute the
let_*sender's attributes from the transformed completion information of the secondary senders, rather than simply exposing the predecessor's attrs. With that change, this composed sender should no longer be treated as fully inline when the secondaryscheduleoperation can complete asynchronously.ReneOschmann commentedon Sep 15, 2026
We figured out a Sender-Receiver implementation, which seems to work with our use-cases. Not sure if entirely correct. The idea is simple. We wrap the original receiver in a forward receiver, which checks the
stop_tokenon the sender's (yield's)set_valuecompletion and translate it into aset_stoppedrather than continuing with the next continuation.We additionally need to advertise
completion_behavior_t<stdexec::set_value_t>asexec::completion_behavior::asynchronous_affine. Otherwise the original sender will still be executed.This works on fee4d65 and 6bac4e1
alwaysprince05 commentedon Oct 5, 2026
No longer reproduces for me against current main (
f4c123f7, Apple clang 21,-O2): 200 runs of the repro from this issue all complete in order, and it's clean under ASAN and TSAN.I bisected it: still broken at
9a301946~1(hangs instead of completing), works at9a301946— "fix(read_env): advertise inline_completion only for the tags it sends". That matches the analysis above:get_scheduler()was advertising inline completion for tags it doesn't send, so the co_await took the completes-inline path in__sender_awaiter, and the inline completion made the awaiter treat its result as valueless and bail through__coroutine_unhandled_stopped.Looks closable. @ReneOschmann — worth a quick check on your side in case it still shows up on x86_64 Linux; I could only test arm64 macOS.
ReneOschmann commentedon Oct 15, 2026
@alwaysprince05 : Sorry for the late reply I tested on 6bac4e1 which contains 9a30194 and it does not work in my scenario on x86_64 Linux and the above implementation. One correction I have to make. We were using
exec::taskrather thanstdexec::task(I'll make the edit above)Also the current state of #2216 doesn't seem to work. I will try to port our stuff from
exec::tasktostdexec::taskmaybe that's the culprit.alwaysprince05 commentedon Oct 15, 2026
Thanks for the correction —
exec::taskvsstdexec::taskwas a real gap in what I tested. I went back and tested both types explicitly.exec::taskturns out not to be the differentiator: both task implementations route the co_await throughSTDEXEC::as_awaitable(stdexec::taskat__task.hpp:889,exec::taskattask.hpp:605), so theread_envchange applies to both. The reason it is fixed is that__completes_inlineneeds all three tagsinline-or-never-sent, andread_envno longer claimsset_stopped_t— which it never sends.let_valueforwards the predecessor's environment, so that claim was what made the whole composition look inline.Concretely, on
0ad49535(arm64 macOS, Apple clang 21,-O2) and in an emulated x86_64 Linux gcc-13 container:exec::task<void>: 200/200 in order, ASAN-clean;Tools::yieldscenario, one task and two tasks underwhen_all, for both task types: 200/200, with the interleaving you assert in the issue;482c2605(SIGSEGV), so they do reach the buggy path.Two things I could not test: native x86_64 Linux with your toolchain (mine is emulated amd64), and the custom sender with the hand-written
Attrsfrom your snippet, which is truncated in the paste.6bac4e1is also 119 commits behind current main, so a retest pinned to a current SHA would say more than my results do.I'm not treating this as closable on my evidence — an x86_64 report beats an emulated non-reproduction. If you can point at the yield path and tell me whether it segfaults or hangs, and on which SHA, I'll take that and dig in.
test: pin read_env's completion-behavior claims for get_scheduler() (N…
ReneOschmann commentedon Oct 16, 2026
Thanks for following up.
It is always a bit of effort for us to upgrade externals. I did however manually apply the current state of #2216 on top of that. I will test with newer SHA's as soon as I have time.
Just for the record, I am currently testing on x86_64 with clang19 and we use c++20
I updated the comment with the definition of the operator just for completeness.
I think last I tried, the segfault itself didn't happen, but the the assertions outlined in my initial example failed i.e. the interleaving was not happening. We always took this as indication, that the problematic inline code-path is involved and the crash just doesn't happen by chance. If you think this should be treated as a separate issue, I can also split it off. If that is actually intended behaviour then I would be happy to get educated on the why, because then my understanding of the
schedule()is lacking.test: pin read_env's completion-behavior claims for get_scheduler() (#…
WillTrojak commentedon Oct 29, 2026
@mhoemmen @ericniebler would you be able to take a look at this?
alwaysprince05 commentedon Oct 31, 2026
@ReneOschmann thanks, that helps. I retried the interleaving case specifically, this time closer to your setup: clang 19.1.7 on x86_64 Linux (an emulated amd64 container, so not quite native), -std=c++20 with ASan, plus arm64 macOS. I ran it against plain 6bac4e1 and against current main (7ea2b1b).
I ran 200 iterations each of the original expression from the issue, your foo_intended_use (two tasks under when_all, get_scheduler then Tools::yield()), and the
just(7) | Tools::yieldoverload that goes through when_all(just(value...), yield()). I tried both exec::task and stdexec::task. All of them came out 1,3,2,4 with nothing from ASan. The same test does blow up under ASan at 482c260, so it is hitting the old bug path.So on 6bac4e1 by itself I get the interleaving you expect. The one difference left from your build is #2216 applied on top. Could you try your case on 6bac4e1 without #2216? If the order comes back right, the problem is coming from #2216 and not this issue. If it still fails, a small godbolt repro would help a lot, since godbolt has x86_64 clang with stdexec trunk.
mhoemmen commentedon Nov 3, 2026
@WillTrojak Hi! We'll take a look at this. Thanks for reporting this issue!