-
Notifications
You must be signed in to change notification settings - Fork 19
Description
Summary
when_all and when_any treat io_result error codes as successful completions, forcing users to wrap I/O tasks with exception-throwing boilerplate to get correct error propagation. Additionally, io_result itself is limited to 0–3 template parameters with ~400 lines of repetitive manual specializations.
Problem
when_all ignores io_result errors
when_all only detects failure through exceptions (unhandled_exception()). When a child task returns io_result<std::size_t>{connection_reset, 0}, it is stored as a successful result. Sibling tasks are not cancelled and execution continues until all complete.
// User expectation: early return on first error
auto [r1, r2] = co_await when_all(
stream1.read_some(buf1), // returns io_result{connection_reset, 0}
stream2.read_some(buf2) // keeps running, no cancellation
);
// Both results returned, r1 has error — user must manually checkwhen_any treats io_result errors as winners
when_any determines the winner by first completion regardless of error status. An errored io_result can claim winner status, returning a failure to the user while a sibling that would have succeeded gets cancelled.
// User expectation: first *successful* result
auto result = co_await when_any(
stream1.read_some(buf1), // returns io_result{eof, 0} first
stream2.read_some(buf2) // would succeed, but gets cancelled
);
// result contains the error from stream1Workaround requires exceptions for non-exceptional errors
The only way to get correct semantics today is wrapping each task:
auto checked_read = [&](auto& stream, auto buf) -> task<std::size_t> {
auto [ec, n] = co_await stream.read_some(buf);
if (ec) throw std::system_error(ec); // "disappointments" forced into exceptions
co_return n;
};This is both a performance concern and a design smell — error codes exist specifically to avoid exceptions for expected I/O failures.
io_result is limited and repetitive
- Hard limit of 3 template parameters (primary template
static_asserts on >3) - 4 manually-written specializations with identical patterns
- 12 free-standing
get()overloads (MSVC workaround) - ~400 lines for a type that could be ~50–80 with variadic implementation
- Generic member names (
t1,t2,t3)
Proposed Solution
1. Add operator bool() and error() to io_result
These align with std::expected's interface (C++23), which provides operator bool(), has_value(), value(), and error().
io_result should adopt at minimum:
explicit operator bool() const— true if no errorerror()— returns thestd::error_codevalue()— returns the value(s) or throwsstd::system_error
This shared interface is a design constraint, not an afterthought. See §5 below.
2. Introduce a Failable concept
Rather than a closed is_io_result<T> trait, when_all/when_any should constrain on a concept that captures the shared interface:
template<typename T>
concept Failable = requires(T const& t) {
{ static_cast<bool>(t) }; // true = success
{ t.error() }; // the error value
};Both io_result and std::expected naturally satisfy this concept.
3. Make when_all error-aware for Failable results
In make_when_all_runner, when the result type satisfies Failable, check static_cast<bool>(result) after co_await. On failure: capture the error (analogous to capture_exception), request stop for siblings. After all tasks complete, if a Failable error was captured, propagate it.
Design question: How to propagate the error. Options include returning the errored result directly (requires rethinking when_all's return type) or wrapping in std::system_error and rethrowing (simpler but converts error codes to exceptions).
4. Make when_any error-aware for Failable results
In make_when_any_runner, when the result type satisfies Failable, do not call try_win() if static_cast<bool>(result) is false. Store the error as a fallback. If all tasks fail via Failable errors, return the first error.
Design question: Cancelled siblings (returning operation_canceled) are cancelled by when_any itself. These should likely be ignored entirely rather than treated as fallback errors.
5. std::expected forward compatibility
Capy targets C++20, so std::expected (C++23) is not directly usable. However, by constraining on Failable rather than is_io_result, the combinators will automatically support task<std::expected<T, E>> when users compile with C++23 — no changes to when_all/when_any needed.
The combinators don't need to interpret the error type — they only need static_cast<bool>(t) to decide success/failure. The full result (including the error) is already stored as the task's return value. Complexity would only arise if we wanted to merge errors from multiple failed tasks (e.g., when_any all-fail), but that's an orthogonal design decision.
Actual std::expected support is out of scope for this issue, but the design must not preclude it.
6. Redesign io_result as a variadic template
Replace the 4 manual specializations with a single variadic implementation. Key constraints:
- Must preserve aggregate initialization —
co_return {{}, n}syntax is load-bearing throughout the codebase - Must support structured bindings — aggregate decomposition on non-MSVC, tuple protocol on MSVC
- Must keep
ecas a named member — direct.ecaccess is ergonomic and widely used - Should support arbitrary parameter count — remove the 3-arg limit
- Must add
operator bool(),error(),value()— shared interface withstd::expected
Files Affected
include/boost/capy/io_result.hpp— variadic redesign, addFailableinterfaceinclude/boost/capy/when_all.hpp— error-aware runner forFailableresultsinclude/boost/capy/when_any.hpp— error-aware runner forFailableresultsinclude/boost/capy/concept/— newFailableconcept headertest/unit/io_result.cpp— updated tests for new interfacetest/unit/when_all.cpp— tests forio_resulterror propagationtest/unit/when_any.cpp— tests forio_resulterror handling- Documentation updates for new semantics