This page is a snapshot from the LWG issues list, see the Library Active Issues List for more information and the meaning of New status.

4540. future-senders returned from spawn_future do not forward stop requests to spawned work

Section: 33.9.12.18 [exec.spawn.future] Status: New Submitter: Ian Petersen Opened: 2026-03-10 Last modified: 2026-03-13

Priority: Not Prioritized

View all issues with New status.

Discussion:

The wording that describes spawn_future (specifically 33.9.12.18 [exec.spawn.future] paragraph 13 and paragraph 14) does not capture a critical element of the design intent originally expressed in P3149R11, section 5.5.

P3149R11, section 5.5 reads in part,

When fsop is started, if fsop receives a stop request from its receiver before the eagerly-started work has completed then an attempt is made to abandon the eagerly-started work. Note that it's possible for the eagerly-started work to complete while fsop is requesting stop; once the stop request has been delivered, either fsop completes with the result of the eagerly-started work if it's ready, or it completes with set_stopped() without waiting for the eagerly-started work to complete.

In the foregoing, fsop is the name of an operation state constructed by connecting a future-sender (i.e. a sender returned from spawn_future) to a receiver.

Paragraphs 13 and 14 of 33.9.12.18 [exec.spawn.future] describe the behaviour of the future-sender returned from spawn_future in terms of the basic-sender machinery like so:

-13- The exposition-only class template impls-for (33.9.2 [exec.snd.expos]) is specialized for spawn_future_t as follows:

namespace std::execution {
  template<>
  struct impls-for<spawn_future_t> : default-impls {
    static constexpr auto start = see below;                    // exposition only
  };
}

-14- The member impls-for<spawn_future_t>::start is initialized with a callable object equivalent to the following lambda:

[](auto& state, auto& rcvr) noexcept -> void {
  state->consume(rcvr);
}

Since there's no specification for the behaviour of std::execution::impls-for<spawn_future_t>::get_state, the behaviour is the default provided by std::execution::default-impls::get_state, which just returns the "data" member of the original result of make-sender. In this case, that is the object named u defined in 33.9.12.18 [exec.spawn.future] bullet 16.2, which is an instance of a specialization of std::unique_ptr. There is therefore no wording to require that a future-sender that has been connected and started take any action in response to stop requests received through the receiver to which it was connected, contrary to the LEWG-approved design intent.

An implementation that addresses this issue is included in stdexec PR 1713, specifically in commit 5209ffdcaf9a3badf0079746b5578c12a1d0da4f, which is just the difference between the current wording and the intended design.

Proposed resolution:

