When split(scheduler, sender) is used and the predecessor has already
completed (predecessor_done == true) by the time add_continuation() is
called by a new subscriber, the downstream completion signal was fired
inline on whatever thread called add_continuation(). This violated the
P2300 get_completion_scheduler<CPO> contract, which requires that
completions on the set_value and set_stopped signals are dispatched by
the scheduler passed to split.
The code itself acknowledged this with a TODO comment:
// TODO: Should this preserve the scheduler? It does not
// if we call set_* inline.
Fix:
* Add a virtual schedule_completion(continuation_type&&) method to
shared_state with a default implementation that fires inline (preserving
existing behaviour for the scheduler-free case).
* Replace the two "fire inline" paths inside add_continuation (the
predecessor_done fast-path and the lock-then-done path) with calls to
schedule_completion, so all completion dispatch goes through a single
overridable hook.
* Add shared_state_scheduler<Sched> — a new subclass of shared_state —
that overrides schedule_completion to post the continuation through
schedule(sched). The operation state is kept alive via a self-owning
intrusive_ptr-based holder (mirroring the pattern in start_detached.hpp),
so the async lifetime is correct regardless of how quickly the thread
pool processes the work item.
* Add a second constructor overload to split_sender for generic
(non-run_loop) schedulers that allocates shared_state_scheduler instead
of plain shared_state.
* Add algorithm_split_scheduler unit test that covers:
- Basic split with no scheduler (regression guard)
- split with thread_pool_scheduler: late subscriber receives value on
the pool, not inline
- Multiple concurrent late subscribers all receive the value
- ensure_started (eager submission) is unaffected
No behavioural change for the scheduler-free split or the run_loop split;
only the generic-scheduler path gains the new subclass.
Signed-off-by: arpittkhandelwal <arpitkhandelwal810@gmail.com>
Problem Statement
This PR addresses a P2300 correctness violation in the split algorithm. Previously, the split sender failed to preserve the associated scheduler when a receiver connected after the predecessor had already completed (the "late subscriber" scenario). In these cases, the completion signal was fired inline, bypassing the execution context guaranteed by the sender's completion scheduler.
Proposed Changes
Virtualized Completion Hooks: Introduced a virtual void schedule_completion(continuation_type&&) method to the shared_state base class. This allows subclasses to reroute completion signals through the appropriate execution context.
Scheduler-Aware shared_state: Implemented shared_state_scheduler, a new subclass that captures the attached scheduler. Overrode schedule_completion to dispatch stored continuations via hpx::execution::experimental::schedule(sched).
Safe Asynchronous Management: Implemented a self-owning schedule_op_holder to manage the lifetime of the schedule() operation state.
Memory Safety: Adopted the standard HPX allocator pattern, rebinding the shared_state allocator to handle internal task metadata without raw new/delete.
Race Prevention: Added an intrusive_ptr owner guard before calling start() to prevent use-after-free if a scheduler executes synchronously.
CPO & Dispatch Refactoring: Updated split_t overloads to support generic schedulers, enabling both automatic scheduler discovery and explicit injection.
Cleaned up constructor SFINAE in split_sender to handle no_scheduler, run_loop_scheduler, and generic Scheduler types without ambiguity.
Verification Results
New Test Suite: Added algorithm_split_scheduler.cpp which specifically targets the late-subscriber race condition.
Regression Testing: Verified that the legacy no_scheduler and run_loop paths remain unaffected.
Performance: Used HPX_NO_UNIQUE_ADDRESS and intrusive pointers to keep metadata overhead at an absolute minimum.