BufferSink Concept Design
Overview
This document describes the design of the BufferSink concept, the rationale
behind each member function, and the relationship between BufferSink,
WriteSink, and the pull_from algorithm. BufferSink models the
"callee owns buffers" pattern: the sink provides writable memory and the
caller writes directly into it, enabling zero-copy data transfer.
Where WriteSink requires the caller to supply buffer sequences containing
the data to be written, BufferSink inverts the ownership: the sink
exposes its internal storage and the caller fills it in place. The two
concepts are independent — neither refines the other — but the
type-erased wrapper any_buffer_sink satisfies both, bridging the two
patterns behind a single runtime interface.
Concept Definition
template<typename T>
concept BufferSink =
requires(T& sink, std::span<mutable_buffer> dest, std::size_t n)
{
// Synchronous: get writable buffers from sink's internal storage
{ sink.prepare(dest) } -> std::same_as<std::span<mutable_buffer>>;
// Async: commit n bytes written
{ sink.commit(n) } -> IoAwaitable;
requires awaitable_decomposes_to<
decltype(sink.commit(n)),
std::error_code>;
// Async: commit n final bytes and signal end of data
{ sink.commit_eof(n) } -> IoAwaitable;
requires awaitable_decomposes_to<
decltype(sink.commit_eof(n)),
std::error_code>;
};
BufferSink is a standalone concept. It does not refine WriteSink or
WriteStream. The two concept families model different ownership
patterns and can coexist on the same concrete type.
Caller vs Callee Buffer Ownership
The library provides two concept families for writing data:
| Aspect | WriteSink (caller owns) | BufferSink (callee owns) |
|---|---|---|
Buffer origin |
Caller allocates and fills buffers, then passes them to the sink. |
Sink exposes its internal storage; caller writes into it. |
Copy cost |
One copy: caller’s buffer → sink’s internal storage (or I/O submission). |
Zero copies when the sink’s internal storage is the final destination (e.g., a ring buffer, kernel page, or DMA region). |
API shape |
|
|
Natural for |
Protocol serializers that produce data into their own buffers, then hand it off. |
Sinks backed by pre-allocated memory (ring buffers, memory-mapped files, hardware DMA descriptors). |
Both patterns are necessary. A compression sink, for example, naturally
owns the output buffer where compressed data lands; the caller feeds
uncompressed data and the compressor writes results directly into the
ring buffer. Conversely, an HTTP serializer naturally produces header
bytes into its own scratch space and then hands the buffer sequence to a
WriteSink.
Member Functions
prepare(dest) — Expose Writable Buffers
Fills the provided span with mutable buffer descriptors pointing to the sink’s internal storage. This operation is synchronous.
Semantics
-
Returns a (possibly empty) subspan of
destpopulated with buffer descriptors. Each descriptor points to a writable region of the sink’s internal storage. -
If the returned span is empty, the sink has no available space. The caller should call
commit(possibly withn == 0) to flush buffered data and then retryprepare. -
The returned buffers remain valid until the next call to
prepare,commit,commit_eof, or until the sink is destroyed.
Why Synchronous
prepare is synchronous because it is a bookkeeping operation: the sink
returns pointers into memory it already owns. No I/O or blocking is
involved. Making prepare asynchronous would force a coroutine
suspension on every iteration of the write loop, adding overhead with no
benefit.
When the sink has no available space, the correct response is to
commit the pending data (which is asynchronous, as it may trigger
I/O), then call prepare again. This keeps the synchronous fast path
free of unnecessary suspensions.
Why a Span Parameter
The caller provides the output span rather than the sink returning a fixed-size container. This lets the caller control the stack allocation and avoids heap allocation for the buffer descriptor array:
mutable_buffer arr[16];
auto bufs = sink.prepare(arr);
The sink fills as many descriptors as it can (up to dest.size()) and
returns the populated subspan.
commit(n) — Finalize Written Data
Commits n bytes that the caller wrote into the buffers returned by
the most recent prepare. Returns (error_code).
commit_eof(n) — Commit Final Data and Signal EOF
Commits n bytes written to the most recent prepare buffers and
signals end-of-stream. Returns (error_code).
After a successful call, no further prepare, commit, or
commit_eof operations are permitted.
Why commit_eof Takes a Byte Count
Combining the final commit with the EOF signal in a single operation
enables the same optimizations that motivate write_eof(buffers) on
the WriteSink side:
-
HTTP chunked encoding:
commit_eof(n)can emit the data chunk followed by the terminal0\r\n\r\nin a single system call. -
Compression (deflate):
commit_eof(n)can passZ_FINISHto the finaldeflate()call, producing the compressed data and the stream trailer together. -
TLS close-notify:
commit_eof(n)can coalesce the final application data with the TLS close-notify alert.
A separate commit(n) followed by commit_eof(0) would prevent these
optimizations because the sink cannot know during commit that no more
data will follow.
Relationship to pull_from
pull_from is a composed algorithm that transfers data from a
ReadSource (or ReadStream) into a BufferSink. It is the
callee-owns-buffers counterpart to push_to, which transfers from a
BufferSource to a WriteSink.
template<ReadSource Src, BufferSink Sink>
io_task<std::size_t>
pull_from(Src& source, Sink& sink);
template<ReadStream Src, BufferSink Sink>
io_task<std::size_t>
pull_from(Src& source, Sink& sink);
The algorithm loops:
-
Call
sink.prepare(arr)to get writable buffers. -
Call
source.read(bufs)(orsource.read_some(bufs)) to fill them. -
Call
sink.commit(n)to finalize the data. -
When the source signals EOF, call
sink.commit_eof(0)to finalize the sink.
pull_from is the right tool when the data source satisfies
ReadSource or ReadStream and the destination satisfies BufferSink.
It avoids the intermediate caller-owned buffer that a WriteSink-based
transfer would require.
The two pull_from overloads differ in how they read from the source:
| Overload | Behavior |
|---|---|
|
Uses |
|
Uses |
Relationship to WriteSink
BufferSink and WriteSink are independent concepts serving different
ownership models. A concrete type may satisfy one, the other, or both.
The type-erased wrapper any_buffer_sink satisfies both concepts. When
the wrapped type satisfies only BufferSink, the WriteSink operations
(write_some, write, write_eof) are synthesized from prepare and
commit with a buffer_copy step. When the wrapped type satisfies both
BufferSink and WriteSink, the native write operations are forwarded
directly through the virtual boundary with no extra copy.
This dual-concept bridge lets algorithms constrained on WriteSink work
with any BufferSink through any_buffer_sink, and lets algorithms
constrained on BufferSink work natively with the callee-owns-buffers
pattern.
Transfer Algorithm Matrix
| Source | Sink | Algorithm |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
Use Cases
Zero-Copy Transfer
When the sink’s internal storage is the final destination (a ring buffer, a kernel page, a DMA region), the caller writes directly into it with no intermediate copy.
template<BufferSink Sink>
task<> fill_sink(Sink& sink, std::string_view data)
{
std::size_t written = 0;
while(written < data.size())
{
mutable_buffer arr[16];
auto bufs = sink.prepare(arr);
if(bufs.empty())
{
auto [ec] = co_await sink.commit(0);
if(ec)
co_return;
continue;
}
std::size_t n = buffer_copy(
bufs,
const_buffer(
data.data() + written,
data.size() - written));
written += n;
if(written == data.size())
{
auto [ec] = co_await sink.commit_eof(n);
if(ec)
co_return;
}
else
{
auto [ec] = co_await sink.commit(n);
if(ec)
co_return;
}
}
}
Transferring from a ReadSource
The pull_from algorithm reads data directly into the sink’s buffers,
avoiding a caller-owned intermediate buffer entirely.
template<ReadSource Source, BufferSink Sink>
task<> transfer(Source& source, Sink& sink)
{
auto [ec, total] = co_await pull_from(source, sink);
if(ec)
co_return;
// total bytes transferred with zero intermediate copies
}
Compare with the WriteSink approach, which requires an intermediate
buffer:
template<ReadStream Source, WriteSink Sink>
task<> transfer(Source& source, Sink& sink)
{
char buf[8192]; // intermediate buffer
for(;;)
{
auto [ec, n] = co_await source.read_some(
mutable_buffer(buf));
if(ec == cond::eof)
{
auto [wec] = co_await sink.write_eof();
co_return;
}
if(ec)
co_return;
auto [wec, nw] = co_await sink.write(
const_buffer(buf, n));
if(wec)
co_return;
}
}
The BufferSink path eliminates the buf[8192] intermediate buffer.
HTTP Response Body Sink
An HTTP response body can be consumed through a BufferSink interface.
The concrete implementation handles transfer encoding behind the
abstraction.
task<> receive_body(
any_buffer_sink& body,
ReadSource auto& source)
{
auto [ec, n] = co_await pull_from(source, body);
if(ec)
co_return;
// Body fully received and committed
}
The caller does not know whether the body uses content-length, chunked
encoding, or compression. The BufferSink interface handles the
difference.
Compression Pipeline
A compression sink owns an output ring buffer where compressed data
lands. The caller writes uncompressed data into prepared buffers, and
commit triggers a compression pass.
template<BufferSink Sink>
task<> compress_input(Sink& sink, std::span<const char> input)
{
std::size_t pos = 0;
while(pos < input.size())
{
mutable_buffer arr[16];
auto bufs = sink.prepare(arr);
if(bufs.empty())
{
auto [ec] = co_await sink.commit(0);
if(ec)
co_return;
continue;
}
std::size_t n = buffer_copy(
bufs,
const_buffer(input.data() + pos,
input.size() - pos));
pos += n;
auto [ec] = co_await sink.commit(n);
if(ec)
co_return;
}
auto [ec] = co_await sink.commit_eof(0);
if(ec)
co_return;
}
The commit_eof(0) call lets the compression sink pass Z_FINISH to
the final deflate call, flushing the compressed stream trailer.
Bridging to WriteSink via any_buffer_sink
When a function is constrained on WriteSink but the concrete type
satisfies only BufferSink, any_buffer_sink bridges the gap.
template<WriteSink Sink>
task<> send_message(Sink& sink, std::string_view msg);
// Concrete type satisfies BufferSink only
my_ring_buffer ring;
any_buffer_sink abs(ring);
// Works: any_buffer_sink satisfies WriteSink
co_await send_message(abs, "hello");
When the wrapped type also satisfies WriteSink, any_buffer_sink
forwards the native write operations directly, avoiding the synthesized
prepare + buffer_copy + commit path.
Alternatives Considered
Combined Prepare-and-Commit
An alternative design combined the prepare and commit steps into a
single asynchronous operation: write(dest) → (error_code, span),
where the sink returns writable buffers and the commit happens on the
next call. This was rejected because:
-
The synchronous
prepareis a pure bookkeeping operation. Making it asynchronous forces a coroutine suspension on every iteration, even when the sink has space available. -
Separating
preparefromcommitlets the caller fill multiple prepared buffers before incurring the cost of an asynchronous commit. -
The two-step protocol makes the buffer lifetime explicit: buffers from
prepareare valid untilcommitorcommit_eof.
prepare Returning a Count Instead of a Span
An earlier design had prepare fill a raw pointer array and return a
count (std::size_t prepare(mutable_buffer* arr, std::size_t max)).
This was replaced by the span-based interface because:
-
std::span<mutable_buffer>is self-describing: it carries both the pointer and the size, eliminating a class of off-by-one errors. -
Returning a subspan of the input span is idiomatic C++ and composes well with range-based code.
-
The raw-pointer interface required two parameters (pointer + count) where the span interface requires one.
Separate flush Operation
A design with an explicit flush method (distinct from commit) was
considered, where commit would only buffer data and flush would
trigger I/O. This was rejected because:
-
It adds a fourth operation to the concept without clear benefit. The
commitoperation already serves both roles: it finalizes the caller’s data and may trigger I/O at the sink’s discretion. -
A sink that wants to defer I/O can do so internally by accumulating committed data and flushing when its buffer is full. The caller does not need to know when physical I/O occurs.
-
Adding
flushwould complicate thepull_fromalgorithm, which would need to decide when to callflushversuscommit.
BufferSink Refining WriteSink
A design where BufferSink refined WriteSink (requiring all types to
implement both interfaces) was considered. This was rejected because:
-
Many natural
BufferSinktypes (ring buffers, DMA descriptors) have no meaningfulwrite_someprimitive. Their data path is prepare-then-commit, not write-from-caller-buffer. -
Requiring
write_some,write, andwrite_eofon everyBufferSinkwould force implementations to synthesize these operations even when they are never called. -
The
any_buffer_sinkwrapper provides the bridge when needed, without burdening every concrete type.
Summary
| Function | Contract | Use Case |
|---|---|---|
|
Synchronous. Fills span with writable buffer descriptors from the sink’s internal storage. Returns empty span if no space is available. |
Every write iteration: obtain writable memory before filling it. |
|
Async. Commits |
Interior iterations of a transfer loop. |
|
Async. Commits |
Final iteration: deliver last data and close the stream. |
BufferSink is the callee-owns-buffers counterpart to WriteSink. The
pull_from algorithm transfers data from a ReadSource or
ReadStream into a BufferSink, and any_buffer_sink bridges the two
patterns by satisfying both BufferSink and WriteSink behind a
single type-erased interface.