diff --git a/.codecov.yml b/.codecov.yml index e67db52b9..2a6ca9941 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -12,12 +12,12 @@ codecov: after_n_builds: 1 wait_for_ci: yes -# Make coverage checks informational (report but never fail CI) coverage: status: project: default: - informational: true + target: auto + threshold: 0% patch: default: informational: true diff --git a/.github/compilers.json b/.github/compilers.json index f0484482a..69cfdd942 100644 --- a/.github/compilers.json +++ b/.github/compilers.json @@ -146,7 +146,6 @@ "cxx": "clang++", "cc": "clang", "b2_toolset": "clang", - "cxxflags": "-fvisibility=hidden -fvisibility-inlines-hidden", "is_latest": true } ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1851cba1a..5f944ecf2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -297,12 +297,13 @@ jobs: --warnings-as-errors='*' - name: FlameGraph - uses: alandefreitas/cpp-actions/flamegraph@v1.9.0 + uses: alandefreitas/cpp-actions/flamegraph@v1.9.5 if: matrix.time-trace + continue-on-error: true with: source-dir: capy-root build-dir: capy-root/__build__ - github_token: ${{ secrets.GITHUB_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} - name: Generate Coverage Report if: ${{ matrix.coverage && matrix.linux }} diff --git a/README.md b/README.md index cffa42774..0444fc996 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ The Beast2 family of libraries includes: Currently, the C++ Standard does not deliver facilities optimized for networking I/O. We believe that Capy should become a standard library component to fill this gap. Our first paper based on Capy introduces the _IoAwaitable_ family of concepts: -- Paper: https://github.com/cppalliance/wg21-papers/blob/master/source/d4003-io-awaitables.md +- Paper: https://github.com/cppalliance/wg21-papers/blob/master/source/_reserve/d4003-io-awaitables.md ## The Beman Way diff --git a/doc/modules/ROOT/pages/4.coroutines/4a.tasks.adoc b/doc/modules/ROOT/pages/4.coroutines/4a.tasks.adoc index 8a14c71ed..42d3d5eb9 100644 --- a/doc/modules/ROOT/pages/4.coroutines/4a.tasks.adoc +++ b/doc/modules/ROOT/pages/4.coroutines/4a.tasks.adoc @@ -115,7 +115,7 @@ task<> example() auto t = compute(); // Task created, but "Computing..." NOT printed yet std::cout << "Task created\n"; - int result = co_await t; // NOW "Computing..." is printed + int result = co_await std::move(t); // NOW "Computing..." is printed std::cout << "Result: " << result << "\n"; } ---- @@ -167,11 +167,11 @@ task compute(); task<> example() { auto t1 = compute(); - auto t2 = std::move(t1); // OK: ownership transferred - + auto t2 = std::move(t1); // OK: ownership transferred, t1 is now empty + // auto t3 = t2; // Error: task is not copyable - - int result = co_await t2; // t1 is now empty + + int result = co_await std::move(t2); } ---- diff --git a/doc/modules/ROOT/pages/4.coroutines/4b.launching.adoc b/doc/modules/ROOT/pages/4.coroutines/4b.launching.adoc index cbcd2eab0..c58e4f579 100644 --- a/doc/modules/ROOT/pages/4.coroutines/4b.launching.adoc +++ b/doc/modules/ROOT/pages/4.coroutines/4b.launching.adoc @@ -36,8 +36,7 @@ int main() run_async(pool.get_executor())(compute()); // Task is now running on the thread pool - // pool destructor waits for work to complete - return 0; + pool.join(); // wait for outstanding work to complete } ---- @@ -72,7 +71,7 @@ Always use the two-call pattern in a single expression. [source,cpp] ---- -// Result handler only (exceptions rethrown) +// Result handler only (an unhandled exception calls std::terminate) run_async(ex, [](int result) { std::cout << "Got: " << result << "\n"; })(compute()); @@ -89,7 +88,12 @@ run_async(ex, )(compute()); ---- -When no handlers are provided, results are discarded and exceptions are rethrown (causing `std::terminate` if uncaught). +When no result handler is provided, the result is discarded. An exception +that goes unhandled (no error handler was supplied, or a handler let one +escape) calls `std::terminate`. To react to an error, pass an error handler; +it receives the `std::exception_ptr` and should handle it in place rather +than rethrowing. To catch an error, `co_await` the work inside a coroutine +and use `try`/`catch` rather than launching it fire-and-forget. == run: Executor Hopping Within Coroutines diff --git a/doc/modules/ROOT/pages/4.coroutines/4c.executors.adoc b/doc/modules/ROOT/pages/4.coroutines/4c.executors.adoc index 4839686f8..76e239747 100644 --- a/doc/modules/ROOT/pages/4.coroutines/4c.executors.adoc +++ b/doc/modules/ROOT/pages/4.coroutines/4c.executors.adoc @@ -9,14 +9,25 @@ This section explains executors and execution contexts—the mechanisms that con == The Executor Concept -An *executor* is an object that can schedule work for execution. In Capy, executors must provide three methods: +An *executor* is an object that can schedule work for execution. An executor must be nothrow copy- and move-constructible and provide the following interface: [source,cpp] ---- -concept Executor = requires(E ex, continuation& c) { - { ex.dispatch(c) } -> std::same_as>; - { ex.post(c) } -> std::same_as; - { ex.context() } -> std::convertible_to; +concept Executor = requires(E const& ce, E const& ce2, continuation& c) { + // Equality comparable + { ce == ce2 } noexcept -> std::convertible_to; + + // Owning context, returned as an lvalue reference to a type + // derived from execution_context + { ce.context() } noexcept; + + // Work tracking + { ce.on_work_started() } noexcept; + { ce.on_work_finished() } noexcept; + + // Scheduling + { ce.dispatch(c) } -> std::same_as>; + { ce.post(c) }; }; ---- @@ -49,8 +60,9 @@ int main() { thread_pool pool; executor_ref ex = pool.get_executor(); // Type erasure - - schedule_work(ex, some_coroutine); + + continuation c = /* ... */; + schedule_work(ex, c); } ---- @@ -74,8 +86,8 @@ int main() // Launch work on the pool run_async(ex)(my_task()); - - // pool destructor waits for all work to complete + + pool.join(); // wait for outstanding work to complete } ---- 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 80b6f2df9..f05e033b2 100644 --- a/doc/modules/ROOT/pages/4.coroutines/4d.io-awaitable.adoc +++ b/doc/modules/ROOT/pages/4.coroutines/4d.io-awaitable.adoc @@ -53,36 +53,37 @@ This signature receives: ** `env->stop_token` — A stop token for cooperative cancellation ** `env->frame_allocator` — An optional frame allocator -The return type enables symmetric transfer. +Many IoAwaitables return `std::coroutine_handle<>` to enable symmetric transfer, but the concept does not require any particular return type. == IoAwaitable Concept -An awaitable satisfies `IoAwaitable` if: +An awaitable satisfies `IoAwaitable` if `a.await_suspend(h, env)` is a valid expression: [source,cpp] ---- -template -concept IoAwaitable = requires(T& t, std::coroutine_handle<> h, io_env const* env) { - { t.await_ready() } -> std::convertible_to; - { t.await_suspend(h, env) } -> std::same_as>; - t.await_resume(); -}; +template +concept IoAwaitable = + requires(A a, std::coroutine_handle<> h, io_env const* env) { + a.await_suspend(h, env); + }; ---- -The key difference from standard awaitables is the two-argument `await_suspend` that receives the `io_env`. +The concept constrains only the two-argument `await_suspend` that receives the `io_env`. It does not require `await_ready` or `await_resume`, nor does it constrain the return type of `await_suspend`. A complete awaitable still provides `await_ready` and `await_resume` so it can be `co_await`-ed; the concept simply does not test for them. == IoRunnable Concept -For tasks that can be launched from non-coroutine contexts, the `IoRunnable` concept refines `IoAwaitable` with: +For tasks that can be launched from non-coroutine contexts, the `IoRunnable` concept refines `IoAwaitable` and requires a `promise_type` plus the following: -* `handle()` — Access the typed coroutine handle -* `release()` — Transfer ownership of the frame -* `exception()` — Check for captured exceptions -* `result()` — Access the result value (non-void tasks) +* `handle()`: Access the typed coroutine handle +* `release()`: Transfer ownership of the frame +* `exception()`: Check for captured exceptions (on the promise) +* `result()`: Access the result value (on the promise, for non-void tasks) +* `set_continuation()`: Set the continuation handle (on the promise) +* `set_environment()`: Inject the `io_env` (on the promise) These methods exist because launch functions like `run_async` cannot `co_await` the task directly. The trampoline must be allocated before the task type is known, so it type-erases the task through function pointers and needs a common API to manage lifetime and extract results. -Context injection methods (`set_environment`, `set_continuation`) are internal to the promise and not part of any concept. Launch functions access them through the typed handle provided by `handle()`. +The context injection methods `set_continuation` and `set_environment` are part of the `IoRunnable` concept: it requires them on the `promise_type`. Launch functions access them through the typed handle provided by `handle()`. Capy's `task` satisfies this concept. @@ -215,6 +216,8 @@ stop_cb_.emplace(env->stop_token, h); // h is a raw coroutine_handle See xref:4.coroutines/4e.cancellation.adoc#stoppable-awaitables[Implementing Stoppable Awaitables] for a complete example. +For a production implementation of this exact pattern, read the source of `delay_awaitable` (xref:reference:boost/capy/delay_awaitable.adoc[`delay_awaitable`]): it schedules a timer, registers a stop callback that posts the resume through the executor, and arbitrates between the timer and cancellation with a single atomic claim. + == Reference [cols="1,3"] diff --git a/doc/modules/ROOT/pages/4.coroutines/4e.cancellation.adoc b/doc/modules/ROOT/pages/4.coroutines/4e.cancellation.adoc index 9e8ee15eb..cb3719de3 100644 --- a/doc/modules/ROOT/pages/4.coroutines/4e.cancellation.adoc +++ b/doc/modules/ROOT/pages/4.coroutines/4e.cancellation.adoc @@ -201,7 +201,7 @@ task<> parent() task<> child() { // Receives parent's stop token via IoAwaitable protocol - auto token = co_await get_stop_token(); // Access current token + auto token = co_await this_coro::stop_token; // Access current token } ---- @@ -209,13 +209,13 @@ No manual threading—the protocol handles it. === Accessing the Stop Token -Inside a task, use `get_stop_token()` to access the current stop token: +Inside a task, use `co_await this_coro::stop_token` to access the current stop token: [source,cpp] ---- task<> cancellable_work() { - auto token = co_await get_stop_token(); + auto token = co_await this_coro::stop_token; while (!token.stop_requested()) { @@ -243,8 +243,8 @@ The rule: ---- task<> process_items(std::vector const& items) { - auto token = co_await get_stop_token(); - + auto token = co_await this_coro::stop_token; + for (auto const& item : items) { if (token.stop_requested()) @@ -264,7 +264,7 @@ RAII ensures resources are released on early exit: task<> with_resource() { auto resource = acquire_resource(); // RAII wrapper - auto token = co_await get_stop_token(); + auto token = co_await this_coro::stop_token; while (!token.stop_requested()) { @@ -274,34 +274,31 @@ task<> with_resource() } ---- -=== The operation_aborted Convention +=== The canceled Convention -When cancellation causes an operation to fail, the conventional error code is `error::operation_aborted`: +When cancellation causes an operation to fail, the conventional error code is `error::canceled`, which compares equal to the portable condition `cond::canceled`: [source,cpp] ---- task fetch_with_cancel() { - auto token = co_await get_stop_token(); - + auto token = co_await this_coro::stop_token; + if (token.stop_requested()) { throw std::system_error( - make_error_code(std::errc::operation_canceled)); + make_error_code(error::canceled)); } - + co_return co_await do_fetch(); } ---- == Part 7: OS Integration -Capy's I/O operations (provided by Corosio) respect stop tokens at the OS level: - -* *IOCP* (Windows) — Pending operations can be cancelled via `CancelIoEx` -* *io_uring* (Linux) — Operations can be cancelled via `IORING_OP_ASYNC_CANCEL` +When Capy's I/O is provided by Corosio, requesting stop cancels work in progress rather than waiting for the operation to finish on its own. Corosio cancels the pending operation through whatever backend is active for the platform, and it resolves promptly with `std::errc::operation_canceled`. -When you request stop, pending I/O operations are cancelled at the OS level, providing immediate response rather than waiting for the operation to complete naturally. +The mechanism depends on the backend: completion-based backends (Windows IOCP, and io_uring when enabled on Linux) cancel the operation in the kernel, while readiness-based backends (Linux epoll, kqueue on the BSDs and macOS, and the portable select fallback) remove it from the reactor before its system call runs. Either way the operation is reported as cancelled instead of blocking until the I/O would have completed. [[stoppable-awaitables]] == Part 8: Implementing Stoppable Awaitables diff --git a/doc/modules/ROOT/pages/4.coroutines/4f.composition.adoc b/doc/modules/ROOT/pages/4.coroutines/4f.composition.adoc index d4750b19b..27a7341b9 100644 --- a/doc/modules/ROOT/pages/4.coroutines/4f.composition.adoc +++ b/doc/modules/ROOT/pages/4.coroutines/4f.composition.adoc @@ -175,7 +175,45 @@ 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. -For detailed coverage including error handling, cancellation, and the range overload, see Racing Tasks. +=== 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`. + +If you need "complete on the first child to *finish*, success or error," that behavior is opt-in — wrap the child as shown below. + +=== Treating an Error as a Win + +To make a child win on an error, wrap it so the error becomes a success before `when_any` sees it. + +The first pattern translates a specific, benign error into success. Other errors propagate unchanged, so they still do not win: + +[source,cpp] +---- +// canceled is benign here: translate it to success so when_any picks this child. +io_task<> wrapped() +{ + auto [ec] = co_await inner(); + if (ec == cond::canceled) + co_return io_result<>{}; // success: when_any sees a winner + co_return io_result<>{ec}; // propagate other errors unchanged +} +---- + +The second pattern lifts the inner `ec` into the payload. The wrapper always succeeds, so it wins on its first completion, carrying the original error code to the caller: + +[source,cpp] +---- +// Always succeeds; the winner's payload carries the original ec. +io_task wrapped() +{ + auto [ec] = co_await inner(); + co_return io_result{{}, ec}; +} + +// when_any(wrapped(), ...) -> variant +// index 0: every child failed +// index i: child i won; std::get(result) is its original ec +---- == Practical Patterns @@ -228,9 +266,31 @@ task process_all(std::vector const& items) } ---- +=== Asynchronous Sleep + +`delay` is the awaitable counterpart to `std::this_thread::sleep_for`. Instead of blocking the thread, it suspends the current coroutine until the duration elapses, leaving the thread free to run other coroutines in the meantime: + +[source,cpp] +---- +#include + +task<> example() +{ + auto [ec] = co_await delay(100ms); + // 100ms have elapsed; other coroutines ran on this thread while we waited +} +---- + +[NOTE] +==== +A thread is *not* consumed per sleeping coroutine. All concurrently sleeping coroutines on the same execution context share a single timer thread, so a thousand simultaneous `delay()` calls cost one thread, not a thousand. +==== + +`delay` is cancellable. If the environment's stop token is activated before the deadline, the coroutine resumes early with `ec` set to `error::canceled` (compare with `cond::canceled`); otherwise `ec` is clear. A zero or negative duration completes synchronously without scheduling a timer. + === Timeout -The `timeout` combinator races an awaitable against a deadline: +The `timeout` combinator races an awaitable against a deadline. It is built directly on `delay` — the inner awaitable is run against a `delay` of the given duration, and whichever completes first cancels the other: [source,cpp] ---- @@ -281,6 +341,9 @@ This design ensures proper context propagation to all children. | `` | First-completion racing with when_any +| `` +| Asynchronous sleep that suspends instead of blocking the thread + | `` | Race an awaitable against a deadline |=== diff --git a/doc/modules/ROOT/pages/4.coroutines/4g.allocators.adoc b/doc/modules/ROOT/pages/4.coroutines/4g.allocators.adoc index b0cdeadac..b4fa11aff 100644 --- a/doc/modules/ROOT/pages/4.coroutines/4g.allocators.adoc +++ b/doc/modules/ROOT/pages/4.coroutines/4g.allocators.adoc @@ -267,7 +267,7 @@ Coroutine frame allocation is rarely the bottleneck. Profile your application be | `` | Frame allocator concept and utilities -| `` +| `` | Mixin base for promise types that use the TLS frame allocator | `` diff --git a/doc/modules/ROOT/pages/4.coroutines/4h.lambda-captures.adoc b/doc/modules/ROOT/pages/4.coroutines/4h.lambda-captures.adoc index bbe82a95e..754d62bcf 100644 --- a/doc/modules/ROOT/pages/4.coroutines/4h.lambda-captures.adoc +++ b/doc/modules/ROOT/pages/4.coroutines/4h.lambda-captures.adoc @@ -20,7 +20,7 @@ void process(socket& sock) auto task = [&sock]() -> capy::task<> { char buf[1024]; - auto [ec, n] = co_await sock.read_some(buffer(buf, sizeof(buf))); + auto [ec, n] = co_await sock.read_some(make_buffer(buf)); }(); run_async(executor)(std::move(task)); @@ -58,7 +58,7 @@ void process(socket& sock) auto task = [](socket* s) -> capy::task<> { char buf[1024]; - auto [ec, n] = co_await s->read_some(buffer(buf, sizeof(buf))); + auto [ec, n] = co_await s->read_some(make_buffer(buf)); }(&sock); run_async(executor)(std::move(task)); @@ -126,7 +126,7 @@ auto handler = [&sock]() -> capy::task<> }; // Lambda 'handler' still exists here -run_and_wait(handler()); // Blocks until coroutine completes +capy::test::run_blocking()(handler()); // Blocks until coroutine completes // Lambda destroyed after coroutine finishes ---- diff --git a/doc/modules/ROOT/pages/5.buffers/5b.types.adoc b/doc/modules/ROOT/pages/5.buffers/5b.types.adoc index dc759112b..c3ccd3025 100644 --- a/doc/modules/ROOT/pages/5.buffers/5b.types.adoc +++ b/doc/modules/ROOT/pages/5.buffers/5b.types.adoc @@ -140,12 +140,20 @@ auto buf = make_buffer(str); // From std::string_view std::string_view sv = "hello"; auto buf = make_buffer(sv); + +// From a span (std::span or boost::span) +std::span sp(arr); +auto buf = make_buffer(sp); ---- -The returned buffer type depends on constness: +`make_buffer` accepts any sized, contiguous range of trivially-copyable +elements—including `std::span` and `boost::span`—in addition to the +sources shown above. + +The returned buffer type depends on the element constness of the range: -* Non-const containers → `mutable_buffer` -* Const containers, `string_view` → `const_buffer` +* Ranges of mutable elements → `mutable_buffer` +* Ranges of const elements, `string_view`, string literals → `const_buffer` == Layout Compatibility diff --git a/doc/modules/ROOT/pages/5.buffers/5e.algorithms.adoc b/doc/modules/ROOT/pages/5.buffers/5e.algorithms.adoc index ca169e594..96d0a1f25 100644 --- a/doc/modules/ROOT/pages/5.buffers/5e.algorithms.adoc +++ b/doc/modules/ROOT/pages/5.buffers/5e.algorithms.adoc @@ -88,14 +88,14 @@ Note the distinction: Copies data from one buffer sequence to another: +`buffer_copy` is a function object with a single call operator whose +`at_most` parameter defaults to copying everything available: + [source,cpp] ---- template -std::size_t buffer_copy(Target const& target, Source const& source); - -template -std::size_t buffer_copy(Target const& target, Source const& source, - std::size_t at_most); +std::size_t buffer_copy(Target const& target, Source const& source, + std::size_t at_most = std::size_t(-1)); ---- Returns the number of bytes copied. diff --git a/doc/modules/ROOT/pages/5.buffers/5f.dynamic.adoc b/doc/modules/ROOT/pages/5.buffers/5f.dynamic.adoc index 56e78ffa2..29d8a39c4 100644 --- a/doc/modules/ROOT/pages/5.buffers/5f.dynamic.adoc +++ b/doc/modules/ROOT/pages/5.buffers/5f.dynamic.adoc @@ -14,7 +14,7 @@ Dynamic buffers serve as intermediate storage between a *producer* (typically ne The flow: 1. **Producer** writes data into the buffer -2. **Buffer** grows as needed to accommodate data +2. **Buffer** accommodates data within its capacity 3. **Consumer** reads and processes data 4. **Buffer** releases consumed data @@ -25,38 +25,58 @@ This model decouples production rate from consumption rate—the buffer absorbs [source,cpp] ---- template -concept DynamicBuffer = requires(T& t, std::size_t n) { +concept DynamicBuffer = requires(T& t, T const& ct, std::size_t n) { + typename T::const_buffers_type; + typename T::mutable_buffers_type; + // Producer side - { t.prepare(n) } -> MutableBufferSequence; + { t.prepare(n) } -> std::same_as; { t.commit(n) }; - + // Consumer side - { t.data() } -> ConstBufferSequence; + { ct.data() } -> std::same_as; { t.consume(n) }; - + // Capacity - { t.size() } -> std::same_as; - { t.max_size() } -> std::same_as; - { t.capacity() } -> std::same_as; + { ct.size() } -> std::convertible_to; + { ct.max_size() } -> std::convertible_to; + { ct.capacity() } -> std::convertible_to; }; ---- +A dynamic buffer, within its capacity provides a potentially empty +_readable region_ and a potentially empty _writeable region_. +Sizes of these regions change as operations are invoked on the +dynamic buffer. + + == Producer Interface -=== prepare(n) +=== `prepare(n)` -Returns mutable buffer space for writing up to `n` bytes: +Returns a mutable buffer sequence `mb` for writing up to `n` bytes: [source,cpp] ---- -auto buffers = dynamic_buf.prepare(1024); // Space for up to 1024 bytes +capy::MutableBufferSequence auto buffers = dynamic_buf.prepare(1024); // Space for up to 1024 bytes ---- -The returned space may be larger than requested. The data is not yet part of the readable sequence. +_Effects:_ If `n` exceeds the available capacity, throws. Otherwise, +ensures that a writeable region of size at least `n` exists and returns +an object of type `mutable_buffers_type` representing this region. + +_Throws:_ The concrete adapters throw `std::invalid_argument` when `n` +exceeds the available capacity; `circular_dynamic_buffer` throws +`std::length_error`. `std::bad_alloc` may also be thrown if an allocation +is performed and fails. -=== commit(n) +_Postcondition:_ `buffer_size(buffers) >= n`. -Marks `n` bytes of prepared space as written and readable: + +=== `commit(n)` + +Transfers `n` bytes of from the beginning of the writeable storage to +the end of the readable storage: [source,cpp] ---- @@ -65,6 +85,14 @@ dynamic_buf.commit(bytes_written); // Data is now visible via data() ---- +Let `n1` be the smaller number of `n` and the size of the writeable region. + +_Effects:_ Removes `n1` bytes from the front of the writeable region and +adds them at the back of the readable region. + +_Notes:_ `n` can be smaller than the writeable region size. + + === Typical Producer Pattern [source,cpp] @@ -83,17 +111,20 @@ task<> read_into_buffer(Stream& stream, DynamicBuffer auto& buffer) == Consumer Interface -=== data() +=== `data()` -Returns the readable data as a const buffer sequence: +Returns a const buffer sequence representing the readable region. [source,cpp] ---- -auto readable = dynamic_buf.data(); +capy::ConstBufferSequence auto readable = dynamic_buf.data(); // Process readable bytes ---- -=== consume(n) +_Postcondition:_ `capy::buffer_size(readable) == dynamic_buf.size()`. + + +=== `consume(n)` Removes `n` bytes from the front of readable data: @@ -103,6 +134,13 @@ dynamic_buf.consume(processed_bytes); // Those bytes are no longer in data() ---- +Let `n1` be the smaller of `n` and `size()`. + +_Effects:_ Removes `n1` bytes from the front of the readable region. +Sets the size of the writeable rgion to zero. Invalidates all buffer +sequences previously obtained via calls to `data()` or `prepare(). + + === Typical Consumer Pattern [source,cpp] @@ -126,14 +164,28 @@ void process_buffer(DynamicBuffer auto& buffer) == Capacity Management -`size()`:: -Current number of readable bytes (the length of `data()`). +=== `size()` + +_Returns:_ The size of the readable region. + + +=== `max_size()` + +_Returns:_ The maximum possible sum o sizes of the readable region +and the writeable region. + -`max_size()`:: -Maximum allowed size. Attempts to grow beyond this throw or fail. -`capacity()`:: -Current allocated capacity. May be larger than `size()`. +=== `capacity()` + +_Returns:_ Current allocated capacity. + + +=== Class invariant + +`size() +<=+ capacity()`. + +`capacity() +<=+ max_size()`. == DynamicBufferParam @@ -154,14 +206,18 @@ This concept ensures proper handling of lvalues and rvalues, preventing dangling === flat_dynamic_buffer -Linear storage with single-buffer sequences: +A fixed-capacity adapter over caller-owned contiguous storage, with +single-buffer sequences. It never reallocates: the capacity is fixed at +construction, and a default-constructed buffer has zero capacity (so +`prepare` would throw). [source,cpp] ---- #include -flat_dynamic_buffer buffer; -buffer.prepare(1024); +char storage[1024]; +flat_dynamic_buffer buffer(storage, sizeof(storage)); +auto space = buffer.prepare(256); // ... write data ... buffer.commit(n); @@ -175,7 +231,7 @@ Advantages: Disadvantages: -* May require copying when buffer wraps or grows +* Fixed capacity; `prepare` throws when more space is requested than remains === circular_dynamic_buffer @@ -185,7 +241,8 @@ Ring buffer implementation: ---- #include -circular_dynamic_buffer<1024> buffer; // Fixed capacity +char storage[1024]; +circular_dynamic_buffer buffer(storage, sizeof(storage)); // Fixed capacity ---- Advantages: @@ -200,17 +257,20 @@ Disadvantages: === vector_dynamic_buffer -Backed by `std::vector`: +Backed by a caller-owned `std::vector` of byte-sized elements +(`vector_dynamic_buffer` itself uses `unsigned char`). Use the +`dynamic_buffer` factory, which deduces the element type: [source,cpp] ---- #include -std::vector storage; -vector_dynamic_buffer buffer(storage); +std::vector storage; +auto buffer = dynamic_buffer(storage); ---- -Adapts an existing vector for use as a dynamic buffer. +Adapts an existing vector for use as a dynamic buffer. The vector must +outlive the adapter. === string_dynamic_buffer @@ -221,10 +281,13 @@ Backed by `std::string`: #include std::string storage; -string_dynamic_buffer buffer(storage); +auto buffer = dynamic_buffer(storage); ---- -Useful when you want the final data as a string. +Useful when you want the final data as a string. The `dynamic_buffer` +factory wraps the string (you may also construct +`string_dynamic_buffer(&storage)` directly). The string must outlive the +adapter. == Example: Line-Based Protocol @@ -232,8 +295,9 @@ Useful when you want the final data as a string. ---- task read_line(Stream& stream) { - flat_dynamic_buffer buffer; - + char storage[4096]; + flat_dynamic_buffer buffer(storage, sizeof(storage)); + while (true) { // Prepare space and read diff --git a/doc/modules/ROOT/pages/6.streams/6a.overview.adoc b/doc/modules/ROOT/pages/6.streams/6a.overview.adoc index 479409463..1070bb6cc 100644 --- a/doc/modules/ROOT/pages/6.streams/6a.overview.adoc +++ b/doc/modules/ROOT/pages/6.streams/6a.overview.adoc @@ -85,18 +85,20 @@ The third pair inverts buffer ownership: [source,cpp] ---- // BufferSource: callee provides read-only buffers -const_buffer bufs[8]; -auto [ec, count] = co_await source.pull(bufs, 8); -// bufs[0..count-1] now point to source's internal data +const_buffer arr[8]; +auto [ec, bufs] = co_await source.pull(arr); +// bufs is a span pointing into source's internal data; +// ec == cond::eof with an empty span signals exhaustion +source.consume(n); // mark n bytes consumed before the next pull // BufferSink: callee provides writable buffers -mutable_buffer bufs[8]; -std::size_t count = sink.prepare(bufs, 8); -// Write into bufs[0..count-1], then commit +mutable_buffer arr[8]; +auto bufs = sink.prepare(arr); // returns a span of writable buffers +// Write into bufs, then commit co_await sink.commit(bytes_written); ---- -This pattern enables zero-copy I/O—data never moves through intermediate buffers. +This pattern enables zero-copy I/O: data never moves through intermediate buffers. == Type-Erasing Wrappers @@ -166,7 +168,7 @@ task<> echo(any_stream& stream) char buf[1024]; for (;;) { - auto [ec, n] = co_await stream.read_some(mutable_buffer(buf)); + auto [ec, n] = co_await stream.read_some(make_buffer(buf)); auto [wec, wn] = co_await write(stream, const_buffer(buf, n)); @@ -183,19 +185,19 @@ The caller decides the concrete implementation: [source,cpp] ---- -// Works with Corosio TCP sockets +// Owns a moved-in TCP socket (the lvalue form takes ownership by value) any_stream s1{tcp_socket}; echo(s1); -// Works with TLS streams -any_stream s2{tls_stream}; +// Wraps a TLS stream by pointer (reference semantics, must outlive s2) +any_stream s2{&tls_stream}; echo(s2); -// Works with test mocks +// Owns a temporary test mock any_stream s3{test::stream{}}; echo(s3); ---- -Same code, different transports—compile once, link anywhere. +Same code, different transports: compile once, link anywhere. Continue to xref:6.streams/6b.streams.adoc[Streams (Partial I/O)] to learn the `ReadStream` and `WriteStream` concepts in detail. diff --git a/doc/modules/ROOT/pages/6.streams/6b.streams.adoc b/doc/modules/ROOT/pages/6.streams/6b.streams.adoc index 44f42a0eb..5ebc018de 100644 --- a/doc/modules/ROOT/pages/6.streams/6b.streams.adoc +++ b/doc/modules/ROOT/pages/6.streams/6b.streams.adoc @@ -33,7 +33,9 @@ Attempts to read up to `buffer_size(buffers)` bytes from the stream into the buf If `buffer_size(buffers) > 0`: * If `!ec`, then `n >= 1 && n \<= buffer_size(buffers)`. `n` bytes were read into the buffer sequence. -* If `ec`, then `n >= 0 && n \<= buffer_size(buffers)`. `n` is the number of bytes read before the I/O condition arose. +* If `ec`, then `n >= 0 && n < buffer_size(buffers)`. `n` is the number of bytes read before the I/O condition arose. + +Equivalently, `n == buffer_size(buffers)` implies `!ec`: a read that fills the buffer sequence is a success even when the underlying operation also signals a condition such as end-of-stream. That condition is reported on a subsequent read. If `buffer_empty(buffers)` is true, `n` is 0. The empty buffer is not itself a cause for error, but `ec` may reflect the state of the stream. @@ -48,12 +50,12 @@ I/O conditions from the underlying system are reported via `ec`. Failures in the [source,cpp] ---- 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)); // n might be 1, might be 500, might be 1024 // if !ec, then n >= 1 ---- -This matches underlying OS behavior—reads return when *some* data is available. +This matches underlying OS behavior: reads return when *some* data is available. === Example @@ -66,7 +68,7 @@ task<> dump_stream(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)); std::cout.write(buf, n); @@ -102,7 +104,9 @@ Attempts to write up to `buffer_size(buffers)` bytes from the buffer sequence to If `buffer_size(buffers) > 0`: * If `!ec`, then `n >= 1 && n \<= buffer_size(buffers)`. `n` bytes were written from the buffer sequence. -* If `ec`, then `n >= 0 && n \<= buffer_size(buffers)`. `n` is the number of bytes written before the I/O condition arose. +* If `ec`, then `n >= 0 && n < buffer_size(buffers)`. `n` is the number of bytes written before the I/O condition arose. + +Equivalently, `n == buffer_size(buffers)` implies `!ec`: a write that drains the entire buffer sequence is a success even when the underlying operation also signals a condition. That condition is reported on a subsequent write. If `buffer_empty(buffers)` is true, `n` is 0. The empty buffer is not itself a cause for error, but `ec` may reflect the state of the stream. @@ -132,11 +136,16 @@ Wraps any `ReadStream` in a type-erased container: ---- #include +// Owning: takes ownership of a moved-in stream +template +any_read_stream(S stream); + +// Reference: wraps by pointer without ownership template -any_read_stream(S& stream); +any_read_stream(S* stream); ---- -The wrapped stream is referenced—the original must outlive the wrapper. +Each wrapper has two construction modes. Passing an object by value takes ownership: the wrapper moves the stream into internally allocated storage. Passing a pointer wraps the pointed-to stream by reference, and that stream must outlive the wrapper. === any_write_stream @@ -147,7 +156,10 @@ Wraps any `WriteStream`: #include template -any_write_stream(S& stream); +any_write_stream(S stream); // owning + +template +any_write_stream(S* stream); // reference ---- === any_stream @@ -158,19 +170,23 @@ Wraps bidirectional streams (both `ReadStream` and `WriteStream`): ---- #include -template - requires WriteStream -any_stream(S& stream); +template + requires ReadStream && WriteStream +any_stream(S stream); // owning + +template + requires ReadStream && WriteStream +any_stream(S* stream); // reference ---- === Wrapper Characteristics All wrappers share these properties: -* *Reference semantics* — Wrap existing objects without ownership -* *Preallocated coroutine frame* — Zero steady-state allocation -* *Move-only* — Non-copyable; moving transfers the cached frame -* *Lifetime requirement* — Wrapped object must outlive wrapper +* *Owning or reference*: By-value construction owns a moved-in object; pointer construction wraps by reference +* *Preallocated coroutine frame*: Zero steady-state allocation +* *Move-only*: Non-copyable; moving transfers the cached frame +* *Lifetime requirement*: A pointer-wrapped object must outlive the wrapper Example usage: @@ -181,7 +197,7 @@ void process_stream(any_stream& stream); tcp::socket socket; // ... connect socket ... -any_stream wrapped{socket}; // Type erasure here +any_stream wrapped{&socket}; // Type erasure, references the existing socket process_stream(wrapped); // process_stream doesn't know about tcp::socket ---- @@ -199,7 +215,7 @@ task<> handle_connection(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)); auto [wec, wn] = co_await write(stream, const_buffer(buf, n)); 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 4ea54bfa5..874059770 100644 --- a/doc/modules/ROOT/pages/6.streams/6c.sources-sinks.adoc +++ b/doc/modules/ROOT/pages/6.streams/6c.sources-sinks.adoc @@ -147,7 +147,10 @@ task<> send_response(Sink& sink, response const& resp) #include template -any_read_source(S& source); +any_read_source(S source); // owning: takes ownership of a moved-in source + +template +any_read_source(S* source); // reference: wraps by pointer, source must outlive the wrapper ---- === any_write_sink @@ -157,7 +160,10 @@ any_read_source(S& source); #include template -any_write_sink(S& sink); +any_write_sink(S sink); // owning: takes ownership of a moved-in sink + +template +any_write_sink(S* sink); // reference: wraps by pointer, sink must outlive the wrapper ---- == Example: HTTP Body Handler @@ -185,16 +191,16 @@ The caller decides the concrete implementation: ---- // Content-length mode content_length_sink cl_sink(socket, data.size()); -any_write_sink body{cl_sink}; +any_write_sink body{&cl_sink}; // references cl_sink send_body(body, data); // Chunked mode chunked_sink ch_sink(socket); -any_write_sink body{ch_sink}; +any_write_sink body{&ch_sink}; // references ch_sink send_body(body, data); ---- -Same `send_body` function, different transfer encodings—the library handles the difference. +Same `send_body` function, different transfer encodings: the library handles the difference. == Streams vs Sources/Sinks diff --git a/doc/modules/ROOT/pages/6.streams/6d.buffer-concepts.adoc b/doc/modules/ROOT/pages/6.streams/6d.buffer-concepts.adoc index 3f4af412c..f18bfc78c 100644 --- a/doc/modules/ROOT/pages/6.streams/6d.buffer-concepts.adoc +++ b/doc/modules/ROOT/pages/6.streams/6d.buffer-concepts.adoc @@ -15,7 +15,7 @@ With streams and sources/sinks, the *caller* provides buffers: ---- // Caller owns the buffer char my_buffer[1024]; -co_await stream.read_some(mutable_buffer(my_buffer)); +co_await stream.read_some(make_buffer(my_buffer)); ---- Data flows: source → caller's buffer → processing @@ -25,9 +25,9 @@ With buffer sources/sinks, the *callee* provides buffers: [source,cpp] ---- // Callee owns the buffers -const_buffer bufs[8]; -auto [ec, count] = co_await source.pull(bufs, 8); -// bufs now point into source's internal storage +const_buffer arr[8]; +auto [ec, bufs] = co_await source.pull(arr); +// bufs is a span pointing into source's internal storage ---- Data flows: source's internal buffer → processing (no copy!) @@ -40,8 +40,10 @@ A `BufferSource` provides read-only buffers from its internal storage: ---- template concept BufferSource = - requires(T& source, const_buffer* arr, std::size_t max_count) { - { source.pull(arr, max_count) } -> IoAwaitable; + requires(T& src, std::span dest, std::size_t n) { + { src.pull(dest) } -> IoAwaitable; + // pull await-returns (error_code, std::span) + src.consume(n); }; ---- @@ -49,16 +51,17 @@ concept BufferSource = [source,cpp] ---- -IoAwaitable auto pull(const_buffer* arr, std::size_t max_count); +IoAwaitable auto pull(std::span dest); +void consume(std::size_t n); ---- -Await-returns `(error_code, std::size_t)`: +`pull` await-returns `(error_code, std::span)`. The source fills `dest` with descriptors pointing into its internal storage and returns a span over the filled prefix: -* On success: `!ec`, fills `arr[0..count-1]` with buffer descriptors -* On exhausted: `count == 0` indicates no more data -* On error: `ec` +* On success: `!ec`, the returned span describes the available data +* On exhaustion: `ec == cond::eof` with an empty span +* On error: `ec` set to the error condition -The buffers point into the source's internal storage. You must consume all returned data before calling `pull()` again—the previous buffers become invalid. +The returned buffers point into the source's internal storage and remain valid until you call `consume`. Calling `pull` again *without* an intervening `consume` returns the same unconsumed data. Call `consume(n)` to mark `n` bytes as consumed and advance the source. === Example @@ -67,21 +70,27 @@ The buffers point into the source's internal storage. You must consume all retur template task<> process_source(Source& source) { - const_buffer bufs[8]; - + const_buffer arr[8]; + for (;;) { - auto [ec, count] = co_await source.pull(bufs, 8); - + auto [ec, bufs] = co_await source.pull(arr); + + if (ec == cond::eof) + break; // Source exhausted + if (ec) throw std::system_error(ec); - - if (count == 0) - break; // Source exhausted - + // Process buffers (zero-copy!) - for (std::size_t i = 0; i < count; ++i) - process_data(bufs[i].data(), bufs[i].size()); + std::size_t total = 0; + for (auto const& b : bufs) + { + process_data(b.data(), b.size()); + total += b.size(); + } + + source.consume(total); } } ---- @@ -94,11 +103,10 @@ A `BufferSink` provides writable buffers for direct write access: ---- template concept BufferSink = - requires(T& sink, mutable_buffer* arr, std::size_t max_count, std::size_t n) { - { sink.prepare(arr, max_count) } -> std::same_as; - { sink.commit(n) } -> IoAwaitable; - { sink.commit(n, bool{}) } -> IoAwaitable; - { sink.commit_eof() } -> IoAwaitable; + requires(T& sink, std::span dest, std::size_t n) { + { sink.prepare(dest) } -> std::same_as>; + { sink.commit(n) } -> IoAwaitable; // await-returns (error_code) + { sink.commit_eof(n) } -> IoAwaitable; // await-returns (error_code) }; ---- @@ -106,21 +114,20 @@ concept BufferSink = [source,cpp] ---- -std::size_t prepare(mutable_buffer* arr, std::size_t max_count); +std::span prepare(std::span dest); ---- -Synchronous operation. Returns the number of buffers prepared (may be less than `max_count`). Fills `arr[0..count-1]` with writable buffer descriptors. +Synchronous operation. The sink fills `dest` with writable buffer descriptors from its internal storage and returns a span over the filled prefix (which may be shorter than `dest`). === commit Semantics [source,cpp] ---- IoAwaitable auto commit(std::size_t n); -IoAwaitable auto commit(std::size_t n, bool eof); -IoAwaitable auto commit_eof(); +IoAwaitable auto commit_eof(std::size_t n); ---- -Finalizes `n` bytes of prepared data. The `eof` flag or `commit_eof()` signals end-of-stream. +`commit(n)` finalizes `n` bytes of prepared data and await-returns `(error_code)`. `commit_eof(n)` finalizes `n` final bytes and signals end-of-stream; pass `0` to signal end-of-stream with no further data. === Example @@ -130,29 +137,33 @@ template task<> write_to_sink(Sink& sink, std::span data) { std::size_t written = 0; - + while (written < data.size()) { - mutable_buffer bufs[8]; - std::size_t count = sink.prepare(bufs, 8); - - if (count == 0) + mutable_buffer arr[8]; + auto bufs = sink.prepare(arr); + + if (bufs.empty()) throw std::runtime_error("sink full"); - + // Copy into sink's buffers std::size_t copied = 0; - for (std::size_t i = 0; i < count && written < data.size(); ++i) + for (auto& b : bufs) { + if (written >= data.size()) + break; std::size_t chunk = (std::min)( - bufs[i].size(), + b.size(), data.size() - written); - std::memcpy(bufs[i].data(), data.data() + written, chunk); + std::memcpy(b.data(), data.data() + written, chunk); written += chunk; copied += chunk; } - - bool eof = (written == data.size()); - co_await sink.commit(copied, eof); + + if (written == data.size()) + co_await sink.commit_eof(copied); + else + co_await sink.commit(copied); } } ---- @@ -163,27 +174,34 @@ Buffer sources/sinks enable true zero-copy I/O: === Memory-Mapped Files +A `BufferSource` is a concept, not a base class, so a type models it +simply by providing the required members: + [source,cpp] ---- -class mmap_source : public BufferSource +class mmap_source // models BufferSource { void* mapped_region_; std::size_t size_; std::size_t offset_ = 0; - + public: - io_result pull(const_buffer* arr, std::size_t max_count) + io_task> pull(std::span dest) { if (offset_ >= size_) - co_return {error_code{}, 0}; // Exhausted - - // Return pointer into mapped memory—no copy! - arr[0] = const_buffer( + co_return {error::eof, {}}; // Exhausted + + // Return pointer into mapped memory: no copy! + dest[0] = const_buffer( static_cast(mapped_region_) + offset_, size_ - offset_); - offset_ = size_; - - co_return {error_code{}, 1}; + + co_return {{}, dest.subspan(0, 1)}; + } + + void consume(std::size_t n) + { + offset_ += n; } }; ---- @@ -200,8 +218,13 @@ DMA buffers, GPU memory, network card ring buffers—all can be exposed through ---- #include +// Owning: takes ownership of the source +template +any_buffer_source(S source); + +// Reference: wraps without ownership, source must outlive the wrapper template -any_buffer_source(S& source); +any_buffer_source(S* source); ---- === any_buffer_sink @@ -210,8 +233,13 @@ any_buffer_source(S& source); ---- #include +// Owning: takes ownership of the sink template -any_buffer_sink(S& sink); +any_buffer_sink(S sink); + +// Reference: wraps without ownership, sink must outlive the wrapper +template +any_buffer_sink(S* sink); ---- == Example: Compression Pipeline @@ -223,23 +251,27 @@ any_buffer_sink(S& sink); task<> decompress_stream(any_buffer_source& compressed, any_write_sink& output) { - const_buffer bufs[8]; - + const_buffer arr[8]; + for (;;) { - auto [ec, count] = co_await compressed.pull(bufs, 8); + auto [ec, bufs] = co_await compressed.pull(arr); + if (ec == cond::eof) + break; if (ec) throw std::system_error(ec); - if (count == 0) - break; - - for (std::size_t i = 0; i < count; ++i) + + std::size_t total = 0; + for (auto const& b : bufs) { - auto decompressed = decompress_block(bufs[i]); + auto decompressed = decompress_block(b); co_await output.write(make_buffer(decompressed)); + total += b.size(); } + + compressed.consume(total); } - + co_await output.write_eof(); } ---- diff --git a/doc/modules/ROOT/pages/6.streams/6e.algorithms.adoc b/doc/modules/ROOT/pages/6.streams/6e.algorithms.adoc index 7692211fa..5bce28b23 100644 --- a/doc/modules/ROOT/pages/6.streams/6e.algorithms.adoc +++ b/doc/modules/ROOT/pages/6.streams/6e.algorithms.adoc @@ -20,15 +20,14 @@ Fills a buffer completely by looping `read_some`: #include template -task> -read(Stream& stream, Buffers const& buffers); +io_task +read(Stream& stream, Buffers buffers); ---- -Keeps reading until: +Await-returns `io_result`, destructuring as `[ec, n]`. Keeps reading until: * Buffer is full (`n == buffer_size(buffers)`) -* EOF is reached (returns `cond::eof` with partial count) -* Error occurs (returns error with partial count) +* The underlying `read_some` reports a condition before the buffer is full (the condition is propagated with the partial count) Example: @@ -45,9 +44,9 @@ Reads until EOF into a growable buffer: [source,cpp] ---- -template -task> -read(Stream& stream, Buffer&& buffer); +template +io_task +read(Stream& stream, Buffer&& buffer, std::size_t initial_amount = 2048); ---- Example: @@ -68,8 +67,8 @@ Writes all data by looping `write_some`: #include template -task> -write(Stream& stream, Buffers const& buffers); +io_task +write(Stream& stream, Buffers buffers); ---- Keeps writing until: @@ -98,16 +97,16 @@ Transfers data from a `BufferSource` to a destination: // To WriteSink (with EOF propagation) template -task> +io_task push_to(Source& source, Sink& sink); // To WriteStream (streaming, no EOF) template -task> +io_task push_to(Source& source, Stream& stream); ---- -The source provides buffers via `pull()`. Data is pushed to the destination. Buffer ownership stays with the source—no intermediate copying when possible. +The source provides buffers via `pull()`. Data is pushed to the destination. Buffer ownership stays with the source: no intermediate copying when possible. Example: @@ -128,12 +127,12 @@ Transfers data from a source to a `BufferSink`: // From ReadSource template -task> +io_task pull_from(Source& source, Sink& sink); // From ReadStream (streaming) template -task> +io_task pull_from(Stream& stream, Sink& sink); ---- @@ -156,34 +155,38 @@ Instead, compose with an intermediate stage: [source,cpp] ---- -// Transform: BufferSource → processing → BufferSink +// Transform: BufferSource -> processing -> BufferSink task<> process_pipeline(any_buffer_source& source, any_buffer_sink& sink) { - const_buffer src_bufs[8]; - + const_buffer src_arr[8]; + while (true) { - auto [ec, count] = co_await source.pull(src_bufs, 8); - if (count == 0) + auto [ec, src_bufs] = co_await source.pull(src_arr); + if (ec == cond::eof) break; - - for (std::size_t i = 0; i < count; ++i) + + std::size_t consumed = 0; + for (auto const& b : src_bufs) { - auto processed = transform(src_bufs[i]); - + auto processed = transform(b); + // Write processed data to sink - mutable_buffer dst_bufs[8]; - std::size_t dst_count = sink.prepare(dst_bufs, 8); - + mutable_buffer dst_arr[8]; + auto dst_bufs = sink.prepare(dst_arr); + std::size_t copied = buffer_copy( - std::span(dst_bufs, dst_count), + dst_bufs, make_buffer(processed)); - + co_await sink.commit(copied); + consumed += b.size(); } + + source.consume(consumed); } - - co_await sink.commit_eof(); + + co_await sink.commit_eof(0); } ---- diff --git a/doc/modules/ROOT/pages/6.streams/6f.isolation.adoc b/doc/modules/ROOT/pages/6.streams/6f.isolation.adoc index ff2218db2..52c8355a5 100644 --- a/doc/modules/ROOT/pages/6.streams/6f.isolation.adoc +++ b/doc/modules/ROOT/pages/6.streams/6f.isolation.adoc @@ -93,7 +93,7 @@ Type erasure decouples your code from specific transport implementations: task<> send_message(any_write_sink& sink, message const& msg) { co_await sink.write(make_buffer(msg.header)); - co_await sink.write(make_buffer(msg.body), true); + co_await sink.write_eof(make_buffer(msg.body)); } ---- @@ -103,26 +103,26 @@ Callers provide any conforming implementation: ---- // TCP socket tcp::socket socket; -any_write_sink sink{socket}; +any_write_sink sink{&socket}; // references socket send_message(sink, msg); // TLS stream tls::stream stream; -any_write_sink sink{stream}; +any_write_sink sink{&stream}; // references stream send_message(sink, msg); // HTTP chunked encoding chunked_sink chunked{underlying}; -any_write_sink sink{chunked}; +any_write_sink sink{&chunked}; // references chunked send_message(sink, msg); // Test mock test::write_sink mock; -any_write_sink sink{mock}; +any_write_sink sink{&mock}; // references mock send_message(sink, msg); ---- -Same `send_message` function, different transports—compile once, use everywhere. +Same `send_message` function, different transports: compile once, use everywhere. == API Design Guidelines @@ -143,8 +143,8 @@ task<> process(tcp::socket& socket); ---- void caller(tcp::socket& socket) { - any_stream stream{socket}; // Wrap here - process(stream); // Call with erased type + any_stream stream{&socket}; // Wrap by reference here + process(stream); // Call with erased type } ---- @@ -159,7 +159,8 @@ tcp::socket create_socket(); // Then caller wraps if needed auto socket = create_socket(); -any_stream stream{socket}; +any_stream stream{&socket}; // reference; socket must outlive stream +// or: any_stream stream{std::move(socket)}; // wrapper takes ownership ---- Returning type-erased values forces heap allocation. Return concrete types when the caller knows what they need. @@ -200,7 +201,7 @@ Users don't need to know how HTTP is implemented: tcp::socket socket; // ... connect ... -any_stream conn{socket}; +any_stream conn{&socket}; // references socket auto response = co_await send_request(conn, { .method = "GET", .url = "/api/data" diff --git a/doc/modules/ROOT/pages/7.testing/7e.buffer-inspection.adoc b/doc/modules/ROOT/pages/7.testing/7e.buffer-inspection.adoc index 5b8aa3481..c1b21ced4 100644 --- a/doc/modules/ROOT/pages/7.testing/7e.buffer-inspection.adoc +++ b/doc/modules/ROOT/pages/7.testing/7e.buffer-inspection.adoc @@ -49,7 +49,7 @@ void test_all_splits() while(bg) { auto [b1, b2] = co_await bg.next(); - BOOST_TEST_EQ(buffer_to_string(b1, b2), data); + BOOST_TEST_EQ(buffer_to_string(b1.data(), b2.data()), data); } }); BOOST_TEST(r.success); @@ -97,11 +97,13 @@ two positions: 0 and size. === Mutability Preservation -`bufgrind` is templated on a `ConstBufferSequence` but the split type it -produces follows the mutability of the input. Passing a `mutable_buffer` -yields `mutable_buffer` slices; passing a `const_buffer` yields -`const_buffer` slices. This matters for tests that need to write into the -produced buffers rather than only read from them. +`bufgrind` is templated on a `ConstBufferSequence` but the slices it +produces follow the mutability of the input. Each half is a buffer-slice +view (`slice_type`); its `data()` member exposes the underlying buffer +sequence. Passing a `mutable_buffer` yields halves whose `data()` models +`MutableBufferSequence`; passing a `const_buffer` yields halves whose +`data()` models `ConstBufferSequence`. This matters for tests that need +to write into the produced buffers rather than only read from them. [source,cpp] ---- @@ -112,8 +114,9 @@ bufgrind bg(mb); while(bg) { auto [b1, b2] = co_await bg.next(); - // b1 and b2 are mutable_buffer; callers may write into them - static_assert(std::is_same_v); + // b1 and b2 are buffer-slice views; data() exposes the underlying + // buffer sequence, here a MutableBufferSequence callers may write into + static_assert(MutableBufferSequence); } ---- @@ -180,7 +183,7 @@ auto r = f.inert([&](fuse&) -> task<> { while(bg) { auto [b1, b2] = co_await bg.next(); - BOOST_TEST_EQ(buffer_to_string(b1, b2), original); + BOOST_TEST_EQ(buffer_to_string(b1.data(), b2.data()), original); } }); BOOST_TEST(r.success); @@ -242,8 +245,8 @@ void test_parser_all_splits() // Feed the split as two discrete reads read_stream rs(f); - rs.provide(buffer_to_string(b1)); - rs.provide(buffer_to_string(b2)); + rs.provide(buffer_to_string(b1.data())); + rs.provide(buffer_to_string(b2.data())); std::string got = co_await read_all(rs); BOOST_TEST_EQ(got, input); diff --git a/doc/modules/ROOT/pages/8.examples/8a.hello-task.adoc b/doc/modules/ROOT/pages/8.examples/8a.hello-task.adoc index 4be42e699..24b547646 100644 --- a/doc/modules/ROOT/pages/8.examples/8a.hello-task.adoc +++ b/doc/modules/ROOT/pages/8.examples/8a.hello-task.adoc @@ -32,6 +32,7 @@ int main() { capy::thread_pool pool; capy::run_async(pool.get_executor())(say_hello()); + pool.join(); return 0; } ---- @@ -70,7 +71,7 @@ capy::thread_pool pool; `thread_pool` provides an execution context with worker threads. By default, it creates one thread per CPU core. -The pool's destructor waits for all work to complete before returning. This ensures the program doesn't exit while tasks are running. +Call `pool.join()` before the pool is destroyed to wait for all outstanding work to finish. The destructor does *not* wait for queued work: `~thread_pool` calls `stop()`, which abandons any work that has not yet started, then joins the worker threads. Without the explicit `pool.join()`, `main` could destroy the pool and exit before `say_hello` ever runs, so `"Hello from Capy!"` might never print. === Launching diff --git a/doc/modules/ROOT/pages/8.examples/8d.mock-stream-testing.adoc b/doc/modules/ROOT/pages/8.examples/8d.mock-stream-testing.adoc index 4d7fc39c0..534de6757 100644 --- a/doc/modules/ROOT/pages/8.examples/8d.mock-stream-testing.adoc +++ b/doc/modules/ROOT/pages/8.examples/8d.mock-stream-testing.adoc @@ -181,26 +181,26 @@ target_link_libraries(mock_stream_testing PRIVATE capy) [source,cpp] ---- capy::test::fuse f; // test::fuse -capy::test::stream mock(f); // test::stream -mock.provide("hello\n"); +auto [a, b] = capy::test::make_stream_pair(f); // connected test::stream pair +b.provide("hello\n"); // supply read input on one end ---- `test::stream` is a bidirectional mock that satisfies both `ReadStream` and `WriteStream`: -* Constructor takes a `fuse&` for error injection -* `provide(data)` — Supplies data for reads -* `data()` — Returns data written to the mock -* Second constructor parameter controls max bytes per operation +* Streams are obtained in connected pairs from `make_stream_pair(f)`; the shared `fuse` enables error injection +* `provide(data)` — Supplies data for the peer's reads +* `data()` — Returns data written to this end +* `set_max_read_size(n)` — Limits bytes returned per read, simulating chunked delivery === Type-Erased Streams [source,cpp] ---- -// Wrap mock in any_stream using pointer construction for reference semantics -capy::any_stream stream{&mock}; // any_stream +// Wrap one end in any_stream using pointer construction for reference semantics +capy::any_stream stream{&a}; // any_stream ---- -Use pointer construction (`&mock`) so the `any_stream` wrapper references the mock without taking ownership. This allows inspecting `mock.data()` after operations. +Use pointer construction (`&a`) so the `any_stream` wrapper references the stream end without taking ownership. This allows inspecting `b.data()` after operations. === Synchronous Testing @@ -218,7 +218,7 @@ capy::test::run_blocking([&](bool r) { result = r; })(echo_line_uppercase(stream ---- capy::test::fuse f; // test::fuse auto r = f.armed([&](capy::test::fuse&) -> capy::task<> { - capy::test::stream mock(f); // test::stream + auto [a, b] = capy::test::make_stream_pair(f); // connected test::stream pair // ... run test ... }); ---- diff --git a/doc/modules/ROOT/pages/9.design/9a.CapyLayering.adoc b/doc/modules/ROOT/pages/9.design/9a.CapyLayering.adoc index 4b3b2abe7..1d8700334 100644 --- a/doc/modules/ROOT/pages/9.design/9a.CapyLayering.adoc +++ b/doc/modules/ROOT/pages/9.design/9a.CapyLayering.adoc @@ -41,7 +41,7 @@ task<> echo(any_stream& stream) for(;;) { auto [ec, n] = co_await stream.read_some( - mutable_buffer(buf)); + make_buffer(buf)); if(ec) co_return; co_await write(stream, const_buffer(buf, n)); @@ -84,7 +84,7 @@ The user chooses where the boundary falls. Not the library. Buffer sink and buffer source invert the traditional ownership model. Instead of the caller allocating a buffer, filling it with data, and handing it to the library, the library exposes its own internal storage and the caller fills it in place. Zero copies. The data goes directly where it needs to be. -The `BufferSink` concept formalizes this with three operations. `prepare()` returns writable buffers from the sink's internal storage. The caller writes data into those buffers. `commit()` tells the sink how many bytes were written: +The `BufferSink` concept formalizes this with three operations. `prepare()` returns writable buffers from the sink's internal storage. The caller writes data into those buffers. `commit()` tells the sink how many bytes were written, and `commit_eof()` commits the final bytes and signals end-of-stream: [source,cpp] ---- @@ -95,6 +95,7 @@ concept BufferSink = { sink.prepare(dest) } -> std::same_as>; { sink.commit(n) } -> IoAwaitable; + { sink.commit_eof(n) } -> IoAwaitable; }; ---- diff --git a/doc/modules/ROOT/pages/9.design/9c.ReadStream.adoc b/doc/modules/ROOT/pages/9.design/9c.ReadStream.adoc index 4edfce423..77fa2b052 100644 --- a/doc/modules/ROOT/pages/9.design/9c.ReadStream.adoc +++ b/doc/modules/ROOT/pages/9.design/9c.ReadStream.adoc @@ -30,7 +30,9 @@ Attempts to read up to `buffer_size(buffers)` bytes from the stream into the buf If `buffer_size(buffers) > 0`: - If `!ec`, then `n >= 1 && n \<= buffer_size(buffers)`. `n` bytes were read into the buffer sequence. -- If `ec`, then `n >= 0 && n \<= buffer_size(buffers)`. `n` is the number of bytes read before the I/O condition arose. +- If `ec`, then `n >= 0 && n \< buffer_size(buffers)`. `n` is the number of bytes read before the I/O condition arose. + +Equivalently, `n == buffer_size(buffers)` implies `!ec`: a completion that fills the buffer sequence is a success, even when the underlying operation also signals a condition such as end-of-stream. That condition is reported on a subsequent read. If `buffer_empty(buffers)` is true, `n` is 0. The empty buffer is not itself a cause for error, but `ec` may reflect the state of the stream. @@ -104,7 +106,7 @@ task<> read_header(Stream& stream) { char header[16]; auto [ec, n] = co_await read( - stream, mutable_buffer(header)); + stream, make_buffer(header)); if(ec == cond::eof) co_return; // clean shutdown if(ec) @@ -172,7 +174,7 @@ task<> echo(Stream& stream, WriteStream auto& dest) for(;;) { auto [ec, n] = co_await stream.read_some( - mutable_buffer(buf)); + make_buffer(buf)); auto [wec, nw] = co_await dest.write_some( const_buffer(buf, n)); @@ -245,21 +247,23 @@ Because `ReadSource` refines `ReadStream`, this relay function also accepts `Rea | `WriteSink::write` |=== -== Design Foundations: Why Errors May Accompany Data +== Design Foundations: Why a Full Buffer Is Always Success -The `read_some` contract permits `n > 0` when `ec` is set. Data and errors are not mutually exclusive: the implementation reports exactly what happened. This is the most consequential design decision in the `ReadStream` concept, with implications for every consumer of `read_some` in the library. This section explains the design and its consequences. +The `read_some` contract treats a completion that fills the buffer sequence as a success: `n == buffer_size(buffers)` implies `!ec`. An error is reported only when the transfer was incomplete, in which case `n \< buffer_size(buffers)`. A pending condition, such as end-of-stream, is never delivered alongside a full buffer; it is deferred to the next read. This is the most consequential design decision in the `ReadStream` concept, with implications for every consumer of `read_some` in the library. This section explains the design and its consequences. === The Return Type's Purpose -POSIX `read(2)` returns a single `ssize_t` -- either a byte count or -1 with `errno`. It cannot report both a byte count and an error simultaneously. When a partial transfer occurs before an error, POSIX returns the byte count on the current call and defers the error to the next. The `(error_code, size_t)` return type was designed to transcend this limitation. It can carry both values at once, allowing implementations to report partial transfers alongside the condition that stopped the transfer, as a single result. +The `(error_code, size_t)` return type carries both a byte count and a condition, but the contract assigns them disjoint roles. A nonzero `ec` describes only a transfer that fell short of the requested size; it never qualifies a complete transfer. This gives every consumer a single, unambiguous test: if `n == buffer_size(buffers)` the operation succeeded and the bytes are valid, full stop. There is no need to inspect `ec` to decide whether the data can be used. + +=== Deferring Conditions to the Next Read -=== Departing from Asio +A condition such as end-of-stream is a property of the stream, not of the bytes that were just delivered. If a read happens to fill the buffer exactly as the stream reaches its end, the bytes are still good and the read still succeeded. Reporting EOF on that same completion would force every caller to reconcile "I got all my bytes" with "but there was also an error," which is precisely the ambiguity the contract removes. Instead the condition surfaces on the next read, when `n` is necessarily less than the buffer size, and the caller observes it cleanly. -Asio's `AsyncReadStream` concept requires `bytes_transferred == 0` on error. This was a reasonable design for an API built around POSIX-like streams, where the underlying system calls enforce binary outcomes per call. However, it imposes a burden on layered streams that do not share this limitation. +This is what lets generic composition algorithms such as `when_all` and `when_any` distinguish a completed transfer from a failure by inspecting `n` alone. A short read signals a condition; a full read does not. -A TLS stream might decrypt 100 bytes into user space, then receive a fatal alert on the next record. Under the strict rule it must either report `(!ec, 100)` now and `(ec, 0)` on the next call (requiring deferred-error bookkeeping), or report `(ec, 0)` and discard 100 valid bytes. Neither is clean. Under the relaxed rule, the TLS stream reports `(ec, 100)` honestly: here are the bytes that arrived, and here is the condition that stopped the transfer. +=== The Implementation Burden Is Internal -The `ReadStream` concept permits both behaviors. Streams that naturally produce `(ec, 0)` on error (such as POSIX socket wrappers) conform. Streams that report `(ec, n)` with `n > 0` (such as TLS or compression layers) also conform. The concept imposes the weakest postcondition that all conforming types can satisfy. +Deferral is forced only at the boundary, when the final bytes exactly fill the buffer. Because `(ec, buffer_size(buffers))` is not a permitted result, a stream that fills the buffer and simultaneously reaches a stopping condition reports `(!ec, buffer_size(buffers))` now and surfaces the condition on the next call. When the buffer is not filled, no deferral is required: the stream may report the condition directly alongside the partial count. The bookkeeping for the rare exact-fill case lives inside the stream, where it already has the context, rather than being pushed onto every caller. The concept imposes the postcondition that makes consumers simplest, and conforming streams arrange their internal state to honor it. === The Empty-Buffer Rule @@ -292,23 +296,17 @@ if(ec) The advance-then-check ordering is the only correct pattern. It is required for any operation that can report partial progress alongside an error -- `read` returning `(eof, 47)` being the canonical example. If the check precedes the advance, the 47 bytes are silently dropped. -Under the strict rule (`n == 0` on error), the advance is a harmless no-op. Under the relaxed rule (`n >= 0` on error), the advance captures partial progress. The caller writes identical code either way. The perceived simplification of the strict rule exists only if the caller writes the check-then-advance anti-pattern, which is already incorrect for other reasons. +Because an error can accompany partial data, the advance must run before the check so the bytes that did arrive are counted; on a clean completion the same code advances by the full amount. Writing the check first would silently drop those bytes, so advance-then-check is the only correct order. === Implementer Freedom -Under the strict rule, every stream that might encounter an error after a partial transfer must choose between: +When a stream produces some bytes and then observes a stopping condition before the buffer is full, it reports both at once: `(ec, k)` with `k \< buffer_size(buffers)`. There is no deferred state, no discarded data, and no internal replay buffer. A stream that decrypts or decompresses into the caller's buffer and then hits a terminal marker simply returns the bytes and the condition together. -- **Deferred errors.** Report `(!ec, k)` now, remember the error, and report `(ec, 0)` on the next call. This requires per-stream state and makes the stream's behavior depend on call history. -- **Data loss.** Report `(ec, 0)` and discard the `k` bytes that were transferred. -- **Internal buffering.** Copy the `k` bytes into an internal buffer and replay them on the next call. This adds allocation and copying overhead. - -Under the relaxed rule, the implementation reports what happened: `(ec, k)`. No deferred state, no data loss, no internal buffering. +The one case that requires deferral is the exact-fill boundary, where the final bytes leave no free space. Since `(ec, buffer_size(buffers))` is not permitted, the stream reports `(!ec, buffer_size(buffers))` and carries the condition to the next call. This case is rare, and its bookkeeping is local to the stream. === Consistency from Primitives Through Composed Operations -The strict postcondition on `read_some` does not propagate to composed operations. The composed `read` returns `(ec, m)` where `m > 0` on failure, because it accumulates data across multiple internal `read_some` calls. The `(ec, n > 0)` case that the strict rule eliminates from `read_some` is immediately reintroduced one layer up. - -The relaxed postcondition avoids this inconsistency. Partial progress alongside an error code is the same pattern at every level -- from `read_some` through composed `read` -- rather than being forbidden at the primitive level and required at the composed level. +`read_some` and the composed `read` report progress with the same shape: `(ec, n)`, where `n` counts the bytes transferred before the condition. The composed `read` returns `(eof, m)` with `m` short of the requested total when the stream ends early; the primitive `read_some` likewise returns `(ec, n)` with `n` short of the buffer size. Partial progress alongside an error code is the same pattern at every level. The single refinement at the primitive level, that an exactly full buffer is reported as success, keeps `n` a reliable proxy for completion at every layer. === Conforming Sources @@ -335,4 +333,4 @@ No source is forced into an unnatural pattern. Sources that naturally separate d - Algorithms that need to process data as it arrives use `read_some` directly. - `ReadSource` refines `ReadStream` by adding `read` for complete-read semantics. -The contract permits errors to accompany partial data. This uses the richer `(error_code, size_t)` return type to its full potential, avoids forcing non-POSIX streams into a deferred-error model, and produces a postcondition that is consistent from `read_some` through composed operations. The canonical advance-then-check loop handles both cases correctly with no additional call-site cost. +The contract permits errors to accompany partial data, with one rule: a completely filled buffer is always reported as success, and any condition that coincides with it is deferred to the next call. This uses the `(error_code, size_t)` return type to its full potential, keeps a stream from deferring in the common partial case, and keeps `n` a reliable proxy for completion from `read_some` through composed operations. The canonical advance-then-check loop handles every case correctly with no additional call-site cost. diff --git a/doc/modules/ROOT/pages/9.design/9d.ReadSource.adoc b/doc/modules/ROOT/pages/9.design/9d.ReadSource.adoc index 0e0a61b73..25eaacaf3 100644 --- a/doc/modules/ROOT/pages/9.design/9d.ReadSource.adoc +++ b/doc/modules/ROOT/pages/9.design/9d.ReadSource.adoc @@ -31,7 +31,9 @@ Attempts to read up to `buffer_size(buffers)` bytes from the source into the buf If `buffer_size(buffers) > 0`: - If `!ec`, then `n >= 1 && n \<= buffer_size(buffers)`. `n` bytes were read into the buffer sequence. -- If `ec`, then `n >= 0 && n \<= buffer_size(buffers)`. `n` is the number of bytes read before the I/O condition arose. +- If `ec`, then `n >= 0 && n \< buffer_size(buffers)`. `n` is the number of bytes read before the I/O condition arose. + +Equivalently, `n == buffer_size(buffers)` implies `!ec`: a completion that fills the buffer sequence is a success, even when the underlying operation also signals a condition such as end-of-stream. That condition is reported on a subsequent read. If `buffer_empty(buffers)` is true, `n` is 0. The empty buffer is not itself a cause for error, but `ec` may reflect the state of the source. @@ -269,7 +271,7 @@ task<> relay_decompressed(Source& inflater, Sink& dest) { // read_some: decompress whatever is available auto [ec, n] = co_await inflater.read_some( - mutable_buffer(buf)); + make_buffer(buf)); if(ec == cond::eof) { auto [wec] = co_await dest.write_eof(); @@ -299,7 +301,7 @@ task<> relay(Src& src, Sink& dest) for(;;) { auto [ec, n] = co_await src.read_some( - mutable_buffer(buf)); + make_buffer(buf)); auto [wec, nw] = co_await dest.write( const_buffer(buf, n)); @@ -350,16 +352,16 @@ Examples of types that satisfy `ReadSource`: - **File source**: `read_some` is a single `read()` syscall. `read` loops until the buffer is filled or EOF. - **Memory source**: `read_some` returns available bytes. `read` fills the buffer from the memory region. -== Errors May Accompany Data +== A Full Buffer Is Always Success -The `read_some` contract (inherited from `ReadStream`) permits `n > 0` when `ec` is set, including on EOF. The implementation reports exactly what happened: the bytes that arrived and the condition that stopped the transfer. See xref:9.design/9c.ReadStream.adoc#_design_foundations_why_errors_may_accompany_data[ReadStream: Why Errors May Accompany Data] for the full rationale. The key points: +The `read_some` contract (inherited from `ReadStream`) treats a completion that fills the buffer sequence as a success: `n == buffer_size(buffers)` implies `!ec`. An error is reported only when the transfer was incomplete, in which case `n \< buffer_size(buffers)`. A pending condition such as end-of-stream is deferred to the next read. See xref:9.design/9c.ReadStream.adoc#_design_foundations_why_a_full_buffer_is_always_success[ReadStream: Why a Full Buffer Is Always Success] for the full rationale. The key points: -- The `(error_code, size_t)` return type can carry both values simultaneously, transcending the POSIX limitation of reporting only one per call. -- Layered streams (TLS, compression) may encounter an error after a partial transfer. Allowing `(ec, n)` with `n > 0` avoids forcing deferred-error bookkeeping or data loss. -- The canonical advance-then-check loop handles both cases correctly with no additional call-site cost. -- Concrete types that naturally produce `(ec, 0)` on error (POSIX socket wrappers) continue to do so. +- A nonzero `ec` describes only a transfer that fell short of the requested size; it never qualifies a complete transfer. +- A condition observed exactly as the buffer fills is held for the next call, where `n` is necessarily less than the buffer size, rather than delivered alongside good bytes. +- Consumers test `n == buffer_size(buffers)` alone to know the read succeeded, and generic algorithms such as `when_all` and `when_any` distinguish a completed transfer from a failure on that basis. +- A layered stream (TLS, compression) keeps the deferred-condition bookkeeping internal, where it has the context to manage it. -This contract carries over to `ReadSource` unchanged. Both `read_some` and `read` allow `n > 0` on error or EOF, reporting the bytes that were transferred before the condition arose. +The `read` operation is a different primitive and uses the complete-read semantics described above: it either fills the entire buffer with `!ec`, or returns `ec` (including `cond::eof`) with `n` indicating the bytes transferred before the condition arose. == Summary diff --git a/doc/modules/ROOT/pages/9.design/9f.WriteStream.adoc b/doc/modules/ROOT/pages/9.design/9f.WriteStream.adoc index 42cebd6c4..4db67494e 100644 --- a/doc/modules/ROOT/pages/9.design/9f.WriteStream.adoc +++ b/doc/modules/ROOT/pages/9.design/9f.WriteStream.adoc @@ -30,7 +30,9 @@ Attempts to write up to `buffer_size(buffers)` bytes from the buffer sequence to If `buffer_size(buffers) > 0`: - If `!ec`, then `n >= 1 && n \<= buffer_size(buffers)`. `n` bytes were written from the buffer sequence. -- If `ec`, then `n >= 0 && n \<= buffer_size(buffers)`. `n` is the number of bytes written before the I/O condition arose. +- If `ec`, then `n >= 0 && n \< buffer_size(buffers)`. `n` is the number of bytes written before the I/O condition arose. + +Equivalently, `n == buffer_size(buffers)` implies `!ec`: a completion that writes the entire buffer sequence is a success, even when the underlying operation also signals a condition. That condition is reported on a subsequent write. If `buffer_empty(buffers)` is true, `n` is 0. The empty buffer is not itself a cause for error, but `ec` may reflect the state of the stream. diff --git a/doc/modules/ROOT/pages/9.design/9g.WriteSink.adoc b/doc/modules/ROOT/pages/9.design/9g.WriteSink.adoc index 3e9aae202..db8fefd1b 100644 --- a/doc/modules/ROOT/pages/9.design/9g.WriteSink.adoc +++ b/doc/modules/ROOT/pages/9.design/9g.WriteSink.adoc @@ -57,7 +57,9 @@ This is the low-level primitive inherited from `WriteStream`. It is appropriate If `buffer_size(buffers) > 0`: - If `!ec`, then `n >= 1 && n \<= buffer_size(buffers)`. `n` bytes were written from the buffer sequence. -- If `ec`, then `n >= 0 && n \<= buffer_size(buffers)`. `n` is the number of bytes written before the I/O condition arose. +- If `ec`, then `n >= 0 && n \< buffer_size(buffers)`. `n` is the number of bytes written before the I/O condition arose. + +Equivalently, `n == buffer_size(buffers)` implies `!ec`: a completion that writes the entire buffer sequence is a success, even when the underlying operation also signals a condition. That condition is reported on a subsequent write. If `buffer_empty(buffers)` is true, `n` is 0. The empty buffer is not itself a cause for error, but `ec` may reflect the state of the stream. @@ -179,7 +181,7 @@ task<> relay(Source& src, Sink& dest) for(;;) { auto [ec, n] = co_await src.read_some( - mutable_buffer(buf)); + make_buffer(buf)); // Forward whatever arrived before checking the error std::size_t written = 0; diff --git a/doc/modules/ROOT/pages/9.design/9h.BufferSink.adoc b/doc/modules/ROOT/pages/9.design/9h.BufferSink.adoc index f2389603d..5eb26abc4 100644 --- a/doc/modules/ROOT/pages/9.design/9h.BufferSink.adoc +++ b/doc/modules/ROOT/pages/9.design/9h.BufferSink.adoc @@ -281,7 +281,7 @@ task<> transfer(Source& source, Sink& sink) for(;;) { auto [ec, n] = co_await source.read_some( - mutable_buffer(buf)); + make_buffer(buf)); if(ec == cond::eof) { auto [wec] = co_await sink.write_eof(); diff --git a/doc/modules/ROOT/pages/9.design/9l.RunApi.adoc b/doc/modules/ROOT/pages/9.design/9l.RunApi.adoc index a29495f22..414c370c7 100644 --- a/doc/modules/ROOT/pages/9.design/9l.RunApi.adoc +++ b/doc/modules/ROOT/pages/9.design/9l.RunApi.adoc @@ -8,7 +8,7 @@ This document explains the naming conventions and call syntax of the two launche === `run_async` -- Fire-and-Forget Launch -`run_async` launches any _IoRunnable_ from non-coroutine code: `main()`, callback handlers, event loops. `task` is the most common conforming type, but any user-defined type satisfying the concept works. The function does not return a value to the caller. Handlers receive the task's result or exception after completion. +`run_async` launches any _IoRunnable_ from non-coroutine code: `main()`, callback handlers, event loops. `task` is the most common conforming type, but any user-defined type satisfying the concept works. The function does not return a value to the caller. Handlers receive the task's result or exception after completion, as data; they should not throw. An exception that no handler consumes (none was supplied, or a handler let one escape) calls `std::terminate`; it is never silently discarded. To catch an error instead, `co_await` the work inside a coroutine. [source,cpp] ---- diff --git a/doc/modules/ROOT/pages/9.design/9n.WhyNotCobaltConcepts.adoc b/doc/modules/ROOT/pages/9.design/9n.WhyNotCobaltConcepts.adoc index 7a0c8b8ff..0c9f37ac7 100644 --- a/doc/modules/ROOT/pages/9.design/9n.WhyNotCobaltConcepts.adoc +++ b/doc/modules/ROOT/pages/9.design/9n.WhyNotCobaltConcepts.adoc @@ -38,7 +38,7 @@ concept IoAwaitable = }; ---- -`IoRunnable` refines `IoAwaitable` with operations needed to start a task from non-coroutine contexts: `handle()`, `release()`, `exception()`, and `result()`. +`IoRunnable` refines `IoAwaitable` with operations needed to start a task from non-coroutine contexts: `handle()`, `release()`, `exception()`, `result()`, and the promise-level `set_continuation()` and `set_environment()`. [source,cpp] ---- @@ -46,18 +46,21 @@ template concept IoRunnable = IoAwaitable && requires { typename T::promise_type; } && - requires(T& t, T const& ct, typename T::promise_type const& cp) + requires(T& t, T const& ct, typename T::promise_type const& cp, + typename T::promise_type& p) { { ct.handle() } noexcept -> std::same_as>; { cp.exception() } noexcept -> std::same_as; { t.release() } noexcept; + { p.set_continuation(std::coroutine_handle<>{}) } noexcept; + { p.set_environment(static_cast(nullptr)) } noexcept; } && (std::is_void_v().await_resume())> || requires(typename T::promise_type& p) { p.result(); }); ---- -Context injection (`set_environment`, `set_continuation`) is handled internally by the promise through `await_suspend` and is not part of any concept. +Context injection (`set_environment`, `set_continuation`) consists of `noexcept` requirements that `IoRunnable` places on `T::promise_type`; launch functions invoke them through the typed handle returned by `handle()` before resuming the frame. Each concept has documented syntactic requirements, semantic requirements, conforming signatures, and examples. A user who wants to create a custom task type can read the concept definition and know exactly what to provide. The compiler enforces the syntactic requirements at constraint-check time. @@ -305,10 +308,17 @@ Capy's `WriteStream` concept includes semantic requirements in the concept's doc // // If !ec, then n >= 1 && n <= buffer_size( buffers ). // n bytes were written from the buffer sequence. -// If ec, then n >= 0 && n <= buffer_size( buffers ). +// If ec, then n >= 0 && n < buffer_size( buffers ). // n is the number of bytes written before the I/O // condition arose. // +// Equivalently, n == buffer_size( buffers ) implies !ec: a +// completion that writes the entire buffer sequence is a success, +// even when the underlying operation also signals a condition. That +// condition is reported on a subsequent write. This lets generic +// composition algorithms such as when_all and when_any distinguish +// a completed transfer from a failure. +// // If buffer_empty( buffers ) is true, n is 0. The empty // buffer is not itself a cause for error, but ec may reflect // the state of the stream. @@ -341,7 +351,7 @@ The concept also includes a coroutine-specific warning about buffer lifetime: | | Error reporting semantics -| Documented (`ec` + `n >= 0 && n \<= buffer_size`) +| Documented (`ec` + `n >= 0 && n \< buffer_size`) | | Partial write guarantees diff --git a/doc/modules/ROOT/pages/9.design/9o.WhyNotTMC.adoc b/doc/modules/ROOT/pages/9.design/9o.WhyNotTMC.adoc index 4b006ac39..8bb472d2f 100644 --- a/doc/modules/ROOT/pages/9.design/9o.WhyNotTMC.adoc +++ b/doc/modules/ROOT/pages/9.design/9o.WhyNotTMC.adoc @@ -187,7 +187,7 @@ The awaitable receives: * `env` — The execution environment containing: ** `env->executor` — The executor (where to resume) ** `env->stop_token` — A stop token (for cancellation) -** `env->allocator` — An optional allocator for frame allocation +** `env->frame_allocator` — An optional `std::pmr::memory_resource*` for coroutine frame allocation (null selects the default allocator) *TMC's approach:* diff --git a/doc/modules/ROOT/pages/index.adoc b/doc/modules/ROOT/pages/index.adoc index a0153eef3..a2559acb5 100644 --- a/doc/modules/ROOT/pages/index.adoc +++ b/doc/modules/ROOT/pages/index.adoc @@ -6,7 +6,7 @@ Capy abstracts away sockets, files, and asynchrony with type-erased streams and * *Lazy coroutine tasks* — `task` with forward-propagating stop tokens and automatic cancellation * *Buffer sequences* — taken straight from Asio and improved -* *Stream concepts* — `ReadStream`, `WriteStream`, `ReadSource`, `WriteSink`, `BufferSource`, `BufferSink` +* *Stream concepts* — seven coroutine stream concepts: `ReadStream`, `WriteStream`, `Stream`, `ReadSource`, `WriteSink`, `BufferSource`, `BufferSink` * *Type-erased streams* — `any_stream`, `any_read_stream`, `any_write_stream` for fast compilation * *Concurrency facilities* — executors, strands, thread pools, `when_all`, `when_any` * *Test utilities* — mock streams, mock sources/sinks, error injection @@ -86,7 +86,7 @@ task<> echo(any_stream& stream) char buf[1024]; for(;;) { - auto [ec, n] = co_await stream.read_some(mutable_buffer(buf)); + auto [ec, n] = co_await stream.read_some(make_buffer(buf)); auto [wec, wn] = co_await write(stream, const_buffer(buf, n)); @@ -100,10 +100,13 @@ task<> echo(any_stream& stream) int main() { - thread_pool pool; - // In a real application, you would obtain a stream from Corosio - // and call: run_async(pool.get_executor())(echo(stream)); - return 0; + // In a real application, you would obtain a stream from Corosio, + // then launch the coroutine on its io_context and run it: + // + // corosio::io_context ioc; + // corosio::tcp_socket stream = /* from an acceptor or connect */; + // run_async(ioc.get_executor())(echo(stream)); + // ioc.run(); } ---- @@ -118,4 +121,4 @@ The `task<>` return type (equivalent to `task`) creates a lazy coroutine t * xref:3.concurrency/3a.foundations.adoc[Concurrency Tutorial] — Understand threads, mutexes, and synchronization * xref:4.coroutines/4a.tasks.adoc[Coroutines in Capy] — Deep dive into `task` and the IoAwaitable protocol * xref:5.buffers/5a.overview.adoc[Buffer Sequences] — Master the concept-driven buffer model -* xref:6.streams/6a.overview.adoc[Stream Concepts] — Understand the six stream concepts +* xref:6.streams/6a.overview.adoc[Stream Concepts] — Understand the seven stream concepts diff --git a/doc/modules/ROOT/pages/quick-start.adoc b/doc/modules/ROOT/pages/quick-start.adoc index d04ef68f5..370f46698 100644 --- a/doc/modules/ROOT/pages/quick-start.adoc +++ b/doc/modules/ROOT/pages/quick-start.adoc @@ -46,7 +46,9 @@ int main() // Launch the coroutine on the pool's executor capy::run_async(pool.get_executor())(greet()); - // Pool destructor waits for all work to complete + // join() waits for outstanding work to complete; the pool + // destructor only stops the pool and discards pending work + pool.join(); } ---- @@ -55,7 +57,7 @@ int main() [source,bash] ---- # With GCC -g++ -std=c++20 -o hello_coro hello_coro.cpp -lboost_system -pthread +g++ -std=c++20 -o hello_coro hello_coro.cpp -lcapy -pthread # Run ./hello_coro @@ -75,7 +77,7 @@ The answer is 42 4. `greet()` runs until it hits `co_await answer()` 5. `answer()` runs and returns 42 6. `greet()` resumes with the result and prints it -7. `greet()` completes, the pool destructor waits and returns +7. `greet()` completes, `pool.join()` returns, and `main()` exits The key insight: both coroutines ran on the same executor because affinity propagated automatically through the `co_await`. diff --git a/example/hello-task/hello_task.cpp b/example/hello-task/hello_task.cpp index 843ca46cc..2c497f913 100644 --- a/example/hello-task/hello_task.cpp +++ b/example/hello-task/hello_task.cpp @@ -22,5 +22,6 @@ int main() { capy::thread_pool pool; capy::run_async(pool.get_executor())(say_hello()); + pool.join(); return 0; } diff --git a/include/boost/capy/buffers.hpp b/include/boost/capy/buffers.hpp index 9fe509754..04d676a40 100644 --- a/include/boost/capy/buffers.hpp +++ b/include/boost/capy/buffers.hpp @@ -200,17 +200,28 @@ concept MutableBufferSequence = /** Return an iterator to the first buffer in a sequence. - Handles single buffers and ranges uniformly. For a single buffer, - returns a pointer to it (forming a one-element range). + @functionobject */ -constexpr struct begin_mrdocs_workaround_t +constexpr struct { + /** Return a pointer to a single buffer, forming a one-element range. + + @param b A single buffer. + + @return A pointer to `b`. + */ template ConvertibleToBuffer> auto operator()(ConvertibleToBuffer const& b) const noexcept -> ConvertibleToBuffer const* { return std::addressof(b); } + /** Return an iterator to the first buffer of a sequence. + + @param bs The buffer sequence. + + @return An iterator to the first buffer of `bs`. + */ template requires (!std::convertible_to) auto operator()(BS const& bs) const noexcept @@ -218,6 +229,12 @@ constexpr struct begin_mrdocs_workaround_t return std::ranges::begin(bs); } + /** Return an iterator to the first buffer of a sequence. + + @param bs The buffer sequence. + + @return An iterator to the first buffer of `bs`. + */ template requires (!std::convertible_to) auto operator()(BS& bs) const noexcept @@ -228,17 +245,28 @@ constexpr struct begin_mrdocs_workaround_t /** Return an iterator past the last buffer in a sequence. - Handles single buffers and ranges uniformly. For a single buffer, - returns a pointer one past it. + @functionobject */ -constexpr struct end_mrdocs_workaround_t +constexpr struct { + /** Return a pointer one past a single buffer, forming a one-element range. + + @param b A single buffer. + + @return A pointer one past `b`. + */ template ConvertibleToBuffer> auto operator()(ConvertibleToBuffer const& b) const noexcept -> ConvertibleToBuffer const* { return std::addressof(b) + 1; } + /** Return an iterator past the last buffer of a sequence. + + @param bs The buffer sequence. + + @return An iterator one past the last buffer of `bs`. + */ template requires (!std::convertible_to) auto operator()(BS const& bs) const noexcept @@ -246,6 +274,12 @@ constexpr struct end_mrdocs_workaround_t return std::ranges::end(bs); } + /** Return an iterator past the last buffer of a sequence. + + @param bs The buffer sequence. + + @return An iterator one past the last buffer of `bs`. + */ template requires (!std::convertible_to) auto operator()(BS& bs) const noexcept @@ -256,16 +290,9 @@ constexpr struct end_mrdocs_workaround_t /** Return the total byte count across all buffers in a sequence. - Sums the `size()` of each buffer in the sequence. This differs - from `buffer_length` which counts the number of buffer elements. - - @par Example - @code - std::array bufs = { ... }; - std::size_t total = buffer_size( bufs ); // sum of both sizes - @endcode + @functionobject */ -constexpr struct buffer_size_mrdocs_workaround_t +constexpr struct { // GCC 13 falsely flags reads of arr_[i].n_ in detail::buffer_array // when iterating here. The class uses union storage with placement @@ -277,6 +304,21 @@ constexpr struct buffer_size_mrdocs_workaround_t #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wmaybe-uninitialized" #endif + /** Return the total byte count across all buffers in a sequence. + + Sums the `size()` of each buffer in the sequence. This differs + from `buffer_length` which counts the number of buffer elements. + + @param bs The buffer sequence. + + @return The sum of the sizes of all buffers in `bs`. + + @par Example + @code + std::array bufs = { ... }; + std::size_t total = buffer_size( bufs ); // sum of both sizes + @endcode + */ template constexpr std::size_t operator()( CB const& bs) const noexcept @@ -294,16 +336,22 @@ constexpr struct buffer_size_mrdocs_workaround_t /** Check if a buffer sequence contains no data. - @return `true` if all buffers have size zero or the sequence - is empty. + @functionobject */ -constexpr struct buffer_empty_mrdocs_workaround_t +constexpr struct { // See note on buffer_size above — same union-storage false positive. #if defined(__GNUC__) && !defined(__clang__) #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wmaybe-uninitialized" #endif + /** Check if a buffer sequence contains no data. + + @param bs The buffer sequence. + + @return `true` if all buffers have size zero or the sequence + is empty. + */ template constexpr bool operator()( CB const& bs) const noexcept diff --git a/include/boost/capy/buffers/buffer_copy.hpp b/include/boost/capy/buffers/buffer_copy.hpp index a374acb8c..166747d28 100644 --- a/include/boost/capy/buffers/buffer_copy.hpp +++ b/include/boost/capy/buffers/buffer_copy.hpp @@ -20,24 +20,26 @@ namespace capy { /** Copy the contents of a buffer sequence into another buffer sequence. - This function copies bytes from the constant buffer sequence `src` into - the mutable buffer sequence `dest`, stopping when any limit is reached. + @functionobject +*/ +constexpr struct +{ + /** Copy the contents of a buffer sequence into another buffer sequence. - @par Constraints - @code - MutableBufferSequence && - ConstBufferSequence - @endcode + Copies bytes from the constant buffer sequence `src` into the + mutable buffer sequence `dest`, stopping when any limit is + reached. - @return The number of bytes copied, equal to - `std::min(size(dest), size(src), at_most)`. + @param dest The destination buffer sequence. - @param dest The destination buffer sequence. - @param src The source buffer sequence. - @param at_most The maximum bytes to copy. Default copies all available. -*/ -constexpr struct buffer_copy_mrdocs_workaround_t -{ + @param src The source buffer sequence. + + @param at_most The maximum bytes to copy. Default copies all + available. + + @return The number of bytes copied, equal to + `std::min(size(dest), size(src), at_most)`. + */ template< MutableBufferSequence MB, ConstBufferSequence CB> diff --git a/include/boost/capy/buffers/buffer_slice.hpp b/include/boost/capy/buffers/buffer_slice.hpp index 2d83bfd0a..3bfe5c33d 100644 --- a/include/boost/capy/buffers/buffer_slice.hpp +++ b/include/boost/capy/buffers/buffer_slice.hpp @@ -48,12 +48,13 @@ namespace capy { Iterators and buffer descriptors obtained through `data()` follow the same invalidation rules as those of `seq`. - @par Parameters - @li `seq` The underlying buffer sequence. Must outlive the + @param seq The underlying buffer sequence. Must outlive the returned slice and any `data()` view obtained from it. - @li `offset` Number of bytes to skip from the start of `seq`. + + @param offset Number of bytes to skip from the start of `seq`. Clamped to `buffer_size(seq)`. - @li `length` Maximum number of bytes the slice will expose, + + @param length Maximum number of bytes the slice will expose, starting at `offset`. Clamped to `buffer_size(seq) - offset`. Defaults to the maximum value of `std::size_t`, i.e. "to end". @@ -108,6 +109,15 @@ buffer_slice( auto bufs = some_dynamic_buffer.data(); // named, lives in scope auto s = buffer_slice( bufs ); // OK @endcode + + @param seq An rvalue buffer sequence (`const&&`). Binding the + slice to a temporary would dangle, so this overload is + deleted to reject such calls at compile time. + + @param offset Number of bytes to skip from the start of `seq`. + + @param length Maximum number of bytes the slice would expose, + starting at `offset`. */ template requires MutableBufferSequence diff --git a/include/boost/capy/buffers/front.hpp b/include/boost/capy/buffers/front.hpp index 13626d3bd..11ca0adfa 100644 --- a/include/boost/capy/buffers/front.hpp +++ b/include/boost/capy/buffers/front.hpp @@ -17,10 +17,18 @@ namespace boost { namespace capy { /** Return the first buffer in a sequence. + + @functionobject */ -constexpr struct front_mrdocs_workaround_t +constexpr struct { - /// Return the first mutable buffer, or an empty buffer. + /** Return the first mutable buffer, or an empty buffer. + + @param bs The buffer sequence to inspect. + + @return The first buffer in `bs`, or an empty buffer if `bs` + contains no buffers. + */ template mutable_buffer operator()( @@ -32,7 +40,13 @@ constexpr struct front_mrdocs_workaround_t return {}; } - /// Return the first const buffer, or an empty buffer. + /** Return the first const buffer, or an empty buffer. + + @param bs The buffer sequence to inspect. + + @return The first buffer in `bs`, or an empty buffer if `bs` + contains no buffers. + */ template requires (!MutableBufferSequence) const_buffer diff --git a/include/boost/capy/buffers/make_buffer.hpp b/include/boost/capy/buffers/make_buffer.hpp index 791d2c353..f4b288ad9 100644 --- a/include/boost/capy/buffers/make_buffer.hpp +++ b/include/boost/capy/buffers/make_buffer.hpp @@ -124,242 +124,6 @@ make_buffer( size < max_size ? size : max_size); } -/** Return a buffer from a C-style array. -*/ -template - requires std::is_trivially_copyable_v -[[nodiscard]] -mutable_buffer -make_buffer( - T (&data)[N]) noexcept -{ - return mutable_buffer( - data, N * sizeof(T)); -} - -/** Return a buffer from a C-style array with a maximum size. -*/ -template - requires std::is_trivially_copyable_v -[[nodiscard]] -mutable_buffer -make_buffer( - T (&data)[N], - std::size_t max_size) noexcept -{ - return mutable_buffer( - data, - N * sizeof(T) < max_size ? N * sizeof(T) : max_size); -} - -/** Return a buffer from a const C-style array. -*/ -template - requires std::is_trivially_copyable_v -[[nodiscard]] -const_buffer -make_buffer( - T const (&data)[N]) noexcept -{ - return const_buffer( - data, N * sizeof(T)); -} - -/** Return a buffer from a const C-style array with a maximum size. -*/ -template - requires std::is_trivially_copyable_v -[[nodiscard]] -const_buffer -make_buffer( - T const (&data)[N], - std::size_t max_size) noexcept -{ - return const_buffer( - data, - N * sizeof(T) < max_size ? N * sizeof(T) : max_size); -} - -// std::array - -/** Return a buffer from a std::array. -*/ -template - requires std::is_trivially_copyable_v -[[nodiscard]] -mutable_buffer -make_buffer( - std::array& data) noexcept -{ - return mutable_buffer( - data.data(), data.size() * sizeof(T)); -} - -/** Return a buffer from a std::array with a maximum size. -*/ -template - requires std::is_trivially_copyable_v -[[nodiscard]] -mutable_buffer -make_buffer( - std::array& data, - std::size_t max_size) noexcept -{ - return mutable_buffer( - data.data(), - data.size() * sizeof(T) < max_size - ? data.size() * sizeof(T) : max_size); -} - -/** Return a buffer from a const std::array. -*/ -template - requires std::is_trivially_copyable_v -[[nodiscard]] -const_buffer -make_buffer( - std::array const& data) noexcept -{ - return const_buffer( - data.data(), data.size() * sizeof(T)); -} - -/** Return a buffer from a const std::array with a maximum size. -*/ -template - requires std::is_trivially_copyable_v -[[nodiscard]] -const_buffer -make_buffer( - std::array const& data, - std::size_t max_size) noexcept -{ - return const_buffer( - data.data(), - data.size() * sizeof(T) < max_size - ? data.size() * sizeof(T) : max_size); -} - -// std::vector - -/** Return a buffer from a std::vector. -*/ -template - requires std::is_trivially_copyable_v -[[nodiscard]] -mutable_buffer -make_buffer( - std::vector& data) noexcept -{ - return mutable_buffer( - data.size() ? data.data() : nullptr, - data.size() * sizeof(T)); -} - -/** Return a buffer from a std::vector with a maximum size. -*/ -template - requires std::is_trivially_copyable_v -[[nodiscard]] -mutable_buffer -make_buffer( - std::vector& data, - std::size_t max_size) noexcept -{ - return mutable_buffer( - data.size() ? data.data() : nullptr, - data.size() * sizeof(T) < max_size - ? data.size() * sizeof(T) : max_size); -} - -/** Return a buffer from a const std::vector. -*/ -template - requires std::is_trivially_copyable_v -[[nodiscard]] -const_buffer -make_buffer( - std::vector const& data) noexcept -{ - return const_buffer( - data.size() ? data.data() : nullptr, - data.size() * sizeof(T)); -} - -/** Return a buffer from a const std::vector with a maximum size. -*/ -template - requires std::is_trivially_copyable_v -[[nodiscard]] -const_buffer -make_buffer( - std::vector const& data, - std::size_t max_size) noexcept -{ - return const_buffer( - data.size() ? data.data() : nullptr, - data.size() * sizeof(T) < max_size - ? data.size() * sizeof(T) : max_size); -} - -// std::basic_string - -/** Return a buffer from a std::basic_string. -*/ -template -[[nodiscard]] -mutable_buffer -make_buffer( - std::basic_string& data) noexcept -{ - return mutable_buffer( - data.size() ? &data[0] : nullptr, - data.size() * sizeof(CharT)); -} - -/** Return a buffer from a std::basic_string with a maximum size. -*/ -template -[[nodiscard]] -mutable_buffer -make_buffer( - std::basic_string& data, - std::size_t max_size) noexcept -{ - return mutable_buffer( - data.size() ? &data[0] : nullptr, - data.size() * sizeof(CharT) < max_size - ? data.size() * sizeof(CharT) : max_size); -} - -/** Return a buffer from a const std::basic_string. -*/ -template -[[nodiscard]] -const_buffer -make_buffer( - std::basic_string const& data) noexcept -{ - return const_buffer( - data.data(), - data.size() * sizeof(CharT)); -} - -/** Return a buffer from a const std::basic_string with a maximum size. -*/ -template -[[nodiscard]] -const_buffer -make_buffer( - std::basic_string const& data, - std::size_t max_size) noexcept -{ - return const_buffer( - data.data(), - data.size() * sizeof(CharT) < max_size - ? data.size() * sizeof(CharT) : max_size); -} - // std::basic_string_view /** Return a buffer from a std::basic_string_view. @@ -390,62 +154,6 @@ make_buffer( ? data.size() * sizeof(CharT) : max_size); } -// std::span - -/** Return a buffer from a mutable std::span. -*/ -template - requires (!std::is_const_v && sizeof(T) == 1) -[[nodiscard]] -mutable_buffer -make_buffer( - std::span data) noexcept -{ - return mutable_buffer(data.data(), data.size()); -} - -/** Return a buffer from a mutable std::span with a maximum size. -*/ -template - requires (!std::is_const_v && sizeof(T) == 1) -[[nodiscard]] -mutable_buffer -make_buffer( - std::span data, - std::size_t max_size) noexcept -{ - return mutable_buffer( - data.data(), - data.size() < max_size ? data.size() : max_size); -} - -/** Return a buffer from a const std::span. -*/ -template - requires (sizeof(T) == 1) -[[nodiscard]] -const_buffer -make_buffer( - std::span data) noexcept -{ - return const_buffer(data.data(), data.size()); -} - -/** Return a buffer from a const std::span with a maximum size. -*/ -template - requires (sizeof(T) == 1) -[[nodiscard]] -const_buffer -make_buffer( - std::span data, - std::size_t max_size) noexcept -{ - return const_buffer( - data.data(), - data.size() < max_size ? data.size() : max_size); -} - // Contiguous ranges namespace detail { @@ -473,11 +181,17 @@ concept const_contiguous_range = } // detail /** Return a buffer from a mutable contiguous range. + + Accepts any sized, contiguous range of trivially-copyable, + non-const elements, including `std::vector`, `std::array`, + `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. */ template [[nodiscard]] mutable_buffer -make_buffer(T& data) noexcept +make_buffer(T&& data) noexcept { return mutable_buffer( std::ranges::size(data) ? std::ranges::data(data) : nullptr, @@ -490,7 +204,7 @@ template [[nodiscard]] mutable_buffer make_buffer( - T& data, + T&& data, std::size_t max_size) noexcept { auto const n = std::ranges::size(data) * sizeof(std::ranges::range_value_t); @@ -500,6 +214,12 @@ make_buffer( } /** Return a buffer from a const contiguous range. + + Accepts any sized, contiguous range of trivially-copyable + 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. */ template [[nodiscard]] diff --git a/include/boost/capy/concept/read_stream.hpp b/include/boost/capy/concept/read_stream.hpp index 15180b872..2e138da2b 100644 --- a/include/boost/capy/concept/read_stream.hpp +++ b/include/boost/capy/concept/read_stream.hpp @@ -43,10 +43,17 @@ namespace capy { @li If `!ec`, then `n >= 1 && n <= buffer_size( buffers )`. `n` bytes were read into the buffer sequence. - @li If `ec`, then `n >= 0 && n <= buffer_size( buffers )`. + @li If `ec`, then `n >= 0 && n < buffer_size( buffers )`. `n` is the number of bytes read before the I/O condition arose. + Equivalently, `n == buffer_size( buffers )` implies `!ec`: a + completion that fills the buffer sequence is a success, even when + the underlying operation also signals a condition such as + end-of-stream. That condition is reported on a subsequent read. + This lets generic composition algorithms such as `when_all` and + `when_any` distinguish a completed transfer from a failure. + If `buffer_empty( buffers )` is `true`, `n` is 0. The empty buffer is not itself a cause for error, but `ec` may reflect the state of the stream. diff --git a/include/boost/capy/concept/write_stream.hpp b/include/boost/capy/concept/write_stream.hpp index c4bd725d2..d55a4218c 100644 --- a/include/boost/capy/concept/write_stream.hpp +++ b/include/boost/capy/concept/write_stream.hpp @@ -47,10 +47,17 @@ namespace capy { @li If `!ec`, then `n >= 1 && n <= buffer_size( buffers )`. `n` bytes were written from the buffer sequence. - @li If `ec`, then `n >= 0 && n <= buffer_size( buffers )`. + @li If `ec`, then `n >= 0 && n < buffer_size( buffers )`. `n` is the number of bytes written before the I/O condition arose. + Equivalently, `n == buffer_size( buffers )` implies `!ec`: a + completion that writes the entire buffer sequence is a success, even + when the underlying operation also signals a condition. That + condition is reported on a subsequent write. This lets generic + composition algorithms such as `when_all` and `when_any` distinguish + a completed transfer from a failure. + If `buffer_empty( buffers )` is `true`, `n` is 0. The empty buffer is not itself a cause for error, but `ec` may reflect the state of the stream. diff --git a/include/boost/capy/detail/run_callbacks.hpp b/include/boost/capy/detail/run_callbacks.hpp index 64526ac08..db8206135 100644 --- a/include/boost/capy/detail/run_callbacks.hpp +++ b/include/boost/capy/detail/run_callbacks.hpp @@ -11,6 +11,7 @@ #define BOOST_CAPY_DETAIL_RUN_CALLBACKS_HPP #include +#include #include #include @@ -34,8 +35,18 @@ struct default_handler void operator()(std::exception_ptr ep) const { - if(ep) + if(!ep) + return; + try + { std::rethrow_exception(ep); + } + catch(stop_requested_exception const&) + { + // Cancellation is a normal completion, not an error. + } + // A real unhandled exception propagates to the trampoline's + // unhandled_exception, which terminates. } }; @@ -92,7 +103,7 @@ struct handler_pair if constexpr(std::invocable) h1_(ep); else - std::rethrow_exception(ep); + default_handler{}(ep); } }; diff --git a/include/boost/capy/error.hpp b/include/boost/capy/error.hpp index 29ec382ac..02833c47a 100644 --- a/include/boost/capy/error.hpp +++ b/include/boost/capy/error.hpp @@ -1,5 +1,6 @@ // // Copyright (c) 2025 Vinnie Falco (vinnie.falco@gmail.com) +// Copyright (c) 2026 Michael Vandeberg // // Distributed under the Boost Software License, Version 1.0. (See accompanying // file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) @@ -72,6 +73,8 @@ struct BOOST_CAPY_SYMBOL_VISIBLE ) const noexcept override; BOOST_CAPY_DECL std::string message( int) const override; + BOOST_CAPY_DECL std::error_condition default_error_condition( + int) const noexcept override; constexpr error_cat_type() noexcept = default; }; diff --git a/include/boost/capy/ex/run_async.hpp b/include/boost/capy/ex/run_async.hpp index 09268219f..83ab93211 100644 --- a/include/boost/capy/ex/run_async.hpp +++ b/include/boost/capy/ex/run_async.hpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -167,7 +168,12 @@ struct BOOST_CAPY_CORO_DESTROY_WHEN_COMPLETE run_async_trampoline { } - void unhandled_exception() noexcept {} // LCOV_EXCL_LINE unsupported: throwing task with no error handler + // An exception reaches here only by escaping a handler: a handler + // that threw, or the default handler rethrowing an otherwise + // unhandled task exception. Cancellation is filtered out earlier + // by default_handler, so this is always a genuine error with no + // owner to receive it: fail fast. + void unhandled_exception() noexcept { std::terminate(); } // LCOV_EXCL_LINE }; std::coroutine_handle h_; @@ -261,9 +267,8 @@ struct BOOST_CAPY_CORO_DESTROY_WHEN_COMPLETE { } - void unhandled_exception() noexcept - { - } + // See primary template: an escaping handler exception is fatal. + void unhandled_exception() noexcept { std::terminate(); } // LCOV_EXCL_LINE }; std::coroutine_handle h_; @@ -427,7 +432,10 @@ class [[nodiscard]] run_async_wrapper storing the wrapper and calling it later violates LIFO ordering. Uses the default recycling frame allocator for coroutine frames. - With no handlers, the result is discarded and exceptions are rethrown. + With no handlers, the result is discarded. An unhandled exception + thrown by the task calls `std::terminate`; pass an error handler to + receive it as an `exception_ptr`, or `co_await` the work inside a + coroutine if you want to catch it. @par Thread Safety The wrapper and handlers may be called from any thread where the @@ -461,7 +469,7 @@ run_async(Ex ex) The handler `h1` is called with the task's result on success. If `h1` is also invocable with `std::exception_ptr`, it handles exceptions too. - Otherwise, exceptions are rethrown. + Otherwise, an unhandled exception calls `std::terminate`. @par Thread Safety The handler may be called from any thread where the executor @@ -549,8 +557,8 @@ run_async(Ex ex, H1 h1, H2 h2) /** Asynchronously launch a lazy task with stop token support. The stop token is propagated to the task, enabling cooperative - cancellation. With no handlers, the result is discarded and - exceptions are rethrown. + cancellation. With no handlers, the result is discarded and an + unhandled exception calls `std::terminate`. @par Thread Safety The wrapper may be called from any thread where the executor diff --git a/include/boost/capy/ex/strand.hpp b/include/boost/capy/ex/strand.hpp index f607610d3..b761622c9 100644 --- a/include/boost/capy/ex/strand.hpp +++ b/include/boost/capy/ex/strand.hpp @@ -57,7 +57,7 @@ namespace capy { @par Example @code thread_pool pool(4); - auto strand = make_strand(pool.get_executor()); + strand strand(pool.get_executor()); // CTAD deduces the executor type // Continuations are linked intrusively into the strand's queue, // so each one must outlive its time there. Storage is typically @@ -71,7 +71,7 @@ namespace capy { @tparam E The type of the underlying executor. Must satisfy the `Executor` concept. - @see make_strand, Executor + @see Executor */ template class strand diff --git a/include/boost/capy/ex/thread_pool.hpp b/include/boost/capy/ex/thread_pool.hpp index 29097ec60..f6e1b9625 100644 --- a/include/boost/capy/ex/thread_pool.hpp +++ b/include/boost/capy/ex/thread_pool.hpp @@ -37,7 +37,8 @@ namespace capy { thread_pool pool(4); // 4 worker threads auto ex = pool.get_executor(); ex.post(some_coroutine); - // pool destructor waits for all work to complete + pool.join(); // wait for outstanding work to complete + // pool destructor stops the pool, discarding any pending work @endcode */ class BOOST_CAPY_DECL diff --git a/include/boost/capy/io_result.hpp b/include/boost/capy/io_result.hpp index ac2f13434..8a058ae22 100644 --- a/include/boost/capy/io_result.hpp +++ b/include/boost/capy/io_result.hpp @@ -33,8 +33,11 @@ namespace capy { if (ec) { ... } @endcode - @note Payload members are only meaningful when - `ec` does not indicate an error. + @note Whether the payload is meaningful when `ec` is set is + defined by the operation that produced the result. Many I/O + operations report a meaningful partial result alongside `ec` + (for example, the number of bytes transferred before the + condition, as with EOF); others leave it unspecified. @tparam Ts Ordered payload types following the leading `std::error_code`. @@ -45,7 +48,8 @@ struct [[nodiscard]] io_result /// The error code from the operation. std::error_code ec; - /// The payload values. Unspecified when `ec` is set. + /// The payload values. Their meaning when `ec` is set is defined + /// by the producing operation (see the class note). std::tuple values; /// Construct a default io_result. diff --git a/include/boost/capy/read.hpp b/include/boost/capy/read.hpp index 4a85aa9cd..054771216 100644 --- a/include/boost/capy/read.hpp +++ b/include/boost/capy/read.hpp @@ -33,7 +33,7 @@ namespace capy { until: @li either the entire buffer sequence @c buffers is filled, - @li or a contingency occurs. + @li or a contingency occurs on `stream.read_some`. If `buffer_size(buffers) == 0` then no awaiting `stream.read_some` is performed. This is not a contingency. @@ -46,15 +46,19 @@ namespace capy { Contingencies: - @li The first contingency reported from awaiting @c stream.read_some . + @li The first contingency reported from awaiting @c stream.read_some + while `buffers` is not yet filled. A contingency that accompanies + the read which fills `buffers` is not reported: a completed + transfer is a success. Notable conditions: @li @c cond::canceled — Operation was cancelled, - @li @c cond::eof — Stream reached end before `buffers` was filled. + @li @c cond::eof — Stream reached end before @c buffers was filled. @par Await-postcondition - `ec || n == buffer_size(buffers)`. + If `n == buffer_size(buffers)` the transfer completed and `ec` is + success; otherwise `ec` is set. @param stream The stream to read from. If the lifetime of `stream` ends before the coroutine finishes, the behavior is undefined. @@ -100,7 +104,9 @@ read(S& stream, MB buffers) -> auto [ec, n] = co_await stream.read_some(consuming.data()); consuming.remove_prefix(n); total_read += n; - if(ec) + // A contingency that still completed the transfer is a success: + // report it only when the buffer was not filled. + if(ec && total_read < total_size) co_return {ec, total_read}; } @@ -112,11 +118,19 @@ read(S& stream, MB buffers) -> @par Await-effects Reads data from `stream` via awaiting `stream.read_some` repeatedly - and appending the results to `dynbuf`, - until a contingency occurs. + and appending it to `dynbuf` using prepare/commit semantics + until: + + @li either @c dynbuf.size() == @c dynbuf.max_size() , + @li or a contingency on @c stream.read_some occurs. + + The last, potenitally partial, read is also appended. + + The value passed in the first call to `dynbuf.prepare` is `initial_amount`. + The value is grown to 1.5 times the preceding value only after a read that + completely filled the prepared buffer; otherwise it is left unchanged for + the next call. - Data is appended using prepare/commit semantics. - The buffer grows with 1.5x factor when filled. @par Await-returns @@ -129,8 +143,15 @@ read(S& stream, MB buffers) -> @li The first contingency, other than one matching to @c cond::eof, reported from awaiting @c stream.read_some . + @par Await-throws - `std::bad_alloc` when append to `dynbuf` fails. + + Whatever operations on @c dunbuf throw. + + (Note: types modeling @c DynamicBufferParam provided by Capy throw + @c std::bad_alloc from member function + @c prepare .) + @param stream The stream to read from. If the lifetime of `stream` ends before the coroutine finishes, the behavior is undefined. @@ -138,7 +159,8 @@ read(S& stream, MB buffers) -> @param dynbuf The dynamic buffer to append data to. If the lifetime of the buffer sequence represented by `dynbuf` ends before the coroutine finishes, the behavior is undefined. - @param initial_amount Initial bytes to prepare (default 2048). + @param initial_amount Hint for the value to be passed in the initial call to `dynbuf.prepare()` + (default 2048). @par Remarks @@ -191,11 +213,19 @@ read( @par Await-effects Reads data from `stream` by calling `source.read` repeatedly - and appending it to `dynbuf` until a contingency occurs. + and appending it to `dynbuf` using prepare/commit semantics + until: + + @li either @c dynbuf.size() == @c dynbuf.max_size() , + @li or a contingency on @c stream.read occurs. + The last, potenitally partial, read is also appended. - Data is appended using prepare/commit semantics. - The buffer grows with 1.5x factor when filled. + The value passed in the first call to `dynbuf.prepare` is `initial_amount`. + The value is grown to 1.5 times the preceding value only after a read that + completely filled the prepared buffer; otherwise it is left unchanged for + the next call. + @par Await-returns @@ -209,9 +239,15 @@ read( @li The first contingency, other than one matching to @c cond::eof, reported from awaiting @c stream.read_some . + @par Await-throws + + Whatever operations on @c dunbuf throw. - `std::bad_alloc` when append to `dynbuf` fails. + (Note: types modeling @c DynamicBufferParam provided by Capy throw + @c std::bad_alloc from member function + @c prepare .) + @param source The source to read from. If the lifetime of `source` ends before the coroutine finishes, the behavior is undefined. @@ -220,7 +256,8 @@ read( buffer sequence represented by `dynbuf` ends before the coroutine finishes, the behavior is undefined. - @param initial_amount Initial bytes to prepare (default 2048). + @param initial_amount Hint for the value to be passed in the initial call to `dynbuf.prepare()` + (default 2048). @par Remarks Supports _IoAwaitable cancellation_. diff --git a/include/boost/capy/read_until.hpp b/include/boost/capy/read_until.hpp index cf78e4bc9..ab9965580 100644 --- a/include/boost/capy/read_until.hpp +++ b/include/boost/capy/read_until.hpp @@ -193,6 +193,13 @@ struct read_until_awaitable } }; +template +using read_until_return_t = read_until_awaitable< + Stream, + std::remove_reference_t, + M, + !std::is_lvalue_reference_v>; + } // namespace detail /** Match condition that searches for a delimiter string. @@ -239,36 +246,52 @@ struct match_delim /** Asynchronously read until a match condition is satisfied. - Reads data from the stream into the dynamic buffer until the match - condition returns a valid position. Implemented using `read_some`. - If the match condition is already satisfied by existing buffer - data, returns immediately without I/O. + Reads data from `stream` and appends it to `dynbuf` via calling + `stream.read_some` zero or more times and using the prepare/commit + interface until: - @li The operation completes when: - @li The match condition returns a valid position - @li End-of-stream is reached (`cond::eof`) - @li The buffer's `max_size()` is reached (`cond::not_found`) - @li An error occurs - @li The operation is cancelled + @li either @c match returns a valid position, + @li or @c dynbuf.size() == @c dynbuf.max_size() , + @li or a contingency on @c stream.read_some occurs. - @par Cancellation - Supports cancellation via `stop_token` propagated through the - IoAwaitable protocol. When cancelled, returns with `cond::canceled`. + If the match condition is satisfied by data in `dynbuf.data()` upon entry, + no call to `stream.read_some` is performed. + + + @par Await-returns + + 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). + + + Contingencies: + + @li The first contingency, reported from awaiting @c stream.read_some . + @li @c cond::not_found -- when @c dynbuf.size() == @c dynbuf.max_size() + and the match condition is not satisfied by data in @c dynbuf.data() . @param stream The stream to read from. The caller retains ownership. - @param buffers The dynamic buffer to append data to. Must remain + @param dynbuf The dynamic buffer to append data to. Must remain valid until the operation completes. @param match The match condition callable. Copied into the awaitable. @param initial_amount Initial bytes to read per iteration (default 2048). Grows by 1.5x when filled. - @return An awaitable that await-returns `(error_code, std::size_t)`. - On success, `n` is the position returned by the match condition - (bytes up to and including the matched delimiter). Compare error - codes to conditions: - @li `cond::eof` - EOF before match; `n` is buffer size - @li `cond::not_found` - `max_size()` reached before match - @li `cond::canceled` - Operation was cancelled + + + + @par Await-throws + + Whatever operations on @c dunbuf throw. + + (Note: types modeling @c DynamicBufferParam provided by Capy throw + @c std::bad_alloc from member function + @c prepare .) + + @par Remarks + Supports _IoAwaitable cancellation_. @par Example @@ -284,7 +307,7 @@ struct match_delim if( pos != std::string_view::npos ) return pos + 4; if( hint ) - *hint = 3; // partial "\r\n\r" possible + (*hint) = 3; // partial "\r\n\r" possible return std::string_view::npos; } ); if( ec ) @@ -297,10 +320,10 @@ struct match_delim */ template requires DynamicBufferParam -auto +detail::read_until_return_t read_until( Stream& stream, - B&& buffers, + B&& dynbuf, M match, std::size_t initial_amount = 2048) { @@ -309,10 +332,10 @@ read_until( if constexpr(is_lvalue) return detail::read_until_awaitable( - stream, std::addressof(buffers), std::move(match), initial_amount); + stream, std::addressof(dynbuf), std::move(match), initial_amount); else return detail::read_until_awaitable( - stream, std::move(buffers), std::move(match), initial_amount); + stream, std::move(dynbuf), std::move(match), initial_amount); } /** Asynchronously read until a delimiter string is found. @@ -368,7 +391,7 @@ read_until( */ template requires DynamicBufferParam -auto +detail::read_until_return_t read_until( Stream& stream, B&& buffers, diff --git a/include/boost/capy/test/buffer_sink.hpp b/include/boost/capy/test/buffer_sink.hpp index 516c95977..5c6991f02 100644 --- a/include/boost/capy/test/buffer_sink.hpp +++ b/include/boost/capy/test/buffer_sink.hpp @@ -47,18 +47,18 @@ namespace test { auto r = f.armed( [&]( fuse& ) -> task { mutable_buffer arr[16]; - std::size_t count = bs.prepare( arr, 16 ); - if( count == 0 ) + auto bufs = bs.prepare( arr ); + if( bufs.empty() ) co_return; - // Write data into arr[0] - std::memcpy( arr[0].data(), "Hello", 5 ); + // Write data into the first prepared buffer + std::memcpy( bufs[0].data(), "Hello", 5 ); auto [ec] = co_await bs.commit( 5 ); if( ec ) co_return; - auto [ec2] = co_await bs.commit_eof(); + auto [ec2] = co_await bs.commit_eof( 0 ); // bs.data() returns "Hello" } ); @endcode diff --git a/include/boost/capy/test/buffer_source.hpp b/include/boost/capy/test/buffer_source.hpp index 008b80753..f80ad244d 100644 --- a/include/boost/capy/test/buffer_source.hpp +++ b/include/boost/capy/test/buffer_source.hpp @@ -20,6 +20,7 @@ #include #include +#include #include #include diff --git a/include/boost/capy/test/buffer_to_string.hpp b/include/boost/capy/test/buffer_to_string.hpp index 7dd350973..9d66d7327 100644 --- a/include/boost/capy/test/buffer_to_string.hpp +++ b/include/boost/capy/test/buffer_to_string.hpp @@ -41,7 +41,7 @@ namespace test { bufgrind bg( cb ); while( bg ) { auto [b1, b2] = co_await bg.next(); - BOOST_TEST_EQ( buffer_to_string( b1, b2 ), "hello" ); + BOOST_TEST_EQ( buffer_to_string( b1.data(), b2.data() ), "hello" ); } @endcode diff --git a/include/boost/capy/when_any.hpp b/include/boost/capy/when_any.hpp index 789d64f80..a23202326 100644 --- a/include/boost/capy/when_any.hpp +++ b/include/boost/capy/when_any.hpp @@ -771,9 +771,19 @@ template Only a child returning !ec can win. Errors and exceptions do not claim winner status. + @param as The awaitables to race. Each must satisfy @ref + IoAwaitable and is consumed (moved-from) when `when_any` + is awaited. + @return A task yielding variant where index 0 is the failure/no-winner case and index i+1 identifies the winning child. + + @note A failing child does not cancel its siblings; `when_any` + waits for a success or for every child to finish. To make a + benign error (e.g. @c cond::canceled) count as a win, wrap + the child to translate the error into success. See the + Concurrent Composition tutorial. */ template requires (sizeof...(As) > 0) diff --git a/include/boost/capy/write.hpp b/include/boost/capy/write.hpp index 70256af32..31a1f8208 100644 --- a/include/boost/capy/write.hpp +++ b/include/boost/capy/write.hpp @@ -31,7 +31,7 @@ namespace capy { until: @li either the full content of @c buffers is processed, - @li or a contingency occurs. + @li or a contingency in `stream.write_some` occurs. If `buffer_size(buffers) == 0` then no awaiting `stream.write_some` is performed. This is not a contingency. @@ -48,8 +48,10 @@ namespace capy { Contingencies: - @li The first contingency reported from - awaiting @c stream.write_some . + @li The first contingency reported from awaiting @c stream.write_some + while not all bytes have been written. A contingency that accompanies + the write which transfers the last bytes is not reported: a completed + transfer is a success. Notable conditions: @@ -59,7 +61,8 @@ namespace capy { @par Await-postcondition - `ec || n == buffer_size(buffers)`. + If `n == buffer_size(buffers)` the transfer completed and `ec` is + success; otherwise `ec` is set. @param stream The stream to write to. If the lifetime of `stream` ends @@ -100,7 +103,9 @@ auto write(S& stream, CB buffers) -> io_task auto [ec, n] = co_await stream.write_some(consuming.data()); consuming.remove_prefix(n); total_written += n; - if(ec) + // A contingency that still completed the transfer is a success: + // report it only when not all bytes were written. + if(ec && total_written < total_size) co_return {ec, total_written}; } diff --git a/src/error.cpp b/src/error.cpp index 99f5c83bf..9c9269db9 100644 --- a/src/error.cpp +++ b/src/error.cpp @@ -1,5 +1,6 @@ // // Copyright (c) 2025 Vinnie Falco (vinnie.falco@gmail.com) +// Copyright (c) 2026 Michael Vandeberg // // Distributed under the Boost Software License, Version 1.0. (See accompanying // file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) @@ -8,6 +9,7 @@ // #include +#include namespace boost { namespace capy { @@ -38,6 +40,26 @@ message(int code) const } } +// Map each capy error code to its canonical portable condition. +// canceled and timeout have standard equivalents, so they map to the +// generic conditions rather than capy's own cond enumerators; this is +// what lets, e.g., error::canceled compare equal to +// std::errc::operation_canceled. +std::error_condition +error_cat_type:: +default_error_condition(int code) const noexcept +{ + switch(static_cast(code)) + { + case error::eof: return make_error_condition(cond::eof); + case error::canceled: return std::make_error_condition(std::errc::operation_canceled); + case error::stream_truncated: return make_error_condition(cond::stream_truncated); + case error::not_found: return make_error_condition(cond::not_found); + case error::timeout: return std::make_error_condition(std::errc::timed_out); + default: return std::error_condition(code, *this); + } +} + //----------------------------------------------- // msvc 14.0 has a bug that warns about inability diff --git a/test/unit/buffers/buffer_param.cpp b/test/unit/buffers/buffer_param.cpp index 9859bc601..24f57eecb 100644 --- a/test/unit/buffers/buffer_param.cpp +++ b/test/unit/buffers/buffer_param.cpp @@ -13,6 +13,7 @@ #include "test_buffers.hpp" #include +#include #include #include diff --git a/test/unit/buffers/make_buffer.cpp b/test/unit/buffers/make_buffer.cpp index bcc0cd9a9..e23d9af7d 100644 --- a/test/unit/buffers/make_buffer.cpp +++ b/test/unit/buffers/make_buffer.cpp @@ -16,8 +16,25 @@ #include #include #include +#include #include +// boost::span is the type reported in issue #147. It lives in Boost.Core, +// which is on the include path in the full Boost build (and CI) but not in +// the minimal standalone CMake build, so guard on availability. +#if __has_include() +#include +#define BOOST_CAPY_TEST_HAS_BOOST_SPAN +#endif + +// Statically assert the exact return type of a make_buffer(...) call. +// This is the regression guard for issue #147: a silent return-type change +// is caught at compile time. It matters most for const cases, since a +// mutable_buffer implicitly converts to const_buffer, so a const case that +// wrongly became mutable would still compile in a plain assignment. +#define CAPY_ASSERT_RETURNS(Type, ...) \ + static_assert(std::is_same_v) + namespace boost { namespace capy { @@ -31,6 +48,7 @@ struct make_buffer_test // make_buffer(mutable_buffer) { mutable_buffer b(buf, 10); + CAPY_ASSERT_RETURNS(mutable_buffer, b); auto b1 = make_buffer(b); BOOST_TEST_EQ(b1.data(), b.data()); BOOST_TEST_EQ(b1.size(), b.size()); @@ -39,6 +57,7 @@ struct make_buffer_test // make_buffer(mutable_buffer, max_size) - no truncation { mutable_buffer b(buf, 10); + CAPY_ASSERT_RETURNS(mutable_buffer, b, 20); auto b1 = make_buffer(b, 20); BOOST_TEST_EQ(b1.data(), b.data()); BOOST_TEST_EQ(b1.size(), 10u); @@ -47,6 +66,7 @@ struct make_buffer_test // make_buffer(mutable_buffer, max_size) - truncation { mutable_buffer b(buf, 10); + CAPY_ASSERT_RETURNS(mutable_buffer, b, 5); auto b1 = make_buffer(b, 5); BOOST_TEST_EQ(b1.data(), b.data()); BOOST_TEST_EQ(b1.size(), 5u); @@ -61,6 +81,7 @@ struct make_buffer_test // make_buffer(const_buffer) { const_buffer b(buf, 10); + CAPY_ASSERT_RETURNS(const_buffer, b); auto b1 = make_buffer(b); BOOST_TEST_EQ(b1.data(), b.data()); BOOST_TEST_EQ(b1.size(), b.size()); @@ -69,6 +90,7 @@ struct make_buffer_test // make_buffer(const_buffer, max_size) - no truncation { const_buffer b(buf, 10); + CAPY_ASSERT_RETURNS(const_buffer, b, 20); auto b1 = make_buffer(b, 20); BOOST_TEST_EQ(b1.data(), b.data()); BOOST_TEST_EQ(b1.size(), 10u); @@ -77,6 +99,7 @@ struct make_buffer_test // make_buffer(const_buffer, max_size) - truncation { const_buffer b(buf, 10); + CAPY_ASSERT_RETURNS(const_buffer, b, 5); auto b1 = make_buffer(b, 5); BOOST_TEST_EQ(b1.data(), b.data()); BOOST_TEST_EQ(b1.size(), 5u); @@ -87,31 +110,36 @@ struct make_buffer_test testRawPointer() { char buf[10]{}; + char* pbuf = buf; char const* cbuf = buf; // make_buffer(void*, size) { - auto b = make_buffer(buf, 10); + CAPY_ASSERT_RETURNS(mutable_buffer, pbuf, 10); + auto b = make_buffer(pbuf, 10); BOOST_TEST_EQ(b.data(), buf); BOOST_TEST_EQ(b.size(), 10u); } // make_buffer(void*, size, max_size) - no truncation { - auto b = make_buffer(buf, 10, 20); + CAPY_ASSERT_RETURNS(mutable_buffer, pbuf, 10, 20); + auto b = make_buffer(pbuf, 10, 20); BOOST_TEST_EQ(b.data(), buf); BOOST_TEST_EQ(b.size(), 10u); } // make_buffer(void*, size, max_size) - truncation { - auto b = make_buffer(buf, 10, 5); + CAPY_ASSERT_RETURNS(mutable_buffer, pbuf, 10, 5); + auto b = make_buffer(pbuf, 10, 5); BOOST_TEST_EQ(b.data(), buf); BOOST_TEST_EQ(b.size(), 5u); } // make_buffer(void const*, size) { + CAPY_ASSERT_RETURNS(const_buffer, cbuf, 10); auto b = make_buffer(cbuf, 10); BOOST_TEST_EQ(b.data(), cbuf); BOOST_TEST_EQ(b.size(), 10u); @@ -119,6 +147,7 @@ struct make_buffer_test // make_buffer(void const*, size, max_size) - no truncation { + CAPY_ASSERT_RETURNS(const_buffer, cbuf, 10, 20); auto b = make_buffer(cbuf, 10, 20); BOOST_TEST_EQ(b.data(), cbuf); BOOST_TEST_EQ(b.size(), 10u); @@ -126,6 +155,7 @@ struct make_buffer_test // make_buffer(void const*, size, max_size) - truncation { + CAPY_ASSERT_RETURNS(const_buffer, cbuf, 10, 5); auto b = make_buffer(cbuf, 10, 5); BOOST_TEST_EQ(b.data(), cbuf); BOOST_TEST_EQ(b.size(), 5u); @@ -140,6 +170,7 @@ struct make_buffer_test // make_buffer(T(&)[N]) { + CAPY_ASSERT_RETURNS(mutable_buffer, buf); mutable_buffer b = make_buffer(buf); BOOST_TEST_EQ(b.data(), buf); BOOST_TEST_EQ(b.size(), 10u); @@ -147,6 +178,7 @@ struct make_buffer_test // make_buffer(T(&)[N], max_size) - no truncation { + CAPY_ASSERT_RETURNS(mutable_buffer, buf, 20); mutable_buffer b = make_buffer(buf, 20); BOOST_TEST_EQ(b.data(), buf); BOOST_TEST_EQ(b.size(), 10u); @@ -154,6 +186,7 @@ struct make_buffer_test // make_buffer(T(&)[N], max_size) - truncation { + CAPY_ASSERT_RETURNS(mutable_buffer, buf, 5); mutable_buffer b = make_buffer(buf, 5); BOOST_TEST_EQ(b.data(), buf); BOOST_TEST_EQ(b.size(), 5u); @@ -161,6 +194,7 @@ struct make_buffer_test // make_buffer(T const(&)[N]) { + CAPY_ASSERT_RETURNS(const_buffer, cbuf); const_buffer b = make_buffer(cbuf); BOOST_TEST_EQ(b.data(), cbuf); BOOST_TEST_EQ(b.size(), 10u); @@ -168,6 +202,7 @@ struct make_buffer_test // make_buffer(T const(&)[N], max_size) - no truncation { + CAPY_ASSERT_RETURNS(const_buffer, cbuf, 20); const_buffer b = make_buffer(cbuf, 20); BOOST_TEST_EQ(b.data(), cbuf); BOOST_TEST_EQ(b.size(), 10u); @@ -175,6 +210,7 @@ struct make_buffer_test // make_buffer(T const(&)[N], max_size) - truncation { + CAPY_ASSERT_RETURNS(const_buffer, cbuf, 5); const_buffer b = make_buffer(cbuf, 5); BOOST_TEST_EQ(b.data(), cbuf); BOOST_TEST_EQ(b.size(), 5u); @@ -183,6 +219,7 @@ struct make_buffer_test // Multi-byte element type { int ibuf[5]{}; + CAPY_ASSERT_RETURNS(mutable_buffer, ibuf); mutable_buffer b = make_buffer(ibuf); BOOST_TEST_EQ(b.data(), ibuf); BOOST_TEST_EQ(b.size(), 5u * sizeof(int)); @@ -197,6 +234,7 @@ struct make_buffer_test // make_buffer(std::array&) { + CAPY_ASSERT_RETURNS(mutable_buffer, arr); mutable_buffer b = make_buffer(arr); BOOST_TEST_EQ(b.data(), arr.data()); BOOST_TEST_EQ(b.size(), 10u); @@ -204,6 +242,7 @@ struct make_buffer_test // make_buffer(std::array&, max_size) - no truncation { + CAPY_ASSERT_RETURNS(mutable_buffer, arr, 20); mutable_buffer b = make_buffer(arr, 20); BOOST_TEST_EQ(b.data(), arr.data()); BOOST_TEST_EQ(b.size(), 10u); @@ -211,6 +250,7 @@ struct make_buffer_test // make_buffer(std::array&, max_size) - truncation { + CAPY_ASSERT_RETURNS(mutable_buffer, arr, 5); mutable_buffer b = make_buffer(arr, 5); BOOST_TEST_EQ(b.data(), arr.data()); BOOST_TEST_EQ(b.size(), 5u); @@ -218,6 +258,7 @@ struct make_buffer_test // make_buffer(std::array const&) { + CAPY_ASSERT_RETURNS(const_buffer, carr); const_buffer b = make_buffer(carr); BOOST_TEST_EQ(b.data(), carr.data()); BOOST_TEST_EQ(b.size(), 10u); @@ -225,6 +266,7 @@ struct make_buffer_test // make_buffer(std::array const&, max_size) - no truncation { + CAPY_ASSERT_RETURNS(const_buffer, carr, 20); const_buffer b = make_buffer(carr, 20); BOOST_TEST_EQ(b.data(), carr.data()); BOOST_TEST_EQ(b.size(), 10u); @@ -232,6 +274,7 @@ struct make_buffer_test // make_buffer(std::array const&, max_size) - truncation { + CAPY_ASSERT_RETURNS(const_buffer, carr, 5); const_buffer b = make_buffer(carr, 5); BOOST_TEST_EQ(b.data(), carr.data()); BOOST_TEST_EQ(b.size(), 5u); @@ -240,6 +283,7 @@ struct make_buffer_test // Multi-byte element type { std::array iarr{}; + CAPY_ASSERT_RETURNS(mutable_buffer, iarr); mutable_buffer b = make_buffer(iarr); BOOST_TEST_EQ(b.data(), iarr.data()); BOOST_TEST_EQ(b.size(), 5u * sizeof(int)); @@ -255,6 +299,7 @@ struct make_buffer_test // make_buffer(std::vector&) { + CAPY_ASSERT_RETURNS(mutable_buffer, vec); mutable_buffer b = make_buffer(vec); BOOST_TEST_EQ(b.data(), vec.data()); BOOST_TEST_EQ(b.size(), 10u); @@ -262,6 +307,7 @@ struct make_buffer_test // make_buffer(std::vector&, max_size) - no truncation { + CAPY_ASSERT_RETURNS(mutable_buffer, vec, 20); mutable_buffer b = make_buffer(vec, 20); BOOST_TEST_EQ(b.data(), vec.data()); BOOST_TEST_EQ(b.size(), 10u); @@ -269,6 +315,7 @@ struct make_buffer_test // make_buffer(std::vector&, max_size) - truncation { + CAPY_ASSERT_RETURNS(mutable_buffer, vec, 5); mutable_buffer b = make_buffer(vec, 5); BOOST_TEST_EQ(b.data(), vec.data()); BOOST_TEST_EQ(b.size(), 5u); @@ -276,6 +323,7 @@ struct make_buffer_test // make_buffer(std::vector const&) { + CAPY_ASSERT_RETURNS(const_buffer, cvec); const_buffer b = make_buffer(cvec); BOOST_TEST_EQ(b.data(), cvec.data()); BOOST_TEST_EQ(b.size(), 10u); @@ -283,6 +331,7 @@ struct make_buffer_test // make_buffer(std::vector const&, max_size) - no truncation { + CAPY_ASSERT_RETURNS(const_buffer, cvec, 20); const_buffer b = make_buffer(cvec, 20); BOOST_TEST_EQ(b.data(), cvec.data()); BOOST_TEST_EQ(b.size(), 10u); @@ -290,6 +339,7 @@ struct make_buffer_test // make_buffer(std::vector const&, max_size) - truncation { + CAPY_ASSERT_RETURNS(const_buffer, cvec, 5); const_buffer b = make_buffer(cvec, 5); BOOST_TEST_EQ(b.data(), cvec.data()); BOOST_TEST_EQ(b.size(), 5u); @@ -297,6 +347,7 @@ struct make_buffer_test // Empty vector { + CAPY_ASSERT_RETURNS(mutable_buffer, empty_vec); mutable_buffer b = make_buffer(empty_vec); BOOST_TEST_EQ(b.data(), nullptr); BOOST_TEST_EQ(b.size(), 0u); @@ -305,6 +356,7 @@ struct make_buffer_test // Multi-byte element type { std::vector ivec(5); + CAPY_ASSERT_RETURNS(mutable_buffer, ivec); mutable_buffer b = make_buffer(ivec); BOOST_TEST_EQ(b.data(), ivec.data()); BOOST_TEST_EQ(b.size(), 5u * sizeof(int)); @@ -320,6 +372,7 @@ struct make_buffer_test // make_buffer(std::string&) { + CAPY_ASSERT_RETURNS(mutable_buffer, str); mutable_buffer b = make_buffer(str); BOOST_TEST_EQ(b.data(), &str[0]); BOOST_TEST_EQ(b.size(), 10u); @@ -327,6 +380,7 @@ struct make_buffer_test // make_buffer(std::string&, max_size) - no truncation { + CAPY_ASSERT_RETURNS(mutable_buffer, str, 20); mutable_buffer b = make_buffer(str, 20); BOOST_TEST_EQ(b.data(), &str[0]); BOOST_TEST_EQ(b.size(), 10u); @@ -334,6 +388,7 @@ struct make_buffer_test // make_buffer(std::string&, max_size) - truncation { + CAPY_ASSERT_RETURNS(mutable_buffer, str, 5); mutable_buffer b = make_buffer(str, 5); BOOST_TEST_EQ(b.data(), &str[0]); BOOST_TEST_EQ(b.size(), 5u); @@ -341,6 +396,7 @@ struct make_buffer_test // make_buffer(std::string const&) { + CAPY_ASSERT_RETURNS(const_buffer, cstr); const_buffer b = make_buffer(cstr); BOOST_TEST_EQ(b.data(), cstr.data()); BOOST_TEST_EQ(b.size(), 10u); @@ -348,6 +404,7 @@ struct make_buffer_test // make_buffer(std::string const&, max_size) - no truncation { + CAPY_ASSERT_RETURNS(const_buffer, cstr, 20); const_buffer b = make_buffer(cstr, 20); BOOST_TEST_EQ(b.data(), cstr.data()); BOOST_TEST_EQ(b.size(), 10u); @@ -355,6 +412,7 @@ struct make_buffer_test // make_buffer(std::string const&, max_size) - truncation { + CAPY_ASSERT_RETURNS(const_buffer, cstr, 5); const_buffer b = make_buffer(cstr, 5); BOOST_TEST_EQ(b.data(), cstr.data()); BOOST_TEST_EQ(b.size(), 5u); @@ -362,6 +420,7 @@ struct make_buffer_test // Empty string { + CAPY_ASSERT_RETURNS(mutable_buffer, empty_str); mutable_buffer b = make_buffer(empty_str); BOOST_TEST_EQ(b.data(), nullptr); BOOST_TEST_EQ(b.size(), 0u); @@ -370,6 +429,7 @@ struct make_buffer_test // Wide string { std::wstring wstr = L"hello"; + CAPY_ASSERT_RETURNS(mutable_buffer, wstr); mutable_buffer b = make_buffer(wstr); BOOST_TEST_EQ(b.data(), &wstr[0]); BOOST_TEST_EQ(b.size(), 5u * sizeof(wchar_t)); @@ -384,6 +444,7 @@ struct make_buffer_test // make_buffer(std::string_view) { + CAPY_ASSERT_RETURNS(const_buffer, sv); const_buffer b = make_buffer(sv); BOOST_TEST_EQ(b.data(), sv.data()); BOOST_TEST_EQ(b.size(), 10u); @@ -391,6 +452,7 @@ struct make_buffer_test // make_buffer(std::string_view, max_size) - no truncation { + CAPY_ASSERT_RETURNS(const_buffer, sv, 20); const_buffer b = make_buffer(sv, 20); BOOST_TEST_EQ(b.data(), sv.data()); BOOST_TEST_EQ(b.size(), 10u); @@ -398,6 +460,7 @@ struct make_buffer_test // make_buffer(std::string_view, max_size) - truncation { + CAPY_ASSERT_RETURNS(const_buffer, sv, 5); const_buffer b = make_buffer(sv, 5); BOOST_TEST_EQ(b.data(), sv.data()); BOOST_TEST_EQ(b.size(), 5u); @@ -405,6 +468,7 @@ struct make_buffer_test // Empty string_view { + CAPY_ASSERT_RETURNS(const_buffer, empty_sv); const_buffer b = make_buffer(empty_sv); BOOST_TEST_EQ(b.data(), nullptr); BOOST_TEST_EQ(b.size(), 0u); @@ -413,6 +477,7 @@ struct make_buffer_test // Wide string_view { std::wstring_view wsv = L"hello"; + CAPY_ASSERT_RETURNS(const_buffer, wsv); const_buffer b = make_buffer(wsv); BOOST_TEST_EQ(b.data(), wsv.data()); BOOST_TEST_EQ(b.size(), 5u * sizeof(wchar_t)); @@ -428,14 +493,26 @@ struct make_buffer_test // make_buffer(std::span) - mutable { std::span sp(buf); + CAPY_ASSERT_RETURNS(mutable_buffer, sp); mutable_buffer b = make_buffer(sp); BOOST_TEST_EQ(b.data(), buf); BOOST_TEST_EQ(b.size(), 10u); } + // make_buffer(std::span) - mutable, passed as a temporary. + // Regression: a prvalue must still yield a mutable_buffer once + // the dedicated by-value span overload is gone. + { + CAPY_ASSERT_RETURNS(mutable_buffer, std::span(buf)); + mutable_buffer b = make_buffer(std::span(buf)); + BOOST_TEST_EQ(b.data(), buf); + BOOST_TEST_EQ(b.size(), 10u); + } + // make_buffer(std::span, max_size) - no truncation { std::span sp(buf); + CAPY_ASSERT_RETURNS(mutable_buffer, sp, 20); mutable_buffer b = make_buffer(sp, 20); BOOST_TEST_EQ(b.data(), buf); BOOST_TEST_EQ(b.size(), 10u); @@ -444,6 +521,7 @@ struct make_buffer_test // make_buffer(std::span, max_size) - truncation { std::span sp(buf); + CAPY_ASSERT_RETURNS(mutable_buffer, sp, 5); mutable_buffer b = make_buffer(sp, 5); BOOST_TEST_EQ(b.data(), buf); BOOST_TEST_EQ(b.size(), 5u); @@ -452,6 +530,7 @@ struct make_buffer_test // make_buffer(std::span) - const { std::span sp(cbuf); + CAPY_ASSERT_RETURNS(const_buffer, sp); const_buffer b = make_buffer(sp); BOOST_TEST_EQ(b.data(), cbuf); BOOST_TEST_EQ(b.size(), 10u); @@ -460,6 +539,7 @@ struct make_buffer_test // make_buffer(std::span, max_size) - no truncation { std::span sp(cbuf); + CAPY_ASSERT_RETURNS(const_buffer, sp, 20); const_buffer b = make_buffer(sp, 20); BOOST_TEST_EQ(b.data(), cbuf); BOOST_TEST_EQ(b.size(), 10u); @@ -468,6 +548,7 @@ struct make_buffer_test // make_buffer(std::span, max_size) - truncation { std::span sp(cbuf); + CAPY_ASSERT_RETURNS(const_buffer, sp, 5); const_buffer b = make_buffer(sp, 5); BOOST_TEST_EQ(b.data(), cbuf); BOOST_TEST_EQ(b.size(), 5u); @@ -476,12 +557,72 @@ struct make_buffer_test // Fixed-extent span { std::span sp(buf); + CAPY_ASSERT_RETURNS(mutable_buffer, sp); mutable_buffer b = make_buffer(sp); BOOST_TEST_EQ(b.data(), buf); BOOST_TEST_EQ(b.size(), 10u); } } +#ifdef BOOST_CAPY_TEST_HAS_BOOST_SPAN + void + testBoostSpan() + { + char buf[10]{}; + char const cbuf[10]{}; + + // Regression for issue #147: make_buffer(boost::span) must + // return a mutable_buffer. boost::span has no dedicated overload, + // so it exercises the generic forwarding-reference overload. + + // boost::span passed as a temporary (the reported case) + { + CAPY_ASSERT_RETURNS(mutable_buffer, boost::span(buf)); + mutable_buffer b = make_buffer(boost::span(buf)); + BOOST_TEST_EQ(b.data(), buf); + BOOST_TEST_EQ(b.size(), 10u); + } + + // boost::span lvalue + { + boost::span sp(buf); + CAPY_ASSERT_RETURNS(mutable_buffer, sp); + mutable_buffer b = make_buffer(sp); + BOOST_TEST_EQ(b.data(), buf); + BOOST_TEST_EQ(b.size(), 10u); + } + + // boost::span with max_size - truncation + { + boost::span sp(buf); + CAPY_ASSERT_RETURNS(mutable_buffer, sp, 5); + mutable_buffer b = make_buffer(sp, 5); + BOOST_TEST_EQ(b.data(), buf); + BOOST_TEST_EQ(b.size(), 5u); + } + + // boost::span -> const_buffer + { + boost::span sp(cbuf); + CAPY_ASSERT_RETURNS(const_buffer, sp); + const_buffer b = make_buffer(sp); + BOOST_TEST_EQ(b.data(), cbuf); + BOOST_TEST_EQ(b.size(), 10u); + } + } +#endif + + void + testStringLiteral() + { + // A string literal is a const char array; it must resolve to the + // const range overload (not string_view) and include the trailing + // '\0', matching the prior dedicated C-array overload. + CAPY_ASSERT_RETURNS(const_buffer, "Hello"); + const_buffer b = make_buffer("Hello"); + BOOST_TEST_EQ(b.size(), 6u); + } + void run() { @@ -494,6 +635,10 @@ struct make_buffer_test testStdString(); testStdStringView(); testStdSpan(); +#ifdef BOOST_CAPY_TEST_HAS_BOOST_SPAN + testBoostSpan(); +#endif + testStringLiteral(); } }; @@ -503,3 +648,5 @@ TEST_SUITE( } // capy } // boost + +#undef CAPY_ASSERT_RETURNS diff --git a/test/unit/concept/buffer_source.cpp b/test/unit/concept/buffer_source.cpp index 9cab22153..7720978e1 100644 --- a/test/unit/concept/buffer_source.cpp +++ b/test/unit/concept/buffer_source.cpp @@ -15,6 +15,7 @@ #include #include +#include #include namespace boost { diff --git a/test/unit/cond.cpp b/test/unit/cond.cpp index 7dfd4c2e4..1262939a3 100644 --- a/test/unit/cond.cpp +++ b/test/unit/cond.cpp @@ -91,6 +91,10 @@ class cond_test // Equivalence: stream_truncated and timeout. BOOST_TEST(make_error_code(error::stream_truncated) == cond::stream_truncated); + // A non-matching code exercises cond_cat::equivalent for + // stream_truncated, which the positive comparison above now + // resolves via error_cat::default_error_condition instead. + BOOST_TEST(!(make_error_code(error::eof) == cond::stream_truncated)); BOOST_TEST(make_error_code(error::timeout) == cond::timeout); // Out-of-range condition is equivalent to nothing. diff --git a/test/unit/error.cpp b/test/unit/error.cpp index 13e18e052..d11a98ebe 100644 --- a/test/unit/error.cpp +++ b/test/unit/error.cpp @@ -1,5 +1,6 @@ // // Copyright (c) 2025 Vinnie Falco (vinnie.falco@gmail.com) +// Copyright (c) 2026 Michael Vandeberg // // Distributed under the Boost Software License, Version 1.0. (See accompanying // file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) @@ -45,6 +46,21 @@ class error_category_test // Out-of-range value hits the default branch. BOOST_TEST(cat.message(9999) == "unknown"); + + // issue #267: capy error codes compare equal to their portable + // std conditions via default_error_condition(). + BOOST_TEST(make_error_code(error::canceled) == std::errc::operation_canceled); + BOOST_TEST(make_error_code(error::timeout) == std::errc::timed_out); + + // exact repro from the issue + { + std::error_code e = error::canceled; + std::error_condition c{std::errc::operation_canceled}; + BOOST_TEST(e == c); + } + + // non-matching codes still differ + BOOST_TEST(!(make_error_code(error::eof) == std::errc::operation_canceled)); } }; diff --git a/test/unit/ex/run_async.cpp b/test/unit/ex/run_async.cpp index 61ebeb153..e54e26f15 100644 --- a/test/unit/ex/run_async.cpp +++ b/test/unit/ex/run_async.cpp @@ -18,6 +18,7 @@ #include #include +#include #include #include #include @@ -25,6 +26,11 @@ #include #include +#if !defined(_WIN32) +#include +#include +#endif + /* Implementation Notes for run_async ================================== @@ -305,10 +311,52 @@ struct run_async_test co_return; } - // Note: testDefaultRethrow removed - if no error handler is provided - // and the task throws, the exception goes to unhandled_exception which - // is undefined behavior. Users must provide an error handler if they - // want to handle exceptions. + // Note: a task that throws with no error handler calls std::terminate + // (the trampoline's unhandled_exception). That path is fatal and not + // unit-testable here; pass an error handler to observe exceptions. + +#if !defined(_WIN32) + // Death test: an exception escaping a handler must call std::terminate. + // Run each scenario in a forked child (the child aborts); the parent + // verifies the child did not exit normally. POSIX-only. + void + testTerminateOnUnhandled() + { + auto terminates = [](auto fn) { + ::pid_t pid = ::fork(); + BOOST_TEST(pid >= 0); + if(pid == 0) + { + // Hush the abort message; the binding satisfies freopen's + // warn_unused_result. + [[maybe_unused]] auto* f = + std::freopen("/dev/null", "w", stderr); + fn(); + _exit(0); // reached only if no terminate happened + } + int status = 0; + ::waitpid(pid, &status, 0); + return !(WIFEXITED(status) && WEXITSTATUS(status) == 0); + }; + + // No handler + throwing task: default handler rethrows -> terminate. + BOOST_TEST(terminates([] { + int dc = 0; + sync_executor d(dc); + run_async(d)(throws_exception()); + })); + + // Error handler rethrows -> escapes the handler -> terminate. + BOOST_TEST(terminates([] { + int dc = 0; + sync_executor d(dc); + run_async(d, + [](int) {}, + [](std::exception_ptr ep) { std::rethrow_exception(ep); } + )(throws_exception()); + })); + } +#endif void testErrorHandlerReceivesException() @@ -714,6 +762,9 @@ struct run_async_test testOverloadedHandler(); // Exception Handling +#if !defined(_WIN32) + testTerminateOnUnhandled(); +#endif testErrorHandlerReceivesException(); testOverloadedHandlerException(); diff --git a/test/unit/io/any_buffer_sink.cpp b/test/unit/io/any_buffer_sink.cpp index 2e29e2fd7..8159d8513 100644 --- a/test/unit/io/any_buffer_sink.cpp +++ b/test/unit/io/any_buffer_sink.cpp @@ -26,6 +26,7 @@ #include #include +#include #include #include diff --git a/test/unit/io/any_buffer_source.cpp b/test/unit/io/any_buffer_source.cpp index 91b4696d4..f752bdb69 100644 --- a/test/unit/io/any_buffer_source.cpp +++ b/test/unit/io/any_buffer_source.cpp @@ -25,6 +25,7 @@ #include #include +#include #include #include diff --git a/test/unit/io/any_read_stream.cpp b/test/unit/io/any_read_stream.cpp index bb8705a1e..7fe13cbcb 100644 --- a/test/unit/io/any_read_stream.cpp +++ b/test/unit/io/any_read_stream.cpp @@ -24,6 +24,7 @@ #include #include +#include #include #include diff --git a/test/unit/io/any_write_stream.cpp b/test/unit/io/any_write_stream.cpp index ae298094a..484b43af4 100644 --- a/test/unit/io/any_write_stream.cpp +++ b/test/unit/io/any_write_stream.cpp @@ -24,6 +24,7 @@ #include #include +#include #include #include #include diff --git a/test/unit/read.cpp b/test/unit/read.cpp index c73ba4f1f..53f118992 100644 --- a/test/unit/read.cpp +++ b/test/unit/read.cpp @@ -180,6 +180,43 @@ struct circular_dynamic_buffer_factory } // namespace +// Mock whose read_some reports a contingency in the SAME completion that +// transfers bytes. The test read_stream cannot do this (it reports errors +// and eof with zero bytes), so it is needed to exercise the +// "buffer filled but ec set" boundary. +struct contingent_read_stream +{ + std::error_code ec; + std::size_t deliver; + + template + auto + read_some(MB buffers) + { + struct awaitable + { + contingent_read_stream* self_; + MB buffers_; + + bool await_ready() const noexcept { return true; } + + void await_suspend( + std::coroutine_handle<>, io_env const*) const noexcept {} + + io_result + await_resume() + { + std::size_t const cap = buffer_size(buffers_); + std::size_t const n = + self_->deliver < cap ? self_->deliver : cap; + self_->deliver -= n; + return {self_->ec, n}; + } + }; + return awaitable{this, buffers}; + } +}; + struct read_test { //---------------------------------------------------------- @@ -351,6 +388,48 @@ struct read_test })); } + void + testFullTransferContingency() + { + // A contingency on the read that fills the buffer is a success + // (n == buffer_size); a contingency on a short transfer is + // reported. + + // eof coincident with a full fill -> success + BOOST_TEST(test::fuse().inert([](test::fuse&) -> task + { + contingent_read_stream rs{error::eof, 8}; + single_buffer_factory bf(8); + auto [ec, n] = co_await read(rs, bf.buffer()); + BOOST_TEST(! ec); + BOOST_TEST_EQ(n, 8u); + })); + + // contingency with a short transfer -> reported + BOOST_TEST(test::fuse().inert([](test::fuse&) -> task + { + contingent_read_stream rs{error::eof, 5}; + single_buffer_factory bf(8); + auto [ec, n] = co_await read(rs, bf.buffer()); + BOOST_TEST(ec == cond::eof); + BOOST_TEST_EQ(n, 5u); + })); + + // the suppressed condition is deferred, not lost: the next read + // surfaces it (here the stream is at eof with no more data). + BOOST_TEST(test::fuse().inert([](test::fuse&) -> task + { + contingent_read_stream rs{error::eof, 8}; + single_buffer_factory bf(8); + auto [ec1, n1] = co_await read(rs, bf.buffer()); + BOOST_TEST(! ec1); + BOOST_TEST_EQ(n1, 8u); + auto [ec2, n2] = co_await read(rs, bf.buffer()); + BOOST_TEST(ec2 == cond::eof); + BOOST_TEST_EQ(n2, 0u); + })); + } + void testReadStream() { @@ -358,6 +437,7 @@ struct read_test testReadBufferArray(); testReadBufferPair(); testReadStoredAwaitableTemporarySequence(); + testFullTransferContingency(); } //---------------------------------------------------------- diff --git a/test/unit/test/buffer_sink.cpp b/test/unit/test/buffer_sink.cpp index d04f83724..de0381648 100644 --- a/test/unit/test/buffer_sink.cpp +++ b/test/unit/test/buffer_sink.cpp @@ -17,6 +17,7 @@ #include "test/unit/test_helpers.hpp" #include +#include #include namespace boost { diff --git a/test/unit/test/buffer_source.cpp b/test/unit/test/buffer_source.cpp index ba788579b..398c055cf 100644 --- a/test/unit/test/buffer_source.cpp +++ b/test/unit/test/buffer_source.cpp @@ -18,6 +18,7 @@ #include "test/unit/test_helpers.hpp" +#include #include namespace boost { diff --git a/test/unit/write.cpp b/test/unit/write.cpp index 3dcbe98a5..5806c366b 100644 --- a/test/unit/write.cpp +++ b/test/unit/write.cpp @@ -120,6 +120,42 @@ struct buffer_pair_factory } // namespace +// Mock whose write_some reports a contingency in the SAME completion that +// transfers bytes, to exercise the "all bytes written but ec set" +// boundary. The test write_stream reports errors with zero bytes. +struct contingent_write_stream +{ + std::error_code ec; + std::size_t accept; + + template + auto + write_some(CB buffers) + { + struct awaitable + { + contingent_write_stream* self_; + CB buffers_; + + bool await_ready() const noexcept { return true; } + + void await_suspend( + std::coroutine_handle<>, io_env const*) const noexcept {} + + io_result + await_resume() + { + std::size_t const cap = buffer_size(buffers_); + std::size_t const n = + self_->accept < cap ? self_->accept : cap; + self_->accept -= n; + return {self_->ec, n}; + } + }; + return awaitable{this, buffers}; + } +}; + struct write_test { //---------------------------------------------------------- @@ -283,6 +319,33 @@ struct write_test })); } + void + testFullTransferContingency() + { + // A contingency on the write that transfers all bytes is a + // success (n == buffer_size); a short transfer reports it. + + // contingency coincident with a full write -> success + BOOST_TEST(test::fuse().inert([](test::fuse&) -> task + { + contingent_write_stream ws{error::canceled, 8}; + single_buffer_factory bf("12345678"); + auto [ec, n] = co_await write(ws, bf.buffer()); + BOOST_TEST(! ec); + BOOST_TEST_EQ(n, 8u); + })); + + // contingency with a short write -> reported + BOOST_TEST(test::fuse().inert([](test::fuse&) -> task + { + contingent_write_stream ws{error::canceled, 5}; + single_buffer_factory bf("12345678"); + auto [ec, n] = co_await write(ws, bf.buffer()); + BOOST_TEST(!! ec); + BOOST_TEST_EQ(n, 5u); + })); + } + void testWriteStream() { @@ -290,6 +353,7 @@ struct write_test testWriteBufferArray(); testWriteBufferPair(); testWriteStoredAwaitableTemporarySequence(); + testFullTransferContingency(); } //----------------------------------------------------------