This wording is relative to N5032.

  1. Modify 33.9.12.18 [exec.spawn.future] as indicated:

    -2- The name spawn_future denotes a customization point object. For subexpressions sndr, token, and env,

    […]

    If any of sender<Sndr>, scope_token<Token>, or queryable<Env> are not satisfied, the expression spawn_future(sndr, token, env) is ill-formed.

    Let try-cancelable be the exposition-only class:

    namespace std::execution {
      struct try-cancelable {                   // exposition only
        virtual void try-cancel() noexcept = 0; // exposition only
      };
    }
    

    -3- Let spawn-future-state-base be the exposition-only class template: […]

    namespace std::execution {
      template<class Completions>
      struct spawn-future-state-base;           // exposition only
      
      template<class... Sigs>
      struct spawn-future-state-base<completion_signatures<Sigs...>> { // exposition only
        : try-cancelable {
        using variant-t = see below;            // exposition only
        variant-t result;                       // exposition only
        virtual void complete() noexcept = 0;   // exposition only
      };
    }
    

    […]

    -7- Let spawn-future-state be the exposition-only class template:

    namespace std::execution {
      template<class Alloc, scope_token Token, sender Sender, class Env>
      struct spawn-future-state                     // exposition only
        : spawn-future-state-base<completion_signatures_of_t<future-spawned-sender<Sender, Env>>> {
        […]
        void complete() noexcept override;          // exposition only
        void consume(receiver auto& rcvr) noexcept; // exposition only
        void abandon() noexcept;                    // exposition only
        void try-cancel() noexcept override;        // exposition only
        […]
      };
      […]
    }
    

    -8- For purposes of determining the existence of a data race, complete, consume, try-cancel, and abandon behave as atomic operations (6.10.2 [intro.multithread]). These operations on a single object of a type that is a specialization of spawn-future-state appear to occur in a single total order.

    void complete() noexcept;
    

    -9- Effects:

    • (9.1) — No effects if this invocation of complete happens before an invocation of consumetry-cancel, or abandon on *this;

    • (9.2) — otherwise, if an invocation of consume and no invocation of try-cancel on *this happened before this invocation of complete on *this happens before this invocation of complete then there is a receiver, rcvr, registered and that receiver is deregistered and completed as if by consume(rcvr);

    • (9.3) — otherwise, destroy is invoked.

    void consume(receiver auto& rcvr) noexcept;
    

    -10- Effects:

    • (10.1) — If this invocation of consume happens before an invocation of complete on *this and no invocation of try-cancel on *this happened before this invocation of consume then rcvr is registered to be completed when complete is subsequently invoked on *this;

    • (10.?) — otherwise, if this invocation of consume happens after an invocation of try-cancel on *this and no invocation of complete on *this happened before this invocation of consume then rcvr is completed as if by set_stopped(std::move(rcvr));

    • (10.2) — otherwise, rcvr is completed as if by: […]

    void try-cancel() noexcept;
    

    -?- Effects:

    • (?.1) — No effects if this invocation of try-cancel happens after an invocation of complete on *this;

    • (?.2) — otherwise, if this invocation of try-cancel happens before an invocation of consume on *this then invokes ssource.request_stop();

    • (?.3) — otherwise,

      • (?.3.1) — invokes ssource.request_stop(), and

      • (?.3.2) — if there is a receiver, rcvr, still registered then that receiver is deregistered and completed as if by set_stopped(std::move(rcvr)).

      [Note: an invocation of complete on *this may have happened after the just-described invocation of ssource.request_stop() and happened before the check to see if there is a receiver still registered; if so, it would have deregistered and completed the previously-registered receiver. Only one of try-cancel or complete completes the registered receiver and no data races are introduced between the two invocations. — end note]

    void abandon() noexcept;
    

    […]

    void destroy() noexcept;
    

    -12- Effects: Equivalent to:

    auto associated = std::move(this->associated);
    {
      using traits = allocator_traits<Alloc>::template rebind_traits<spawn-future-state>;
      typename traits::allocator_type alloc(std::move(this->alloc));
      traits::destroy(alloc, this);
      traits::deallocate(alloc, this, 1);
    }
    

    Let future-operation be the exposition-only class template:

    
    namespace std::execution {
      template<class StatePtr, class Rcvr>
      struct future-operation {                              // exposition only
        struct callback {                                    // exposition only
          try-cancelable* state;                             // exposition only
          
          void operator()() noexcept {
            state->try-cancel();
          };
        };
    
        using stop-token-t =                                 // exposition only
          stop_token_of_t<env_of_t<Rcvr>>;
    
        using stop-callback-t =                              // exposition only
          stop_callback_for_t<stop-token-t, callback>;
    
        struct receiver {                                    // exposition only
          using receiver_concept = receiver_t;
          future-operation* op;                              // exposition only
    
          template<class... T>
          void set_value(T&&... ts) && noexcept {
            op->set-complete<set_value_t>(std::forward<T>(ts)...);
          }
    
          template<class E>
          void set_error(E&& e) && noexcept {
            op->set-complete<set_error_t>(std::forward<E>(e));
          }
    
          void set_stopped() && noexcept {
            op->set-complete<set_stopped_t>();
          }
    
          env_of_t<Rcvr> get_env() const noexcept {
            return op->rcvr.get_env();
          }
        };
    
        Rcvr rcvr;                                           // exposition only
    
        union {
          StatePtr state;                                    // exposition only
          receiver inner;                                    // exposition only
        };
    
        union {
          stop-callback-t stopCallback;                      // exposition only
        };
    
        future-operation(StatePtr state, Rcvr rcvr) noexcept // exposition only
          : rcvr(std::move(rcvr))
        {
          construct_at(addressof(state), std::move(state));
        }
    
        future-operation(future-operation&&) = delete;
    
        ~future-operation() {
          destroy_at(addressof(state));
        }
    
        void run() & noexcept {                              // exposition only
          constexpr bool nothrow =
              is_nothrow_constructible_v<stop-callback-t, stop-token-t, callback>;
          try {
            construct_at(addressof(stopCallback), get_stop_token(rcvr), callback(state.get()));
          }
          catch (...) {
            if constexpr (!nothrow) {
              set_error(std::move(rcvr), current_exception());
              return;
            }
          }
    
          auto* state = state.release();
          destroy_at(addressof(state));
          construct_at(addressof(inner), this);
          state->consume(inner);
        }
    
        template<class CPO, class... T>
        void set-complete(T&&... ts) noexcept {              // exposition only
          destroy_at(addressof(stopCallback));
          destroy_at(addressof(inner));
          construct_at(addressof(state), nullptr);
          CPO{}(std::move(rcvr), std::forward<T>(ts)...);
        }
      };
    }
    

    -13- The exposition-only class template impls-for (33.9.2 [exec.snd.expos]) is specialized for spawn_future_t as follows:

    namespace std::execution {
      template<>
      struct impls-for<spawn_future_t> : default-impls {
        static constexpr auto start = see below;       // exposition only
        static constexpr auto get-state = see below;   // exposition only
      };
    }
    

    -14- The member impls-for<spawn_future_t>::start is initialized with a callable object equivalent to the following lambda:

    [](auto& state, auto& rcvr) noexcept -> void {
      state.run()->consume(rcvr);
    }
    

    -?- The member impls-for<spawn_future_t>::get-state is initialized with a callable object equivalent to the following lambda:

    []<class Sndr, class Rcvr>(Sndr&& sndr, Rcvr& rcvr) noexcept {
      auto& [_, data] = sndr;
      using state_ptr = remove_cvref_t<decltype(data)>;
      return future-operation<state_ptr, Rcvr>(std::move(data), std::move(rcvr));
    }