Repository navigation
fix(exec): own the range in iterate's operation state - #2325
Conversation
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 NVIDIA#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 NVIDIA#2305.
|
/ok to test fa1afa0 |
| std::ranges::sentinel_t<_Range>>) | ||
| : __range_{static_cast<_Range&&>(__range)} | ||
| , __iterator_{std::ranges::begin(__range_)} | ||
| , __sentinel_{std::ranges::end(__range_)} |
There was a problem hiding this comment.
if you are storing the range, you don't need to store the sentinel separately.
There was a problem hiding this comment.
Done — the sentinel member is gone; __start_next now compares against std::ranges::end(__range_).
One thing I kept deliberately: the constructor still evaluates ranges::end(__range_) once without storing it. #2319's throwing_range<false> case in "transform_each - subscribe propagates range exceptions" requires that an end() that throws propagates out of subscribe; since __start_next is noexcept, a purely lazy sentinel would terminate instead. If you'd rather have that reported through set_error on the receiver, I can move the end check into the try block and update that test — let me know.
There was a problem hiding this comment.
hrm. just because end(__range_) didn't throw in the ctor doesn't mean it won't throw in __start_next. maybe it's better the way you had it originally. the alternative would be to only store the sentinel if end(__range_) is potentially throwing.
There was a problem hiding this comment.
Done — the sentinel is now cached exactly when ranges::end is potentially throwing. It's evaluated once, in the constructor's initializer, so a throwing end() still escapes subscribe; when end() is noexcept an __ignore occupies the sentinel's slot in the operation state (nothing extra stored) and __start_next recomputes the end from the owned range.
I checked the case you pointed at with a range whose end() succeeds on its first call and then throws on every later one: on the previous commit the second call, from the noexcept __start_next, terminated the program; now end() is called exactly once and the iteration completes. test.exec and test.stdexec are green in Release and Debug with the extra type checking on.
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. NVIDIA#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.
|
/ok to test c19ce04 |
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.
|
/ok to test 4ee2abc |
| STDEXEC_ATTRIBUTE(no_unique_address) _Range __range_; | ||
| STDEXEC_ATTRIBUTE(no_unique_address) std::ranges::iterator_t<_Range> __iterator_; | ||
| STDEXEC_ATTRIBUTE(no_unique_address) |
There was a problem hiding this comment.
since this is in an operation state, all of these STDEXEC_ATTRIBUTE(no_unique_address) should be STDEXEC_IMMOVABLE_NO_UNIQUE_ADDRESS because of gcc's odd handling of immovable types with no_unique_address members.
There was a problem hiding this comment.
Done — all three members now use STDEXEC_IMMOVABLE_NO_UNIQUE_ADDRESS in 547d3bc.
…on 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.
|
/ok to test 547d3bc |
|
/ok to test 547d3bc |
Part 2 of #2305 (
exec::iteratewith a range that owns its elements). Thewrite_envhalf of that issue landed in #2306; this is the remaining half.Problem
exec::iterate(range)decay-copies the range into the sender, butsubscribebuilds the operation state fromranges::begin/ranges::endof that sender-owned copy and stores only the iterators and sentinel. When the range is a view over caller-owned data (span,subrange) that is fine — the iterators point at the caller's storage. When the range itself owns its elements (astd::vector, say), the elements live in the sender, and destroying the sender aftersubscribereturns leaves the operation holding dangling iterators:AddressSanitizer on main (
7df6de8e) reports heap-use-after-free for this pattern, as traced in #2305. Subscribing an rvalue sequence sender does not compile at all for an owning container:ranges::beginrejects an rvalue non-borrowed range.Fix
__subscribe_fnnow decay-copies the range into the operation state — moved out of an rvalue sender, copied from an lvalue one — the same way the defaultget-stateowns a sender's data, and takesbegin/endfrom that stored copy:__operation_base_baseis now parameterized on the range and declares the range member first, so the iterators are initialized from the stored copy and destroyed before it; the child item operations stored in the derived__operationare destroyed before both.iterate_t's item-type aliases (__item_sender_t,_NextReceiver) compute from__decay_t<__data_of<_Sequence>>— the mutable copy the operation actually iterates — rather than from the sender's data, whose cvref qualification can differ. Subscribing a const sender over a range with distinct const iterators is now well-formed.subscribe'snoexceptnow accounts for constructing the owned range in addition tobegin/end, and rvalue subscribe over an owning range is well-formed.__optionalstill is), so nothing that relied on that changed.Tests
Three regression tests in
test/exec/sequence/test_iterate.cpp, all failing on main and passing with this change:Verification
test.exec(394 cases) andtest.stdexec(628 cases) green in Release and Debug, and withSTDEXEC_ENABLE_EXTRA_TYPE_CHECKINGon.-std=c++20 -Wall -Wextra -Werror) compiles the changed header, the test TU, and both standalone repros.7df6de8e) and runs clean with this change; a deterministic poison-range repro reads the poisoned value (sum=-3) on main and the correct sum with the change.Closes #2305.