WriteSink Concept Design
Overview
This document describes the design of the WriteSink concept, the rationale
behind each member function, and the relationship between WriteSink,
WriteStream, and the write_now algorithm. The design was arrived at
through deliberation over several alternative approaches, each of which
is discussed here with its trade-offs.
Concept Hierarchy
The write-side concept hierarchy consists of two concepts:
// Partial write primitive
template<typename T>
concept WriteStream =
requires(T& stream, const_buffer_archetype buffers)
{
{ stream.write_some(buffers) } -> IoAwaitable;
requires awaitable_decomposes_to<
decltype(stream.write_some(buffers)),
std::error_code, std::size_t>;
};
// Complete write with EOF signaling
template<typename T>
concept WriteSink =
WriteStream<T> &&
requires(T& sink, const_buffer_archetype buffers)
{
{ sink.write(buffers) } -> IoAwaitable;
requires awaitable_decomposes_to<
decltype(sink.write(buffers)),
std::error_code, std::size_t>;
{ sink.write_eof(buffers) } -> IoAwaitable;
requires awaitable_decomposes_to<
decltype(sink.write_eof(buffers)),
std::error_code, std::size_t>;
{ sink.write_eof() } -> IoAwaitable;
requires awaitable_decomposes_to<
decltype(sink.write_eof()),
std::error_code>;
};
WriteSink refines WriteStream. Every WriteSink is a WriteStream.
Algorithms constrained on WriteStream accept both raw streams and sinks.
Member Functions
write_some(buffers) — Partial Write
Writes one or more bytes from the buffer sequence. May consume less than
the full sequence. Returns (error_code, std::size_t) where n is the
number of bytes written.
This is the low-level primitive inherited from WriteStream. It is
appropriate when the caller manages its own consumption loop or when
forwarding data incrementally without needing a complete-write guarantee.
write(buffers) — Complete Write
Writes the entire buffer sequence. All bytes are consumed before the
operation completes. Returns (error_code, std::size_t) where n is
the number of bytes written.
Semantics
-
On success:
!ec,n == buffer_size(buffers). -
On error:
ec,nis the number of bytes written before the error occurred. -
If
buffer_empty(buffers): completes immediately,!ec,n == 0.
When to Use
-
Writing complete protocol messages or frames.
-
Serializing structured data where each fragment must be fully delivered before producing the next.
-
Any context where partial delivery is not meaningful.
Why write Belongs in the Concept
For many concrete types, write is the natural primitive, not a loop
over write_some:
-
File sinks: the OS
writecall is the primitive.write_somewould simply delegate towrite. -
Buffered writers:
writeis amemcpyinto the circular buffer (or drain-then-copy). It is not a loop overwrite_some. -
Compression sinks (deflate, zstd):
writefeeds data to the compressor and flushes the output. The internal operation is a single compression call, not iterated partial writes.
Requiring write in the concept lets each type implement the operation
in the way that is natural and efficient for that type.
write_eof(buffers) — Atomic Final Write
Writes the entire buffer sequence and then signals end-of-stream, as a
single atomic operation. Returns (error_code, std::size_t) where n
is the number of bytes written.
After a successful call, no further writes or EOF signals are permitted.
Semantics
-
On success:
!ec,n == buffer_size(buffers). The sink is finalized. -
On error:
ec,nis bytes written before the error. The sink state is unspecified.
Why Atomicity Matters
Combining the final write with the EOF signal in a single operation enables optimizations that two separate calls cannot:
-
HTTP chunked encoding:
write_eof(data)can emit the data chunk followed by the terminal0\r\n\r\nin a single system call. Callingwrite(data)thenwrite_eof()separately forces two calls and may result in two TCP segments. -
Compression (deflate):
write_eof(data)can passZ_FINISHto the finaldeflate()call, producing the compressed data and the stream trailer together. Separatewrite+write_eofwould require an extra flush. -
TLS close-notify:
write_eof(data)can coalesce the final application data with the TLS close-notify alert.
This optimization cannot be achieved by splitting the operation into
write(data) followed by write_eof().
Relationship to write_now
write_now is a composed algorithm that operates on any WriteStream.
It loops write_some until the entire buffer sequence is consumed. It
has two properties that a plain write_some loop does not:
-
Eager completion: if every
write_somecall completes synchronously (returnstruefromawait_ready), the entirewrite_nowoperation completes inawait_readywith zero coroutine suspensions. -
Frame caching: the internal coroutine frame is cached and reused across calls, eliminating repeated allocation.
write_now is the right tool for code constrained on WriteStream
alone (for example, writing to a raw TCP socket). Code constrained on
WriteSink should use write directly, because the concrete type’s
write may be more efficient than looping write_some, and because
write_now cannot replicate the atomic write_eof(buffers) operation.
Use Cases
Serializing Structured Data
When producing output fragment by fragment (e.g., JSON serialization), each fragment must be fully consumed before the next is produced. The final fragment signals EOF.
template<WriteSink Sink>
task<> serialize_json(Sink& sink, json::value const& jv)
{
auto [ec1, n1] = co_await sink.write(make_buffer("{"));
if(ec1)
co_return;
auto body = serialize_fields(jv);
auto [ec2, n2] = co_await sink.write(make_buffer(body));
if(ec2)
co_return;
auto [ec3, n3] = co_await sink.write_eof(make_buffer("}"));
if(ec3)
co_return;
}
Here write guarantees each fragment is fully delivered, and
write_eof atomically writes the closing brace and finalizes the sink.
Relaying a Streaming Body
When forwarding data from a source to a sink, the interior chunks use
write_some for incremental progress. The final chunk uses write_eof
for atomic delivery plus EOF.
template<ReadStream Source, WriteSink Sink>
task<> relay(Source& src, Sink& dest)
{
char buf[8192];
for(;;)
{
auto [ec, n] = co_await src.read_some(
mutable_buffer(buf));
if(ec == cond::eof)
{
// Signal EOF to the destination
auto [ec2] = co_await dest.write_eof();
co_return;
}
if(ec)
co_return;
// Interior: partial write is acceptable
std::size_t written = 0;
while(written < n)
{
auto [ec2, n2] = co_await dest.write_some(
const_buffer(buf + written, n - written));
if(ec2)
co_return;
written += n2;
}
}
}
The interior loop uses write_some because the relay does not need
complete-write guarantees for intermediate data. When read_some
returns EOF, n is 0 (per the ReadStream contract), so the relay
signals EOF via write_eof() with no data.
Writing Complete Messages
When sending discrete messages where each must be fully delivered, write
is the natural choice.
template<WriteSink Sink>
task<> send_messages(Sink& sink, std::span<std::string> messages)
{
for(auto const& msg : messages)
{
auto [ec, n] = co_await sink.write(make_buffer(msg));
if(ec)
co_return;
}
auto [ec] = co_await sink.write_eof();
if(ec)
co_return;
}
HTTP Response Body
An HTTP response handler writes the body through a type-erased sink.
The concrete implementation handles transfer encoding (content-length,
chunked, compressed) behind the WriteSink interface.
task<> send_response(any_write_sink& body, response const& resp)
{
// Write headers portion of body
auto headers = format_headers(resp);
auto [ec1, n1] = co_await body.write(make_buffer(headers));
if(ec1)
co_return;
// Write body with EOF
auto [ec2, n2] = co_await body.write_eof(
make_buffer(resp.body));
if(ec2)
co_return;
}
The caller does not know whether the body is content-length, chunked,
or compressed. The WriteSink interface handles the difference.
Compression Pipeline
A deflate sink wraps an underlying WriteStream and compresses data
on the fly. write_eof sets Z_FINISH on the final deflate call.
template<WriteSink Sink>
task<> compress_and_send(Sink& sink, std::string_view data)
{
// Write compressed data
auto [ec, n] = co_await sink.write_eof(make_buffer(data));
if(ec)
co_return;
// sink.write_eof triggered Z_FINISH internally,
// flushing the final compressed block and trailer
}
Buffered Writer
A buffered writer interposes a buffer between the caller and the
underlying stream. write_some appends to the buffer without draining.
write ensures all data is buffered (draining if necessary). write_eof
flushes the buffer and signals EOF to the underlying stream.
template<WriteSink Sink>
task<> buffered_output(Sink& sink)
{
// Small writes accumulate in the buffer
auto [ec1, n1] = co_await sink.write(make_buffer("key: "));
if(ec1)
co_return;
auto [ec2, n2] = co_await sink.write(make_buffer("value\r\n"));
if(ec2)
co_return;
// Final write flushes buffer + signals EOF
auto [ec3, n3] = co_await sink.write_eof(
make_buffer("end\r\n"));
if(ec3)
co_return;
}
Raw Stream with write_now
When only a WriteStream is available (no EOF signaling needed), the
write_now algorithm provides complete-write behavior with eager
completion and frame caching.
template<WriteStream Stream>
task<> send_data(Stream& stream)
{
write_now wn(stream);
auto [ec1, n1] = co_await wn(make_buffer("hello"));
if(ec1)
co_return;
// Frame is cached; no allocation on second call
auto [ec2, n2] = co_await wn(make_buffer("world"));
if(ec2)
co_return;
}
Because WriteSink refines WriteStream, write_now also works on
sinks. This can be useful when a function is generic over WriteStream
and does not need EOF signaling.
Alternatives Considered
WriteSink with Only write and write_eof
The initial design had WriteSink require only write(buffers),
write(buffers, bool eof), and write_eof(), with no write_some.
This made WriteSink disjoint from WriteStream: a function
constrained on WriteStream (using write_some) could not accept a
WriteSink, and vice versa.
This was rejected because it prevents generic algorithms from working
across both streams and sinks. The refinement relationship
(WriteSink refines WriteStream) is strictly more useful.
WriteSink with Only write_some and write_eof
A minimal design was considered where WriteSink required only
write_some and write_eof, with callers using write_now for
complete-write behavior. This approach has three problems:
-
No atomic final write:
write_nowoverwrite_somefollowed bywrite_eof()is two operations. This prevents concrete types from coalescing the final data with the EOF signal (chunked encoding, compression trailers, TLS close-notify). -
writeis the natural primitive for many types: files, buffered writers, and compression sinks implementwritedirectly, not as a loop overwrite_some. Forcing these types to express complete-write semantics through a function calledwrite_someis semantically misleading. -
Implementation burden on callers: every caller that needs complete-write behavior must construct a
write_nowobject and manage it, rather than callingsink.write(buffers)directly.
write(buffers, bool eof) Instead of write_eof(buffers)
An earlier version used write(buffers, bool eof) to combine data
writing with optional EOF signaling. This was replaced by
write_eof(buffers) because:
-
Boolean parameters are opaque at the call site.
write(data, true)does not convey intent as clearly aswrite_eof(data). -
write_eofis self-documenting: the name states that EOF is signaled. -
No risk of accidentally passing the wrong boolean value.
Three-Concept Hierarchy (WriteStream / WriteCloser / WriteSink)
A three-level hierarchy was considered, with an intermediate concept
(WriteCloser or similar) requiring write_some + write_eof but
not write. This was rejected because the intermediate concept serves
no practical purpose: any concrete type that has write_some and
write_eof can and should provide write. There is no use case where
a type offers partial writes and EOF signaling but cannot offer complete
writes.
Summary
| Function | Contract | Use Case |
|---|---|---|
|
Writes one or more bytes. May consume less than the full sequence. |
Relay interiors, backpressure, implementing composed algorithms. |
|
Writes the entire buffer sequence. |
Protocol messages, serialization, structured output. |
|
Writes the entire buffer sequence and signals EOF atomically. |
Final chunk of a relay, last fragment of serialized output. |
|
Signals EOF without data. |
When the final data was already written separately. |
WriteSink refines WriteStream. The write_now algorithm operates on
any WriteStream and provides complete-write behavior with eager
completion and frame caching, but it cannot replicate the atomic
write_eof(buffers) that WriteSink enables.