Skip to content

Revise the design around io_result #425

Closed

Description

@akrzemi1

While the naming is revised separately, I would like to rehash the idea of returning a product type of error_code with other things.

It looks to me like the idea of returning tuple<error_code, size_t> only makes sense for stream reads and writes. By streams, I mean ReadStream, WriteStream and their derivatives: Stream, ReadSource, WriteSink and read_some_at in random_access_file (which I am surprised not to find part of the stream concept system). Any other function in the Capy-family uses error_code to represent either error or data.

While more functions return tuple<error_code, something>, the something uses dummy, meaningless values upon non-zero error_code. This is looks like a desperate attempt to fit into the tuple that is inadequate in these contexts.

In all occurrences of io_result<> its number of arguments is either zero or one. In the cases where it is one:

  • we are either in the stream read or write operation, and the argument is of type size_t, or
  • we are using dummy, meaningless values when error_code is non-zero, in order to try to use tuple as a sum type.

So, this use of a tuple is an overreach. It blurs the difference between funcitons signalling an error, and streams reads/writes returning a two-fold information. This is also why when_all and when_any cannot be designed satisfactorily.

I would recommend separating the stream read/write contract from other functions' contract.

Activity

  1. vinniefalco commented on Sep 19, 2026

    @vinniefalco
    Member

    Did you do a survey of open source projects which use awaitables to find out how many use arity of 3 or higher?

  2. vinniefalco commented on Sep 20, 2026

    @vinniefalco
    Member

    The Tuple Stops at Two

    Evidence from sixty codebases for splitting the byte-transfer result contract from the error-only contract

    Report type: Analytical / recommendation.
    Audience: the Capy and Network Endeavor design group.
    Decision served: whether to keep one variadic io_result<Ts...> for all operations, or to reserve the compound result for byte-transfer operations and give every other operation an error-only contract.

    Executive summary

    Split the contracts. A five-way survey of open source C++ coroutine code, covering roughly sixty repositories across every major ecosystem, finds that byte-transfer operations (read_some, write_some, and their positional and datagram variants) are the only operations that ever return a compound result of the form (error, bytes). No surveyed byte-transfer operation anywhere grows a third element. Where other operations return three or more elements, the result is either a combinator aggregate whose arity equals its child count, or an envelope whose trailing elements are dummy values when the leading status reports an error. The dummy-on-error pattern is not hypothetical: it ships in production code at ScyllaDB, in seastar's RPC layer, in libcoro's UDP peer, and in every as_tuple call site examined.

    The recommendation has three parts. First, specify the byte-transfer contract as its own named shape, io_result<std::size_t>, covering stream reads and writes, positional file reads and writes, and datagram send and receive. Second, specify every other operation as returning io_result<> or a bare error_code. Third, treat when_all's flattened io_result<R1, ..., Rn> as a combinator result type, documented separately from any operation contract, because the survey shows combinators are a different species with different semantics. Confidence: high, because five independent sweeps converged on the same taxonomy with no contradictory stragglers.

    Contents

    1. The recommendation
    2. The question
    3. Method
    4. Findings
    5. Discussion
    6. Alternatives considered
    7. Conclusions and recommendations
    8. References
    9. Appendix: survey inventory

    1. The recommendation comes first

    Reserve the compound result for byte transfer. Give everything else an error-only result. The current design routes every asynchronous operation through one variadic product type, io_result<Ts...>, whose first element is an error_code and whose remaining elements are payloads. The proposal under evaluation would keep that single shape for streams, files, sockets, timers, signals, resolvers, and acceptors alike.

    The evidence says the single shape conflates two contracts that never meet in practice. One contract is two-fold: a byte transfer always produces both a status and a count, and the count is meaningful even on failure, because a partial transfer is real data the caller owns. The other contract is singular: an operation either succeeds or fails, and on failure there is no payload to report. Operations in the second group that today return io_result<T> for some T are paying for a tuple element that is a dummy on every error path.

    2. The question is whether one product type can serve two contracts

    The Capy family defines io_result<Ts...> as a product type with error_code in the first position, destructured at the call site as auto [ec, n] = co_await stream.read_some(buf). The design question, raised during the naming revision, is whether this variadic form is an overreach: whether tuple<error_code, size_t> makes sense only for stream reads and writes, whether every other use pads the tuple with dummy values on error, and whether the conflation is what makes when_all and when_any hard to design satisfactorily.

    A secondary question concerns scope. read_some_at on random_access_file is excluded from the stream concept system by deliberate design, because positional access has no cursor and cannot satisfy Stream. If the compound result belongs to the transfer contract rather than the stream concepts, then read_some_at is a member of the transfer family despite standing outside the concept hierarchy, and the taxonomy should say so.

    3. Five parallel surveys covered every major coroutine ecosystem

    Five research agents surveyed disjoint slices of open source C++ on 2026-09-19, each looking for the same pattern: a co_await expression whose result is destructured into three or more names, as in auto [a, b, c] = co_await op(). The two-element case (error_code, size_t) is the known baseline; the surveys hunted for everything beyond it. The slices were the Asio and Beast ecosystem including Capy and Corosio; the Folly, libunifex, stdexec, and beman ecosystem; the async_simple, cppcoro, libcoro, and yalantinglibs ecosystem; the Boost.Cobalt, QCoro, seastar, and drogon ecosystem; and a broad ecosystem-agnostic sweep across GitHub.

    The method combined shallow clones of roughly forty repositories, grepped in full with multiline regular expressions, with GitHub code search, Sourcegraph, and grep.app queries for the binding pattern. Every reported hit was verified by reading the actual source file, and each hit records the operation, its declared result type, and the semantics of each element, with specific attention to whether trailing elements are meaningful when the leading status reports an error. Two limitations deserve disclosure. Sourcegraph and grep.app were partially blocked by bot walls during some sweeps, so long-tail coverage of personal forks is incomplete. And absence claims are bounded by the search pattern: an operation returning a named struct rather than a tuple-like would not match, which is why the surveys also recorded what each ecosystem's accept, resolve, read, and write operations actually return.

    4. Findings: four shapes, and only one of them is byte transfer

    The surveys found that three-or-more-element co_await results exist in roughly fifty-five repositories, but they cluster into exactly four shapes. Table 1 summarizes the taxonomy; the subsections present the evidence per shape.

    Table 1. The four shapes of multi-element co_await results. Verified occurrences from the five surveys of 2026-09-19. "Dummy on error" means trailing elements are meaningless when the leading status reports failure.

    Shape Example Arity source Elements on error
    Byte transfer read_some -> (ec, n) Fixed at 2 Count still meaningful (partial transfer)
    Combinator aggregate when_all, collectAll, parallel_group Equals child count Per-child slots; semantics vary by combinator
    Envelope with error slot seastar RPC frames, ScyllaDB verbs, libcoro UDP Fixed per operation Trailing elements dummy or disengaged
    Protocol multi-value async-mqtt5 publish/receive, QCoro signals Fixed per operation Varies; often dummy under as_tuple

    4.1 Byte-transfer results never grow past two elements

    The strongest finding is a negative one. Across Asio, Beast, Boost.Redis, Boost.MySQL, yalantinglibs, seastar, async_simple, libcoro, and every application project examined, no byte-transfer operation returns more than (error, count). Beast's entire surface tops out at auto [ec, _] = co_await stream.async_read(buffer, net::as_tuple). Boost.MySQL and Boost.Redis never exceed two elements. yalantinglibs' coro_io, coro_rpc, and cinatra are uniformly std::pair<error_code, size_t>. A Sourcegraph global regex for the three-element pattern returned zero matches against its indexed corpus while the equivalent two-element pattern returned over seventeen hundred; the index is incomplete, so this corroborates rarity rather than proving absence.

    The two-fold shape is also semantically stable: the count is meaningful on every path. A read that transfers five hundred bytes and then hits ECONNRESET produces both a real error and a real count. This is the property that no other operation group shares.

    4.2 Combinators produce the widest tuples, and every element is meaningful

    The largest destructurings found are combinator results. ScyllaDB destructures seven elements from seastar::coroutine::all over seven schema tables in production code (db/schema_tables.cc:2184). seastar's own test suite goes to six. Redpanda destructures four from when_all_succeed in disk-space management. Asio's awaitable_operators.hpp reaches five elements inside operator&& and operator||, and Capy's when_all destructures as [ec, v1, ..., vn] up to five names in its doc programs.

    Two properties set this shape apart. The arity equals the number of children, so the tuple size is a function of the call site, not the operation. And the elements are per-child payloads, not one operation's compound result. Capy's when_all is the notable design: a failed child still contributes whatever payload it produced, so its trailing elements are not dummies even when the shared leading ec is set. This is the opposite convention from as_tuple, and the difference matters for section 5.3.

    4.3 Envelope operations carry error slots, and the trailing elements are dummies

    The third shape is the one the design question predicted. libcoro's UDP read_from returns std::tuple<io_status, socket_address, std::span<std::byte>>, and every error path in its implementation returns socket_address::make_uninitialised() and an empty span: elements two and three are dummies whenever the status is non-ok. ScyllaDB's RPC verbs destructure as [result, hit_rate, opt_exception], with the error slot last; when opt_exception is engaged, the other elements are dummies. The five-element send_read_digest adds a timestamp and a last-position that are independently optional. seastar's RPC frame readers return (msg_id, optional handler-time, optional payload) where a disengaged payload signals the error. Cobalt's as_tuple channel receive yields [ec, a, b, respond_to] where the payload elements are dummies when the channel is closed, which the very next line (if (ec) break;) demonstrates.

    Every ecosystem that ships this shape invented its own answer to the same problem: how to report an error from an operation that also has payload slots. The answers disagree (leading status, trailing optional, disengaged optional, as_tuple flattening), which is evidence that the tuple is being stretched past its natural fit.

    4.4 One genuine counterexample family exists: protocol operations

    The surveys found one family that breaks the dichotomy honestly. async-mqtt5's operations carry multi-value completion signatures: async_publish yields (error_code, reason_code, puback_props), and async_receive yields four elements, [rec, topic, payload, publish_props]. These are message envelopes, not byte transfers, and the trailing values carry protocol data rather than counts. QCoro's multi-argument signals and Ceph's monitor commands ([code, message, out]) are similar. The family is real but narrow, and its members are protocol-specific operations rather than vocabulary types. Section 6 discusses what the recommendation means for them.

    5. Discussion: the dichotomy is transfer versus non-transfer

    5.1 The tuple is honest exactly where the operation is two-fold

    The survey evidence reframes the original claim slightly. The natural split is not "streams versus everything else" but "byte transfer versus everything else." Stream reads and writes, positional file reads and writes, and datagram sends and receives all share the two-fold property: every completion, success or failure, produces both a status and a count, and both are meaningful. This resolves the secondary question from section 2. read_some_at belongs to the transfer family by its result contract, even though random_access_file correctly stands outside the Stream concept hierarchy. The concept system and the result contract are orthogonal axes, and the design is cleaner when it says so.

    Outside the transfer family, no operation in sixty repositories produces a compound result where the payload is meaningful on failure. The operations that carry extra tuple elements anyway - libcoro's UDP peer, the as_tuple call sites, the ScyllaDB RPC verbs - all pay for their payload slots with dummy values on the error path. The original intuition called this a desperate attempt to fit the tuple. The survey confirms the description and adds that the desperation is widespread, which strengthens rather than weakens the case: the industry keeps reinventing the same awkward fit because no vocabulary names the distinction.

    5.2 Dummy values are a confirmed design smell, not a hypothetical one

    Three independent codebases ship the dummy-on-error pattern in production or near-production code, and each signals it differently. libcoro documents it in the doc comment ("if ok then also the peer who sent the data and the data"). ScyllaDB gates the payload behind an explicit opt_exception check. Cobalt's example discards the payload with if (ec) break;. The pattern is common enough to have conventions and awkward enough that every convention is different. A contract that requires the caller to know which elements are valid under which status is a contract that the type system could state directly, at no cost, by not having the elements.

    5.3 when_all is a different species, and the conflation is what hurts it

    The surveys show that combinator results differ from operation results in both dimensions that matter. Their arity is a function of the call site. Their elements are per-child and independently meaningful - Capy's own when_all preserves each child's payload even when a sibling failed, which no operation result does. A single io_result<Ts...> that tries to be both an operation result and a combinator result must therefore serve two semantics with one definition: fixed-arity two-fold transfer, and variable-arity aggregation. This is the structural reason when_all and when_any resist satisfactory design under the unified type. The combinator wants to lift the error_code out and flatten child payloads; the operation contract wants exactly one payload whose meaning is tied to the error. Naming them separately - an operation contract and a combinator result type - removes the tension without changing either behavior.

    6. Alternatives considered

    Four criteria judge the options, each grounded in the findings. Semantic honesty: no element of a result should be meaningless on a path where the caller can observe it. Contract clarity: a reader should be able to tell from the result type whether an operation reports two-fold information or a bare status. Combinator tractability: when_all and when_any must be specifiable without special cases. Ecosystem fit: the design should match what sixty codebases actually do, because convergence is the strongest evidence a vocabulary is right.

    Do nothing: keep one variadic io_result<Ts...> for all operations. This is the status quo and the do-nothing option. Its cost is the documented one: non-transfer operations carry payload slots that are dummies on error, the type blurs the line between signalling an error and reporting two-fold information, and the combinator design inherits the ambiguity. Its benefit is uniformity of spelling. The survey shows the uniformity is superficial, because the semantics underneath already split into the four shapes of Table 1.

    Adopt a sum type instead. An expected-style result, where error and payload are mutually exclusive, would eliminate dummy values by construction. The survey shows why this fails for the transfer family: a partial transfer is precisely the case where error and payload coexist, and the five hundred bytes read before ECONNRESET are real data the caller owns. A sum type would force the transfer family to choose between losing the count and losing the error, which is the sender channel model's known failure, documented in the two-error-models analysis. Sum types are the right answer for operations whose payload is meaningless on failure - and for those operations the simpler answer is to not return a payload at all.

    Keep the unified type but document the conventions. A middle option: retain io_result<Ts...> and specify per-operation which elements are meaningful under which conditions. This is what libcoro, ScyllaDB, and Cobalt each do informally, with three different conventions. Standardizing the type without standardizing the convention would codify the ambiguity rather than resolve it.

    Handle protocol envelopes as their own family. The async-mqtt5 shape (error plus protocol data, all meaningful on success) does not fit either proposed contract cleanly. The recommendation is to leave such operations to domain libraries, as the ecosystem already does: no surveyed vocabulary library attempts to generalize them, and their payloads are protocol-specific by nature. If the standard later wants this shape, the seastar convention of std::optional members keeps every element honest without dummy values.

    7. Conclusions and recommendations

    The evidence supports the separation, with one refinement to the original framing: the dividing line is byte transfer, not stream membership. Three recommendations follow, each with an owner and a next step.

    Recommendation 1: define the transfer contract as its own named shape. Specify io_result<std::size_t> (or a dedicated alias such as transfer_result) as the result of read_some, write_some, read_some_at, write_some_at, and datagram send/receive/send_to/receive_from. State its invariant explicitly: the count is meaningful on every completion path, including failure. Owner: the stream concepts and files papers (D0011/D0012, D0019/D0020, and the UDP paper). Next step: add one paragraph to each paper naming the transfer contract and its partial-transfer guarantee. Confidence: high - the survey found no counterexample to the two-fold shape in any ecosystem.

    Recommendation 2: reduce every non-transfer operation to an error-only result. open, close, connect, bind, listen, shutdown, wait, and write_eof return io_result<> or a bare error_code. Operations that produce a genuine value on success only (accept, resolve) should return that value through a contract that is honest about the error path, not a tuple slot that is a dummy on failure. Owner: the Capy API surface and the Stage Two papers. Next step: audit every io_result<T> occurrence with non-empty T against the transfer-family list; each occurrence outside the family is a candidate for reduction. Confidence: high - every surveyed non-transfer operation with a payload slot pays for it with dummy values.

    Recommendation 3: specify when_all's result as a combinator type, not an operation type. Document io_result<R1, ..., Rn> from when_all as the combinator's own aggregate, with its own stated semantics (shared leading error, per-child payloads preserved). Do not present it as an instance of the operation contract. Owner: the combinators paper (P4124 lineage). Next step: add a paragraph to the combinators design stating that the aggregate's arity and per-element semantics derive from the children, not from any operation. Confidence: medium - the direction is clear, but the exact presentation depends on the naming revision still in flight.

    The original claim survives contact with sixty codebases almost intact. The one correction is taxonomic: read_some_at and the datagram operations stand with the streams, because the transfer contract, not the stream concept, is what legitimizes the tuple.

    8. References

    Primary evidence (collected 2026-09-19, five survey files, each verified against source):

    Design context:

    • Stream Concepts (D0011R0) and Stream Concepts: Design Rationale (D0012R0), 2026-05-15.
    • Files: Design Rationale (D0020R0), 2026-05-15, section 4.2 on why random_access_file does not satisfy Stream.
    • Combinators and Compound Results from I/O (P4124R1), 2026-05-01, on when_all and the io_result return type design.
    • Benefits of Frame-Visible Coroutines for Senders (P4166R0 lineage), on the io_result definition and the sender channel mapping.

    9. Appendix: survey inventory

    Table 2. Coverage and hit counts by survey. "Hits" are verified co_await destructurings of three or more names. Test and example code dominates the combinator rows; production hits are noted in section 4.

    Survey slice Repos examined Verified hits Dominant shape
    Asio / Beast / Capy / Corosio 11 cloned + cross-checks 41 parallel_group, MQTT envelopes, capy::when_all
    Folly / libunifex / stdexec / beman 11 cloned 12 collectAll family (test code)
    async_simple / cppcoro / libcoro / yalantinglibs 10 cloned 20 (12 vendored asio) when_all, libcoro UDP envelope
    Cobalt / QCoro / seastar / drogon 5 cloned + 3 searched 15 coroutine::all, RPC envelopes
    Broad sweep 16 cloned + per-repo searches ~55 repos with hits when_all family, domain tuples

    Method limitations, restated from section 3: Sourcegraph and grep.app were partially bot-blocked; named-struct results would not match the binding pattern; fork coverage is incomplete. None of these limitations cuts against the findings' direction, because each limitation hides potential hits rather than manufacturing them, and the hits that exist already sort into the four shapes.

    2026-09-19 17:15 - kimi-k3

  3. akrzemi1 commented on Sep 20, 2026

    @akrzemi1
    ContributorAuthor

    Looks like you have already done the analysis you requested. Looks like a lot of libraries has the same problem and use the wrong tool for the job.

  4. akrzemi1 commented on Sep 21, 2026

    @akrzemi1
    ContributorAuthor

    A peculiar constraint we are under is that we cannot use other Boost libraries in the implementation. This disqualifies Boost.System, which exists to handle exactly this very problem.

    Options I can see right now:

    Option 1. Leave io_result as is, and document that there are two kinds of contract implemented in the same type.

    Option 2. Reimplement boost::result<> from Boost.System and use it whenever an error sum type is needed.

    Option 3. Wrap in a dedicated class template the current error sum type cases. That would be a very minimalist implementation of boost::result<>, without manual lifetime management features, that would only work for types with a dispensable, cheap default-constructed value.

  5. locked and limited conversation to collaborators on Sep 29, 2026
  6. converted this issue into a discussion #435 on Sep 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    • Status
      Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions