Repository navigation
Description
What happens
exec::merge_each never calls its receiver if an item of the input sequence (a sender of a nested sequence) completes with set_error or set_stopped instead of sending a sequence. sync_wait on such an operation blocks forever. The same happens when subscribe on the nested sequence throws, and when a stop request arrives between two nested sequences of merge_each(iterate(...) | transform_each(...)).
Reproducer
Reproduced on main at a8449ab with Apple clang 21 (arm64 macOS), and earlier at f71dc7d with g++ 13.3.0 and clang 20.1.8 (libstdc++) on x86_64 Linux, Debug.
#include <stdexec/execution.hpp>
#include <exec/sequence/ignore_all_values.hpp>
#include <exec/sequence/merge_each.hpp>
#include <cstdio>
namespace ex = stdexec;
int main()
{
try
{
ex::sync_wait(exec::merge_each(ex::just(1)
| ex::then(
[](int i)
{
if (i == 1)
throw 42;
return ex::just(i);
}))
| exec::ignore_all_values());
std::puts("sync_wait returned");
}
catch (int e)
{
std::printf("sync_wait threw %d\n", e);
}
}- Actual: the program hangs in
sync_wait. - Expected:
sync_wait threw 42.
A longer reproducer that connects and starts the operations inline and reports which ones never complete (throwing sender, stopped sender, stop request between nested sequences) prints on main:
sender of nested sequence throws -> NEVER COMPLETED
sender of nested sequence is stopped -> NEVER COMPLETED
stop requested between nested sequences -> NEVER COMPLETED
repro.cpp
// exec::merge_each never completes when the sender of a nested sequence
// completes with set_error or set_stopped instead of producing a sequence.
//
// Build, from the stdexec checkout:
// c++ -std=c++20 -pthread -I include repro.cpp -o repro
// Exit code: 0 if every operation completed, 1 otherwise.
#include <stdexec/execution.hpp>
#include <exec/sequence/ignore_all_values.hpp>
#include <exec/sequence/iterate.hpp>
#include <exec/sequence/merge_each.hpp>
#include <exec/sequence/transform_each.hpp>
#include <exec/variant_sender.hpp>
#include <cstdio>
#include <ranges>
namespace ex = stdexec;
struct receiver
{
using receiver_concept = ex::receiver_tag;
void set_value() noexcept
{
*result = "set_value()";
}
template <class Error>
void set_error(Error&&) noexcept
{
*result = "set_error(...)";
}
void set_stopped() noexcept
{
*result = "set_stopped()";
}
auto get_env() const noexcept
{
return ex::prop{ex::get_stop_token, token};
}
char const ** result;
ex::inplace_stop_token token{};
};
template <class Sender>
bool run(char const * what, Sender&& sndr, ex::inplace_stop_token token = {})
{
char const * result = nullptr;
{
auto op = ex::connect(static_cast<Sender&&>(sndr), receiver{&result, token});
ex::start(op);
}
// Everything above runs inline, so the receiver has been called by now, or
// it never will be.
std::printf("%-58s -> %s\n", what, result ? result : "NEVER COMPLETED");
return result != nullptr;
}
int main()
{
bool ok = true;
// 1. The sender of the nested sequence completes with set_error.
ok &= run("sender of nested sequence throws",
exec::merge_each(ex::just(1)
| ex::then(
[](int i)
{
if (i == 1)
throw 42;
return ex::just(i);
}))
| exec::ignore_all_values());
// 2. The sender of the nested sequence completes with set_stopped.
using maybe_sequence_t =
exec::variant_sender<decltype(ex::just(ex::just(0))), decltype(ex::just_stopped())>;
ok &= run("sender of nested sequence is stopped",
exec::merge_each(
ex::just(1)
| ex::let_value([](int) noexcept -> maybe_sequence_t { return ex::just_stopped(); }))
| exec::ignore_all_values());
// 3. Stop is requested while the second of three nested sequences runs. The
// sender of the third nested sequence then completes with set_stopped.
ex::inplace_stop_source stop_source;
int count = 0;
ok &= run("stop requested between nested sequences",
exec::merge_each(
exec::iterate(std::views::iota(0, 3))
| exec::transform_each(
ex::then([](int) noexcept { return exec::iterate(std::views::iota(0, 3)); })))
| exec::transform_each(ex::then(
[&](int) noexcept
{
if (++count == 4)
stop_source.request_stop();
}))
| exec::ignore_all_values(),
stop_source.get_token());
return ok ? 0 : 1;
}Where it goes wrong
include/exec/sequence/merge_each.hpp, __receive_nested_sequence:
- the catch block in
set_value(line 1004) andset_error(line 1013) call__op_->nested_sequence_break(); set_stopped(line 1018) calls__op_->nested_sequence_complete().
Those update the bookkeeping of the merge_each operation but do not complete the next receiver of the input sequence. __receive_nested_values goes through __next_seq_op_->nested_sequence_complete()/nested_sequence_break() instead (lines 746, 753, 758), which complete the next receiver first. #2175 fixed error propagation for nested values, nested sequences and the outer sequence; this is the remaining position.
Possible fix
Call the __next_seq_op_ functions in those three places. A PR with this change and regression tests follows. One choice in it is open: when the sender is stopped and no stop was requested, the patch skips that item and keeps merging the rest; ending the input sequence instead would be a one-line change.
AI disclosure: this bug was found by reading the code with an AI assistant (Claude Code, claude-fable-5-2), and reproduced with the programs above by Claude Code (re-run on a8449ab with claude-opus-5-5).
Activity