Skip to content

exec::merge_each never completes when the sender of a nested sequence completes with set_error or set_stopped #2445

Closed

Description

@0z5a

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) and set_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).

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions