You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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
The recommendation
The question
Method
Findings
Discussion
Alternatives considered
Conclusions and recommendations
References
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):
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.
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.
Activity
vinniefalco commentedon Sep 19, 2026
Did you do a survey of open source projects which use awaitables to find out how many use arity of 3 or higher?
vinniefalco commentedon Sep 20, 2026
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 everyas_tuplecall 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 returningio_result<>or a bareerror_code. Third, treatwhen_all's flattenedio_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 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 anerror_codeand 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 someTare 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 witherror_codein the first position, destructured at the call site asauto [ec, n] = co_await stream.read_some(buf). The design question, raised during the naming revision, is whether this variadic form is an overreach: whethertuple<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 makeswhen_allandwhen_anyhard to design satisfactorily.A secondary question concerns scope.
read_some_atonrandom_access_fileis excluded from the stream concept system by deliberate design, because positional access has no cursor and cannot satisfyStream. If the compound result belongs to the transfer contract rather than the stream concepts, thenread_some_atis 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_awaitexpression whose result is destructured into three or more names, as inauto [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_awaitresults 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_awaitresults. Verified occurrences from the five surveys of 2026-09-19. "Dummy on error" means trailing elements are meaningless when the leading status reports failure.read_some->(ec, n)when_all,collectAll,parallel_groupas_tuple4.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 uniformlystd::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
ECONNRESETproduces 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::allover seven schema tables in production code (db/schema_tables.cc:2184). seastar's own test suite goes to six. Redpanda destructures four fromwhen_all_succeedin disk-space management. Asio'sawaitable_operators.hppreaches five elements insideoperator&&andoperator||, and Capy'swhen_alldestructures 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_allis the notable design: a failed child still contributes whatever payload it produced, so its trailing elements are not dummies even when the shared leadingecis set. This is the opposite convention fromas_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_fromreturnsstd::tuple<io_status, socket_address, std::span<std::byte>>, and every error path in its implementation returnssocket_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; whenopt_exceptionis engaged, the other elements are dummies. The five-elementsend_read_digestadds 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'sas_tuplechannel 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_tupleflattening), 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_publishyields(error_code, reason_code, puback_props), andasync_receiveyields 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_atbelongs to the transfer family by its result contract, even thoughrandom_access_filecorrectly stands outside theStreamconcept 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_tuplecall 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_exceptioncheck. Cobalt's example discards the payload withif (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_allis a different species, and the conflation is what hurts itThe 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_allpreserves each child's payload even when a sibling failed, which no operation result does. A singleio_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 reasonwhen_allandwhen_anyresist satisfactory design under the unified type. The combinator wants to lift theerror_codeout 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_allandwhen_anymust 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 beforeECONNRESETare 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::optionalmembers 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 astransfer_result) as the result ofread_some,write_some,read_some_at,write_some_at, and datagramsend/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, andwrite_eofreturnio_result<>or a bareerror_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 everyio_result<T>occurrence with non-emptyTagainst 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. Documentio_result<R1, ..., Rn>fromwhen_allas 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_atand 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:
random_access_filedoes not satisfyStream.when_alland theio_resultreturn type design.io_resultdefinition and the sender channel mapping.9. Appendix: survey inventory
Table 2. Coverage and hit counts by survey. "Hits" are verified
co_awaitdestructurings of three or more names. Test and example code dominates the combinator rows; production hits are noted in section 4.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
akrzemi1 commentedon Sep 20, 2026
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.
akrzemi1 commentedon Sep 21, 2026
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_resultas 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.