TeeForge 0.1 specification
Status: accepted for implementation on 2026-08-22; updated on 2026-08-24.
Package
- Package ID and assembly name:
TeeForge. - Version:
0.1.0. - Target framework:
net10.0only. - Public namespaces are organized by stable feature family:
TeeForge.Composition,TeeForge.Mirroring,TeeForge.Broadcasting,TeeForge.Hashing,TeeForge.RandomAccess,TeeForge.Networking, andTeeForge.ErasureCoding. - The root
TeeForgenamespace contains no public types. Consumers import only the feature families they use. - License: MIT.
- Public concrete classes remain unsealed.
- The sole runtime NuGet dependency is Microsoft's MIT-licensed
System.IO.Hashing, used for checksums and incremental hash destinations. - The package contains XML documentation, portable symbols, Source Link data, README, changelog, license, and third-party notices.
HandoffStream
HandoffStream is a stable Stream endpoint over a replaceable current stream.
Construction takes the initial stream and an optional leaveOpen value that
defaults to false. Capabilities are read from the current stream and may
change after a handoff; they report false after disposal.
All ordinary stream operations, disposal, and handoffs share one asynchronous gate. A handoff waits for the active operation to finish, flushes the outgoing stream, installs the supplied replacement atomically, and allows queued operations to continue against the replacement. The replacement is assumed to have the same final destination. No operation spans both streams. This serialization makes handoffs deterministic for streams whose reads, writes, and seeks share one position.
The public handoff API is:
void Handoff(Stream stream)
ValueTask HandoffAsync(
Stream stream,
CancellationToken cancellationToken = default)
The caller constructs the replacement before handing it in; for example, it may
construct a System.IO.BufferedStream over the shared destination. The outgoing
stream is not disposed during handoff. Its replacement or the caller retains
responsibility for its lifetime. If the outgoing flush fails or an asynchronous
wait is canceled, it remains current. Supplying null or the HandoffStream
itself is rejected.
HandoffStream implements ITeeRandomAccessStream. It delegates explicit-
offset operations to a native current capability when available. A seekable
current stream instead uses a serialized save/seek/operate/restore fallback,
which preserves Position and keeps random access available after handing off
to a standard System.IO.BufferedStream. CanReadAt and CanWriteAt reflect
the current native or fallback capability.
Synchronous and asynchronous disposal wait for any active operation. Unless
leaveOpen is true, disposal cascades through the current stream. When
leaveOpen is true the current stream is flushed and remains open. Streams
retired by earlier handoffs are never disposed by HandoffStream.
MigratingStream
MigratingStream is a stable readable, writable, seekable endpoint over a
logical byte sequence being moved from one backing stream to another. Source
and destination must be distinct readable, writable, seekable streams. The
constructor captures the source length and position, resizes the destination,
and starts migration at logical offset zero without changing either backing
position.
Migration copies at most MigratingStreamOptions.BufferSize bytes in one
quantum. A quantum holds the shared operation gate across its source read and
destination write, then releases it. A foreground waiter count prevents the
worker from starting another quantum while any caller operation is queued. An
already active quantum is not interrupted.
The copied range is one contiguous migrated prefix. While migration is active,
reads below its end use the destination and reads at or beyond its end use the
source. A read crossing the boundary may return at the boundary, as permitted
by Stream.Read. Writes and length changes apply source-first to both streams.
Once the complete destination is flushed, it becomes the sole backing for all
operations. The stream has its own logical Position; physical I/O uses native
ITeeRandomAccessStream operations or a serialized save/seek/restore fallback.
MigrationCompletion represents copy, destination flush, and optional source
truncation. Copy cancellation or failure stops migration and routes subsequent
operations to the source. A failed destination read is retried from the source.
A destination failure after a source-first mutation is reported to that caller
and faults migration. If optional source cleanup fails after the destination
has already become authoritative, the completion task faults but operations
remain routed to the complete destination.
MigratingStreamOptions independently controls source and destination
ownership, positive migration buffer size, and explicit source truncation after
successful destination flush. Source truncation defaults to false because a
generic Stream cannot delete its underlying resource. Disposal cancels and
waits for migration before flushing leave-open streams or disposing owned
streams.
HandoffStream.MigrateAsync integrates both transition boundaries. Under the
handoff operation gate it flushes the current stream, constructs a paused
MigratingStream over that source, installs it as current, and starts migration.
Foreground operations through the stable endpoint therefore use the migration
wrapper. When MigrationCompletion succeeds, the method flushes the wrapper,
transfers destination ownership to HandoffStream, and atomically installs the
destination. The retired wrapper is then disposed, applying source ownership
from MigratingStreamOptions; HandoffStream.LeaveOpen controls the adopted
destination's eventual disposal.
Copy failure or cancellation atomically restores the original source before
the migration wrapper is disposed. Source ownership transfers back so wrapper
disposal cannot close the restored endpoint. A failure confined to optional
source cleanup occurs after destination activation and therefore keeps the
destination installed. If an unrelated handoff replaces the migration wrapper
before either boundary, migration completion does not overwrite that newer
replacement and reports InvalidOperationException.
ReplicaStream
ReplicaStream is a write-only, forward-only Stream that sends the same byte
sequence to one or more replicas. It is intended for destinations that need not
support reads, seeks, length, position, set-length, or random access.
The public constructors are:
ReplicaStream(params Stream[] replicas)
ReplicaStream(ReplicaStreamOptions options, params Stream[] replicas)
ReplicaStream(
IEnumerable<Stream> replicas,
ReplicaStreamOptions? options = null)
At least one non-null, writable replica is required. Duplicate object references are rejected. The input is copied to an internal array and is not publicly exposed. All validation completes before the wrapper takes ownership.
CanRead and CanSeek are always false. CanWrite and CanTimeout are
intersections across the replicas while the wrapper remains open. Length,
Position, reads, seeks, and set-length throw NotSupportedException.
WriteTimeout is exposed only through the ordinary Stream timeout contract;
its getter requires replicas to report the same value and its setter fans out.
Writes and flushes attempt every replica. Async calls are started for every
replica before the phase is awaited. Sync calls run in replica-index order by
default; ReplicaStreamOptions.SynchronousMode can select concurrent dispatch.
Separate caller operations are not serialized.
A single underlying failure is rethrown with its original stack. Multiple
failures are reported in an index-ordered AggregateException. If every
failure is cancellation, the first cancellation is rethrown. Once dispatch has
started, failures do not prevent attempts against later replicas. Replication
is consequently best-effort for each operation rather than transactional; an
unsuccessful write may leave replica lengths or contents different.
ReplicaStreamOptions.LeaveOpen defaults to false. Sync disposal attempts all
owned replicas using the configured synchronous dispatch mode, and async
disposal starts all owned disposals before awaiting them. The wrapper becomes
disposed even when disposal fails.
TeeStream
Construction and ownership
The public constructors are:
TeeStream(params Stream[] destinations)
TeeStream(TeeStreamOptions options, params Stream[] destinations)
TeeStream(IEnumerable<Stream> destinations, TeeStreamOptions? options = null)
At least one non-null destination is required. Duplicate object references are
rejected. The input is copied to an internal array and is not publicly exposed.
The first destination is the primary stream. LeaveOpen defaults to false and
applies to all destinations.
Capabilities and operations
CanRead, CanWrite, CanSeek, and CanTimeout are intersections across all
destinations. Read, write, seek, length, position, timeout, flush, set-length,
and disposal operations fan out where the Stream contract permits them.
Separate caller operations are not serialized. Callers retain the usual Stream
single-owner responsibility. Async fan-out operations run concurrently, except
that mirrored reads first require the primary result. Sync operations default
to deterministic destination-index order; Concurrent mode fans out all sync
operations where no primary dependency prevents it.
Every operation attempts every relevant destination even after an earlier
failure. One underlying failure is rethrown with its original stack. Multiple
failures are reported in a deterministic-index AggregateException.
Reads
The primary reads directly into the caller's buffer. If it returns a positive count, every mirror is read until exactly that count has been obtained, using a rented temporary buffer per mirror, then compared. Legal short-read chunking is normalized. Async mirror reads run concurrently.
If the primary returns zero for a non-empty request, TeeStream returns zero and does not probe mirrors. If the primary throws, each mirror receives the original caller request so every failure can be observed; successful mirrors may advance.
Mismatches
TeeStreamMismatchBehavior has three values:
ThrowAndContinue(default): throw for this mismatch and keep operating.ThrowAndFault: throw and permanently fault the wrapper after a mismatch.UsePrimary: expose primary data/results despite successful differences.
Destination exceptions always fail the current operation but do not change
future wrapper state. ThrowAndFault applies only to successful-but-inconsistent
results or data.
TeeStreamConsistencyException : IOException reports the operation, optional
primary numeric result, and indexed mismatch metadata. Read mismatches record
the first differing offset but never retain or print byte contents.
Cancellation and disposal
A pre-canceled token invokes no destination. After an async operation begins, the token is passed to every destination and all operations are awaited. If all unsuccessful results are cancellation, the operation is canceled; any ordinary failure produces an aggregate that also contains cancellation exceptions.
Synchronous and asynchronous disposal attempt every owned destination.
DisposeAsync fans out concurrently. The wrapper becomes disposed even if one
or more destination disposals fail.
Logical random access and bounded ranges
ITeeRandomAccessStream is the position-independent buffer capability:
bool CanReadAt { get; }
bool CanWriteAt { get; }
int ReadAt(Span<byte> buffer, long offset)
ValueTask<int> ReadAtAsync(Memory<byte> buffer, long offset, CancellationToken cancellationToken = default)
void WriteAt(ReadOnlySpan<byte> buffer, long offset)
ValueTask WriteAtAsync(ReadOnlyMemory<byte> buffer, long offset, CancellationToken cancellationToken = default)
Calls operate on a wrapper's logical byte sequence and neither observe nor
modify Stream.Position. Concurrent capability calls are safe, although a
wrapper may serialize them to protect logical metadata. Overlapping reads and
writes have no additional snapshot or transaction semantics.
ITeeRangeReadSource.OpenReadRangeAsync(offset, length, cancellationToken) returns
an independently owned, read-only, forward-only Stream. It is bounded by both
the requested length and the source length. A zero-length request or an offset
at or beyond end of stream returns an empty stream. Range streams let a source
reserve and stream a useful multi-megabyte transfer while a consumer begins
from its small prefix, avoiding one network request for every small read.
TeeRandomAccess.TryGet discovers an existing implementation or adapts a
FileStream through System.IO.RandomAccess. It deliberately does not adapt
an arbitrary CanSeek stream. A public save/seek/restore adapter could race an
unrelated owner of the same stream; only wrappers with exclusive ownership may
use such a fallback internally.
TeeStream exposes a capability only when every destination exposes it.
Positional reads retain primary-sized consistency checking, positional writes
attempt every destination, and a range open owns one bounded child stream per
destination. Asynchronous fan-out starts all independent destination writes
offsets through its BAT and uses an upstream capability for physical I/O when present. Independent upstream operations may be submitted together before awaiting their completion. These patterns permit an upstream device to exploit queueing such as NCQ without defining an NCQ API or guaranteeing a particular scheduling policy.
HttpRandomAccessStream is a caller-HttpClient-owned, read-only leaf. Open
uses a bytes=0-0 GET probe to establish length and range support. Exact
ReadAt calls request exactly their bounded buffer range. Range streams keep
the original response body streaming and resume only an unread suffix after a
premature EOF or transport failure. No client-side concurrency cap or cache is
provided by this layer.
The default HTTP representation policy records a strong ETag when available,
otherwise records Last-Modified as a best-effort validator, and otherwise
continues with length and range validation. Configuration may require a strong
validator or disable validator checks. A later 412, changed validator, or
changed total Content-Range faults the source with
HttpRepresentationChangedException; optional representation retries continue
to target only the original snapshot. HTTP 429 and 503 responses update one
shared not-before time across all in-flight reads. Defaults are three slowdown
retries, a two-minute maximum requested wait, zero representation-change
retries, three body-resume retries, and 250 ms exponential backoff with jitter.
The HTTP leaf intentionally does not preload or cache. A future adaptive
read-ahead/cache layer should consume ITeeRangeReadSource, initially reserve a
large range (the current design target is 4 MiB), expose progressively filled
prefixes, coalesce overlapping readers, and grow its reservation only after
capability at its member-I/O boundary when a member implements it.
TeeBufferedStream
TeeBufferedStream is adapted from Microsoft's BufferedStream source pinned
to the same .NET release/10.0 commit as BroadcastPipe. TeeBufferedStreamOptions
is an immutable, unsealed child of TeeStreamOptions that adds a positive
BufferSize, defaulting to 4 KiB. Buffered sequence constructors receive that
complete options object rather than a separate buffer-size argument.
The public constructors are:
TeeBufferedStream(params Stream[] destinations)
TeeBufferedStream(int bufferSize, params Stream[] destinations)
TeeBufferedStream(TeeBufferedStreamOptions options, params Stream[] destinations)
TeeBufferedStream(
IEnumerable<Stream> destinations,
TeeBufferedStreamOptions? options = null)
The direct int overload is retained as a BufferedStream-style convenience;
it constructs the corresponding options internally. Constructors do not accept
a base TeeStreamOptions plus a loose buffer size.
One lazy byte array is shared between logical reads and writes. Microsoft's large-operation bypass, temporary shadow-buffer heuristic, seek bookkeeping, sync and async paths, APM compatibility, and copy behavior are retained. When buffered data is emitted, a TeeStream applies capability intersection, primary-sized reads, consistency checking, fan-out ordering, aggregate failure reporting, and ownership to every destination.
Capabilities are sampled when the internal TeeStream is created because the buffering hot paths query them repeatedly and Stream capabilities conventionally remain stable while open. The outer stream reports false after disposal.
Writes and their destination failures may be deferred until the shared buffer fills, the caller flushes, an incompatible read/seek requires a flush, or the stream is disposed. As in Microsoft's implementation, the shared mutable buffer is not safe for overlapping synchronous caller operations; asynchronous paths use the upstream serialization discipline.
The initial random-access extension preserves the adapted Microsoft buffering control flow. A positional read or range open flushes pending sequential writes before reading upstream, and a positional write flushes earlier buffered writes before bypassing the write buffer. A future optimization may allow positional readers to reference or overlay an in-flight write buffer instead of forcing that flush, but it is deferred until buffer lifetime, ordering, retry, and failure visibility can remain compatible with the original implementation.
TeeHashStream
TeeHashStream derives from TeeBufferedStream and is write-only. It requires
at least one unique, non-null, writable caller destination, then appends one
internal HashWriteStream destination for each configured algorithm.
Cryptographic destinations use IncrementalHash; non-cryptographic
destinations use the corresponding System.IO.Hashing implementation with its
default seed or parameter set. Every constructor requires an algorithm or
algorithm sequence as its first parameter. A sequence must contain at least one
unique value; unsupported algorithms fail construction before ownership is
taken.
The public constructors are:
TeeHashStream(
HashAlgorithmName algorithm,
out TeeHashResults results,
params Stream[] destinations)
TeeHashStream(
IEnumerable<HashAlgorithmName> algorithms,
out TeeHashResults results,
IEnumerable<Stream> destinations,
TeeBufferedStreamOptions? options = null)
TeeHashStream(
TeeHashAlgorithm algorithm,
out TeeHashResults results,
params Stream[] destinations)
TeeHashStream(
IEnumerable<TeeHashAlgorithm> algorithms,
out TeeHashResults results,
IEnumerable<Stream> destinations,
TeeBufferedStreamOptions? options = null)
The HashAlgorithmName overloads support .NET interoperability and remain cryptographic-only. TeeHashAlgorithm
contains MD5, SHA1, SHA256, SHA384, SHA512, SHA3_256, SHA3_384,
SHA3_512, Crc32, Crc64, XxHash32, XxHash64, XxHash3, and
XxHash128. One enum-based call may mix both families. Member documentation
identifies the family, warns that CRC and XXHash are unsuitable for security,
warns about MD5 and SHA-1 collision resistance, and records platform-dependent
SHA-3 availability.
TeeHashAlgorithmAdapter publicly converts the eight standard cryptographic
identifiers between HashAlgorithmName and TeeHashAlgorithm. It exposes
ToTeeHashAlgorithm, TryToTeeHashAlgorithm, and
TryToHashAlgorithmName. Unknown names and undefined enum values fail their
try-conversion. Non-cryptographic enum members also fail conversion to
HashAlgorithmName; adapters do not extend the .NET constructor path with
non-cryptographic names.
Hashing runs inline through the ordinary TeeStream fan-out without worker threads, queues, or payload copies. Hash destinations preserve TeeBufferedStream delivery and retry behavior. A digest describes the ordered bytes accepted by that hash destination, including bytes accepted again when a partial buffered failure is retried; it does not certify the final state of ordinary mirrors.
TeeHashResults implements
IReadOnlyDictionary<TeeHashAlgorithmId, TeeHashResult>. Before completion it
has zero entries and IsComplete is false. Flush does not complete hashing.
Dispose and DisposeAsync always finalize and dispose internal hash
destinations, regardless of LeaveOpen, then atomically publish all results in
configured order. LeaveOpen applies only to caller destinations. An ordinary
destination disposal failure is still reported but does not suppress hashes
that finalized successfully. If any hash cannot finalize, the dictionary stays
empty, IsComplete stays false, and disposal reports the failure.
Each published TeeHashResult is immutable. It exposes its
TeeHashAlgorithmId, digest as ReadOnlyMemory<byte>, uppercase hexadecimal,
padded Base64, unpadded URL-safe Base64 (Base64Url), and uppercase, padded
RFC 4648 Base32 (Base32). Text encodings are computed lazily and safely under
concurrent access.
Both input forms publish the same non-generic result types. TeeHashAlgorithmId
implicitly accepts TeeHashAlgorithm and HashAlgorithmName, so indexers,
ContainsKey, and TryGetValue accept either form. Standard cryptographic
identifiers compare equally across both forms, including SHA-3 names. The key
exposes Name and IsCryptographic; equality is case-sensitive and distinguishes
cryptographic names from non-cryptographic checksums with the same text.
Named .NET algorithms are passed through to IncrementalHash, even when the
name is outside the enum. Runtime support is checked during construction.
Names are not invented for checksums in the HashAlgorithmName API. The default
shared identifier is unnamed and is rejected when creating a result.
Multi-destination CopyToAsync
TeeForge.Broadcasting.StreamCopyExtensions provides async-only extensions:
Task CopyToAsync(this Stream source, params Stream[] destinations)
Task CopyToAsync(this Stream source, IEnumerable<Stream> destinations,
CancellationToken cancellationToken = default)
Task CopyToAsync(this Stream source, IEnumerable<Stream> destinations,
BroadcastCopyOptions options, CancellationToken cancellationToken = default)
Single-destination Stream instance methods retain their normal overload binding. These extensions snapshot the destination enumeration once and validate every destination before source I/O. Source must be readable; destinations must be nonempty, writable, nonnull, unique object references, and distinct from source. Copying begins at the current source/destination positions. All caller-owned streams remain open and no implicit flush occurs, on success or failure.
Each destination receives its own BroadcastStream reader and progresses independently through shared pooled segments. The asynchronous copy path writes directly from pipe memory and advances its consumed cursor after successful writes, retaining memory until each awaited write completes. There is no separate payload queue or copy buffer per destination. BroadcastCopyOptions defaults to BufferSize 4096, PauseWriterThreshold 65536, ResumeWriterThreshold 32768, and FailureBehavior Stop. Source reads and destination writes are at most BufferSize; source backpressure and memory overhead follow BroadcastStreamOptions.
BroadcastCopyFailureBehavior.Stop cancels other copies and the pump when a destination write fails. Continue disposes the failed reader, releases its retained data, and lets healthy destinations finish. If every destination fails, the pump is stopped without draining the source. Both policies await all started operations, then throw AggregateException containing indexed destination errors. Each BroadcastCopyDestinationException exposes DestinationIndex (zero-based in the supplied collection) and retains the original InnerException. Destination errors are ordered by index; source errors are deduplicated by identity and reported once before destination errors. Published source data may be drained before a source error reaches destination workers.
Caller cancellation stops the entire copy under either failure policy. Expected cancellation from stopping sibling copies does not obscure the initiating fault. With no other failures, caller cancellation produces a canceled Task. An independently canceled destination is a destination failure. In-flight I/O that ignores cancellation must finish before completion and memory reclamation. No rollback or automatic retry occurs; failures can leave unequal destination prefixes.
Hash-returning copies
Algorithm-first CopyToAsync overloads accept a single algorithm or an algorithm
sequence using either TeeHashAlgorithm or HashAlgorithmName. All return
Task<TeeHashResults> with the same shared result identifiers.
Each algorithm shape supports params Stream[] destinations, or a single
Stream destination / IEnumerable<Stream> destinations followed by optional
BroadcastCopyOptions? options = null and CancellationToken cancellationToken = default.
At least one destination and one distinct, valid algorithm are required.
Algorithm and destination enumerations are snapshotted once before source I/O.
These overloads use BroadcastHashStream with the same destination copy engine. Each remaining source byte is hashed once regardless of the destination count or progress. Returned results are complete, preserve algorithm selection order, and describe the bytes read from the source's initial position through EOF. They exclude preexisting destination contents. The task returns hashes only after the source and every destination finish successfully. Failure aggregation, Stop/Continue behavior, cancellation, and ownership match the ordinary copy contract; a faulted or canceled task does not return hashes even when source EOF was reached. No synchronous hash-returning copy extension is provided.
BroadcastStream and BroadcastHashStream
TeeForge.Broadcasting.BroadcastStream is an IDisposable/IAsyncDisposable
coordinator with a stable IReadOnlyList<Stream> Readers, Task Completion, and
long BytesBroadcast. It does not derive from Stream. Its constructor takes a
readable source, positive reader count, optional BroadcastStreamOptions, and
optional broadcast cancellation token. An asynchronous pump starts at construction
and reads the source from its current position. The caller grants exclusive use
of the source until the pump stops.
The pump reads directly into shared BroadcastPipe memory. Reader endpoints independently consume the complete ordered sequence. Ordinary reads copy into caller-provided buffers; asynchronous copying writes shared memory directly to its destination. They support synchronous and asynchronous reads, ReadByte, and copying; all paths advance the endpoint cursor. Position reports bytes consumed from zero, while Length, seeking, positional I/O, and writes are unsupported. Concurrent reads on the same endpoint are rejected; different readers operate concurrently. Zero-length reads return zero without being interpreted as EOF.
BroadcastStreamOptions is immutable and unsealed. Its defaults are BufferSize
4096, PauseWriterThreshold 65536, ResumeWriterThreshold 32768, and LeaveOpen false.
All sizes and thresholds must be positive and resume must not exceed pause.
The slowest reader's unread bytes govern backpressure; a source read can exceed
the pause threshold by less than BufferSize. Pool allocation rounding and retained
segment boundaries are additional physical memory overhead. Payload segments are
released only when every active reader has consumed them.
Reader disposal cancels its pending read, waits for the read to release its buffer, and completes its pipe endpoint. It removes that reader from backpressure and retention. Losing all readers before EOF cancels the pump, including a cancellable in-flight source read. Cancellation of an individual read leaves the reader active and does not affect siblings. Consumers must run concurrently or dispose unused endpoints; waiting for one reader to finish before starting another can deadlock under backpressure.
Completion succeeds only after source EOF; readers may still hold unread bytes. On failure or broadcast cancellation, readers can drain published bytes before observing the terminal exception. Disposal cancels and awaits the pump, disposes every reader, and disposes the source unless LeaveOpen is set. It is idempotent and does not read the remainder. Sources that ignore cancellation can delay disposal. Pump exceptions remain observable through Completion and endpoints; disposal reports cleanup failures and attempts cleanup of all owned resources.
TeeForge.Hashing.BroadcastHashStream derives from BroadcastStream and adds one
set of hashes independent of reader count and cursor positions. Its constructors
take an explicit HashAlgorithmName, TeeHashAlgorithm, or corresponding ordered
algorithm sequence first, then out TeeHashResults, source, reader count,
options, and token.
Algorithm validation and selection match TeeHashStream. Each source chunk is
hashed once before admission to the broadcast. All results publish together at
source EOF before Completion succeeds. Failure, cancellation, or abandonment
before EOF leaves results incomplete, even after disposal. Hash state is always
released when the pump stops; disposal never publishes a prefix as a full hash.
BroadcastPipe
BroadcastPipe, BroadcastPipeOptions, and BroadcastPipeReaderFailureBehavior form the pipeline broadcast API. Before the first release, the current public surface is tracked in PublicAPI.Unshipped.txt; PublicAPI.Shipped.txt is empty apart from its nullable directive. Development names and removal markers are not release compatibility commitments.
Construction and API
The public constructors are:
BroadcastPipe(int readerCount)
BroadcastPipe(int readerCount, BroadcastPipeOptions options)
readerCount must be positive. The public endpoints are:
PipeWriter Writer
IReadOnlyList<PipeReader> Readers
IReadOnlyList<Task<Exception?>> ReaderCompletions
void Reset()
The endpoint lists are immutable. Writer and reader instances are stable across
generations. Reset is permitted only after the writer and every reader have
completed. The caller must retrieve ReaderCompletions again after reset; a
cached old list continues to describe its original generation.
Broadcast and ownership
Every reader independently observes the complete flushed byte sequence. The writer and each individual reader retain the standard single-owner Pipe rules; distinct readers may operate concurrently.
Payload is stored once in a shared pooled segment chain. Each reader has its own consumed and examined cursors. One shared lock, adapted from Microsoft Pipe, protects brief state transitions. No additional locks are introduced and data processing occurs outside the critical sections.
Backpressure and reclamation
The active reader with the greatest unexamined byte count controls pause and
resume. The writer pauses when any reader reaches PauseWriterThreshold and
resumes only after all readers fall below ResumeWriterThreshold. A segment is
returned to the pool only after every active reader has consumed it.
A completed reader immediately leaves the active set and can unblock the writer
or permit reclamation. FlushResult.IsCompleted becomes true only after the
last reader completes. CancelPendingRead is per reader;
CancelPendingFlush is global.
Completion and failures
ReaderCompletions[index] completes successfully with null for normal reader
completion or with the exception supplied to Complete(exception). These tasks
never fault.
BroadcastPipeReaderFailureBehavior.Continue is the default. A faulted reader leaves
the active set, the writer and healthy readers continue, and its exception is
only historical completion data.
With CompletePipe, the first concurrent reader fault becomes the pipe-wide
terminal exception. The writer faults and rejects further writes. Healthy
readers may drain already-flushed data and then observe the terminal exception;
BroadcastPipe neither discards their buffers nor completes them on their behalf. Every
reader completion task still retains its own exception.
Options
BroadcastPipeOptions follows PipeOptions defaults:
- shared memory pool;
- thread-pool reader and writer schedulers;
- 64 KiB pause threshold;
- 32 KiB resume threshold;
- 4 KiB minimum segment size;
- synchronization-context capture enabled;
- reader-failure behavior
Continue.
Mutual QUIC connections
MutualQuicConnectionListener accepts authenticated connections, and
MutualQuicConnection opens, accepts, and dispatches every application stream
on one System.Net.Quic.QuicConnection. Both endpoints may initiate streams.
The client performs a short version handshake after TLS and ALPN negotiation;
the connection then owns one inbound accept pump so named streams, service
negotiation, and positional requests cannot compete for native accepts.
MutualQuicConnectionOptions requires three existing local files: an X.509
certificate PEM, its matching unencrypted private-key PEM, and the peer
certificate to trust in PEM or DER form. Both endpoints present certificates,
pin the SHA-256 hash of the complete peer certificate, and check its validity
period. TLS proves possession of the associated private key. On Windows, the
PEM key is re-imported through a temporary persisted PKCS#12 key container
because Schannel/MsQuic cannot authenticate with an ephemeral private key.
Named streams
OpenStreamAsync dynamically reserves a nonempty application name of at most
255 UTF-8 bytes and opens one native bidirectional QUIC stream. Its uncompressed
opening preface carries the protocol version, stream kind, selected compression,
and name. The receiver validates the preface, reserves the name, acknowledges
the selection, and publishes the resulting NamedQuicStream through
AcceptStreamAsync. QUIC's native NamedQuicStream.Id distinguishes the
physical stream, so no second application identifier follows the preface.
Only one live pair owns a name. An active duplicate is rejected at stream scope. If both endpoints reserve an unused name concurrently, the client-initiated stream wins and the server attempt fails. Disposing the winning pair releases the name for reuse. Pending accepted streams and concurrent inbound streams are bounded independently by connection options.
NamedQuicStream is a non-seekable Stream and IDuplexPipe. Reads serialize
with reads; writes, flushes, compression finalization, and write completion
serialize with writes. One read and one write remain concurrent. Callers choose
one access surface per direction because direct stream operations and pipe
operations consume the same sequence.
The opener selects None, BrotliFastest, or BrotliOptimal; the receiver's
AllowedCompressions policy accepts or rejects the exact selection. Named-stream
compression is transparent and applies to every payload byte after the preface,
using separate read and write contexts. Flush flushes the compressor and
CompleteWrites finalizes it before half-closing QUIC. Compression contexts are
never shared across native streams.
Random-access services
RegisterRandomAccess associates a dynamic service name with a caller-owned,
thread-safe ITeeRandomAccessStream; unregistering or disposing the connection
does not dispose the backing capability. OpenRandomAccessAsync negotiates the
name, positional capabilities, compression, threshold, maximum request size,
and a short connection-local handle, returning a QuicRandomAccessChannel.
Each ReadAt or WriteAt uses a new bidirectional QUIC stream containing the
handle, operation, offset, and bounded lengths. There is deliberately no pool
of reusable request streams: independent streams retain independent ordering,
flow control, cancellation, and failure. A request or actual response payload
whose uncompressed length is at least CompressionThreshold uses the channel's
negotiated compression; smaller payloads remain uncompressed. The default
threshold is 16 KiB and the default maximum operation size is 1 MiB.
Multipath streams
MultipathSenderStream and MultipathReceiverStream form one directional
logical byte stream over dynamically supplied reliable ordered Stream paths.
Each path starts with a versioned session and path hello. Length-delimited data
frames carry a membership epoch, logical group sequence, distribution mode,
shard geometry, logical length, and XXH64 payload checksum. Receiver paths are
pumped independently into one bounded reorder window.
RAID 1 sends a group on every active path and publishes the first valid copy.
RAID 0 sends successive groups over successive paths and faults rather than
skip a missing assigned group. Erasure mode sends k systematic Reed-Solomon
shards and r parity shards over distinct paths and reconstructs a group from
any k valid shards. Fewer than k + r active paths automatically selects
RAID 1 while leaving erasure as the desired mode; erasure delivery resumes
automatically when enough paths return.
Adding or removing a path advances the sender's membership epoch, and mode
changes take effect only after the current partial group is published. With no
path, operations wait subject to cancellation and the configured path timeout.
A sender owns the effective mode; a receiver may send advisory health reports,
mode requests, and transport-neutral endpoint advertisements through a separate
optional MultipathControlChannel. The application authenticates endpoint
hints and creates each transport. Raw UDP reliability is not part of the
initial contract.
The protocol rationale, security boundary, and implementation status are detailed in the multipath stream design.
Verification
- Unit tests cover API validation, consistency, exception aggregation, cancellation, ownership, buffered I/O and seeking, broadcast delivery, cursor independence, backpressure, completion, failure modes, reset, QUIC mutual authentication, duplex I/O, same-direction serialization, multiplexed random access, multipath duplication, RAID-0 ordering, erasure recovery, fallback transitions, path churn, and control messages.
- Stress tests randomize independent reader progress and cancellation.
- An AOT smoke application is published in CI.
- Package contents and runtime dependency metadata are tested.
- BenchmarkDotNet experiments cover 4 KiB, 64 KiB, and 1 MiB payloads; curated results and conclusions remain in the repository under the repository-wide sampling and retention policy.
ErasureStream
ErasureStream maps one fixed-length byte sequence onto k data streams and
m parity streams using systematic Reed-Solomon coding. Members contain only
encoded payload: no persistent header, journal, identity, or membership record.
Write and reopen
using TeeForge.ErasureCoding;
var options = new ErasureStreamOptions(leaveOpen: true);
await using (ErasureStream encoded = ErasureStream.Create(
members, dataShardCount: 4, parityShardCount: 2,
logicalLength: source.Length, blockSize: 128 * 1024, options))
{
await source.CopyToAsync(encoded);
await encoded.CompleteAsync();
}
await using ErasureStream decoded = ErasureStream.Open(
members, 4, 2, source.Length, 128 * 1024, options);
await decoded.CopyToAsync(destination);
Keep member order, logical length, block size, and data/parity counts externally.
For forward-only members, supply fresh readers positioned at payload byte zero.
Seekable members are addressed from offset zero. Create truncates seekable
writable members; use it only for outputs whose contents may be replaced.
Stream behavior
- Capabilities follow the available member capabilities.
- Ordinary reads/writes use logical
Position. Positional operations preserve it; writes to the same codeword serialize. - One codeword covers
k * BlockSizelogical bytes. Each member receives oneBlockSizepayload, including zero padding in the final codeword. - The logical length is fixed;
SetLengthis unsupported. - Forward-only writers must supply exactly the declared length and call
CompleteAsync. This emits the final partial codeword and flushes members.Flushdoes not finalize a partial codeword; disposal does not replace completion. - The stream owns members unless
LeaveOpenis true.Opennever initializes or truncates member contents. - The cache budget controls retained entries. Active operations may temporarily require additional complete codewords, so bound caller concurrency as well.
RequireAllMembers defaults to true. For degraded reads, set it to false and
supply a null at each missing member position. At least k readable members
are required. Missing members make the stream read-only. Reed-Solomon recovers
known missing members; this layout does not identify silently corrupted members.
Partial writes can succeed on some members and fail on others. There is no transactional recovery or safe automatic retry guarantee. Flush behavior is the behavior supplied by the underlying streams.
The default block size remains 128 KiB. Existing benchmark observations are historical evidence; changing defaults requires equivalent sampled comparisons under the benchmark policy.
Run the forward-only sample to encode, reopen, and recover with two missing members.