From fa1afa007c5f825983a4bad59909fe18da4e2693 Mon Sep 17 00:00:00 2001 From: alwaysprince05 Date: Sun, 11 Oct 2026 22:38:49 +0530 Subject: [PATCH 1/4] fix(exec): own the range in iterate's operation state exec::subscribe(iterate(range), rcvr) built the operation state from ranges::begin/end of the sender's stored range and kept only the iterators, so the operation depended on the sender outliving it. When the range owns its elements (a std::vector, say) they live in the sender: destroying the sender after subscribe leaves the operation holding dangling iterators, and AddressSanitizer reports heap-use-after-free once the operation is started, as traced in #2305. Subscribing an rvalue sender was ill-formed outright, since ranges::begin rejects an rvalue non-borrowed range. The operation state now owns a decayed copy of the range -- copied from an lvalue sender, moved from an rvalue one -- the way the default get-state owns a sender's data, and takes begin/end from that stored copy. The range member is declared before the iterators so they are initialized from it and destroyed before it, and the child item operations in the derived class are destroyed before both. Item types are computed from the decayed range the operation actually iterates, so subscribing a const sender whose range has distinct const iterators is well-formed too. The noexcept of subscribe now covers constructing the owned range in addition to begin/end, and rvalue subscribe over an owning range is well-formed. Three regression tests in test/exec/sequence/test_iterate.cpp fail on main and pass with this change: the operation state outliving the sender (detected deterministically by a range that poisons its storage on destruction, without a sanitizer), rvalue subscribe with a move-only range (which also guards against an always-copy fix), and const lvalue subscribe. Refs #2305. --- include/exec/sequence/iterate.hpp | 105 ++++++++++++++---------- test/exec/sequence/test_iterate.cpp | 122 ++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 43 deletions(-) diff --git a/include/exec/sequence/iterate.hpp b/include/exec/sequence/iterate.hpp index dad01b9b5..25f742e25 100644 --- a/include/exec/sequence/iterate.hpp +++ b/include/exec/sequence/iterate.hpp @@ -50,20 +50,38 @@ namespace experimental::execution { using namespace STDEXEC; - template + // The operation state owns the range it iterates, mirroring what the + // default `get-state` does for a sender's data: the range is copied out of + // an lvalue sender and moved out of an rvalue one. The range member is + // declared first so that the iterators are initialized from the stored + // copy and destroyed before it; the child item operations stored in the + // derived class are destroyed before anything in this base class. + template struct __operation_base_base { - STDEXEC_ATTRIBUTE(no_unique_address) _Iterator __iterator_; - STDEXEC_ATTRIBUTE(no_unique_address) _Sentinel __sentinel_; + constexpr explicit __operation_base_base(_Range __range) + noexcept(__nothrow_move_constructible<_Range> && noexcept(std::ranges::begin(__range_)) + && noexcept(std::ranges::end(__range_)) + && __nothrow_move_constructible, + std::ranges::sentinel_t<_Range>>) + : __range_{static_cast<_Range&&>(__range)} + , __iterator_{std::ranges::begin(__range_)} + , __sentinel_{std::ranges::end(__range_)} + {} + + STDEXEC_ATTRIBUTE(no_unique_address) _Range __range_; + STDEXEC_ATTRIBUTE(no_unique_address) std::ranges::iterator_t<_Range> __iterator_; + STDEXEC_ATTRIBUTE(no_unique_address) std::ranges::sentinel_t<_Range> __sentinel_; trampoline_scheduler __scheduler_{}; }; - template - struct __operation_base : __operation_base_base<_Iterator, _Sentinel> + template + struct __operation_base : __operation_base_base<_Range> { - constexpr explicit __operation_base(_Iterator __it, _Sentinel __sen, _Receiver&& __rcvr) - : __operation_base_base<_Iterator, _Sentinel>{static_cast<_Iterator&&>(__it), - static_cast<_Sentinel&&>(__sen)} + constexpr explicit __operation_base(_Range __range, _Receiver&& __rcvr) + noexcept(noexcept(__operation_base_base<_Range>{static_cast<_Range&&>(__range)}) + && __nothrow_move_constructible<_Receiver>) + : __operation_base_base<_Range>{static_cast<_Range&&>(__range)} , __rcvr_{static_cast<_Receiver&&>(__rcvr)} {} @@ -73,7 +91,7 @@ namespace experimental::execution _Receiver __rcvr_; }; - template + template struct __item_operation { constexpr void start() noexcept @@ -82,28 +100,28 @@ namespace experimental::execution } STDEXEC_IMMOVABLE_NO_UNIQUE_ADDRESS - _ItemRcvr __rcvr_; - __operation_base_base<_Iterator, _Sentinel>* __parent_; + _ItemRcvr __rcvr_; + __operation_base_base<_Range>* __parent_; }; - template + template struct __sender { using sender_concept = STDEXEC::sender_tag; - using completion_signatures = - STDEXEC::completion_signatures)>; + using completion_signatures = STDEXEC::completion_signatures>)>; template _ItemRcvr> constexpr auto connect(_ItemRcvr __rcvr) const & noexcept(__nothrow_decay_copyable<_ItemRcvr>) - -> __item_operation<_Iterator, _Sentinel, _ItemRcvr> + -> __item_operation<_Range, _ItemRcvr> { return {static_cast<_ItemRcvr&&>(__rcvr), __parent_}; } - __operation_base_base<_Iterator, _Sentinel>* __parent_; + __operation_base_base<_Range>* __parent_; }; - template + template struct __next_receiver { using receiver_concept = STDEXEC::receiver_tag; @@ -124,22 +142,22 @@ namespace experimental::execution return STDEXEC::get_env(__op_->__rcvr_); } - __operation_base<_Iterator, _Sentinel, _Receiver>* __op_; + __operation_base<_Range, _Receiver>* __op_; }; - template - struct __operation final : __operation_base<_Iterator, _Sentinel, _Receiver> + template + struct __operation final : __operation_base<_Range, _Receiver> { - using __item_sender_t = __result_of, - __sender<_Iterator, _Sentinel>>; - using __next_receiver_t = __next_receiver<_Iterator, _Sentinel, _Receiver>; + using __item_sender_t = + __result_of, __sender<_Range>>; + using __next_receiver_t = __next_receiver<_Range, _Receiver>; using __next_sender_t = next_sender_of_t<_Receiver, __item_sender_t>; - constexpr explicit __operation(_Iterator __iterator, _Sentinel __sentinel, _Receiver&& __rcvr) - : __operation_base<_Iterator, _Sentinel, _Receiver>{static_cast<_Iterator&&>(__iterator), - static_cast<_Sentinel&&>(__sentinel), - static_cast<_Receiver&&>(__rcvr)} + constexpr explicit __operation(_Range __range, _Receiver&& __rcvr) + noexcept(noexcept(__operation_base_base<_Range>{static_cast<_Range&&>(__range)}) + && __nothrow_move_constructible<_Receiver>) + : __operation_base<_Range, _Receiver>{static_cast<_Range&&>(__range), + static_cast<_Receiver&&>(__rcvr)} {} constexpr void __start_next() noexcept final @@ -156,7 +174,7 @@ namespace experimental::execution STDEXEC::connect, exec::set_next(this->__rcvr_, exec::sequence(STDEXEC::schedule(this->__scheduler_), - __sender<_Iterator, _Sentinel>{this})), + __sender<_Range>{this})), __next_receiver_t{this})); } STDEXEC_CATCH_ALL @@ -178,16 +196,15 @@ namespace experimental::execution struct __subscribe_fn { template - constexpr auto operator()(__ignore, _Range&& __range) - noexcept(noexcept(std::ranges::begin(static_cast<_Range&&>(__range))) - && noexcept(std::ranges::end(static_cast<_Range&&>(__range))) - && __nothrow_move_constructible, - std::ranges::sentinel_t<_Range>, - _Receiver>) + constexpr auto operator()(__ignore, _Range&& __range) noexcept( + __nothrow_decay_copyable<_Range> + && noexcept(__operation<__decay_t<_Range>, _Receiver>{static_cast<_Range&&>(__range), + static_cast<_Receiver&&>(__rcvr_)})) { - return __operation{std::ranges::begin(static_cast<_Range&&>(__range)), - std::ranges::end(static_cast<_Range&&>(__range)), - static_cast<_Receiver&&>(__rcvr_)}; + // The operation state owns a decayed copy of the range so that its + // iterators never point into the sender, which may be a temporary. + return __operation<__decay_t<_Range>, _Receiver>{static_cast<_Range&&>(__range), + static_cast<_Receiver&&>(__rcvr_)}; } _Receiver& __rcvr_; @@ -204,20 +221,22 @@ namespace experimental::execution } template - using __sender_t = __sender, std::ranges::sentinel_t<_Range>>; + using __sender_t = __sender<_Range>; using __completion_sigs = completion_signatures; + // The operation state iterates a decayed, non-const copy of the + // sequence's range (see `__subscribe_fn` above), so the item types are + // computed from that copy rather than from the sender's data, whose + // cvref qualification can differ. template using __item_sender_t = __result_of, - __sender_t<__data_of<_Sequence>>>; + __sender_t<__decay_t<__data_of<_Sequence>>>>; template - using _NextReceiver = __next_receiver>, - std::ranges::sentinel_t<__data_of<_Sequence>>, - _Receiver>; + using _NextReceiver = __next_receiver<__decay_t<__data_of<_Sequence>>, _Receiver>; template using _NextSender = next_sender_of_t<_Receiver, __item_sender_t<_Sequence>>; diff --git a/test/exec/sequence/test_iterate.cpp b/test/exec/sequence/test_iterate.cpp index 454a97301..e19167a02 100644 --- a/test/exec/sequence/test_iterate.cpp +++ b/test/exec/sequence/test_iterate.cpp @@ -19,9 +19,12 @@ #include "stdexec/execution.hpp" #include +#include #include +#include #include #include +#include namespace { @@ -225,4 +228,123 @@ namespace } #endif // !STDEXEC_NO_STDCPP_EXCEPTIONS() + // A forward range over a fixed array that poisons its storage when + // destroyed, so that a read through an iterator that dangles into a + // destroyed sender is caught deterministically, without a sanitizer. + struct poison_on_destroy_range + { + static constexpr int poison = -1; + + int values_[3]{}; + + explicit poison_on_destroy_range(std::initializer_list list) noexcept + { + std::size_t index = 0; + for (int value: list) + { + values_[index++] = value; + } + } + + poison_on_destroy_range(poison_on_destroy_range const &) = default; + + ~poison_on_destroy_range() + { + for (int& value: values_) + { + value = poison; + } + } + + int* begin() noexcept + { + return values_; + } + + int* end() noexcept + { + return values_ + 3; + } + + int const * begin() const noexcept + { + return values_; + } + + int const * end() const noexcept + { + return values_ + 3; + } + }; + + // A forward range whose elements cannot be copied: a fix that always + // copies the range instead of moving out of an rvalue sender fails to + // compile against this range. + struct move_only_range + { + std::vector values_; + + explicit move_only_range(std::initializer_list list) + : values_(list) + {} + + move_only_range(move_only_range&&) = default; + move_only_range(move_only_range const &) = delete; + + auto begin() noexcept + { + return values_.begin(); + } + + auto end() noexcept + { + return values_.end(); + } + + auto begin() const noexcept + { + return values_.begin(); + } + + auto end() const noexcept + { + return values_.end(); + } + }; + + TEST_CASE("iterate - operation state outlives the sequence sender", "[sequence_senders][iterate]") + { + using seq_t = decltype(exec::iterate(poison_on_destroy_range{})); + + int sum = 0; + std::optional sndr; + sndr.emplace(exec::iterate(poison_on_destroy_range{10, 20, 30})); + auto op = exec::subscribe(*sndr, sum_receiver<>{.sum_ = sum}); + sndr.reset(); // the sender is gone; the operation state must not depend on it + STDEXEC::start(op); + CHECK(sum == 60); + } + + TEST_CASE("iterate - rvalue subscribe moves an owning range", "[sequence_senders][iterate]") + { + int sum = 0; + auto op = exec::subscribe(exec::iterate(move_only_range{1, 2, 3}), sum_receiver<>{.sum_ = sum}); + STDEXEC::start(op); + CHECK(sum == 6); + } + + TEST_CASE("iterate - const lvalue subscribe copies an owning range", + "[sequence_senders][iterate]") + { + using seq_t = decltype(exec::iterate(poison_on_destroy_range{})); + + int sum = 0; + std::optional sndr; + sndr.emplace(exec::iterate(poison_on_destroy_range{1, 2, 3})); + auto op = exec::subscribe(std::as_const(*sndr), sum_receiver<>{.sum_ = sum}); + sndr.reset(); + STDEXEC::start(op); + CHECK(sum == 6); + } + } // namespace From 807190f52eb5148cb0d9c2ca946dd505aee6bef6 Mon Sep 17 00:00:00 2001 From: alwaysprince05 Date: Mon, 12 Oct 2026 00:13:57 +0530 Subject: [PATCH 2/4] fix(exec): compute iterate's sentinel from the owned range The operation state stores the range, so it no longer needs a sentinel member next to it: the end is computed from the owned range on demand. The constructor still evaluates ranges::end once, without keeping the result. #2319's "transform_each - subscribe propagates range exceptions" test requires a throwing end() to propagate out of subscribe; computing the sentinel lazily would instead throw from the noexcept start and terminate. Also fixes the clang-format violation in __sender that fails the style check. --- include/exec/sequence/iterate.hpp | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/include/exec/sequence/iterate.hpp b/include/exec/sequence/iterate.hpp index 25f742e25..431809436 100644 --- a/include/exec/sequence/iterate.hpp +++ b/include/exec/sequence/iterate.hpp @@ -53,25 +53,32 @@ namespace experimental::execution // The operation state owns the range it iterates, mirroring what the // default `get-state` does for a sender's data: the range is copied out of // an lvalue sender and moved out of an rvalue one. The range member is - // declared first so that the iterators are initialized from the stored - // copy and destroyed before it; the child item operations stored in the - // derived class are destroyed before anything in this base class. + // declared first so that the iterator is initialized from the stored copy + // and destroyed before it; the child item operations stored in the + // derived class are destroyed before anything in this base class. The + // sentinel is not stored separately; it is computed from the owned range + // on demand. template struct __operation_base_base { constexpr explicit __operation_base_base(_Range __range) noexcept(__nothrow_move_constructible<_Range> && noexcept(std::ranges::begin(__range_)) && noexcept(std::ranges::end(__range_)) - && __nothrow_move_constructible, - std::ranges::sentinel_t<_Range>>) + && __nothrow_move_constructible>) : __range_{static_cast<_Range&&>(__range)} , __iterator_{std::ranges::begin(__range_)} - , __sentinel_{std::ranges::end(__range_)} - {} + { + // Validate the sentinel eagerly so that an `end()` that throws still + // propagates out of `subscribe` (as + // "transform_each - subscribe propagates range exceptions" requires) + // rather than out of the noexcept `start`, which would terminate. The + // result is not stored; the sentinel is recomputed from the owned + // range on demand. + (void) std::ranges::end(__range_); + } STDEXEC_ATTRIBUTE(no_unique_address) _Range __range_; STDEXEC_ATTRIBUTE(no_unique_address) std::ranges::iterator_t<_Range> __iterator_; - STDEXEC_ATTRIBUTE(no_unique_address) std::ranges::sentinel_t<_Range> __sentinel_; trampoline_scheduler __scheduler_{}; }; @@ -107,7 +114,7 @@ namespace experimental::execution template struct __sender { - using sender_concept = STDEXEC::sender_tag; + using sender_concept = STDEXEC::sender_tag; using completion_signatures = STDEXEC::completion_signatures>)>; @@ -162,7 +169,7 @@ namespace experimental::execution constexpr void __start_next() noexcept final { - if (this->__iterator_ == this->__sentinel_) + if (this->__iterator_ == std::ranges::end(this->__range_)) { STDEXEC::set_value(static_cast<_Receiver&&>(this->__rcvr_)); } From 4ee2abc24b8d2a17d2d3e7d85bca0dff7047f9a4 Mon Sep 17 00:00:00 2001 From: alwaysprince05 Date: Tue, 13 Oct 2026 02:12:37 +0530 Subject: [PATCH 3/4] fix(exec): cache iterate's sentinel when end() can throw end() not throwing in the constructor does not mean it will not throw when __start_next calls it again: that call sits in a noexcept function, so a throwing end() would terminate the program instead of being reported anywhere. The sentinel is therefore cached whenever ranges::end is potentially throwing -- evaluated once, in the constructor, where the exception still escapes subscribe, as "transform_each - subscribe propagates range exceptions" requires. When end() is noexcept there is nothing to guard against: the end is recomputed from the owned range on each comparison, and an __ignore placeholder occupies the sentinel's slot in the operation state, so no extra state is stored for the common case. Checked with a range whose end() succeeds on its first call and throws on every call after it: on the previous commit the second call, from the noexcept __start_next, terminates the program; with this change end() is called once and the iteration completes. test.exec (394 cases) and test.stdexec (628 cases) pass in Release and Debug with STDEXEC_ENABLE_EXTRA_TYPE_CHECKING on, and the header is clean under clang-format 21. --- include/exec/sequence/iterate.hpp | 37 ++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/include/exec/sequence/iterate.hpp b/include/exec/sequence/iterate.hpp index 431809436..160cdb97b 100644 --- a/include/exec/sequence/iterate.hpp +++ b/include/exec/sequence/iterate.hpp @@ -56,29 +56,46 @@ namespace experimental::execution // declared first so that the iterator is initialized from the stored copy // and destroyed before it; the child item operations stored in the // derived class are destroyed before anything in this base class. The - // sentinel is not stored separately; it is computed from the owned range - // on demand. + // sentinel is cached only when `ranges::end` can throw; when it cannot, + // an empty `__ignore` stands in for it and the end is recomputed from the + // range on each comparison. template struct __operation_base_base { + // A potentially-throwing `end()` is evaluated exactly once, in the + // constructor, so that it escapes `subscribe`; calling it again from + // the noexcept `__start_next` would terminate instead. An `end()` that + // is noexcept needs no such guard. + static constexpr bool __cache_sentinel = !noexcept(std::ranges::end(std::declval<_Range&>())); + constexpr explicit __operation_base_base(_Range __range) noexcept(__nothrow_move_constructible<_Range> && noexcept(std::ranges::begin(__range_)) && noexcept(std::ranges::end(__range_)) && __nothrow_move_constructible>) : __range_{static_cast<_Range&&>(__range)} , __iterator_{std::ranges::begin(__range_)} + , __sentinel_{std::ranges::end(__range_)} + {} + + [[nodiscard]] + constexpr bool + __at_end() noexcept(noexcept(std::declval&>() + == std::declval&>())) { - // Validate the sentinel eagerly so that an `end()` that throws still - // propagates out of `subscribe` (as - // "transform_each - subscribe propagates range exceptions" requires) - // rather than out of the noexcept `start`, which would terminate. The - // result is not stored; the sentinel is recomputed from the owned - // range on demand. - (void) std::ranges::end(__range_); + if constexpr (__cache_sentinel) + { + return __iterator_ == __sentinel_; + } + else + { + return __iterator_ == std::ranges::end(__range_); + } } STDEXEC_ATTRIBUTE(no_unique_address) _Range __range_; STDEXEC_ATTRIBUTE(no_unique_address) std::ranges::iterator_t<_Range> __iterator_; + STDEXEC_ATTRIBUTE(no_unique_address) + std::conditional_t<__cache_sentinel, std::ranges::sentinel_t<_Range>, __ignore> __sentinel_; trampoline_scheduler __scheduler_{}; }; @@ -169,7 +186,7 @@ namespace experimental::execution constexpr void __start_next() noexcept final { - if (this->__iterator_ == std::ranges::end(this->__range_)) + if (this->__at_end()) { STDEXEC::set_value(static_cast<_Receiver&&>(this->__rcvr_)); } From 547d3bc7ac6bbe262f7b54708cd83b24fd8564d6 Mon Sep 17 00:00:00 2001 From: alwaysprince05 Date: Tue, 13 Oct 2026 04:48:17 +0530 Subject: [PATCH 4/4] fix(exec): use STDEXEC_IMMOVABLE_NO_UNIQUE_ADDRESS in iterate operation state The range, iterator, and sentinel members of __operation_base_base are part of an operation state, so the plain no_unique_address attribute is not the right macro for them: on gcc it must be dropped for immovable types (gcc#98995). STDEXEC_IMMOVABLE_NO_UNIQUE_ADDRESS does exactly that -- it expands to nothing on gcc and to [[no_unique_address]] elsewhere -- and is what the rest of the codebase uses for operation state members. clang-format 21 clean; test.exec (394 cases) and test.stdexec (628 cases) pass in Release and Debug with STDEXEC_ENABLE_EXTRA_TYPE_CHECKING on. --- include/exec/sequence/iterate.hpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/include/exec/sequence/iterate.hpp b/include/exec/sequence/iterate.hpp index 160cdb97b..e8203abbc 100644 --- a/include/exec/sequence/iterate.hpp +++ b/include/exec/sequence/iterate.hpp @@ -92,9 +92,11 @@ namespace experimental::execution } } - STDEXEC_ATTRIBUTE(no_unique_address) _Range __range_; - STDEXEC_ATTRIBUTE(no_unique_address) std::ranges::iterator_t<_Range> __iterator_; - STDEXEC_ATTRIBUTE(no_unique_address) + STDEXEC_IMMOVABLE_NO_UNIQUE_ADDRESS + _Range __range_; + STDEXEC_IMMOVABLE_NO_UNIQUE_ADDRESS + std::ranges::iterator_t<_Range> __iterator_; + STDEXEC_IMMOVABLE_NO_UNIQUE_ADDRESS std::conditional_t<__cache_sentinel, std::ranges::sentinel_t<_Range>, __ignore> __sentinel_; trampoline_scheduler __scheduler_{}; };