From af0e08a4b2dc119db41db35196637d2bfa63fd7c Mon Sep 17 00:00:00 2001 From: Michael Vandeberg Date: Wed, 24 Jun 2026 12:36:42 -0600 Subject: [PATCH] docs: correct and complete library documentation Bring the documentation in line with the implementation across public-header docstrings and the Antora manual. Headers: - read_until: the match position is returned on success, not on error. - dynamic_buffer: flat/circular/vector/string are all adapters over external storage; the value-type category is for user-defined owning buffers. - Add missing docstrings to public symbols: task/quitter promise types, the delay awaitable, fuse::result, make_buffer overloads, the recycling memory resource, and frame_alloc_mixin's operator new. - Remove buffer_param references to a nonexistent max_size member. - Note that the recycling memory resource ignores the requested alignment. - Fix this_coro/run_async examples and document the environment precondition. - Correct the executor concept example, io_runnable handle() constness, the executor_ref example, pull_from parameter descriptions, and timeout/when_all and when_any @throws clauses. - Name strand's template parameter Ex and correct its dispatch condition. - Correct the overstated no-throw guarantee on the test mock streams. Manual: - IoAwaitable: the custom-awaitable example resumes the caller by wrapping its handle in a continuation and posting that to the executor; dispatch/post take a continuation&, not a raw coroutine_handle. - Construct buffers from arrays/containers with make_buffer, not mutable_buffer (which has no array/range constructor), in the read/stream examples; a string_view is not itself a ConstBufferSequence and must be wrapped with make_buffer. - Document the real fixed 16-entry buffer window for system I/O. - Correct the mock source/sink prose and member tables. - async_event is a manual-reset event, not one-shot. - Fix the custom-dynamic-buffer read loop to check end-of-stream before commit. - Remove a misplaced whole-catalog summary section. - Correct the type-erasure vtable taxonomy and the run_async wrapper snippet. - Replace the cancellation page's timeout example with the real timeout() combinator; bind executor_ref to a named executor. - Document when_all/when_any all-fail semantics (when_any reports an unspecified failure); replace the FrameAllocator concept with the real allocator requirements and document frame_alloc_mixin. - Document read into a DynamicBuffer, the ReadSource read overload, read_until, and write_now; the composed write takes its buffer sequence by value. - Smaller buffer/stream/source corrections (byte-size note, empty/partial read). Buffer and stream specification: - Explain that const_buffer/mutable_buffer are non-owning handles: the caller owns the underlying bytes and their lifetime; the library manages only the handles and handle-sequences it produces. - Strengthen the rationale for void* over std::byte/span, and state that treating a single buffer as a one-element sequence is a deliberate convenience applied consistently across the buffer API. - Note on the ReadStream/WriteStream concepts that the single buffer archetype in the requires-clause is only a representative: the real contract is that the operation accepts any MutableBufferSequence / ConstBufferSequence, which a C++ concept cannot fully express. Addresses #273, #287, and #296. --- .../ROOT/pages/4.coroutines/4c.executors.adoc | 3 +- .../pages/4.coroutines/4d.io-awaitable.adoc | 16 ++- .../pages/4.coroutines/4e.cancellation.adoc | 26 ++-- .../pages/4.coroutines/4f.composition.adoc | 4 +- .../pages/4.coroutines/4g.allocators.adoc | 40 ++++-- .../ROOT/pages/5.buffers/5a.overview.adoc | 2 +- .../ROOT/pages/5.buffers/5b.types.adoc | 20 ++- .../ROOT/pages/5.buffers/5c.sequences.adoc | 2 + .../ROOT/pages/5.buffers/5d.system-io.adoc | 30 ++-- .../ROOT/pages/6.streams/6b.streams.adoc | 4 + .../pages/6.streams/6c.sources-sinks.adoc | 2 + .../ROOT/pages/6.streams/6e.algorithms.adoc | 108 +++++++++++++- .../ROOT/pages/6.streams/6f.isolation.adoc | 2 +- .../7.testing/7c.mock-sources-sinks.adoc | 6 +- .../8.examples/8b.producer-consumer.adoc | 2 +- .../8.examples/8h.custom-dynamic-buffer.adoc | 14 +- .../pages/8.examples/8j.stream-pipeline.adoc | 20 --- .../ROOT/pages/9.design/9c.ReadStream.adoc | 2 + .../ROOT/pages/9.design/9f.WriteStream.adoc | 4 +- .../pages/9.design/9i.TypeEraseAwaitable.adoc | 76 +++++----- .../ROOT/pages/9.design/9l.RunApi.adoc | 12 +- include/boost/capy/buffers/buffer_param.hpp | 10 +- include/boost/capy/buffers/make_buffer.hpp | 97 +++++++++++-- .../boost/capy/concept/buffer_archetype.hpp | 10 +- include/boost/capy/concept/dynamic_buffer.hpp | 65 ++++++--- include/boost/capy/concept/executor.hpp | 2 +- include/boost/capy/concept/io_runnable.hpp | 4 +- include/boost/capy/concept/stream.hpp | 2 +- include/boost/capy/delay.hpp | 12 ++ include/boost/capy/ex/execution_context.hpp | 15 +- include/boost/capy/ex/executor_ref.hpp | 2 +- include/boost/capy/ex/frame_alloc_mixin.hpp | 8 ++ .../capy/ex/recycling_memory_resource.hpp | 57 +++++++- include/boost/capy/ex/run_async.hpp | 25 +++- include/boost/capy/ex/strand.hpp | 4 +- include/boost/capy/ex/system_context.hpp | 10 +- include/boost/capy/ex/this_coro.hpp | 48 +++++-- include/boost/capy/io/pull_from.hpp | 2 +- include/boost/capy/quitter.hpp | 82 +++++++++++ include/boost/capy/read.hpp | 2 +- include/boost/capy/read_until.hpp | 11 +- include/boost/capy/task.hpp | 132 +++++++++++++++++- include/boost/capy/test/buffer_source.hpp | 2 +- include/boost/capy/test/fuse.hpp | 6 + include/boost/capy/test/read_stream.hpp | 8 +- include/boost/capy/test/write_sink.hpp | 25 +++- include/boost/capy/test/write_stream.hpp | 8 +- include/boost/capy/timeout.hpp | 2 +- include/boost/capy/when_all.hpp | 3 + include/boost/capy/when_any.hpp | 49 ++++--- 50 files changed, 876 insertions(+), 222 deletions(-) diff --git a/doc/modules/ROOT/pages/4.coroutines/4c.executors.adoc b/doc/modules/ROOT/pages/4.coroutines/4c.executors.adoc index 76e239747..d84aea100 100644 --- a/doc/modules/ROOT/pages/4.coroutines/4c.executors.adoc +++ b/doc/modules/ROOT/pages/4.coroutines/4c.executors.adoc @@ -59,7 +59,8 @@ void schedule_work(executor_ref ex, continuation& c) int main() { thread_pool pool; - executor_ref ex = pool.get_executor(); // Type erasure + auto pool_ex = pool.get_executor(); + executor_ref ex = pool_ex; // Type erasure; pool_ex must outlive ex continuation c = /* ... */; schedule_work(ex, c); diff --git a/doc/modules/ROOT/pages/4.coroutines/4d.io-awaitable.adoc b/doc/modules/ROOT/pages/4.coroutines/4d.io-awaitable.adoc index f05e033b2..cc49ce6e5 100644 --- a/doc/modules/ROOT/pages/4.coroutines/4d.io-awaitable.adoc +++ b/doc/modules/ROOT/pages/4.coroutines/4d.io-awaitable.adoc @@ -126,7 +126,7 @@ To create a custom IoAwaitable: struct my_awaitable { io_env const* env_ = nullptr; - std::coroutine_handle<> continuation_; + continuation cont_; result_type result_; bool await_ready() const noexcept @@ -138,8 +138,10 @@ struct my_awaitable { // Store pointer to environment, never copy env_ = env; - continuation_ = h; - + // Wrap the caller's handle in a continuation we own, so it stays + // at a stable address until the executor resumes it. + cont_.h = h; + // Start async operation... start_operation(); @@ -155,8 +157,10 @@ struct my_awaitable private: void on_completion() { - // Resume on caller's executor - env_->executor.dispatch(continuation_); + // Resume the caller on its executor. post() takes the + // continuation by reference and queues it; never resume inline + // from a completion callback (it may run on the wrong thread). + env_->executor.post(cont_); } }; ---- @@ -164,7 +168,7 @@ private: The key points: 1. Store the `io_env` as a pointer (`io_env const*`), never a copy. Launch functions guarantee the `io_env` outlives the awaitable's operation. -2. Use the executor to dispatch completion +2. To resume the caller, wrap its handle in a `continuation` and pass that to the executor's `post` (or `dispatch`) — these take a `continuation&`, not a raw `coroutine_handle`. Store the `continuation` in the awaitable so it keeps a stable address until the executor dequeues and resumes it; the executor links continuations intrusively, so a temporary would dangle. 3. Respect the stop token for cancellation === Stop Callbacks Must Post, Not Resume diff --git a/doc/modules/ROOT/pages/4.coroutines/4e.cancellation.adoc b/doc/modules/ROOT/pages/4.coroutines/4e.cancellation.adoc index cb3719de3..9510c7dc7 100644 --- a/doc/modules/ROOT/pages/4.coroutines/4e.cancellation.adoc +++ b/doc/modules/ROOT/pages/4.coroutines/4e.cancellation.adoc @@ -366,24 +366,28 @@ NOTE: Capy's built-in I/O awaitables (via Corosio) already use the post-back pat === Timeout Pattern -Combine a timer with stop token to implement timeouts: +Capy ships a first-class `timeout()` combinator (``) that races an `io_result`-returning awaitable against a deadline. The first to complete wins and cancels the other; if the timer fires first, the result carries `cond::timeout`: [source,cpp] ---- -task<> with_timeout(task<> operation, std::chrono::seconds timeout) +#include + +using namespace std::chrono_literals; + +task read_with_timeout(socket& sock, mutable_buffer buf) { - std::stop_source source; - - // Timer that requests stop after timeout - auto timer = co_await start_timer(timeout, [&source] { - source.request_stop(); - }); - - // Run operation with our stop token - co_await run_with_token(source.get_token(), std::move(operation)); + auto [ec, n] = co_await capy::timeout(sock.read_some(buf), 50ms); + if (ec == cond::timeout) + { + // deadline elapsed before the read completed + co_return; + } + // ... use the n bytes read } ---- +The deadline itself is built on `delay()` (``), an awaitable that suspends for a duration and resumes with `cond::canceled` if its stop token is activated. Reach for `timeout()` rather than wiring a timer to a `std::stop_source` by hand. + === User Cancellation Connect UI cancellation to stop tokens. Pass the token through `run_async` so it propagates automatically via the execution environment—the task accesses it with `co_await this_coro::stop_token` instead of receiving it as a function argument: diff --git a/doc/modules/ROOT/pages/4.coroutines/4f.composition.adoc b/doc/modules/ROOT/pages/4.coroutines/4f.composition.adoc index 27a7341b9..574015222 100644 --- a/doc/modules/ROOT/pages/4.coroutines/4f.composition.adoc +++ b/doc/modules/ROOT/pages/4.coroutines/4f.composition.adoc @@ -95,7 +95,7 @@ I/O errors are reported through the `ec` field of the `io_result`. When any chil 1. Stop is requested for sibling tasks 2. All tasks complete (or respond to stop) -3. The first `ec` is propagated in the outer `io_result` +3. The first `ec` (in completion order, not input order) is propagated in the outer `io_result` [source,cpp] ---- @@ -175,6 +175,8 @@ task<> example() The result is a `variant` with `error_code` at index 0 (failure/no winner) and one alternative per input task at indices 1..N. Only tasks returning `!ec` can win; errors and exceptions do not count as winning. When a winner is found, stop is requested for all siblings. All tasks complete before `when_any` returns. +When every task fails, `when_any` reports a failure, but *which* one is unspecified: the result either carries an `error_code` at index 0 or rethrows one of the children's exceptions. Unlike `when_all`, there is no priority between error codes and exceptions, and no guarantee about which task's failure surfaces (including no guarantee that it is the first or last to complete). Do not rely on receiving the failure from any particular task. + === Errors Do Not Win (wait_for_one_success) A child that returns a non-zero `ec` (or throws) does *not* win, and it does *not* cancel its siblings. `when_any` keeps waiting until some child succeeds or until every child has finished. Only when *all* children fail does the result settle at index 0, holding an `error_code`. diff --git a/doc/modules/ROOT/pages/4.coroutines/4g.allocators.adoc b/doc/modules/ROOT/pages/4.coroutines/4g.allocators.adoc index b4fa11aff..80bf814ac 100644 --- a/doc/modules/ROOT/pages/4.coroutines/4g.allocators.adoc +++ b/doc/modules/ROOT/pages/4.coroutines/4g.allocators.adoc @@ -55,19 +55,16 @@ capy::safe_resume(h); // saves and restores TLS around h.resume() `safe_resume` saves the current thread-local allocator, calls `h.resume()`, then restores the saved value. This makes TLS behave like a stack: nested resumes cannot spoil the outer value. All of Capy's built-in executors (`thread_pool`, strands, `blocking_context`) use `safe_resume` internally. Custom executor event loops must do the same -- see xref:8.examples/8n.custom-executor.adoc[Custom Executor] for an example. -== The FrameAllocator Concept +== Custom Allocator Requirements -Custom allocators must satisfy the `FrameAllocator` concept, which is compatible with {cpp} allocator requirements: +Custom allocators must meet the usual {cpp} allocator requirements, or be a `std::pmr::memory_resource*`. The library does not expose a separate public concept for them; a value-type allocator works as a frame allocator when it provides, illustratively: [source,cpp] ---- -template -concept FrameAllocator = requires { - typename A::value_type; -} && requires(A& a, std::size_t n) { - { a.allocate(n) } -> std::same_as; - { a.deallocate(std::declval(), n) }; -}; +// Illustrative requirements — not a named public concept: +typename A::value_type; +a.allocate(n) // -> A::value_type* +a.deallocate(p, n); ---- In practice, any standard allocator works. @@ -108,6 +105,31 @@ Capy provides `recycling_memory_resource`, a memory resource optimized for corou This allocator is used by default for `thread_pool` and other execution contexts. +NOTE: `recycling_memory_resource` honors only the default new alignment (`__STDCPP_DEFAULT_NEW_ALIGNMENT__`, typically `alignof(std::max_align_t)`). The alignment argument passed to `do_allocate`/`do_deallocate` is ignored, so over-aligned requests are not satisfied. This is sufficient for coroutine frames but means the resource is not a drop-in replacement where over-aligned allocations are required. + +== Frame Allocator Mixin + +Most users never need to allocate coroutine frames manually -- `task` and the built-in awaitable types already participate in TLS frame allocation. When you write your own coroutine promise type and want it to use the same fast path, inherit from `frame_alloc_mixin`: + +[source,cpp] +---- +struct my_coroutine +{ + struct promise_type : capy::frame_alloc_mixin + { + // get_return_object, initial_suspend, ... + }; +}; +---- + +`frame_alloc_mixin` (in ``) supplies `operator new` and `operator delete` that: + +* Read the thread-local frame allocator set by `run_async` (falling back to `std::pmr::get_default_resource()` when none is set). +* Bypass virtual dispatch when that allocator is the default recycling memory resource. +* Store the resolved allocator pointer at the tail of each frame, so deallocation uses the correct resource even if the thread-local allocator has since changed. + +This is the same strategy used internally by `io_awaitable_promise_base`. Use the mixin directly when your promise type does not need the full environment and continuation support that `io_awaitable_promise_base` provides. The allocation fast path uses thread-local storage and needs no synchronization; the global pool fallback is mutex-protected. + == HALO Optimization *Heap Allocation eLision Optimization* (HALO) allows the compiler to allocate coroutine frames on the stack instead of the heap when: diff --git a/doc/modules/ROOT/pages/5.buffers/5a.overview.adoc b/doc/modules/ROOT/pages/5.buffers/5a.overview.adoc index fdf0dfc35..9ce2cf267 100644 --- a/doc/modules/ROOT/pages/5.buffers/5a.overview.adoc +++ b/doc/modules/ROOT/pages/5.buffers/5a.overview.adoc @@ -95,7 +95,7 @@ This single signature accepts: * A single `const_buffer` * A `span` * A `vector` -* A `string_view` (converts to single buffer) +* A `string_view` wrapped with `make_buffer` (which yields a single `const_buffer`) * A custom composite type * *Any composition of the above—without allocation* diff --git a/doc/modules/ROOT/pages/5.buffers/5b.types.adoc b/doc/modules/ROOT/pages/5.buffers/5b.types.adoc index c3ccd3025..38799868b 100644 --- a/doc/modules/ROOT/pages/5.buffers/5b.types.adoc +++ b/doc/modules/ROOT/pages/5.buffers/5b.types.adoc @@ -7,13 +7,27 @@ This section introduces Capy's fundamental buffer types: `const_buffer` and `mut * Completed xref:5.buffers/5a.overview.adoc[Why Concepts, Not Spans] * Understanding of why concept-driven buffers enable composition -== Why Not std::byte? +== Buffers Are Handles + +A `const_buffer` or `mutable_buffer` is a *handle*: a non-owning `(pointer, size)` view of memory it does not own. Constructing one copies no bytes, and destroying one frees nothing. + +This splits lifetime responsibility cleanly: + +* *You own the bytes.* The memory a buffer refers to—a stack array, a `std::string`, a slab from your allocator—is yours to keep alive. It must remain valid for the entire duration of any operation you hand the buffer to, including across the suspension points of a `co_await`-ed I/O operation. +* *The library owns the handles.* Capy creates and manages buffer handles and handle-sequences on your behalf—the buffers a dynamic buffer exposes through `prepare`/`data`, the sub-range a `buffer_slice` produces, the descriptors a type-erased stream passes to the OS. Each such handle is valid only for the window its API documents, typically until the next call that mutates the owner. + +The library never copies or takes ownership of your bytes through a buffer; it only moves handles. This split explains every buffer-lifetime rule in this chapter. + +== Why `void*`, Not `std::byte`? `std::byte` imposes a semantic opinion. It says "this is raw bytes"—but that is itself an opinion about the data's nature. POSIX uses `void*` for buffers. This expresses semantic neutrality: "I move memory without opining on what it contains." The OS doesn't care if the bytes represent text, integers, or compressed data—it moves them. -But `std::span` doesn't compile. {cpp} can't express a type-agnostic buffer abstraction using `span`. +Two concrete forces favor `void*` specifically over `std::span`: + +* *Platform types already use it.* The OS structures Capy maps onto—`iovec`'s `iov_base`, `WSABUF`'s `buf`—are `void*`/`char*`. Erasing to `void*` makes conversion to those structures a layout match rather than a reinterpretation. +* *Callers supply many element types.* User data arrives as `char[]`, `unsigned char[]`, `std::byte[]`, `std::string`, and more. A single neutral pointer erases all of them to one representation. `std::span` would force every caller to reinterpret their bytes first, and `std::span` is ill-formed—{cpp} cannot express a type-agnostic buffer with `span`. Capy provides `const_buffer` and `mutable_buffer` as semantically neutral buffer types with known layout. @@ -155,6 +169,8 @@ The returned buffer type depends on the element constness of the range: * Ranges of mutable elements → `mutable_buffer` * Ranges of const elements, `string_view`, string literals → `const_buffer` +The buffer's size, in bytes, is `count * sizeof(element)`. + == Layout Compatibility `const_buffer` and `mutable_buffer` have the same memory layout as OS buffer structures: diff --git a/doc/modules/ROOT/pages/5.buffers/5c.sequences.adoc b/doc/modules/ROOT/pages/5.buffers/5c.sequences.adoc index e4101711b..d0fa847bf 100644 --- a/doc/modules/ROOT/pages/5.buffers/5c.sequences.adoc +++ b/doc/modules/ROOT/pages/5.buffers/5c.sequences.adoc @@ -15,6 +15,8 @@ A *buffer sequence* is any type that can produce an iteration of buffers. Formal * A range of buffers (like `vector`) is a multi-element sequence * Any bidirectional range with buffer-convertible values qualifies +Treating a single buffer as a one-element sequence is a deliberate convenience, not an accident of the definition. It lets one concept-constrained signature serve both the common single-buffer call and scatter/gather composition, with no overload and no explicit wrap at the call site. Capy favors this convenience as a primary design goal and applies it consistently—`make_buffer`, for instance, accepts any contiguous range of bytes—so that buffer-passing reads the same whether you hand over one region or many. + == The Concepts === ConstBufferSequence diff --git a/doc/modules/ROOT/pages/5.buffers/5d.system-io.adoc b/doc/modules/ROOT/pages/5.buffers/5d.system-io.adoc index 8be48d0ec..5a07768ad 100644 --- a/doc/modules/ROOT/pages/5.buffers/5d.system-io.adoc +++ b/doc/modules/ROOT/pages/5.buffers/5d.system-io.adoc @@ -67,7 +67,11 @@ Internally, Capy: == Stack-Based Conversion -For common cases (small numbers of buffers), conversion happens on the stack: +Conversion always happens on the stack—the implementation never +allocates. A fixed-size, on-frame window of buffer descriptors (16 +entries) is filled from the sequence and passed to the OS call. If the +sequence has more buffers than fit in the window, the window is refilled +and the OS call is repeated for the remaining buffers: [source,cpp] ---- @@ -75,24 +79,22 @@ For common cases (small numbers of buffers), conversion happens on the stack: template auto platform_write(Buffers const& buffers) { - std::size_t count = buffer_length(buffers); - - if (count <= 8) // Small buffer optimization - { - iovec iovecs[8]; - fill_iovecs(iovecs, buffers, count); - return writev(fd, iovecs, count); - } - else // Heap fallback + iovec iovecs[16]; // fixed on-frame window, never heap-allocated + + auto it = begin(buffers); + auto last = end(buffers); + while (it != last) { - std::vector iovecs(count); - fill_iovecs(iovecs.data(), buffers, count); - return writev(fd, iovecs.data(), count); + std::size_t count = fill_iovecs(iovecs, it, last, 16); // up to 16 + auto result = writev(fd, iovecs, count); + // ... advance the window past the buffers just written } } ---- -Most real-world code uses fewer than 8 buffers, so heap allocation is rarely needed. +The window size (16) is fixed and implementation-defined. Sequences with +more buffers than the window are handled by refilling it across +successive OS calls; there is no heap fallback. == Scatter/Gather Benefits diff --git a/doc/modules/ROOT/pages/6.streams/6b.streams.adoc b/doc/modules/ROOT/pages/6.streams/6b.streams.adoc index 5ebc018de..6bc5f6312 100644 --- a/doc/modules/ROOT/pages/6.streams/6b.streams.adoc +++ b/doc/modules/ROOT/pages/6.streams/6b.streams.adoc @@ -20,6 +20,8 @@ concept ReadStream = }; ---- +The `requires` clause names a single representative buffer (`mutable_buffer_archetype`) because a {cpp} concept cannot say "works with every buffer sequence." The real contract is that `read_some` accepts *any* `MutableBufferSequence`—one buffer or a range; the archetype only samples that requirement. + === read_some Semantics [source,cpp] @@ -91,6 +93,8 @@ concept WriteStream = }; ---- +As with `ReadStream`, the `const_buffer_archetype` is only a representative: the real contract is that `write_some` accepts *any* `ConstBufferSequence`, which a {cpp} concept cannot fully express. + === write_some Semantics [source,cpp] diff --git a/doc/modules/ROOT/pages/6.streams/6c.sources-sinks.adoc b/doc/modules/ROOT/pages/6.streams/6c.sources-sinks.adoc index 874059770..132e66f3e 100644 --- a/doc/modules/ROOT/pages/6.streams/6c.sources-sinks.adoc +++ b/doc/modules/ROOT/pages/6.streams/6c.sources-sinks.adoc @@ -35,6 +35,8 @@ Await-returns `(error_code, std::size_t)`: * On EOF: `ec == cond::eof`, and `n` is bytes read before EOF (partial read) * On error: `ec`, and `n` is bytes read before error +If `buffer_empty(buffers)` is true, the operation completes immediately with `!ec` and `n` equal to 0. + The key difference from `ReadStream`: a successful read fills the buffer completely. === Use Cases diff --git a/doc/modules/ROOT/pages/6.streams/6e.algorithms.adoc b/doc/modules/ROOT/pages/6.streams/6e.algorithms.adoc index 5bce28b23..37e0f692d 100644 --- a/doc/modules/ROOT/pages/6.streams/6e.algorithms.adoc +++ b/doc/modules/ROOT/pages/6.streams/6e.algorithms.adoc @@ -34,13 +34,13 @@ Example: [source,cpp] ---- char buf[1024]; -auto [ec, n] = co_await read(stream, mutable_buffer(buf)); +auto [ec, n] = co_await read(stream, make_buffer(buf)); // n == 1024, or ec indicates why not ---- === read with DynamicBuffer -Reads until EOF into a growable buffer: +Reads into a growable dynamic buffer, stopping at end-of-stream, when the buffer reaches `max_size()`, or on a non-EOF error: [source,cpp] ---- @@ -53,11 +53,27 @@ Example: [source,cpp] ---- -flat_dynamic_buffer buf; -auto [ec, n] = co_await read(stream, buf); -// buf now contains all data until EOF +std::string storage; +auto buffer = dynamic_buffer(storage); +auto [ec, n] = co_await read(stream, buffer); +// storage holds all data read up to EOF or max_size(); n is the byte count ---- +=== read from a ReadSource with DynamicBuffer + +A third overload accepts a `ReadSource` and a dynamic buffer. It drives the +source's complete-read `read` (rather than `read_some`), appending until EOF +or until the buffer reaches `max_size()`: + +[source,cpp] +---- +template +io_task +read(Source& source, Buffer&& buffer, std::size_t initial_amount = 2048); +---- + +`n` is the total number of bytes read, inclusive of the final partial read. + === write Writes all data by looping `write_some`: @@ -83,6 +99,82 @@ Example: co_await write(stream, make_buffer("Hello, World!")); ---- +=== read_until + +Reads from a stream into a dynamic buffer until a match condition is +satisfied. Useful for delimiter-based protocols (e.g. reading a line or an +HTTP header block): + +[source,cpp] +---- +#include + +// Match-condition overload +template +io_task +read_until(Stream& stream, Buffer&& buffer, Match match, + std::size_t initial_amount = 2048); + +// Delimiter-string convenience overload +template +io_task +read_until(Stream& stream, Buffer&& buffer, std::string_view delim, + std::size_t initial_amount = 2048); +---- + +If `!ec`, the match succeeded and `n` is the number of bytes through the end +of the match (the position one past the matched delimiter). Notable +conditions: + +* `cond::eof` — end-of-stream reached before a match; `n` is the buffer size +* `cond::not_found` — `max_size()` reached before a match + +A `MatchCondition` is a callable `(std::string_view data, std::size_t* hint)` +returning the position past the match, or `std::string_view::npos` on no +match. When `hint` is non-null it may receive an overlap hint so a delimiter +spanning two reads is not missed. The `match_delim` struct adapts a +`std::string_view` delimiter to this interface and underlies the convenience +overload. + +[source,cpp] +---- +std::string line; +auto [ec, n] = co_await read_until( + stream, string_dynamic_buffer(&line), "\r\n"); +if (ec == cond::eof) + co_return line; // partial line at EOF +if (ec) + throw std::system_error(ec); +line.resize(n - 2); // n includes the "\r\n"; strip it +---- + +=== write_now + +`write_now` eagerly writes a complete buffer sequence, attempting to finish +synchronously. If every underlying `write_some` completes without suspending, +the whole operation completes in `await_ready` with no coroutine suspension. +It caches a single coroutine frame and reuses it across calls, avoiding +repeated allocation on a hot write path: + +[source,cpp] +---- +#include + +template +class write_now; +---- + +Construct it from a stream, then call it like a function. Only one operation +may be outstanding at a time: + +[source,cpp] +---- +write_now wn(stream); +auto [ec, n] = co_await wn(make_buffer("hello")); +if (ec) + throw std::system_error(ec); +---- + == Transfer Algorithms Transfer algorithms move data between sources/sinks and streams. @@ -246,6 +338,12 @@ else if (ec) | `` | Composed write operations +| `` +| Read until a match condition or delimiter + +| `` +| Eager write with frame caching + | `` | BufferSource → WriteSink/WriteStream transfer diff --git a/doc/modules/ROOT/pages/6.streams/6f.isolation.adoc b/doc/modules/ROOT/pages/6.streams/6f.isolation.adoc index 52c8355a5..fc95a25e7 100644 --- a/doc/modules/ROOT/pages/6.streams/6f.isolation.adoc +++ b/doc/modules/ROOT/pages/6.streams/6f.isolation.adoc @@ -37,7 +37,7 @@ task<> handle_protocol(any_stream& stream) for (;;) { - auto [ec, n] = co_await stream.read_some(mutable_buffer(buf)); + auto [ec, n] = co_await stream.read_some(make_buffer(buf)); if (ec) co_return; diff --git a/doc/modules/ROOT/pages/7.testing/7c.mock-sources-sinks.adoc b/doc/modules/ROOT/pages/7.testing/7c.mock-sources-sinks.adoc index d3308f125..f66e62d2c 100644 --- a/doc/modules/ROOT/pages/7.testing/7c.mock-sources-sinks.adoc +++ b/doc/modules/ROOT/pages/7.testing/7c.mock-sources-sinks.adoc @@ -110,8 +110,8 @@ own. calls `write()` and `write_eof()` while the test inspects what was written via `data()` and checks whether EOF was signaled via `eof_called()`. Test code may also call `expect()` to register the data it anticipates; -any mismatch between written bytes and that prefix causes `write_some()` -to return `error::test_failure`. Because `fuse` copies share state (see +any mismatch between written bytes and that prefix causes the next write +(`write`, `write_some`, or `write_eof(buffers)`) to return `error::test_failure`. Because `fuse` copies share state (see xref:7.testing/7a.drivers.adoc#_shared_state_across_copies[Shared State Across Copies]), constructing `write_sink ws(f)` by value still ties `ws` to the same fail-point machinery as `f`. @@ -190,10 +190,12 @@ injection. | `write_eof(ConstBufferSequence buffers)` | Atomically write remaining bytes and signal end-of-stream. Sets `eof_called()` to `true` on success. Consults the fuse before the call. + Await-returns `(error_code, n)`. | `write_eof()` | Signal end-of-stream without writing data. Sets `eof_called()` to `true` on success. Consults the fuse before the call. + Await-returns `(error_code)`. | `data() -> std::string_view` | Return bytes written but not yet matched by `expect()`. diff --git a/doc/modules/ROOT/pages/8.examples/8b.producer-consumer.adoc b/doc/modules/ROOT/pages/8.examples/8b.producer-consumer.adoc index 935b264b8..e006eb581 100644 --- a/doc/modules/ROOT/pages/8.examples/8b.producer-consumer.adoc +++ b/doc/modules/ROOT/pages/8.examples/8b.producer-consumer.adoc @@ -93,7 +93,7 @@ A `strand` is an executor adaptor that serializes execution. All coroutines disp capy::async_event data_ready; ---- -`async_event` is a one-shot signaling mechanism. One task can `set()` it; other tasks can `wait()` for it. When set, all waiting tasks resume. +`async_event` is a manual-reset signaling mechanism. One task can `set()` it; other tasks can `wait()` for it. When set, all current waiters resume, and the event stays set (later `wait()` calls return immediately) until `clear()` is called. === Producer diff --git a/doc/modules/ROOT/pages/8.examples/8h.custom-dynamic-buffer.adoc b/doc/modules/ROOT/pages/8.examples/8h.custom-dynamic-buffer.adoc index fd628da6a..62dff0a2e 100644 --- a/doc/modules/ROOT/pages/8.examples/8h.custom-dynamic-buffer.adoc +++ b/doc/modules/ROOT/pages/8.examples/8h.custom-dynamic-buffer.adoc @@ -156,14 +156,16 @@ capy::task<> read_into_tracked_buffer(capy::test::stream& stream, tracked_buffer // ec: std::error_code, n: std::size_t auto [ec, n] = co_await stream.read_some(space); - buffer.commit(n); - - if (n > 0) - std::cout << "Read " << n << " bytes, buffer size now: " - << buffer.size() << "\n"; + if (ec == capy::cond::eof) + break; if (ec) - break; + throw std::system_error(ec); + + buffer.commit(n); + + std::cout << "Read " << n << " bytes, buffer size now: " + << buffer.size() << "\n"; } } diff --git a/doc/modules/ROOT/pages/8.examples/8j.stream-pipeline.adoc b/doc/modules/ROOT/pages/8.examples/8j.stream-pipeline.adoc index 6256f307e..acdab691d 100644 --- a/doc/modules/ROOT/pages/8.examples/8j.stream-pipeline.adoc +++ b/doc/modules/ROOT/pages/8.examples/8j.stream-pipeline.adoc @@ -436,23 +436,3 @@ Output (52 bytes): == Next Steps * xref:8.examples/8k.strand-serialization.adoc[Strand Serialization] -- Lock-free shared state with strands - -== Summary - -This example catalog demonstrated: - -* Basic task creation and launching -* Coroutine synchronization with events -* Buffer composition for scatter/gather I/O -* Unit testing with mock streams -* Compilation firewalls with type erasure -* Cooperative cancellation with stop tokens -* Concurrent execution with `when_all` -* Custom buffer implementations -* Real network I/O with Corosio -* Data transformation pipelines -* Strand-based serialization and async mutexes -* Parallel task distribution across thread pools -* Custom executor implementations - -These patterns form the foundation for building robust, efficient I/O applications with Capy. diff --git a/doc/modules/ROOT/pages/9.design/9c.ReadStream.adoc b/doc/modules/ROOT/pages/9.design/9c.ReadStream.adoc index 77fa2b052..d1c11f853 100644 --- a/doc/modules/ROOT/pages/9.design/9c.ReadStream.adoc +++ b/doc/modules/ROOT/pages/9.design/9c.ReadStream.adoc @@ -19,6 +19,8 @@ concept ReadStream = }; ---- +The `requires` clause checks `read_some` against a single representative buffer, `mutable_buffer_archetype`, because a {cpp} concept cannot quantify over "every buffer sequence." The contract is stronger than what the compiler verifies: a `ReadStream` must accept *any* `MutableBufferSequence`—a single buffer or a range of them—and the archetype stands in for that universally-quantified requirement. Read this as a textual requirement that the concept can only sample, not a claim that conformance is limited to the archetype type. + A `ReadStream` provides a single operation: === `read_some(buffers)` -- Partial Read diff --git a/doc/modules/ROOT/pages/9.design/9f.WriteStream.adoc b/doc/modules/ROOT/pages/9.design/9f.WriteStream.adoc index 4db67494e..81579f5be 100644 --- a/doc/modules/ROOT/pages/9.design/9f.WriteStream.adoc +++ b/doc/modules/ROOT/pages/9.design/9f.WriteStream.adoc @@ -19,6 +19,8 @@ concept WriteStream = }; ---- +The `requires` clause checks `write_some` against a single representative buffer, `const_buffer_archetype`, because a {cpp} concept cannot quantify over "every buffer sequence." The contract is stronger than what the compiler verifies: a `WriteStream` must accept *any* `ConstBufferSequence`—a single buffer or a range of them—and the archetype stands in for that universally-quantified requirement. Read this as a textual requirement that the concept can only sample, not a claim that conformance is limited to the archetype type. + A `WriteStream` provides a single operation: === `write_some(buffers)` -- Partial Write @@ -80,7 +82,7 @@ Two composed algorithms build complete-write behavior on top of `write_some`: [source,cpp] ---- auto write(WriteStream auto& stream, - ConstBufferSequence auto const& buffers) + ConstBufferSequence auto buffers) -> io_task; ---- diff --git a/doc/modules/ROOT/pages/9.design/9i.TypeEraseAwaitable.adoc b/doc/modules/ROOT/pages/9.design/9i.TypeEraseAwaitable.adoc index 46fbec8cb..82fa9fae9 100644 --- a/doc/modules/ROOT/pages/9.design/9i.TypeEraseAwaitable.adoc +++ b/doc/modules/ROOT/pages/9.design/9i.TypeEraseAwaitable.adoc @@ -4,7 +4,7 @@ The `any_*` wrappers type-erase stream and source concepts so that algorithms can operate on heterogeneous concrete types through a uniform interface. Each wrapper preallocates storage for the type-erased awaitable at construction time, achieving zero steady-state allocation. -Two vtable layouts are used depending on how many operations the wrapper exposes. +The vtable layout depends on how many async operations the wrapper exposes and whether those operations share an await-return type. == Single-Operation: Flat Vtable @@ -35,18 +35,18 @@ When there is no outer short-circuit, constructing in `await_ready` lets immedia [source,cpp] ---- bool await_ready() { - vt_->construct_awaitable(stream_, storage_, buffers); + vt_->construct_awaitable(stream_, cached_awaitable_, buffers); awaitable_active_ = true; - return vt_->await_ready(storage_); // true → no suspend + return vt_->await_ready(cached_awaitable_); // true → no suspend } std::coroutine_handle<> await_suspend(std::coroutine_handle<> h, io_env const* env) { - return vt_->await_suspend(storage_, h, env); + return vt_->await_suspend(cached_awaitable_, h, env); } io_result await_resume() { - auto r = vt_->await_resume(storage_); - vt_->destroy_awaitable(storage_); + auto r = vt_->await_resume(cached_awaitable_); + vt_->destroy_awaitable(cached_awaitable_); awaitable_active_ = false; return r; } @@ -63,18 +63,18 @@ bool await_ready() const noexcept { } std::coroutine_handle<> await_suspend(std::coroutine_handle<> h, io_env const* env) { - vt_->construct_awaitable(stream_, storage_, buffers); + vt_->construct_awaitable(stream_, cached_awaitable_, buffers); awaitable_active_ = true; - if(vt_->await_ready(storage_)) + if(vt_->await_ready(cached_awaitable_)) return h; // immediate → resume caller - return vt_->await_suspend(storage_, h, env); + return vt_->await_suspend(cached_awaitable_, h, env); } io_result await_resume() { if(!awaitable_active_) return {{}, 0}; // short-circuited - auto r = vt_->await_resume(storage_); - vt_->destroy_awaitable(storage_); + auto r = vt_->await_resume(cached_awaitable_); + vt_->destroy_awaitable(cached_awaitable_); awaitable_active_ = false; return r; } @@ -82,13 +82,18 @@ io_result await_resume() { Both variants touch the same two cache lines on the hot path. -== Multi-Operation: Split Vtable with awaitable_ops +== Multi-Operation: Per-Construct awaitable_ops -When a wrapper exposes multiple operations that produce different awaitable types (e.g. `any_read_source` with `read_some` and `read`, or `any_write_sink` with `write_some`, `write`, `write_eof(buffers)`, and `write_eof()`), a split layout is required. Each `construct` call returns a pointer to a `static constexpr awaitable_ops` matching the awaitable it created. +When a wrapper exposes more than one async operation, it cannot embed a single set of per-awaitable function pointers in its vtable, because each operation produces a distinct concrete awaitable type. Instead the vtable holds one `construct_*_awaitable` pointer per operation, and each `construct` call returns a pointer to a `static constexpr` ops struct describing the awaitable it just created. + +Two sub-cases arise, distinguished by whether the operations share an await-return type: + +* *Same await-return type* (`any_read_source`: `read_some` and `read` both await-return `io_result`). One ops struct (`awaitable_ops`) serves both operations; both `construct_*_awaitable` pointers return the same layout. +* *Different await-return types* (`any_buffer_source`: `pull` await-returns `io_result>` while its synthesized `read_some`/`read` await-return `io_result`; `any_buffer_sink` and `any_write_sink` similarly mix `io_result<>`, `io_result`). These need more than one ops struct (e.g. `awaitable_ops` plus `read_awaitable_ops`/`write_awaitable_ops`/`eof_awaitable_ops`), and each `construct` returns the ops matching its result type. [source,cpp] ---- -// Per-awaitable dispatch -- 32 bytes +// Per-awaitable dispatch -- one struct per await-return type struct awaitable_ops { bool (*await_ready)(void*); @@ -97,10 +102,11 @@ struct awaitable_ops void (*destroy)(void*); }; -// Vtable -- 32 bytes +// Vtable -- one construct_* pointer per operation struct vtable { - awaitable_ops const* (*construct_awaitable)(...); + awaitable_ops const* (*construct_read_some_awaitable)(...); + awaitable_ops const* (*construct_read_awaitable)(...); size_t awaitable_size; size_t awaitable_align; void (*destroy)(void*); @@ -116,17 +122,17 @@ bool await_ready() const noexcept { } std::coroutine_handle<> await_suspend(std::coroutine_handle<> h, io_env const* env) { - active_ops_ = vt_->construct_awaitable(stream_, storage_, buffers_); - if(active_ops_->await_ready(storage_)) + active_ops_ = vt_->construct_read_some_awaitable(source_, cached_awaitable_, buffers_); + if(active_ops_->await_ready(cached_awaitable_)) return h; // immediate → resume caller - return active_ops_->await_suspend(storage_, h, env); + return active_ops_->await_suspend(cached_awaitable_, h, env); } io_result await_resume() { if(!active_ops_) return {{}, 0}; // short-circuited - auto r = active_ops_->await_resume(storage_); - active_ops_->destroy(storage_); + auto r = active_ops_->await_resume(cached_awaitable_); + active_ops_->destroy(cached_awaitable_); active_ops_ = nullptr; return r; } @@ -142,31 +148,35 @@ Flat (any_read_stream, any_write_stream): 2 cache lines LINE 2 vtable construct → await_ready → await_resume → destroy (contiguous, sequential access, prefetch-friendly) -Split (any_read_source, any_write_sink): 3 cache lines +Per-construct ops (any_read_source, any_buffer_source, + any_buffer_sink, any_write_sink): 3 cache lines LINE 1 object source_, vt_, cached_awaitable_, active_ops_, ... - LINE 2 vtable construct_awaitable + LINE 2 vtable construct_*_awaitable pointers LINE 3 awaitable_ops await_ready → await_suspend → await_resume → destroy (separate .rodata address, defeats spatial prefetch) ---- -The flat layout keeps all per-awaitable function pointers adjacent to `construct_awaitable` in a single 64-byte structure. The split layout places `vtable` and `awaitable_ops` at unrelated addresses in `.rodata`, adding one cache miss on the hot path. +The flat layout keeps all per-awaitable function pointers adjacent to `construct_awaitable` in a single 64-byte structure. The per-construct layout places `vtable` and the returned `awaitable_ops` at unrelated addresses in `.rodata`, adding one cache miss on the hot path. == When to Use Which -[cols="1,1"] +[cols="1,2"] |=== -| Flat vtable | Split vtable +| Layout | Wrappers -| Wrapper has exactly one async operation -| Wrapper has multiple async operations +| Flat vtable (one operation, ops embedded in vtable) +| `any_read_stream` (`read_some`) + + `any_write_stream` (`write_some`) -| `any_read_stream` (`read_some`) -| `any_read_source` (`read_some`, `read`) +| Per-construct ops, single ops struct (multiple operations, all sharing one await-return type) +| `any_read_source` (`read_some`, `read` -- both `io_result`) -| `any_write_stream` (`write_some`) -| `any_write_sink` (`write_some`, `write`, `write_eof(bufs)`, `write_eof()`) +| Per-construct ops, multiple ops structs (operations differ in await-return type) +| `any_buffer_source` (`pull` → `io_result`; synthesized `read_some`/`read` → `io_result`) + + `any_buffer_sink` (`commit`/`commit_eof` → `io_result<>`; `write_some`/`write` → `io_result`) + + `any_write_sink` (`write_some`/`write` → `io_result`; `write_eof()` → `io_result<>`) |=== == Why the Flat Layout Cannot Scale -With multiple operations, each `construct` call produces a different concrete awaitable type. The per-awaitable function pointers (`await_ready`, `await_suspend`, `await_resume`, `destroy`) must match the type that was constructed. The split layout solves this by returning the correct `awaitable_ops const*` from each `construct` call. The flat layout would require duplicating all four function pointers in the vtable for every operation -- workable for two operations, unwieldy for four. +With multiple operations, each `construct` call produces a different concrete awaitable type. The per-awaitable function pointers (`await_ready`, `await_suspend`, `await_resume`, `destroy`) must match the type that was constructed. Returning the correct ops pointer from each `construct` call solves this. Embedding the four function pointers directly in the vtable, as the flat layout does, would require one full set per operation -- workable for one operation, unwieldy for four. When the operations share an await-return type (`any_read_source`) a single ops struct suffices; when they differ (`any_buffer_source`, `any_buffer_sink`, `any_write_sink`) the wrapper carries one ops struct per result type. diff --git a/doc/modules/ROOT/pages/9.design/9l.RunApi.adoc b/doc/modules/ROOT/pages/9.design/9l.RunApi.adoc index 414c370c7..17ab64484 100644 --- a/doc/modules/ROOT/pages/9.design/9l.RunApi.adoc +++ b/doc/modules/ROOT/pages/9.design/9l.RunApi.adoc @@ -277,16 +277,26 @@ The `run_async_wrapper` constructor sets the thread-local allocator: [source,cpp] ---- -run_async_wrapper(Ex ex, std::stop_token st, Handlers h, Alloc a) +run_async_wrapper(Ex ex, std::stop_token st, Handlers h, Alloc a) noexcept : tr_(detail::make_trampoline( std::move(ex), std::move(h), std::move(a))) , st_(std::move(st)) + , saved_tls_(get_current_frame_allocator()) // remember prior TLS { // Set TLS before task argument is evaluated set_current_frame_allocator(tr_.h_.promise().get_resource()); } + +~run_async_wrapper() +{ + // Restore the prior TLS so a stale pointer does not outlive + // the execution context that owns the trampoline's resource. + set_current_frame_allocator(saved_tls_); +} ---- +The constructor saves the current thread-local allocator and the destructor restores it. This save/restore is the load-bearing correctness guarantee: once the wrapper (and the trampoline it owns) is gone, the TLS no longer points at a resource that may have been destroyed. + The task's `operator new` reads it: [source,cpp] diff --git a/include/boost/capy/buffers/buffer_param.hpp b/include/boost/capy/buffers/buffer_param.hpp index dd2eea191..9b53e50a2 100644 --- a/include/boost/capy/buffers/buffer_param.hpp +++ b/include/boost/capy/buffers/buffer_param.hpp @@ -63,9 +63,10 @@ namespace capy { When iterating through large buffer sequences, it is often more efficient to process buffers in batches rather than - one at a time. This class maintains a window of up to - @ref max_size buffer descriptors, automatically refilling - from the underlying sequence as buffers are consumed. + one at a time. This class maintains a window of up to a + fixed, implementation-defined number of buffer descriptors + (currently 16), automatically refilling from the underlying + sequence as buffers are consumed. @par Example @@ -180,7 +181,8 @@ class buffer_param Returns a span of buffer descriptors representing the currently available portion of the buffer sequence. - The span contains at most @ref max_size buffers. + The span contains at most a fixed, implementation-defined + number of buffers (currently 16). When the current window is exhausted, this function automatically refills from the underlying sequence. diff --git a/include/boost/capy/buffers/make_buffer.hpp b/include/boost/capy/buffers/make_buffer.hpp index f4b288ad9..1ce53aa2c 100644 --- a/include/boost/capy/buffers/make_buffer.hpp +++ b/include/boost/capy/buffers/make_buffer.hpp @@ -28,7 +28,10 @@ BOOST_CAPY_MSVC_WARNING_DISABLE(4459) namespace boost { namespace capy { -/** Return a buffer. +/** Return the buffer unchanged. + + @param b The buffer to return. + @return A copy of `b`, referring to the same storage. */ [[nodiscard]] inline mutable_buffer @@ -38,7 +41,12 @@ make_buffer( return b; } -/** Return a buffer with a maximum size. +/** Return the buffer, clamped to a maximum size. + + @param b The buffer to return. + @param max_size The maximum size, in bytes, of the result. + @return A buffer referring to the storage of `b` whose size + is the smaller of `b.size()` and `max_size`. */ [[nodiscard]] inline mutable_buffer @@ -51,7 +59,12 @@ make_buffer( b.size() < max_size ? b.size() : max_size); } -/** Return a buffer. +/** Return a buffer referring to a region of memory. + + @param data A pointer to the start of the region. The region + must outlive the returned buffer. + @param size The size of the region, in bytes. + @return A buffer referring to `[data, data + size)`. */ [[nodiscard]] inline mutable_buffer @@ -62,7 +75,14 @@ make_buffer( return mutable_buffer(data, size); } -/** Return a buffer with a maximum size. +/** Return a buffer referring to a region of memory, clamped to a maximum size. + + @param data A pointer to the start of the region. The region + must outlive the returned buffer. + @param size The size of the region, in bytes. + @param max_size The maximum size, in bytes, of the result. + @return A buffer referring to `data` whose size is the smaller + of `size` and `max_size`. */ [[nodiscard]] inline mutable_buffer @@ -76,7 +96,10 @@ make_buffer( size < max_size ? size : max_size); } -/** Return a buffer. +/** Return the buffer unchanged. + + @param b The buffer to return. + @return A copy of `b`, referring to the same storage. */ [[nodiscard]] inline const_buffer @@ -86,7 +109,12 @@ make_buffer( return b; } -/** Return a buffer with a maximum size. +/** Return the buffer, clamped to a maximum size. + + @param b The buffer to return. + @param max_size The maximum size, in bytes, of the result. + @return A buffer referring to the storage of `b` whose size + is the smaller of `b.size()` and `max_size`. */ [[nodiscard]] inline const_buffer @@ -99,7 +127,12 @@ make_buffer( b.size() < max_size ? b.size() : max_size); } -/** Return a buffer. +/** Return a buffer referring to a region of memory. + + @param data A pointer to the start of the region. The region + must outlive the returned buffer. + @param size The size of the region, in bytes. + @return A buffer referring to `[data, data + size)`. */ [[nodiscard]] inline const_buffer @@ -110,7 +143,14 @@ make_buffer( return const_buffer(data, size); } -/** Return a buffer with a maximum size. +/** Return a buffer referring to a region of memory, clamped to a maximum size. + + @param data A pointer to the start of the region. The region + must outlive the returned buffer. + @param size The size of the region, in bytes. + @param max_size The maximum size, in bytes, of the result. + @return A buffer referring to `data` whose size is the smaller + of `size` and `max_size`. */ [[nodiscard]] inline const_buffer @@ -126,7 +166,12 @@ make_buffer( // std::basic_string_view -/** Return a buffer from a std::basic_string_view. +/** Return a buffer from a `std::basic_string_view`. + + @param data The view whose characters are referenced. The + underlying storage must outlive the returned buffer. + @return A buffer referring to the view's storage. The size, + in bytes, is `data.size() * sizeof(CharT)`. */ template [[nodiscard]] @@ -139,7 +184,13 @@ make_buffer( data.size() * sizeof(CharT)); } -/** Return a buffer from a std::basic_string_view with a maximum size. +/** Return a buffer from a `std::basic_string_view`, clamped to a maximum size. + + @param data The view whose characters are referenced. The + underlying storage must outlive the returned buffer. + @param max_size The maximum size, in bytes, of the result. + @return A buffer referring to the view's storage whose size is + the smaller of `data.size() * sizeof(CharT)` and `max_size`. */ template [[nodiscard]] @@ -187,6 +238,7 @@ concept const_contiguous_range = `std::string`, `std::span`, `boost::span`, and built-in arrays, whether passed as an lvalue or a temporary. The returned buffer refers to the range's storage, which must outlive the buffer. + Its size, in bytes, is `size() * sizeof(element)`. */ template [[nodiscard]] @@ -198,7 +250,16 @@ make_buffer(T&& data) noexcept std::ranges::size(data) * sizeof(std::ranges::range_value_t)); } -/** Return a buffer from a mutable contiguous range with a maximum size. +/** Return a buffer from a mutable contiguous range, clamped to a maximum size. + + Like the unclamped overload, but the result is no larger than + `max_size` bytes. + + @param data The range whose storage is referenced. It must + outlive the returned buffer. + @param max_size The maximum size, in bytes, of the result. + @return A buffer whose size is the smaller of + `size() * sizeof(element)` and `max_size`. */ template [[nodiscard]] @@ -219,7 +280,8 @@ make_buffer( elements with const access, including const `std::vector`, `std::array`, `std::string`, `std::span`, `boost::span`, and string literals. The returned buffer refers to the range's - storage, which must outlive the buffer. + storage, which must outlive the buffer. Its size, in bytes, + is `size() * sizeof(element)`. */ template [[nodiscard]] @@ -231,7 +293,16 @@ make_buffer(T const& data) noexcept std::ranges::size(data) * sizeof(std::ranges::range_value_t)); } -/** Return a buffer from a const contiguous range with a maximum size. +/** Return a buffer from a const contiguous range, clamped to a maximum size. + + Like the unclamped overload, but the result is no larger than + `max_size` bytes. + + @param data The range whose storage is referenced. It must + outlive the returned buffer. + @param max_size The maximum size, in bytes, of the result. + @return A buffer whose size is the smaller of + `size() * sizeof(element)` and `max_size`. */ template [[nodiscard]] diff --git a/include/boost/capy/concept/buffer_archetype.hpp b/include/boost/capy/concept/buffer_archetype.hpp index adc2eaea7..b9f0e031e 100644 --- a/include/boost/capy/concept/buffer_archetype.hpp +++ b/include/boost/capy/concept/buffer_archetype.hpp @@ -19,8 +19,9 @@ namespace capy { /** Archetype for ConstBufferSequence concept checking. This type satisfies @ref ConstBufferSequence but cannot be - instantiated. Use it in concept definitions to verify that - a function template accepts any ConstBufferSequence. + default-constructed; it is intended only as an unevaluated + parameter type in `requires`-clauses, to verify that a function + template accepts any ConstBufferSequence. @par Example @code @@ -54,8 +55,9 @@ using const_buffer_archetype = const_buffer_archetype_; /** Archetype for MutableBufferSequence concept checking. This type satisfies @ref MutableBufferSequence but cannot be - instantiated. Use it in concept definitions to verify that - a function template accepts any MutableBufferSequence. + default-constructed; it is intended only as an unevaluated + parameter type in `requires`-clauses, to verify that a function + template accepts any MutableBufferSequence. @par Example @code diff --git a/include/boost/capy/concept/dynamic_buffer.hpp b/include/boost/capy/concept/dynamic_buffer.hpp index a6b40a833..9d6b5e465 100644 --- a/include/boost/capy/concept/dynamic_buffer.hpp +++ b/include/boost/capy/concept/dynamic_buffer.hpp @@ -21,14 +21,18 @@ There are two kinds of dynamic buffer types: - 1. VALUE TYPES (e.g., flat_dynamic_buffer) - - Store bookkeeping (size, capacity) internally + 1. VALUE TYPES (a user-defined buffer that owns its storage, + e.g. one holding a std::array member) + - Store the bytes and bookkeeping inside the object itself - MUST be passed by lvalue reference to preserve state - - Passing as rvalue loses bookkeeping on coroutine suspend - - 2. WRAPPER ADAPTERS (e.g., string_dynamic_buffer) - - Reference external storage (std::string, std::vector) - - Safe to pass as rvalues; external object retains data + - Passing as rvalue loses the data on coroutine suspend + + 2. WRAPPER ADAPTERS (all of Capy's provided types: + flat_dynamic_buffer, circular_dynamic_buffer, + vector_dynamic_buffer, string_dynamic_buffer) + - Reference external storage (a caller-owned array, + std::string, or std::vector) + - Safe to pass as rvalues; the external object retains data - Define `using is_dynamic_buffer_adapter = void;` When writing functions: @@ -64,11 +68,16 @@ namespace capy { @par Value Types vs Wrapper Adapters Dynamic buffer types fall into two categories: - - **Value types** (e.g., `flat_dynamic_buffer`) store bookkeeping - internally. Passing as rvalue to a coroutine loses state on suspend. + - **Value types** store the bytes and bookkeeping inside the object. + Passing one as an rvalue to a coroutine loses state on suspend. Capy + ships no value types; this category is for user-defined buffers that + own their storage internally. - - **Wrapper adapters** (e.g., `string_dynamic_buffer`) reference external - storage and are safe as rvalues since the external object persists. + - **Wrapper adapters** reference external storage and are safe as rvalues + since the external object persists. All of Capy's provided dynamic + buffers are adapters: `flat_dynamic_buffer` and `circular_dynamic_buffer` + (over a caller-owned array), `vector_dynamic_buffer`, and + `string_dynamic_buffer`. Each defines `using is_dynamic_buffer_adapter = void;`. @par Conforming Signatures For **non-coroutine** functions, use `DynamicBuffer auto&`: @@ -123,9 +132,10 @@ concept DynamicBuffer = - **Lvalues** of any DynamicBuffer (caller manages lifetime) - **Rvalues** only for types with `is_dynamic_buffer_adapter` tag - The distinction exists because some buffer types (like `flat_dynamic_buffer`) - store bookkeeping internally that would be lost if passed by rvalue, - while adapters (like `string_dynamic_buffer`) update external storage directly. + The distinction exists because a value type stores its bytes and + bookkeeping internally, which would be lost if passed by rvalue, while + adapters (such as Capy's `flat_dynamic_buffer` or `string_dynamic_buffer`) + update external storage directly. @par Conforming Signatures For coroutine functions, use a forwarding reference: @@ -138,21 +148,25 @@ concept DynamicBuffer = the value category to enforce the lvalue/rvalue rules. Using the wrong reference type causes incorrect behavior: @code + // owning_buffer is a user-defined value type: it owns its storage and + // has no is_dynamic_buffer_adapter tag. ob is an lvalue of it. + owning_buffer ob; + // WRONG: lvalue ref rejects valid rvalue adapters void bad1( DynamicBufferParam auto& buffers ); - bad1( fb ); // OK + bad1( ob ); // OK bad1( string_dynamic_buffer( &s ) ); // compile error, but should work // WRONG: const ref deduces non-reference, rejects non-adapters void bad2( DynamicBufferParam auto const& buffers ); - bad2( fb ); // compile error, but should work + bad2( ob ); // compile error, but should work bad2( string_dynamic_buffer( &s ) ); // OK (adapter only) // CORRECT: forwarding ref enables proper checking void good( DynamicBufferParam auto&& buffers ); - good( fb ); // OK: lvalue + good( ob ); // OK: lvalue value type good( string_dynamic_buffer( &s ) ); // OK: adapter rvalue - good( flat_dynamic_buffer( storage ) ); // compile error: non-adapter rvalue + good( owning_buffer{} ); // compile error: non-adapter rvalue @endcode @par Adapter Types @@ -167,16 +181,21 @@ concept DynamicBuffer = @par Example @code - // OK: lvalue reference - flat_dynamic_buffer fb( storage ); + // OK: lvalue reference (any DynamicBuffer) + char storage[1024]; + flat_dynamic_buffer fb( storage, sizeof( storage ) ); co_await read( stream, fb ); - // OK: adapter as rvalue, string retains data + // OK: adapter as rvalue — flat references the caller's array, + // which persists across the suspend + co_await read( stream, flat_dynamic_buffer( storage, sizeof( storage ) ) ); + + // OK: adapter as rvalue — the string retains the data std::string s; co_await read( stream, string_dynamic_buffer( &s ) ); - // ERROR: non-adapter rvalue - co_await read( stream, flat_dynamic_buffer( storage ) ); // compile error + // ERROR: a user-defined value type as rvalue loses state on suspend + co_await read( stream, owning_buffer{} ); // compile error: non-adapter rvalue @endcode @see DynamicBuffer diff --git a/include/boost/capy/concept/executor.hpp b/include/boost/capy/concept/executor.hpp index d7582d9ff..efec3d058 100644 --- a/include/boost/capy/concept/executor.hpp +++ b/include/boost/capy/concept/executor.hpp @@ -91,7 +91,7 @@ class execution_context; std::coroutine_handle<> dispatch( continuation& c ) const { - if( ctx_.is_running_on_this_thread() ) + if( ctx_.running_in_this_thread() ) return c.h; // symmetric transfer post( c ); return std::noop_coroutine(); diff --git a/include/boost/capy/concept/io_runnable.hpp b/include/boost/capy/concept/io_runnable.hpp index 2c66e7541..202b69e02 100644 --- a/include/boost/capy/concept/io_runnable.hpp +++ b/include/boost/capy/concept/io_runnable.hpp @@ -33,8 +33,8 @@ namespace capy { @li `T` must satisfy @ref IoAwaitable @li `T::promise_type` must be a valid type - @li `t.handle()` returns `std::coroutine_handle`, - must be `noexcept` + @li `ct.handle()` (callable on a `const` task) returns + `std::coroutine_handle`, must be `noexcept` @li `t.release()` releases ownership, must be `noexcept` @li `p.exception()` returns `std::exception_ptr`, must be `noexcept` @li `p.result()` returns the task result (required for non-void tasks) diff --git a/include/boost/capy/concept/stream.hpp b/include/boost/capy/concept/stream.hpp index 6f5265220..944f0d451 100644 --- a/include/boost/capy/concept/stream.hpp +++ b/include/boost/capy/concept/stream.hpp @@ -37,7 +37,7 @@ namespace capy { task<> echo(S& stream) { char buf[1024]; - auto [ec, n] = co_await stream.read_some(mutable_buffer(buf)); + auto [ec, n] = co_await stream.read_some(make_buffer(buf)); if(ec) co_return; co_await stream.write_some(const_buffer(buf, n)); diff --git a/include/boost/capy/delay.hpp b/include/boost/capy/delay.hpp index 279fd9f44..f4e524554 100644 --- a/include/boost/capy/delay.hpp +++ b/include/boost/capy/delay.hpp @@ -110,6 +110,8 @@ class delay_awaitable } public: + /// Construct an awaitable that waits for `dur` nanoseconds. + /// Prefer the @ref delay factory over constructing directly. explicit delay_awaitable(std::chrono::nanoseconds dur) noexcept : dur_(dur) { @@ -128,6 +130,8 @@ class delay_awaitable { } + /// Tear down any registered stop callback and cancel the + /// pending timer if one is still scheduled. ~delay_awaitable() { if(stop_cb_active_) @@ -140,11 +144,16 @@ class delay_awaitable delay_awaitable& operator=(delay_awaitable const&) = delete; delay_awaitable& operator=(delay_awaitable&&) = delete; + /// Return true for zero or negative durations, completing + /// synchronously without scheduling a timer. bool await_ready() const noexcept { return dur_.count() <= 0; } + /// Suspend the coroutine, scheduling the timer and a stop + /// callback on the environment's executor and stop token. + /// Resumes `h` immediately if stop was already requested. std::coroutine_handle<> await_suspend( std::coroutine_handle<> h, @@ -180,6 +189,9 @@ class delay_awaitable return std::noop_coroutine(); } + /// Clean up the stop callback and timer, then return + /// `io_result<>{error::canceled}` if cancellation claimed + /// the resume, or an empty `io_result<>` otherwise. io_result<> await_resume() noexcept { if(stop_cb_active_) diff --git a/include/boost/capy/ex/execution_context.hpp b/include/boost/capy/ex/execution_context.hpp index 1000dec73..755b1aae8 100644 --- a/include/boost/capy/ex/execution_context.hpp +++ b/include/boost/capy/ex/execution_context.hpp @@ -80,7 +80,7 @@ namespace capy { ctx.find_service(); // also works @endcode - @see service, is_execution_context + @see service, ExecutionContext */ class BOOST_CAPY_DECL execution_context @@ -97,6 +97,19 @@ class BOOST_CAPY_DECL using type = typename T::key_type; }; protected: + /** Construct from the most-derived context type. + + Records the dynamic type of the context so that + @ref target can later downcast `this` to the + requested derived type. Derived classes must pass + `this` typed as the most-derived type (i.e. invoke + this constructor from the most-derived class with + `this` of that type). Passing a pointer typed as a + base class records the wrong type and causes + `target()` to return `nullptr`. + + @tparam Derived The most-derived context type. + */ template< typename Derived > explicit execution_context( Derived* ) noexcept; diff --git a/include/boost/capy/ex/executor_ref.hpp b/include/boost/capy/ex/executor_ref.hpp index f6010b7bd..3eb293a1e 100644 --- a/include/boost/capy/ex/executor_ref.hpp +++ b/include/boost/capy/ex/executor_ref.hpp @@ -101,7 +101,7 @@ inline constexpr executor_vtable vtable_for = { ex.post(my_continuation); } - io_context ctx; + thread_pool ctx; store_executor(ctx.get_executor()); @endcode diff --git a/include/boost/capy/ex/frame_alloc_mixin.hpp b/include/boost/capy/ex/frame_alloc_mixin.hpp index 7888440b2..11d54f692 100644 --- a/include/boost/capy/ex/frame_alloc_mixin.hpp +++ b/include/boost/capy/ex/frame_alloc_mixin.hpp @@ -71,6 +71,14 @@ struct frame_alloc_mixin correct deallocation even when TLS changes. Uses memcpy to avoid alignment requirements on the trailing pointer. Bypasses virtual dispatch for the recycling allocator. + + @param size The size, in bytes, of the coroutine frame. + + @return A pointer to storage for the frame. + + @throws Propagates any exception thrown by the underlying + memory resource's `allocate` (for example `std::bad_alloc` + from `::operator new`). */ static void* operator new(std::size_t size) { diff --git a/include/boost/capy/ex/recycling_memory_resource.hpp b/include/boost/capy/ex/recycling_memory_resource.hpp index 5b9c3ed2f..5fa90a8a8 100644 --- a/include/boost/capy/ex/recycling_memory_resource.hpp +++ b/include/boost/capy/ex/recycling_memory_resource.hpp @@ -33,6 +33,15 @@ namespace capy { This is the default allocator used by run_async when no allocator is specified. + @note This resource honors only the default new alignment + (`__STDCPP_DEFAULT_NEW_ALIGNMENT__`, typically + `alignof(std::max_align_t)`). The alignment argument passed to + `do_allocate`/`do_deallocate` (and to `allocate_fast`/`deallocate_fast`) + is ignored; backing storage comes from `::operator new`. Over-aligned + requests are therefore not satisfied. This is sufficient for coroutine + frame allocation but means the resource cannot be used where + over-aligned memory is required. + @par Thread Safety Thread-safe. The thread-local pool requires no synchronization. The global pool uses a mutex for cross-thread access. @@ -126,6 +135,13 @@ class BOOST_CAPY_DECL recycling_memory_resource : public std::pmr::memory_resour void deallocate_slow(void* p, std::size_t idx); public: + /** Destructor. + + Releases any blocks still held in this resource's thread-local + pool for the calling thread. Blocks held in the process-wide + global pool, and in other threads' thread-local pools, are + released when those pools are destroyed. + */ ~recycling_memory_resource(); /** Allocate without virtual dispatch. @@ -133,9 +149,16 @@ class BOOST_CAPY_DECL recycling_memory_resource : public std::pmr::memory_resour Handles the fast path inline (thread-local bucket pop) and falls through to the slow path for global pool or heap allocation. + + @param bytes The number of bytes to allocate. + + @return A pointer to the allocated storage. + + @note The second (alignment) argument is ignored; only the + default new alignment is honored. See the class-level note. */ void* - allocate_fast(std::size_t bytes, std::size_t) + allocate_fast(std::size_t bytes, std::size_t /*alignment*/) { std::size_t rounded = round_up_pow2(bytes); std::size_t idx = get_class_index(rounded); @@ -152,9 +175,17 @@ class BOOST_CAPY_DECL recycling_memory_resource : public std::pmr::memory_resour Handles the fast path inline (thread-local bucket push) and falls through to the slow path for global pool or heap deallocation. + + @param p Pointer previously returned by `allocate_fast` + (or `do_allocate`) on a resource that compares equal to this one. + + @param bytes The size, in bytes, originally requested for `p`. + + @note The third (alignment) argument is ignored; only the + default new alignment is honored. See the class-level note. */ void - deallocate_fast(void* p, std::size_t bytes, std::size_t) + deallocate_fast(void* p, std::size_t bytes, std::size_t /*alignment*/) { std::size_t rounded = round_up_pow2(bytes); std::size_t idx = get_class_index(rounded); @@ -170,11 +201,29 @@ class BOOST_CAPY_DECL recycling_memory_resource : public std::pmr::memory_resour } protected: + /** Allocate storage (`std::pmr::memory_resource` interface). + + Forwards to `allocate_fast`. The alignment argument is ignored; + see the class-level note. + + @param bytes The number of bytes to allocate. + + @return A pointer to the allocated storage. + */ void* - do_allocate(std::size_t bytes, std::size_t) override; + do_allocate(std::size_t bytes, std::size_t /*alignment*/) override; + /** Deallocate storage (`std::pmr::memory_resource` interface). + + Forwards to `deallocate_fast`. The alignment argument is ignored; + see the class-level note. + + @param p Pointer previously returned by `do_allocate`. + + @param bytes The size, in bytes, originally requested for `p`. + */ void - do_deallocate(void* p, std::size_t bytes, std::size_t) override; + do_deallocate(void* p, std::size_t bytes, std::size_t /*alignment*/) override; bool do_is_equal(const memory_resource& other) const noexcept override diff --git a/include/boost/capy/ex/run_async.hpp b/include/boost/capy/ex/run_async.hpp index 83ab93211..70fd8ed54 100644 --- a/include/boost/capy/ex/run_async.hpp +++ b/include/boost/capy/ex/run_async.hpp @@ -346,7 +346,22 @@ class [[nodiscard]] run_async_wrapper std::pmr::memory_resource* saved_tls_; public: - /// Construct wrapper with executor, stop token, handlers, and allocator. + /** Construct the wrapper and install the frame allocator. + + Builds the trampoline, saves the current thread-local frame + allocator, and installs the trampoline's resource as the new + thread-local allocator so that the task frame (evaluated as the + argument to @ref operator()) is allocated from it. + + @param ex The executor on which the task runs. + @param st The stop token for cooperative cancellation. + @param h The completion handlers. + @param a The allocator for frame allocation. + + @note When `Alloc` is not `std::pmr::memory_resource*` it must be + nothrow move constructible (enforced by a `static_assert`), which + is what allows this constructor to be `noexcept`. + */ run_async_wrapper( Ex ex, std::stop_token st, @@ -367,10 +382,14 @@ class [[nodiscard]] run_async_wrapper set_current_frame_allocator(tr_.h_.promise().get_resource()); } + /** Restore the previously installed frame allocator. + + Resets the thread-local frame allocator to the value saved at + construction, so a stale pointer to the trampoline's resource does + not outlive the execution context that owns it. + */ ~run_async_wrapper() { - // Restore TLS so stale pointer doesn't outlive - // the execution context that owns the resource. set_current_frame_allocator(saved_tls_); } diff --git a/include/boost/capy/ex/strand.hpp b/include/boost/capy/ex/strand.hpp index b761622c9..6a22f3bb0 100644 --- a/include/boost/capy/ex/strand.hpp +++ b/include/boost/capy/ex/strand.hpp @@ -47,7 +47,7 @@ namespace capy { This class satisfies the `Executor` concept, providing: - `context()` - Returns the underlying execution context - `on_work_started()` / `on_work_finished()` - Work tracking - - `dispatch(continuation&)` - May run immediately if strand is idle + - `dispatch(continuation&)` - May run immediately if already executing in this strand - `post(continuation&)` - Always queues for later execution @par Thread Safety @@ -68,7 +68,7 @@ namespace capy { strand.post(c3); @endcode - @tparam E The type of the underlying executor. Must + @tparam Ex The type of the underlying executor. Must satisfy the `Executor` concept. @see Executor diff --git a/include/boost/capy/ex/system_context.hpp b/include/boost/capy/ex/system_context.hpp index 50929549b..ec078bbca 100644 --- a/include/boost/capy/ex/system_context.hpp +++ b/include/boost/capy/ex/system_context.hpp @@ -16,15 +16,17 @@ namespace boost { namespace capy { -/** Return the process-wide system execution context. +/** Return the process-wide system thread pool. - This singleton context serves as a container for services - but does not provide an executor or handle work. + This singleton is the default execution context used when no + explicit context is supplied (for example, by timers and + services). It provides an executor via `get_executor()` and + runs scheduled work on its worker threads. @par Thread Safety Safe to call from any thread. - @return Reference to the system execution context singleton. + @return Reference to the system thread pool singleton. */ BOOST_CAPY_DECL auto get_system_context() -> thread_pool&; diff --git a/include/boost/capy/ex/this_coro.hpp b/include/boost/capy/ex/this_coro.hpp index 6f7bca236..897e27c47 100644 --- a/include/boost/capy/ex/this_coro.hpp +++ b/include/boost/capy/ex/this_coro.hpp @@ -26,7 +26,7 @@ namespace capy { @code task example() { - auto const& env = co_await this_coro::environment; + auto* env = co_await this_coro::environment; auto ex = co_await this_coro::executor; auto token = co_await this_coro::stop_token; auto* alloc = co_await this_coro::frame_allocator; @@ -92,15 +92,21 @@ struct frame_allocator_tag {}; @code task example() { - auto const& env = co_await this_coro::environment; - // env.executor - the executor this coroutine is bound to - // env.stop_token - the stop token for cancellation - // env.frame_allocator - the frame allocator + auto* env = co_await this_coro::environment; + // env->executor - the executor this coroutine is bound to + // env->stop_token - the stop token for cancellation + // env->frame_allocator - the frame allocator } @endcode + @par Preconditions + An `io_env` must have been installed for this coroutine before the tag + is awaited. Launching the coroutine via @ref run or `run_async` installs + one; awaiting the tag without an installed environment is undefined + behavior (an assertion fires in debug builds). + @par Behavior - @li Returns a const reference to the stored `io_env` + @li Returns a pointer to the stored `io_env` @li This operation never suspends; `await_ready()` always returns `true` @see environment_tag @@ -124,9 +130,16 @@ inline constexpr environment_tag environment{}; } @endcode + @par Preconditions + An `io_env` must have been installed for this coroutine before the tag + is awaited (see @ref environment). Awaiting it without an installed + environment is undefined behavior (an assertion fires in debug builds). + @par Behavior - @li If no executor was set, returns a default-constructed - `executor_ref` (where `operator bool()` returns `false`). + @li Returns the installed environment's `executor` field. If the launched + chain installed an `io_env` whose `executor` was left default, the + result is a default-constructed `executor_ref` (where `operator bool()` + returns `false`). @li This operation never suspends; `await_ready()` always returns `true`. @see executor_tag @@ -151,9 +164,16 @@ inline constexpr executor_tag executor{}; } @endcode + @par Preconditions + An `io_env` must have been installed for this coroutine before the tag + is awaited (see @ref environment). Awaiting it without an installed + environment is undefined behavior (an assertion fires in debug builds). + @par Behavior - @li If no stop token was propagated, returns a default-constructed - `std::stop_token` (where `stop_possible()` returns `false`). + @li Returns the installed environment's `stop_token` field. If the launched + chain installed an `io_env` whose `stop_token` was left default, the + result is a default-constructed `std::stop_token` (where + `stop_possible()` returns `false`). @li The returned token remains valid for the coroutine's lifetime. @li This operation never suspends; `await_ready()` always returns `true`. @@ -178,8 +198,14 @@ inline constexpr stop_token_tag stop_token{}; } @endcode + @par Preconditions + An `io_env` must have been installed for this coroutine before the tag + is awaited (see @ref environment). Awaiting it without an installed + environment is undefined behavior (an assertion fires in debug builds). + @par Behavior - @li Returns `nullptr` when the default allocator is in use. + @li Returns the installed environment's `frame_allocator` field, which is + `nullptr` when the default allocator is in use. @li This operation never suspends; `await_ready()` always returns `true`. @see frame_allocator_tag diff --git a/include/boost/capy/io/pull_from.hpp b/include/boost/capy/io/pull_from.hpp index e639add0c..36579bfa4 100644 --- a/include/boost/capy/io/pull_from.hpp +++ b/include/boost/capy/io/pull_from.hpp @@ -111,7 +111,7 @@ pull_from(Src& source, Sink& sink) data incrementally as it arrives. It loops until EOF is encountered or an error occurs. - @tparam Src The source type, must satisfy @ref ReadStream. + @tparam Src The stream type, must satisfy @ref ReadStream. @tparam Sink The sink type, must satisfy @ref BufferSink. @param source The stream to read data from. diff --git a/include/boost/capy/quitter.hpp b/include/boost/capy/quitter.hpp index 494dba660..864c18af8 100644 --- a/include/boost/capy/quitter.hpp +++ b/include/boost/capy/quitter.hpp @@ -86,6 +86,23 @@ template struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE quitter { + /** The coroutine promise type for `quitter`. + + This is the promise object the compiler associates with a + `quitter` coroutine. It satisfies the coroutine promise + requirements and participates in the I/O awaitable protocol via + @ref io_awaitable_promise_base. Unlike @ref task::promise_type, + its `transform_awaitable` checks the stop token before each + awaited result reaches the body, throwing an internal sentinel + exception that unwinds to a "stopped" completion. It is part of + the coroutine machinery and is not intended to be used directly + by callers. + + Result storage and `return_value`/`return_void` are provided by + `detail::quitter_return_base`. + + @see io_awaitable_promise_base, IoRunnable + */ struct promise_type : io_awaitable_promise_base , detail::quitter_return_base @@ -99,11 +116,13 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE completion state_; public: + /// Construct the promise in the running state. promise_type() noexcept : state_(completion::running) { } + /// Destroy the promise, releasing any stored exception. ~promise_type() { if(state_ == completion::exception || @@ -130,12 +149,30 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE return state_ == completion::stopped; } + /** Return the owning `quitter` for this coroutine. + + Called by the compiler to produce the object returned to the + caller when the coroutine is created. + + @return A `quitter` owning the coroutine frame. + */ quitter get_return_object() { return quitter{ std::coroutine_handle::from_promise(*this)}; } + /** Return the initial-suspend awaiter. + + The coroutine always suspends at the initial suspend point, + so the body does not start until the quitter is awaited. When + the body is resumed, the awaiter restores the thread-local + frame allocator and, if stop has already been requested, + throws the internal sentinel exception so the body never + runs and the coroutine completes as stopped. + + @return An awaiter that suspends unconditionally. + */ auto initial_suspend() noexcept { struct awaiter @@ -164,6 +201,15 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE return awaiter{this}; } + /** Return the final-suspend awaiter. + + The coroutine always suspends at the final suspend point. The + awaiter's `await_suspend` performs symmetric transfer to the + stored continuation, resuming the awaiting coroutine. + + @return An awaiter that suspends and transfers to the + continuation. + */ auto final_suspend() noexcept { struct awaiter @@ -186,6 +232,14 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE return awaiter{this}; } + /** Capture the in-flight exception from the coroutine body. + + Called by the compiler when the coroutine body exits via an + unhandled exception. The internal stop sentinel is recorded as + a stopped completion; any other exception is recorded as an + exception completion. The stored exception is surfaced (or + routed to the error handler) when the quitter is awaited or run. + */ void unhandled_exception() { try @@ -213,6 +267,17 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE // transform_awaitable — the key difference from task //------------------------------------------------------ + /** Awaiter wrapping a nested `co_await` of an @ref IoAwaitable. + + Forwards the environment to the inner awaitable's + environment-taking `await_suspend` and restores the + thread-local frame allocator before the body resumes. Unlike + `task`'s, it also checks the stop token on resumption, throwing + the internal sentinel so a stop request unwinds the body before + it observes the I/O result. + + @tparam Awaitable The awaitable being transformed. + */ template struct transform_awaiter { @@ -251,6 +316,17 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE } }; + /** Transform a nested awaitable before `co_await`. + + Wraps an @ref IoAwaitable in a @ref transform_awaiter so the + coroutine's environment is propagated into it and the stop + token is checked on resumption. A diagnostic is emitted if the + awaitable does not satisfy @ref IoAwaitable. + + @param a The awaitable expression from `co_await a`. + + @return A @ref transform_awaiter wrapping `a`. + */ template auto transform_awaitable(Awaitable&& a) { @@ -268,6 +344,12 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE } }; + /** Handle to the owned coroutine frame. + + Null when the quitter is empty (for example after a move or after + @ref release). Prefer @ref handle to read this; the member is + public for use by the coroutine machinery. + */ std::coroutine_handle h_; /// Destroy the quitter and its coroutine frame if owned. diff --git a/include/boost/capy/read.hpp b/include/boost/capy/read.hpp index 9cce67b45..9df99cc18 100644 --- a/include/boost/capy/read.hpp +++ b/include/boost/capy/read.hpp @@ -77,7 +77,7 @@ namespace capy { capy::task<> process_message(capy::ReadStream auto& stream) { std::vector header(16); // known header size for some protocol - auto [ec, n] = co_await capy::read(stream, capy::mutable_buffer(header)); + auto [ec, n] = co_await capy::read(stream, capy::make_buffer(header)); if (ec == capy::cond::eof) co_return; // Connection closed if (ec) diff --git a/include/boost/capy/read_until.hpp b/include/boost/capy/read_until.hpp index fd6b8f41a..b5ba69e76 100644 --- a/include/boost/capy/read_until.hpp +++ b/include/boost/capy/read_until.hpp @@ -265,8 +265,12 @@ struct match_delim An object of type `io_result` destructuring as `[ec, n]`. - If `bool(ec)`, `n` is the position returned by the match condition - (bytes up to and including the matched delimiter). + If `!ec`, the match succeeded and `n` is the position returned by the + match condition (the number of bytes through the end of the + match, i.e. the position one past the matched delimiter). + + If `bool(ec)`, the match was not found and `n` is the number of bytes + accumulated in `dynbuf` before the contingency arose. Contingencies: @@ -367,7 +371,8 @@ read_until( 2048). Grows by 1.5x when filled. @return An awaitable that await-returns `(error_code, std::size_t)`. - On success, `n` is bytes up to and including the delimiter. + On success, `n` is the number of bytes through the end of the + delimiter (i.e. the position one past the delimiter). Compare error codes to conditions: @li `cond::eof` - EOF before delimiter; `n` is buffer size @li `cond::not_found` - `max_size()` reached before delimiter diff --git a/include/boost/capy/task.hpp b/include/boost/capy/task.hpp index 252e4d1fa..119ba442f 100644 --- a/include/boost/capy/task.hpp +++ b/include/boost/capy/task.hpp @@ -98,6 +98,19 @@ template struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE task { + /** The coroutine promise type for `task`. + + This is the promise object the compiler associates with a + `task` coroutine. It satisfies the coroutine promise + requirements and participates in the I/O awaitable protocol via + @ref io_awaitable_promise_base. It is part of the coroutine + machinery and is not intended to be used directly by callers. + + Result storage and `return_value`/`return_void` are provided by + `detail::task_return_base`. + + @see io_awaitable_promise_base, IoRunnable + */ struct promise_type : io_awaitable_promise_base , detail::task_return_base @@ -108,17 +121,24 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE bool has_ep_; public: + /// Construct the promise with no stored exception. promise_type() noexcept : has_ep_(false) { } + /// Destroy the promise, releasing any stored exception. ~promise_type() { if(has_ep_) ep_.~exception_ptr(); } + /** Return the exception captured by the coroutine body, if any. + + @return The stored exception, or a null `std::exception_ptr` + if the coroutine did not exit via an unhandled exception. + */ std::exception_ptr exception() const noexcept { if(has_ep_) @@ -126,11 +146,27 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE return {}; } + /** Return the owning `task` for this coroutine. + + Called by the compiler to produce the object returned to the + caller when the coroutine is created. + + @return A `task` owning the coroutine frame. + */ task get_return_object() { return task{std::coroutine_handle::from_promise(*this)}; } + /** Return the initial-suspend awaiter. + + The coroutine always suspends at the initial suspend point, + so the body does not start until the task is awaited. When the + body is resumed, the awaiter restores the thread-local frame + allocator from the stored environment. + + @return An awaiter that suspends unconditionally. + */ auto initial_suspend() noexcept { struct awaiter @@ -155,6 +191,16 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE return awaiter{this}; } + /** Return the final-suspend awaiter. + + The coroutine always suspends at the final suspend point. The + awaiter's `await_suspend` performs symmetric transfer to the + stored continuation (consuming it), resuming the awaiting + coroutine. + + @return An awaiter that suspends and transfers to the + continuation. + */ auto final_suspend() noexcept { struct awaiter @@ -176,12 +222,26 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE return awaiter{this}; } + /** Capture the in-flight exception from the coroutine body. + + Called by the compiler when the coroutine body exits via an + unhandled exception. The captured exception is rethrown when + the task is awaited. + */ void unhandled_exception() noexcept { new (&ep_) std::exception_ptr(std::current_exception()); has_ep_ = true; } + /** Awaiter wrapping a nested `co_await` of an @ref IoAwaitable. + + Forwards the environment to the inner awaitable's + environment-taking `await_suspend` and restores the + thread-local frame allocator before the body resumes. + + @tparam Awaitable The awaitable being transformed. + */ template struct transform_awaiter { @@ -211,6 +271,16 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE } }; + /** Transform a nested awaitable before `co_await`. + + Wraps an @ref IoAwaitable in a @ref transform_awaiter so the + coroutine's environment is propagated into it. A diagnostic + is emitted if the awaitable does not satisfy @ref IoAwaitable. + + @param a The awaitable expression from `co_await a`. + + @return A @ref transform_awaiter wrapping `a`. + */ template auto transform_awaitable(Awaitable&& a) { @@ -227,6 +297,12 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE } }; + /** Handle to the owned coroutine frame. + + Null when the task is empty (for example after a move or after + @ref release). Prefer @ref handle to read this; the member is + public for use by the coroutine machinery. + */ std::coroutine_handle h_; /// Destroy the task and its coroutine frame if owned. @@ -236,13 +312,28 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE h_.destroy(); } - /// Return false; tasks are never immediately ready. + /** Report whether the awaited task is already complete. + + Always returns `false`; a task is lazy and has not started when + it is awaited, so the awaiting coroutine always suspends. + + @return `false`. + */ bool await_ready() const noexcept { return false; } - /// Return the result or rethrow any stored exception. + /** Return the task's result, rethrowing any captured exception. + + If the coroutine body exited via an unhandled exception, that + exception is rethrown here. Otherwise the result is returned by + move (for `task`) or nothing is returned (for `task`). + + @return The result value for non-void `T`; otherwise `void`. + + @throws The exception captured by the coroutine body, if any. + */ auto await_resume() { if(h_.promise().has_ep_) @@ -253,7 +344,21 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE return; } - /// Start execution with the caller's context. + /** Start the task with the awaiting coroutine's context. + + Stores `cont` as the continuation to resume on completion and + `env` as the execution environment propagated to nested + `co_await` expressions, then transfers control into the task's + coroutine body via the returned handle. + + @param cont The awaiting coroutine to resume when the task + completes. + + @param env The execution environment (executor, stop token, and + frame allocator). It must outlive the task. + + @return The task's coroutine handle, for symmetric transfer. + */ std::coroutine_handle<> await_suspend(std::coroutine_handle<> cont, io_env const* env) { h_.promise().set_continuation(cont); @@ -300,13 +405,30 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE task(task const&) = delete; task& operator=(task const&) = delete; - /// Construct by moving, transferring ownership. + /** Construct by moving, transferring ownership of the frame. + + @par Postconditions + `other` is empty and must not be awaited. + + @param other The task to move from. + */ task(task&& other) noexcept : h_(std::exchange(other.h_, nullptr)) { } - /// Assign by moving, transferring ownership. + /** Assign by moving, transferring ownership of the frame. + + If this task already owns a coroutine frame, that frame is + destroyed first. Self-assignment is a no-op. + + @par Postconditions + `other` is empty and must not be awaited. + + @param other The task to move from. + + @return `*this`. + */ task& operator=(task&& other) noexcept { if(this != &other) diff --git a/include/boost/capy/test/buffer_source.hpp b/include/boost/capy/test/buffer_source.hpp index 0c1a2542f..b666561ad 100644 --- a/include/boost/capy/test/buffer_source.hpp +++ b/include/boost/capy/test/buffer_source.hpp @@ -29,7 +29,7 @@ namespace boost { namespace capy { namespace test { -/** A mock buffer source for testing push operations. +/** A mock buffer source for testing pull (BufferSource) operations. Use this to verify code that transfers data from a buffer source to a sink without needing real I/O. Call @ref provide to supply data, diff --git a/include/boost/capy/test/fuse.hpp b/include/boost/capy/test/fuse.hpp index 975afa103..13fbd062e 100644 --- a/include/boost/capy/test/fuse.hpp +++ b/include/boost/capy/test/fuse.hpp @@ -265,10 +265,16 @@ class fuse */ struct result { + /// Source location of the failing point, set only on failure. std::source_location loc = {}; + + /// Exception captured by @ref fail, or null if none. std::exception_ptr ep = nullptr; + + /// True if the test completed without a failure. bool success = true; + /// Return @ref success. constexpr explicit operator bool() const noexcept { return success; diff --git a/include/boost/capy/test/read_stream.hpp b/include/boost/capy/test/read_stream.hpp index 6e1207769..919439761 100644 --- a/include/boost/capy/test/read_stream.hpp +++ b/include/boost/capy/test/read_stream.hpp @@ -124,7 +124,10 @@ class read_stream remains unchanged. @par Exception Safety - No-throw guarantee. + Injected I/O conditions are reported via the `error_code` + component of the result. Throws `std::system_error` only when + the attached @ref fuse is in exception mode and reaches its + failure point; no-throw otherwise. @par Cancellation If the environment's stop token has been requested, the read @@ -137,6 +140,9 @@ class read_stream @return An awaitable that await-returns `(error_code,std::size_t)`. + @throws std::system_error When the attached @ref fuse is in + exception mode and reaches its failure point. + @see fuse */ template diff --git a/include/boost/capy/test/write_sink.hpp b/include/boost/capy/test/write_sink.hpp index 87a18cb55..daf912238 100644 --- a/include/boost/capy/test/write_sink.hpp +++ b/include/boost/capy/test/write_sink.hpp @@ -233,6 +233,12 @@ class write_sink `max_write_size` and writes all available data, matching the @ref WriteSink semantic contract. + @par Exception Safety + Injected I/O conditions are reported via the `error_code` + component of the result. Throws `std::system_error` only when + the attached @ref fuse is in exception mode and reaches its + failure point; no-throw otherwise. + @param buffers The const buffer sequence containing data to write. @return An awaitable that await-returns `(error_code,std::size_t)`. @@ -242,6 +248,9 @@ class write_sink completes immediately with `error::canceled` and transfers no data. + @throws std::system_error When the attached @ref fuse is in + exception mode and reaches its failure point. + @see fuse */ template @@ -310,7 +319,10 @@ class write_sink unchanged. @par Exception Safety - No-throw guarantee. + Injected I/O conditions are reported via the `error_code` + component of the result. Throws `std::system_error` only when + the attached @ref fuse is in exception mode and reaches its + failure point; no-throw otherwise. @par Cancellation If the environment's stop token has been requested, the operation @@ -321,6 +333,9 @@ class write_sink @return An awaitable that await-returns `(error_code,std::size_t)`. + @throws std::system_error When the attached @ref fuse is in + exception mode and reaches its failure point. + @see fuse */ template @@ -388,7 +403,10 @@ class write_sink If an error is injected by the fuse, the state remains unchanged. @par Exception Safety - No-throw guarantee. + Injected I/O conditions are reported via the `error_code` + component of the result. Throws `std::system_error` only when + the attached @ref fuse is in exception mode and reaches its + failure point; no-throw otherwise. @par Cancellation If the environment's stop token has been requested, the operation @@ -397,6 +415,9 @@ class write_sink @return An awaitable that await-returns `(error_code)`. + @throws std::system_error When the attached @ref fuse is in + exception mode and reaches its failure point. + @see fuse */ auto diff --git a/include/boost/capy/test/write_stream.hpp b/include/boost/capy/test/write_stream.hpp index 2e8f32312..f5c9ccc8e 100644 --- a/include/boost/capy/test/write_stream.hpp +++ b/include/boost/capy/test/write_stream.hpp @@ -141,7 +141,10 @@ class write_stream unchanged. @par Exception Safety - No-throw guarantee. + Injected I/O conditions are reported via the `error_code` + component of the result. Throws `std::system_error` only when + the attached @ref fuse is in exception mode and reaches its + failure point; no-throw otherwise. @par Cancellation If the environment's stop token has been requested, the write @@ -153,6 +156,9 @@ class write_stream @return An awaitable that await-returns `(error_code,std::size_t)`. + @throws std::system_error When the attached @ref fuse is in + exception mode and reaches its failure point. + @see fuse */ template diff --git a/include/boost/capy/timeout.hpp b/include/boost/capy/timeout.hpp index a6233b618..a1b4aaad4 100644 --- a/include/boost/capy/timeout.hpp +++ b/include/boost/capy/timeout.hpp @@ -190,7 +190,7 @@ class timeout_launcher `error::timeout` and the payload values are default-initialized. - @throws Rethrows Any exception from the inner awaitable, + @throws Rethrows any exception thrown by the inner awaitable, regardless of whether the timer has fired. @see delay, cond::timeout diff --git a/include/boost/capy/when_all.hpp b/include/boost/capy/when_all.hpp index f4147c037..807c404be 100644 --- a/include/boost/capy/when_all.hpp +++ b/include/boost/capy/when_all.hpp @@ -699,6 +699,9 @@ template @return A task yielding io_result where each Ri follows the payload flattening rules. + + @throws Rethrows the first child exception after all children + complete (exception beats error_code). */ template requires (sizeof...(As) > 0) diff --git a/include/boost/capy/when_any.hpp b/include/boost/capy/when_any.hpp index a23202326..0db0e7f59 100644 --- a/include/boost/capy/when_any.hpp +++ b/include/boost/capy/when_any.hpp @@ -47,8 +47,9 @@ when_any launches N io_result-returning tasks concurrently. A task wins by returning !ec; errors and exceptions do not win. Once a winner is found, stop is requested for siblings and the winner's - payload is returned. If no winner exists (all fail), the first - error_code is returned or the last exception is rethrown. + payload is returned. If no winner exists (all fail), one of the + failures is surfaced (an error_code at variant index 0, or a child's + exception rethrown); which one is unspecified. ARCHITECTURE: ------------- @@ -107,8 +108,10 @@ -------------------- Exceptions do NOT claim winner status. If a child throws, the exception is recorded but the combinator keeps waiting for a success. Only when - all children complete without a winner does the combinator check: if - any exception was recorded, it is rethrown (exception beats error_code). + all children complete without a winner is a failure surfaced. There is + no priority between errors and exceptions, and no guarantee about which + child's failure is reported: the result either returns an error_code at + variant index 0 or rethrows a child's exception. */ namespace boost { @@ -190,8 +193,9 @@ struct when_any_io_state std::optional result_; std::array runner_handles_{}; - // Last failure (error or exception) for the all-fail case. - // Last writer wins — no priority between errors and exceptions. + // A failure (error or exception) for the all-fail case. record_error + // and record_exception overwrite each other, so which one survives is + // unspecified (no priority between errors and exceptions). std::mutex failure_mu_; std::error_code last_error_; std::exception_ptr last_exception_; @@ -614,9 +618,9 @@ class when_any_io_homogeneous_launcher /** Race a range of io_result-returning awaitables (non-void payloads). Only a child returning !ec can win. Errors and exceptions do not - claim winner status. If all children fail, the last failure - is reported — either the last error_code at variant index 0, - or the last exception rethrown. + claim winner status. If all children fail, an unspecified one of + the failures is reported — either an error_code at variant index 0, + or a child's exception rethrown. @param awaitables Range of io_result-returning awaitables (must not be empty). @@ -626,8 +630,11 @@ class when_any_io_homogeneous_launcher index and payload. @throws std::invalid_argument if range is empty. - @throws Rethrows last exception when no winner and the last - failure was an exception. + @throws Rethrows the winner's exception if extracting or + move-constructing the winning payload throws (a winner was + found but its result could not be produced). + @throws Rethrows a child's exception when all children fail and the + reported failure is an exception (which child is unspecified). @par Example @code @@ -687,7 +694,7 @@ template std::pair{state.core_.winner_index_, std::move(*state.result_)}}; } - // No winner — report last failure + // No winner — report the recorded failure if(state.last_exception_) std::rethrow_exception(state.last_exception_); co_return result_type{std::in_place_index<0>, state.last_error_}; @@ -705,8 +712,8 @@ template is failure and index 1 carries the winner's index. @throws std::invalid_argument if range is empty. - @throws Rethrows first exception when no winner and at least one - child threw. + @throws Rethrows a child's exception when all children fail and the + reported failure is an exception (which child is unspecified). @par Example @code @@ -759,7 +766,7 @@ template state.core_.winner_index_}; } - // No winner — report last failure + // No winner — report the recorded failure if(state.last_exception_) std::rethrow_exception(state.last_exception_); co_return result_type{std::in_place_index<0>, state.last_error_}; @@ -777,7 +784,15 @@ template @return A task yielding variant where index 0 is the failure/no-winner case and index i+1 - identifies the winning child. + identifies the winning child. On all-fail, index 0 holds + an error_code from one of the failed children (unspecified + which; no priority between errors and exceptions). + + @throws Rethrows the winner's exception if extracting or + constructing the winning payload throws (a winner was found + but its result could not be produced). + @throws Rethrows a child's exception when all children fail and the + reported failure is an exception (which child is unspecified). @note A failing child does not cancel its siblings; `when_any` waits for a success or for every child to finish. To make a @@ -812,7 +827,7 @@ template if(state.core_.winner_exception_) std::rethrow_exception(state.core_.winner_exception_); - // No winner — report last failure + // No winner — report the recorded failure if(state.last_exception_) std::rethrow_exception(state.last_exception_); co_return result_type{std::in_place_index<0>, state.last_error_};