Skip to content

Automatic storage - #2401

Draft
ispeters wants to merge 32 commits into
NVIDIA:mainfrom
ispeters:automatic-storage
Draft

ispeters wants to merge 32 commits into
NVIDIA:mainfrom
ispeters:automatic-storage

Conversation

@ispeters

@ispeters ispeters commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

This stack of commits explores some ideas related to defining "automatic storage" for async operations, and defining a family of class templates that allow you to express operation states with the "typestate pattern".

The automatic storage exploration is, I think, a pretty clear win; it's just a prototype so there's a bunch of inefficiency and silliness in the implementation, but I think that can be cleaned up. The basic idea is that you can push<T>() to get back some well-aligned storage for a T, into which you can then construct that T, which is likely an operation state. push also returns a new "allocator" that is a copy of the old one with the just-allocated space for a T removed. This functions like the machine stack, but it's backed by a user-selectable "slab allocator", which defaults to new_delete_resource.

The typestate pattern exploration is a bit more radical. The win is that you can write algorithms without directly managing sub-operation state lifetimes; the loss is that it might be too late to propose as a standard interface. The example reimplements just, then, let_value, sequence, and repeat_n in the typestate pattern using a few different lifetime management strategies that are selected with tag types. None of the algorithm implementation do any explicit lifetime management; instead, let_value, for example, transitions from running the predecessor to running the successor with a phase_.template transition<From, To>(…) call, which manages lifetimes, handles errors, etc. internally. One of the lifetime management strategies invents a new CPO called bind, which is a variation on connect (the bind CPO is implemented for senders that don't have a .bind member as connect); with bind, repeat_n can avoid O(N) copies of the sender being repeated. Another benefit of the typestate pattern is support for "poisoning" unused bytes, which is an ASAN integration that would allow ASAN to detect misuse of out-of-lifetime sub-operation states; a compile-time flag could switch from overlapping child operation state storage to disjoint storage, allowing more ASAN detections at the cost of potentially significant size increases.

I think both explorations show net-positive outcomes, but I think there are more experimental directions to explore before making a final judgment.

As you can tell by all the Co-authored by: trailers, Claude Opus 5.5 wrote all this code at my direction. I haven't reviewed it very closely, yet, as I consider this an exploratory prototype, not a real proposal.

@copy-pr-bot

copy-pr-bot Bot commented Nov 2, 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.

@ispeters
ispeters force-pushed the automatic-storage branch 3 times, most recently from e6f528c to dfedf2e Compare November 3, 2026 16:53
ispeters and others added 26 commits November 3, 2026 18:57
Add an example exploring whether senders can get the equivalent of
automatic storage: LIFO storage, offered through the environment, that an
operation pushes its child's operation state onto when it can't nest it
by value.

`automatic_storage` is a copyable cursor into a strand's chain of slabs,
drawn from a `std::pmr::memory_resource`. `with_automatic_storage` roots a
strand, `indirect` pushes its child's operation state as type erasure
would, `strand` forks a new chain, and `reserve_async_stack` offers room
in its own operation state before falling back.

The demo shows sequential `let_value` phases reusing one address with no
change to `let_value`, nested frames spilling into a second slab that is
then reused, `when_all`'s children overlapping unless each gets its own
strand, and the inline-first fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Extend the automatic storage example with operation states that hold one
phase at a time, in storage sized for the largest phase. Which phase is
live isn't recorded: each phase's child completes through a receiver
whose type names the phase. A transition happens inside the live phase's
completion: move what the next phase needs onto the stack, destroy the
live phase, then construct and start the next one in the same storage.
The last phase is torn down the same way before the operation completes,
so the destructor only needs to know whether the operation was started.

`let_value` gets one possible second phase per value signature of its
child. Its arguments live in the second phase, so they reuse the first
phase's bytes: with a 256-byte value, its operation state is 288 bytes
against stdexec's 552.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Give `phased` the transitions the algorithms were writing by hand.
`transition<From, To>(prepare, ...)` runs `prepare` while the dying phase
and the completion's arguments are alive, destroys the phase, constructs
the next one from what `prepare` returned and starts it.
`finish<Phase>(...)` and `finish_with<Phase>(fn, ...)` end the last phase
and complete. All three handle exceptions themselves, so `just`, `then`,
`let_value` and `sequence` no longer contain any placement new, explicit
destruction, liveness flags or `try`.

State that lives across phases goes in a carried region instead of being
passed from phase to phase, so `then`'s and `let_value`'s functions no
longer move at transitions. `phased` records the live phase in a byte,
for its destructor and its exception paths.

Add a test that makes each move of a value throw in turn, to exercise
every failure point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Make two policies explicit, per algorithm instance.

How an operation ends: `eager_teardown` destroys the last phase before
completing, moving the results onto the stack first, so a completed
operation holds nothing live. `forward_results` completes from inside the
last phase with the results as they came, as stdexec's algorithms do, and
leaves the phase to the destructor.

Where state that crosses a transition lives: `carried_layout`, the
default, keeps `let_value`'s values and `sequence`'s second sender in the
carried region, where they're constructed in place. `overlapped_layout`
keeps them in the phases, reusing the earlier phase's bytes at the cost
of a move through the stack.

Compare operation-state sizes and moves of a value against stdexec. With
`forward_results` and the default layout, none of the four algorithms
costs more than stdexec's. Run the throwing-move test under both endings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When built with AddressSanitizer, `phased` poisons the storage no phase
occupies, so code that touches a phase after it has ended, such as a child
that touches its own state after completing, is reported instead of
silently using dead memory. Bytes the next phase reuses are live again, so
transitions catch accesses only beyond the next phase's size, and under
`forward_results` the last phase is still alive when its child returns.

Add a deliberately broken child, built only with
`-DFRAMES_SHOW_USE_AFTER_FINISH`, to show the report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add a third policy. `connect_entry`, the default, enters an operation's
first phase in `connect`, as before. `start_entry` keeps the first
phase's constructor arguments from `connect` until `start`, which enters
it, so a child whose `connect` throws makes `start` complete with
`set_error` instead of making `connect` throw.

Give every exception path in `phased` its own knowledge of which phase,
if any, is live, so the byte recording the live phase now serves only
the destructor. With `eager_teardown` and `start_entry`, no phase is ever
live when the operation state is destroyed, so `phased` drops that byte
and its destructor destroys only the receiver, the carried region and the
first phase's arguments.

Measure the new combination. Entering at `start` roughly doubles
operation-state sizes, because every level keeps its child sender until
`start` and the child operation keeps its own copy of the same inputs.

Construct the kept arguments directly from `phased`'s constructor
arguments rather than from a prvalue: GCC doesn't elide that copy into a
`[[no_unique_address]]` member (gcc#98995).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under `start_entry`, `connect` now moves the sender into a small `root`
operation that stores it and binds to it. Each typestate sender gains a
member `bind(rcvr) &` returning an operation that refers to the sender:
it uses the sender's function and values in place and binds its children
to its own child senders, so the stored sender is the carried state of
the whole tree and nothing below the root copies it again. A free
`bind(sndr, rcvr)` falls back to `connect(std::move(sndr), rcvr)` for
senders without a member `bind`, so stdexec senders mix in. Senders that
`let_value`'s function returns are created at run time, so their phase
owns them.

`phased` no longer keeps the first phase's arguments: the first phase
needs only the operation, which `start` passes to `begin`.

With binding, eager teardown and start-time entry move values no more
often than stdexec's algorithms in every measured pipeline; `let_value`
is 8 bytes larger than stdexec's. Each bound level still holds a pointer
to its sender, which doubles `then(just(1), fn)`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cost table compared `sequence` with a `let_value` that returns a
captured sender, but stdexec has `exec::sequence`, which stores its
second sender beside a variant of the two children's operations, as
`carried_layout` does. Compare with it instead. It requires every sender
but the last to send no values, so the size row's first sender drops its
big value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Give each typestate sender a second member, `bind(rcvr) const &`, which
binds without consuming: the operation only reads the stored sender, so
the sender is unchanged afterwards and can be bound again. `then` calls
its function as `const`, `just` copies its values as it sends them, and
`let_value` and `sequence` bind their children the same way, so a whole
subtree is read-only. The free `bind` takes the non-consuming path for a
const sender; a sender without the member is connected as a const
lvalue, which copies. Completion signatures follow the sender's
constness.

Add `repeat_n(sndr, n)`: one phase, re-entered for each iteration, which
binds the stored child without consuming it each time. With bound
children it never copies what the child only reads: over 100 iterations
of a `then` whose function captures a counted value, it makes no copies
and 3 moves, where `exec::repeat_n` makes 100 copies, since it reconnects
a copy of its child every iteration. It doesn't trampoline yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bound operation stored a pointer to the sender it was bound to, beside
its receiver's pointer to the parent operation. Drop it: the parent
already knows which of its child senders each phase binds, so
`phase_receiver` asks it (`bound_sender()`), and the root binds with a
receiver that points at the stored sender. The tree now holds one
pointer to a sender, at the root. Each member `bind` asserts that its
receiver leads back to the sender being bound.

With eager teardown and start-time entry, `then(just(1), fn)` drops from
48 to 32 bytes against stdexec's 24, and `sequence` from 576 to 560,
matching `exec::sequence`. Reaching a sender's data now walks up the
receivers instead of loading one pointer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add a third layout. `carried_layout` keeps `let_value`'s values in the
carried region, so they're constructed in place but live as long as the
operation state, behind a `std::variant` whose index says again what the
live phase already says. `overlapped_layout` makes them part of the
second phase, so they end with it, but they reuse the first phase's
bytes and so have to cross on the stack.

`scoped_layout` separates the two: the values belong to the second phase
but sit beside the phases. `phased` lets a phase name such *scoped*
state as `scoped_t` and keeps it in a second region, sized for the
largest. A transition into that phase returns `with_scoped` from
`prepare`, and the state is constructed from its first part while the
dying phase and the completion's arguments are still alive, then
destroyed and poisoned right after the phase it belongs to. One region
holds one phase's state at a time, so consecutive phases can't both have
any, and a first phase, which follows none, can hold its own.

It costs what `carried_layout` costs in moves, and in bytes less the
variant's index where alignment doesn't absorb it. Under eager teardown
the values are destroyed before the operation completes, and a child
that reads them after completing is reported by AddressSanitizer. A new
demo section shows when the values are destroyed under each layout and
ending, and the exception sweep, which only covered `carried_layout`,
runs again under the other two.

`sequence`'s second sender exists from `connect`, before any phase that
could own it, so `scoped_layout` carries it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under `overlapped_layout`, `prepare` built the values on the stack and
then moved them into a `std::tuple` holding the arguments for the second
phase's constructor, so they moved twice before reaching that phase.
Return the values' tuple itself as those arguments instead, and let the
constructor take them one by one, so each value moves once from the
stack. That brings `overlapped_layout` to one move more than
`carried_layout`, as the README says it costs; update its move counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The demo printed operation-state sizes and move counts as two tables,
and the README copied them, so seeing what a policy trades meant
matching rows across them. Print one table instead, in which each
policy heads a bytes column and a moves (copies) column, and lay out the
README's the same way. The two halves of a row are measured on slightly
different pipelines, so name the rows generically and say what each
half measures. Markdown tables can't head two columns with one label, so
the README's table is preformatted ASCII, like the demo's output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`let_value` chose where its values live under each layout itself: a
variant in its carried region, a member of its second phase reached
through the stack, or `phased`'s scoped region. Make that `phased`'s
choice instead. A phase names the values it receives as `received_t`, the
transition into it returns `hand_over(vs...)` from `prepare`, and the
phase reads them through `received<Phase>()`. `phased` takes the layout
as a policy and places the values beside the phases for the whole
operation, in the phases' bytes just before the phase, or beside the
phases for as long as the phase, constructing, moving and destroying
them as each layout requires. `let_value` no longer mentions a layout,
and the other algorithms, which hand nothing over, pass the default.

Moves are unchanged. `let_value`'s second phase loses an empty member
that shared its type with an empty member at the start of its child's
operation state, which had cost 8 bytes of padding: carried with
`start_entry` drops from 560 bytes to 552, scoped from 552 to 544, and
carried with libc++ from 544 to 536, the same as with libstdc++.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The entry policy chose two things at once: when an operation enters its
first phase, and whether its sender's data is moved into its phases
(`connect_entry`) or stays in a sender stored once at the root, which
its operations bind to (`start_entry`). Leave it only the first. Every
phase-based sender's `connect` now stores the sender in a `root` and
binds to it, and the algorithms no longer branch on the entry: `phased`
gains `on_connect`, which the operation calls from its constructor and
which enters the first phase under `connect_entry`, as `begin` does under
`start_entry`. The operation calls it from its constructor body, so the
first phase's children, which find their senders through the operation,
see a fully constructed `phased`.

`sequence` then keeps both senders in the stored sender whatever the
layout, so it loses its layout parameter, and `then`, `let_value` and
`repeat_n` keep their functions and children there too.

Values now move once, into the root, under every policy: `let_value`
moves 5 or 4 times where stdexec's moves 6, `sequence` 6 or 4 where
`exec::sequence` moves 10, and `repeat_n` never copies its child.
Operation states grow under `connect_entry`, though: the root's pointer
to the stored sender and the byte recording the live phase take
`then(just(1), fn)` from 24 bytes to 40, and because `just`'s value now
stays in the stored sender instead of in the first phase,
`overlapped_layout` no longer saves anything in `let_value(just(big),
fn)`, which takes 568 bytes under every layout. Under `start_entry`,
`let_value`'s second phase grows by 16 bytes because the sender its
function returns, a `connect_entry` `just`, is now connected through a
root, and `sequence`'s first phase loses an empty member that collided
with its child's, saving 8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`typestate.hpp` held both the machinery the algorithms are written
against and the algorithms themselves, so the size of the one hid how
small the others are. Keep the machinery there and move `just`, `then`,
`let_value`, `sequence` and `repeat_n` into headers of their own, each
including `typestate.hpp`. Nothing else changes.

While here, stop the README calling `let_value`'s function carried: it
stays in the stored sender.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Binding every operation to a sender stored at the root moves each value
once, but keeps every level's data for the whole operation, where no
later phase can reuse its bytes. Make that a choice. Under
`stored_sender`, the default, nothing changes. Under `split_sender`,
`connect` returns the operation itself, which moves each part of the
sender it uses into `phased`, except the child sender its first phase
connects on entry, which goes straight into that child's `connect`, as
stdexec's algorithms do.

The algorithms say what they use rather than where it is. Each lists
the parts of its sender it uses (`parts<&S::sndr_, &S::fn_>`) and each
phase names the part its child is made from (`connects`); a part is read
through `part<M>()`, and `connect_part<M>(rcvr, incoming...)` makes a
child, binding the stored part or connecting the moved one.
`peek<M>(rcvr, incoming...)` reads a part from an operation's
constructor. Each sender declares its data members before its operation
type now, so that the operation's declarations can name them.

The demo gains an eager+split column. It matches the eager column as it
was before every operation bound to a stored sender, except that
`sequence` and `repeat_n` save a move per connect, since the moved parts
are now constructed in place rather than passed through a temporary,
and overlapped `let_value` takes 296 bytes rather than 280, because the
sender the demo's function returns is a default `just`, connected
through a root of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each algorithm took its ending, layout, entry and data policies as
template parameters, so a tree's policies had to be spelled out at every
level, and a sender created at run time, such as the one `let_value`'s
function returns, kept whatever policies its type named, the defaults
unless told otherwise. Bundle the four as `policies<Ending, Layout,
Entry, Data>` and let a forwarding query, `get_policies`, carry them in
the environment: `phased` asks its receiver's environment, completion
signatures ask the environment they're computed for, and an environment
that doesn't answer gets `default_policies`. The algorithms lose their
policy parameters; `with_policies<P>(sndr)` asks for `P` with
`write_env`.

The demo applies the policies at the top of each pipeline instead. A
sender created at run time now sees the same policies as the rest of its
tree, so overlapped `let_value` under eager+split takes 280 bytes rather
than 296. Wrapping a sender to set the environment adds a layer of
`connect`, which in stdexec at this commit moves the sender's data twice
more, so the demo counts moves by connecting each pipeline to a receiver
whose environment asks for the policies, and starting it, rather than
through `sync_wait`. That leaves out the move `sync_wait` made into its
result, so the `sequence` and `then` rows count one move fewer in every
column, stdexec's included; nothing else changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A phase's child saw its operation's receiver's environment and nothing
else, so an algorithm had no way to give its children anything of its
own: a token from a stop source it owns, say, or for children that run
at once, a fork of automatic storage each. Let it. `phase_receiver`
takes the environment's type as a last parameter, and when one is given,
its `get_env` asks the operation for `env(Tag())`, as `bound_sender`
asks for the child's sender. The type is named up front because the
operation is incomplete while its children's types are worked out;
keyed by the tag, it can differ per child. The operation builds it from
the phase, which `live<Phase>()` returns, and `env_with<Query, Value,
Env>` replaces one query of the receiver's environment and carries over
the rest, policies included.

Nothing uses it yet, and an algorithm that names no environment is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under `split_sender`, the part a first phase connects goes straight from
the incoming sender into its child's `connect`, and every other part the
operation uses is moved into `phased` first. A phase with a child for
each of several parts could name only one of them as `connects`, so the
others would move twice. Let it name `parts<&S::a, &S::b>` as well as a
single pointer to a member; every part it names goes straight into
`connect`. Nothing else changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`when_all` is the first phase-based algorithm whose children run at
once, and the first that needs a stop source of its own: when one child
fails or stops, the others must be asked to stop, and the receiver's
stop requests must reach them too. Those lifetimes are easy to get
wrong, since a child can complete, and so end the operation, from inside
the very `request_stop` that asked it to, so they go in the machinery. A
`join_scope` holds the stop source, the callback forwarding the
receiver's stop requests to it, and a count of the children still
running. A child that fails or stops arrives with `stop_and_arrive()`,
which requests stop before counting the child out, so no child can end
the scope from inside its own `request_stop`. The last to arrive drops
the forwarding callback, waiting for one running on another thread,
before the operation finishes. Under `eager_teardown`, finishing ends
the phase, stop source included, perhaps from inside the source's
`request_stop` when the receiver asked to stop, which stdexec's
`inplace_stop_source` allows since NVIDIA#1889. `attempt(fn)` returns what
`fn` threw, so storing a child's values or error needs no `try`.

`when_all` holds one phase with a child per sender, connected on entry,
each seeing the receiver's environment with the phase's stop token in
place of the receiver's. The children's values and the first error stay
in the phase until the last child completes, so `when_all` sends decayed
copies under either ending. Like stdexec's, it accepts children that
send at most one set of values.

The demo adds `when_all` to the cost table: it moves values less often
than stdexec's except under `split_sender`, and takes 64 to 72 more
bytes, mostly an error slot for the `std::exception_ptr` every algorithm
here declares, the root's pointer and the bytes recording live phases.
Section 15 shows a child's error, and a stop request from the receiver,
reaching the other children through the phase's token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The example explores two things: LIFO automatic storage for operation
states, and operation states built from phases. The phase-based
algorithms don't depend on automatic storage, but one demo and one
README covered both, so a change to either part touched the same two
files. Give each part its own demo and its own document, and keep a
short README that introduces both and says how they relate.

`automatic_storage_demo.cpp` keeps the storage sections, followed by the
two that run the phase-based `let_value` under automatic storage: what
was section 8, and the half of section 11 whose bound phases push
frames. `typestate_demo.cpp` keeps the rest, numbered from 1. Each
builds as its own example. Together they print what the single demo
did, apart from section numbers and the new heading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under `eager_teardown`, `finish_with` copies the child's values onto the
stack, ends the phase and then calls the function, so whatever the
function returns may refer to its arguments without dangling once the
phase is gone. But it passed the copies as rvalues whatever the child
sent, while under `forward_results` the function gets the values as
sent. A function overloaded on `std::string&` and `std::string&&` ran a
different overload depending on the ending, and `then` had to ask for
the ending to work out its completion signatures.

Pass each copy in the value category its value arrived in: a value sent
as an lvalue reaches the function as an lvalue of its copy, one sent as
an rvalue as an rvalue. The same overload runs under either ending, and
`then`'s completion signatures no longer depend on it. The copies still
outlive the completion, so a function that returns a view of its
argument still works under eager teardown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Section 10 runs `then` over a child that sends an lvalue referring to a
string it holds, with a function overloaded on `std::string&` and
`std::string&&` that returns a view of its argument, under each ending,
entry and way of keeping the sender's data. Every column runs the lvalue
overload, and the view is still valid when the operation completes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every phase-based sender had the same `connect`, and the same two forms
of `bind` for a stored sender, and every operation had a
`bound_sender(tag)` naming, again, the part of its sender each child is
made from. Move the first into a base, `phased_sender<S>`, and derive
the second from what the operation's types already say.

`phased_sender<S>` supplies `sender_concept`, `connect` and both forms
of `bind`; `phased_sender<S, read_only>` supplies only the form that
binds through a const reference, for `repeat_n`, which binds its stored
child again for every iteration. Its operations are
`S::opstate<Rcvr, Const>`, as they already were, `Const` when bound
through a const reference.

`phase_receiver`'s fourth parameter now points to the part of the
operation's sender its child is made from, instead of naming the child
sender's type, and its `bound_sender()` returns that part, const if the
operation is: the operation's type says which, even while it's still
incomplete. A child made at run time names no part.

The algorithms lose 20 to 40 lines each, and the demo prints what it
did before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ispeters and others added 2 commits November 3, 2026 18:57
The callback that forwards the receiver's stop requests to `when_all`'s
`join_scope` has a lifetime of its own inside the phase: it's registered
when the operation starts, not when it's connected, and it must be gone
before the operation completes, under either ending, since nothing says
the receiver's stop token outlives the operation's run. `join_scope`
kept it in a `std::optional` that `when_all` opened from its starter and
the last child's `arrive()` reset.

Let a phase name what it holds only while it runs as `active_t`,
constructible without throwing from the phase and the receiver's
environment. `phased` enters it just before the phase starts, from
`begin` or a transition, and ends it first thing in whichever transition
or finish the phase ends with. An operation is never destroyed while it
runs, so nothing records whether one is live. It gets a region of its
own beside the phases, poisoned under AddressSanitizer while no phase
runs.

`join_scope` keeps only its stop source and count, and offers
`join_scope::forwarding<Env>`, which `when_all`'s phase holds while it
runs; `when_all`'s starter only starts the children. Without the
`optional`'s flag, `when_all` takes 8 bytes less in three of the demo's
columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`phased` branched on the Entry and Ending policies in several places:
whether `connect` enters the first phase or `start` must, whether the
live phase needs recording, and in each of `finish`, `finish_copies` and
`finish_with`, whether to copy the results out of the phase and end it
before completing. Those are one question asked of two states. An
operation is connected from `connect` until `start`, running until its
receiver is completed, and completed until it's destroyed, and it's only
destroyed in the first state or the last. Entry says whether being
connected holds the first phase; Ending says whether being completed
holds the phase the operation completed from.

Say so. `phased` derives all of the above from `connected_holds_phase`
and `completed_holds_phase`, and records the live phase only when either
holds one. Every completion and every failure enters the completed state
through `settle<Phase>()`. The three finishes share one `complete`,
which copies what the receiver gets while the phase is alive when the
completed state won't hold it, or when the caller asks for copies, and
turns whatever completing throws into `set_error`.

Nothing the algorithms see changes, and the demo prints what it did
before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ispeters
ispeters force-pushed the automatic-storage branch 3 times, most recently from 44ea0da to b7e92d7 Compare November 4, 2026 04:04
ispeters and others added 4 commits November 3, 2026 20:31
`exec::finally` stores its initial sender's completion, runs its final
sender, and replays the stored completion if the final sender succeeds.
Its operation state does that by hand, and it once destroyed the stored
result after completing with it, by which time the receiver could
already have destroyed the operation (326a118).

Add a `finally` written against the phase machinery, treating its final
sender like a destructor. As in `exec::finally`, the final sender is
connected with the initial sender, just before it: in `connect`, or
under `start_entry` in `start`, so a failure to connect either means
neither runs. Its operation lasts until `finally` completes, so give the
machinery a place for that: a first phase may name a slot, which
`phased` enters just before the phase and ends as the operation
completes, under either Ending, so that whatever waits on something the
slot holds isn't left waiting for the operation to be destroyed. A child
the slot holds completes the operation with `finish_from_slot`, which
copies the results out before the slot ends.

The first phase runs the initial sender. Then, as with `let_value`,
there's one possible second phase per completion signature of the
initial sender, receiving that completion's arguments and starting the
final sender. If the final sender succeeds, that phase replays the
completion with `finish`, and otherwise the operation completes as the
final sender did. The final sender's operation was connected before
anyone knew which second phase would start it, so its completions can't
say which phase is live. Add `visit_live(fn)`, which calls `fn` with
the live phase, read from the byte that records it, and keep that byte
whenever there's a slot; it does the job of the index in
`exec::finally`'s variant of stored completions. The completion lives
wherever the Layout puts received values, and nothing touches the
operation once it completes. The final sender must complete with `set_value()` and
no other values. It sees the receiver's environment plus, as with
`exec::finally`, the initial sender's completion scheduler when it has
exactly one kind of completion.

Both children's operations are alive at once, so the final sender's
operation can't reuse the initial sender's bytes: in the demo's cost
table, `finally` takes 552 to 568 bytes where `exec::finally` takes 576,
and moves the value 4 to 8 times where it moves it 7 or 8. Section 11
replays each kind of completion, and completes as a failing final sender
did, while the receiver destroys the operation from inside the
completion, under every policy and layout. It also shows the two senders
connected in order, a final sender whose `connect` throws, and the final
sender starting on the initial sender's completion scheduler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`phase_receiver::bound_sender()` reads whether its operation was bound
through a const reference from the operation's type, which
`phased_sender` instantiates as `Sndr::opstate<Rcvr, Const>`. An
operation of any other shape fell through to the primary
`bound_const`, which answered "not const"; for an operation bound
through a const reference, that surfaced as an error about binding a
reference to a const part, far from the cause.

Leave the primary template undefined so that such an operation fails to
compile where its constness is read, naming the operation's type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`associate(sndr, token)` asks the token's scope for an association when
it's called, runs the wrapped sender if it got one, and otherwise
completes with `set_stopped()` without connecting it. [exec.associate]
keeps the wrapped sender and the association together, as its
associate-data: the wrapped sender may refer to what the scope keeps
alive, so it's destroyed while the association is held, or as soon as
the scope refuses one. Its operation state holds the association, so the
association lasts until the operation is destroyed, after the operation
has completed.

That's neither a phase's lifetime nor a slot's, since under
`eager_teardown` both end by the time the operation completes, so the
associate-data is `associate`'s carried region. The operation moves it
out of the sender at `connect`, or, bound without consuming, copies it,
which asks the scope for another association, as copying the sender
does; either way the operation then owns the wrapped sender, so it
connects it, under either Data policy. Under every policy, then, the
association lasts as long as the operation state, as it does with
`stdexec::associate`.

An operation whose scope refused enters a different phase instead, one
with no child that completes with `set_stopped()`, as `just`'s
completes with its values. So let an operation name the phase it enters
first, `on_connect<Phase>` and `begin<Phase>`, defaulting to the first
phase.

Section 12 counts the scope's associations as the sender is made,
connected, completed and destroyed, under four policies and with
`stdexec::associate`, and again with a scope that refuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The storage demo's third section could be built with
`-DFRAMES_SHOW_OVERLAP` to run two children on one chain of storage, so
that the second child's frame was constructed over the first's. That's
undefined behaviour, so the demo shouldn't offer to build it; describe
what would happen instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

1 participant