Skip to content

fix(exec): own the range in iterate's operation state - #2325

Merged
ericniebler merged 5 commits into
NVIDIA:mainfrom
alwaysprince05:fix/iterate-own-range-2305
Oct 13, 2026
Merged

ericniebler merged 5 commits into
NVIDIA:mainfrom
alwaysprince05:fix/iterate-own-range-2305

Conversation

@alwaysprince05

Copy link
Copy Markdown
Contributor

Part 2 of #2305 (exec::iterate with a range that owns its elements). The write_env half of that issue landed in #2306; this is the remaining half.

Problem

exec::iterate(range) decay-copies the range into the sender, but subscribe builds the operation state from ranges::begin/ranges::end of 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 (a std::vector, say), the elements live in the sender, and destroying the sender after subscribe returns leaves the operation holding dangling iterators:

std::optional<op_t> op;
{
  std::vector<int> v(64);
  seq_t seq = exec::iterate(std::move(v)); // the sender owns the vector
  op.emplace(stdexec::__emplace_from{[&] {
    return exec::subscribe(seq, rcvr{});
  }});
} // the sender is destroyed here; the op still holds iterators into its vector
stdexec::start(*op);  // reads freed memory

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::begin rejects an rvalue non-borrowed range.

Fix

__subscribe_fn now decay-copies the range into the operation state — moved out of an rvalue sender, copied from an lvalue one — the same way the default get-state owns a sender's data, and takes begin/end from that stored copy:

  • __operation_base_base is 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 __operation are 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's noexcept now accounts for constructing the owned range in addition to begin/end, and rvalue subscribe over an owning range is well-formed.
  • The operation state stays immovable (its child __optional still 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:

  • subscribing from a named sender over a range that poisons its storage on destruction, destroying the sender, then starting the operation state — the poisoned values make the dangling read fail deterministically, without a sanitizer;
  • rvalue subscribe with a move-only range, which also guards against a naive always-copy fix (a copy cannot be taken of a move-only range);
  • the same from a const lvalue sender.

Verification

  • test.exec (394 cases) and test.stdexec (628 cases) green in Release and Debug, and with STDEXEC_ENABLE_EXTRA_TYPE_CHECKING on.
  • gcc 12 in Docker (-std=c++20 -Wall -Wextra -Werror) compiles the changed header, the test TU, and both standalone repros.
  • The issue's ASAN repro reports heap-use-after-free on main (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.

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.
@copy-pr-bot

copy-pr-bot Bot commented Oct 11, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@alwaysprince05

Copy link
Copy Markdown
Contributor Author

/ok to test fa1afa0

std::ranges::sentinel_t<_Range>>)
: __range_{static_cast<_Range&&>(__range)}
, __iterator_{std::ranges::begin(__range_)}
, __sentinel_{std::ranges::end(__range_)}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you are storing the range, you don't need to store the sentinel separately.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@alwaysprince05

Copy link
Copy Markdown
Contributor Author

/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.
@alwaysprince05

Copy link
Copy Markdown
Contributor Author

/ok to test 4ee2abc

Comment thread include/exec/sequence/iterate.hpp Outdated
Comment on lines +95 to +97
STDEXEC_ATTRIBUTE(no_unique_address) _Range __range_;
STDEXEC_ATTRIBUTE(no_unique_address) std::ranges::iterator_t<_Range> __iterator_;
STDEXEC_ATTRIBUTE(no_unique_address)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@alwaysprince05

Copy link
Copy Markdown
Contributor Author

/ok to test 547d3bc

@ericniebler

Copy link
Copy Markdown
Collaborator

/ok to test 547d3bc

@ericniebler
ericniebler merged commit 7e6de64 into NVIDIA:main Oct 13, 2026
39 checks passed
@alwaysprince05
alwaysprince05 deleted the fix/iterate-own-range-2305 branch October 13, 2026 06:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sequence operation states that don't retain sender-owned data: write_env over a sequence (#2294) and exec::iterate

2 participants