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.

Semantics

  • On success: !ec, n >= 1.

  • On error: ec, n == 0.

  • If buffer_empty(buffers): completes immediately, !ec, n == 0.

When to Use

  • Relay interiors: forwarding chunks of data as they arrive without waiting for the entire payload to be consumed.

  • Backpressure-aware pipelines: writing what the destination can accept and returning control to the caller.

  • Implementing write or write_now on top of the primitive.

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, n is 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 write call is the primitive. write_some would simply delegate to write.

  • Buffered writers: write is a memcpy into the circular buffer (or drain-then-copy). It is not a loop over write_some.

  • Compression sinks (deflate, zstd): write feeds 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, n is 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 terminal 0\r\n\r\n in a single system call. Calling write(data) then write_eof() separately forces two calls and may result in two TCP segments.

  • Compression (deflate): write_eof(data) can pass Z_FINISH to the final deflate() call, producing the compressed data and the stream trailer together. Separate write + write_eof would 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().

write_eof() — Bare EOF Signal

Signals end-of-stream without writing any data. Returns (error_code).

After a successful call, no further writes or EOF signals are permitted.

Semantics

  • On success: !ec. The sink is finalized.

  • On error: ec.

When to Use

When the final data has already been written via write or write_some and only the EOF signal remains. This is less common than write_eof(buffers) but necessary when the data and EOF are produced at different times.

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:

  1. Eager completion: if every write_some call completes synchronously (returns true from await_ready), the entire write_now operation completes in await_ready with zero coroutine suspensions.

  2. 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:

  1. No atomic final write: write_now over write_some followed by write_eof() is two operations. This prevents concrete types from coalescing the final data with the EOF signal (chunked encoding, compression trailers, TLS close-notify).

  2. write is the natural primitive for many types: files, buffered writers, and compression sinks implement write directly, not as a loop over write_some. Forcing these types to express complete-write semantics through a function called write_some is semantically misleading.

  3. Implementation burden on callers: every caller that needs complete-write behavior must construct a write_now object and manage it, rather than calling sink.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 as write_eof(data).

  • write_eof is 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

write_some(buffers)

Writes one or more bytes. May consume less than the full sequence.

Relay interiors, backpressure, implementing composed algorithms.

write(buffers)

Writes the entire buffer sequence.

Protocol messages, serialization, structured output.

write_eof(buffers)

Writes the entire buffer sequence and signals EOF atomically.

Final chunk of a relay, last fragment of serialized output.

write_eof()

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.