Skip to content

when_all/when_any ignore io_result errors; io_result needs variadic redesign #206

@mvandeberg

Description

@mvandeberg

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 check

when_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 stream1

Workaround 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 error
  • error() — returns the std::error_code
  • value() — returns the value(s) or throws std::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 initializationco_return {{}, n} syntax is load-bearing throughout the codebase
  • Must support structured bindings — aggregate decomposition on non-MSVC, tuple protocol on MSVC
  • Must keep ec as a named member — direct .ec access is ergonomic and widely used
  • Should support arbitrary parameter count — remove the 3-arg limit
  • Must add operator bool(), error(), value() — shared interface with std::expected

Files Affected

  • include/boost/capy/io_result.hpp — variadic redesign, add Failable interface
  • include/boost/capy/when_all.hpp — error-aware runner for Failable results
  • include/boost/capy/when_any.hpp — error-aware runner for Failable results
  • include/boost/capy/concept/ — new Failable concept header
  • test/unit/io_result.cpp — updated tests for new interface
  • test/unit/when_all.cpp — tests for io_result error propagation
  • test/unit/when_any.cpp — tests for io_result error handling
  • Documentation updates for new semantics

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions