Sane C++ Libraries
C++ Platform Abstraction Libraries
Loading...
Searching...
No Matches
Async Streams

🟨 Bounded byte-stream pipelines with explicit backpressure

SaneCppAsyncStreams.h provides allocation-free readable, writable, transform, and pipeline state machines for moving bytes between asynchronous producers and consumers.

Dependencies

  • Dependencies: (none)
  • All dependencies: (none)

Dependency Graph

What Async Streams Is For

Async Streams is for workloads where data arrives or leaves over time and should be processed without first collecting the entire payload in memory. A source can read a file or socket into a bounded pool of buffers, transforms can process those buffers incrementally, and one or more sinks can consume them concurrently.

The core library defines the stream and pipeline state machines without depending on Async, File, or Socket. AsyncRequestStreams.h supplies templates that adapt compatible asynchronous request and event-loop types; the file and socket compositions in this repository instantiate them with Async.

Use Async Streams when you need:

  • incremental byte processing over asynchronous sources and destinations;
  • bounded memory usage selected by the caller;
  • backpressure when a sink, transform, or buffer pool cannot keep up;
  • a fixed pipeline of one source, up to eight transforms, and up to eight sinks;
  • file, pipe, socket, or compression streams built on the same byte-buffer model.
  • optional cipher transforms and HMAC sinks instantiated with a caller-selected cryptography session type.

It is not a general container for arbitrary objects, a pull-based read() interface, or an unbounded buffering layer. There is currently no object mode, and consumers receive pushed data events rather than a Node.js-style readable event.

Mental Model

An Async Streams graph has four layers:

  1. AsyncBuffersPool names and tracks caller-provided AsyncBufferView objects.
  2. AsyncReadableStream produces buffer IDs; AsyncWritableStream consumes them.
  3. AsyncDuplexStream combines both directions, while AsyncTransformStream consumes input and produces transformed output.
  4. AsyncPipeline connects one source through an optional transform chain to one or more sinks.

Streams exchange buffer IDs instead of owning byte allocations. The pool resolves an ID to its readable or writable span and reference-counts that view while it is in flight. A reusable view returns to the pool when its references reach zero. Child views can expose a slice without copying while retaining the parent buffer.

Read and write request queues are also supplied by the caller. The internal circular queues reserve one slot to distinguish full from empty, so an array of N + 1 requests provides N usable queued requests.

Supplying A Bounded Buffer Pool

This compiled test divides one caller-owned byte allocation into two reusable views. Real applications choose both the number and size of buffers from their expected concurrency and chunk size.

constexpr size_t numberOfBuffers = 2;
constexpr size_t bufferBytesSize = 16;
AsyncBufferView buffers[numberOfBuffers];
Buffer buffer;
SC_TEST_EXPECT(buffer.resizeWithoutInitializing(bufferBytesSize * numberOfBuffers));
for (size_t idx = 0; idx < numberOfBuffers; ++idx)
{
Span<char> writableData;
SC_TEST_EXPECT(buffer.toSpan().sliceStartLength(idx * bufferBytesSize, bufferBytesSize, writableData));
buffers[idx] = writableData;
buffers[idx].setReusable(true);
}
AsyncBuffersPool pool;
pool.setBuffers(buffers);

The pool never grows. When a readable cannot obtain a sufficiently large free buffer, getBufferOrPause() pauses it. When downstream processing releases a buffer, the pipeline can resume the source. This makes memory exhaustion part of the flow-control model instead of an implicit allocation.

A File-To-File Pipeline

The ready-made request streams adapt file and socket descriptors to the core stream model. After constructing the caller-owned queues and associating descriptors with an event loop, a basic file copy is a source connected directly to a sink:

readable.setReadQueue(readableRequests);
writable.setWriteQueue(writableRequests);
SC_TEST_EXPECT(readable.init(pool, eventLoop, readDescriptor));
SC_TEST_EXPECT(writable.init(pool, eventLoop, writeDescriptor));
// Create Pipeline
AsyncPipeline pipeline = {&readable, {}, {&writable}};
SC_TEST_EXPECT(pipeline.pipe());
SC_TEST_EXPECT(pipeline.start());

The source, every transform, and every sink in one pipeline must use the same AsyncBuffersPool. pipe() validates the layout and registers event listeners; start() begins source production. Completion remains asynchronous: the event loop must continue running until the streams finish or are destroyed.

AsyncReadableFileStream and AsyncWritableFileStream accept FileDescriptor and PipeDescriptor, so anonymous and named pipes from File compose without manually extracting their read or write endpoint.

Backpressure And Buffer Lifetime

Backpressure can come from either side of the graph:

  • a readable pauses when its read queue is full or no reusable buffer is available;
  • a writable rejects a new write with an invalid Result when its bounded write queue is full;
  • a transform may retain input while waiting for output space or asynchronous work;
  • a pipeline records bounded pending writes and resumes the relevant readable as writes complete.

Readable implementations acquire a buffer, fill it, call push(bufferID, size), and release their own reference. The stream retains the buffer for queued delivery. Writable implementations finish each accepted operation through finishedWriting(), which advances the queue and releases the stream's reference. Custom streams must respect this protocol; releasing a view too early invalidates downstream data, while failing to finish an operation prevents drain and end propagation.

eventData listeners should treat the buffer as borrowed for the callback unless they explicitly add a reference. Buffers can be read by multiple sinks because the pool tracks references, but writable access is only meaningful where the underlying view permits it.

Transforms And Fan-Out

An AsyncTransformStream is a duplex stream: it consumes buffers through its writable side and emits new buffers from its readable side. SyncZLibTransformStream runs compression work synchronously; AsyncZLibTransformStreamT schedules the same work through a compatible event loop and can be directed to a caller-selected thread pool.

A pipeline has a fixed layout rather than a dynamically growing graph. It supports at most eight transforms and eight sinks. Every sink receives the source data, with buffer references held until each write completes. This is useful for bounded fan-out, but the slowest sink applies backpressure to the shared source. If consumers need independent pacing or unbounded history, that storage and policy belongs outside this library.

Cryptography Adapters Without A Library Dependency

CryptographyTransformStreams.h provides two header-only adapters:

  • AsyncCipherTransformStreamT<T_Cipher> turns a compatible incremental cipher into a transform;
  • AsyncHmacWritableStreamT<T_Hmac> authenticates every byte written to it and is especially useful as a fan-out sink.

The header includes no Cryptography interface. A translation unit that wants the native implementation includes both libraries and chooses the concrete session at instantiation time:

#include "Libraries/AsyncStreams/CryptographyTransformStreams.h"
#include "Libraries/Cryptography/Cryptography.h"
SC_TRY(encrypt.cipher.start(SC::Cryptography::CipherType::AES256CBCPKCS7,
SC::Cryptography::Cipher::Operation::Encrypt, key, initializationVector));
SC_TRY(encrypt.init(buffersPool, cipherReads, cipherWrites));
SC::AsyncPipeline pipeline = {&source, {&encrypt}, {&destination}};
SC_TRY(pipeline.pipe());
SC_TRY(pipeline.start());
Adapts an allocation-free streaming cipher session to AsyncTransformStream.
Definition CryptographyTransformStreams.h:21
Pipes read data from SC::AsyncReadableStream, forwarding them to SC::AsyncWritableStream.
Definition AsyncStreams.h:558
Result pipe()
Reports errors by source, transforms or sinks.
Result start()
Starts the pipeline.
Definition AsyncStreams.h:233
Definition AsyncStreams.h:371

The adapters forward a single constructor argument to the concrete session, so an explicitly selected backend remains possible without teaching Async Streams about Cryptography:

SC::AsyncCipherTransformStreamT<SC::Cryptography::Cipher> encrypt(SC::Cryptography::Backend::OpenSSL);
SC::AsyncHmacWritableStreamT<SC::Cryptography::Hmac> authentication(SC::Cryptography::Backend::OpenSSL);
Adapts an incremental message-authentication session to AsyncWritableStream.
Definition CryptographyTransformStreams.h:119

source, encrypt, and destination must use the same pool. Every reusable writable view that the pool may return to the cipher must be at least 16 bytes. Larger buffers are more efficient: the adapter reserves up to 15 bytes of capacity for output caused by input retained from an earlier update. Decryption uses the same setup with Operation::Decrypt.

This cipher is AES-CBC with PKCS#7 padding. CBC does not authenticate ciphertext and is suitable only when an existing protocol already supplies correct authentication, IV rules, framing, and failure handling. Stream errors include native cipher failures, invalid ciphertext or padding, and undersized output buffers. Decryption may emit earlier plaintext blocks before finish() validates the final padding, so the surrounding protocol must authenticate the ciphertext before any plaintext is released to an application.

An HMAC sink can share source buffers with a storage or network sink through normal pipeline fan-out:

SC_TRY(authentication.hmac.setType(SC::Cryptography::HashType::SHA256));
SC_TRY(authentication.hmac.setKey(macKey));
authentication.setWriteQueue(hmacWrites);
SC_TRY(authentication.init(buffersPool));
SC_TRY_MSG(authentication.eventFinish.addListener(
[&authentication, &mac] { SC_TRUST_RESULT(authentication.hmac.getMac(mac)); }),
"Too many HMAC finish listeners");
SC::AsyncPipeline pipeline = {&source, {}, {&destination, &authentication}};
SC_TRY(pipeline.pipe());
SC_TRY(pipeline.start());
Event< MaxListeners > eventFinish
Emitted when write queue is empty.
Definition AsyncStreams.h:381
Result init(AsyncBuffersPool &buffersPool)
Emitted when the underlying resource has been closed.
constexpr void setWriteQueue(Span< Request > requests)
Sets the write queue for this writable stream.
Definition AsyncStreams.h:390
Definition Cryptography.h:79

Retrieve the MAC inside eventFinish. Streams auto-destroy immediately after that event returns, and destruction resets the HMAC session so key-dependent native state is released. The fan-out does not copy the source buffer: the pool keeps the same view referenced until both sinks finish their writes.

There is intentionally no AES-GCM byte-stream adapter. Secure AEAD decryption must authenticate a complete bounded record before releasing any plaintext, which requires explicit record framing, nonce derivation, tag placement, AAD, and storage policy. Those protocol decisions do not belong in an apparently generic byte transform.

Errors, Ending, And Destruction

Stream errors are delivered through eventError, and AsyncPipeline::eventError forwards failures from connected sources, transforms, and sinks. pushEnd() ends a readable after queued data is delivered; end() lets a writable finish queued and in-flight writes before emitting its finish event.

destroy() is the forceful, idempotent path for stopping a stream and its pending asynchronous work. Custom implementations that override asynchronous destruction must eventually call the corresponding finishedDestroyingReadable() or finishedDestroyingWritable() hook. Request-stream adapters do not close their file or socket descriptor by default; call setAutoCloseDescriptor(true) only when the stream owns that descriptor. Streams auto-destroy after normal completion by default, and this can be disabled when the surrounding owner needs a different lifetime policy.

Allocation Model

Async Streams performs no hidden dynamic allocation. The caller supplies:

  • the byte memory and AsyncBufferView slots in each AsyncBuffersPool;
  • readable and writable request-queue arrays;
  • stream, transform, pipeline, and event-loop objects;
  • any storage or thread pools required by the underlying asynchronous operations.

Some examples use Buffer, String, or Vector to conveniently own test memory. Those allocations belong to the caller and are not required by the stream machinery; fixed spans can be used instead. Capacity failures are observable as pauses or invalid Result values and must be handled as normal control flow.

Choosing The Neighboring Abstraction

  • Use Async directly for individual file, socket, process, timer, or event-loop operations where a stream graph adds no value.
  • Use Async Streams when the unit of work is a sequence of byte chunks and backpressure must connect producer speed to consumer capacity.
  • Use HttpClient for HTTP requests. Its optional async adapter exposes body streams using this model without making the poll-driven core depend on Async Streams.
  • Use Http for the higher-level asynchronous HTTP server and WebSocket compositions already built on these streams.

Details

For the complete API reference, see Async Streams. The tests under Tests/Libraries/AsyncStreams contain working file, pipe, socket, fan-out, backpressure, child-view, cryptography-adapter, and synchronous/asynchronous zlib compositions.

Status

🟨 MVP

The core file, pipe, socket, pipeline, and zlib transform paths are implemented and exercised by tests. The pipeline automatically pauses and resumes streams as part of backpressure handling, but it does not yet expose explicit pipeline-level pause, resume, or cancellation controls. The API remains an MVP: some setup is verbose, and examples outside the test suite are limited.

Videos

This is the list of videos that have been recorded showing some of the internal thoughts that have been going into this library:

Blog

Some relevant blog posts are:

Roadmap

🟩 Usable features:

  • Pipeline pause
  • Pipeline resume

🟦 Complete Features:

  • writev style asyncWrite

💡 Unplanned Features:

  • Object Mode
  • readable + read mode

Statistics

LOC counts exclude comments. Library counts files physically under Libraries/AsyncStreams. Single File counts SaneCppAsyncStreams.h. Standalone counts SaneCppAsyncStreamsStandalone.h and intentionally includes dependency payloads.

Metric Header Source Sum
Library 1037 1799 2836
Single File 1820 2046 3866
Standalone 1820 2046 3866