Repository navigation
Automatic storage - #2401
Draft
ispeters wants to merge 32 commits into
Draft
Automatic storage#2401ispeters wants to merge 32 commits into
ispeters wants to merge 32 commits into
Conversation
ispeters
force-pushed
the
automatic-storage
branch
3 times, most recently
from
November 3, 2026 16:53
e6f528c to
dfedf2e
Compare
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>
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
force-pushed
the
automatic-storage
branch
3 times, most recently
from
November 4, 2026 04:04
44ea0da to
b7e92d7
Compare
`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>
ispeters
force-pushed
the
automatic-storage
branch
from
November 4, 2026 04:31
b7e92d7 to
09b3ef0
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 aT, into which you can then construct thatT, which is likely an operation state.pushalso returns a new "allocator" that is a copy of the old one with the just-allocated space for aTremoved. This functions like the machine stack, but it's backed by a user-selectable "slab allocator", which defaults tonew_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, andrepeat_nin 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 aphase_.template transition<From, To>(…)call, which manages lifetimes, handles errors, etc. internally. One of the lifetime management strategies invents a new CPO calledbind, which is a variation onconnect(thebindCPO is implemented for senders that don't have a.bindmember asconnect); withbind,repeat_ncan 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.