Multipath streams
Status: implemented for reliable ordered Stream paths. The version-1 wire
format remains experimental. Acknowledgements, retransmission, and UDP adapters
are not implemented.
TeeForge.Networking.MultipathSenderStream distributes a logical byte sequence
across application-supplied paths. MultipathReceiverStream reorders,
deduplicates, or reconstructs groups and exposes sequential reads. Both derive
from Stream, are non-seekable, and support only their named direction.
Compose two sender/receiver pairs for full duplex.
Paths can be NamedQuicStream instances or other reliable ordered streams.
The application opens and authenticates transports. The core does not open
sockets, resolve endpoints, authenticate peers, or implement Ethernet PRP or
IEC 62439-3. Paths sharing an underlying connection may share a failure domain;
path count alone does not establish independence.
Working example
This .NET 10 example uses two in-process pipes as connected transports. Sending and receiving run concurrently so bounded queues and transport backpressure can make progress. In a network application, these operations run at their respective endpoints.
using System.IO.Pipelines;
using TeeForge.Networking;
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
CancellationToken token = timeout.Token;
var options = new MultipathStreamOptions(
mode: MultipathStreamMode.Raid1,
framePayloadSize: 16 * 1024,
pathAvailabilityTimeout: TimeSpan.FromSeconds(5));
await using var sender = new MultipathSenderStream(options);
await using var receiver = new MultipathReceiverStream(sender.SessionId, options);
for (int index = 0; index < 2; index++)
{
var pipe = new Pipe();
Task<Guid> joinSender = sender.AddPathAsync(pipe.Writer.AsStream(), token).AsTask();
Task<Guid> joinReceiver = receiver.AddPathAsync(pipe.Reader.AsStream(), token).AsTask();
await Task.WhenAll(joinSender, joinReceiver);
// The receiver reads the same path ID that the sender generated.
}
byte[] payload = new byte[1024 * 1024];
Random.Shared.NextBytes(payload);
using var source = new MemoryStream(payload);
using var destination = new MemoryStream();
Task receive = receiver.CopyToAsync(destination, token);
Task send = SendAsync();
await Task.WhenAll(send, receive);
Console.WriteLine(payload.AsSpan().SequenceEqual(destination.ToArray()));
async Task SendAsync()
{
await source.CopyToAsync(sender, token);
await sender.CompleteAsync(token);
}
Run initializers concurrently at the two endpoints if their greetings require bidirectional interaction. An initializer runs before the multipath hello. The sender writes and flushes the hello; the receiver reads and validates it.
Modes and local status
Write-call boundaries are not preserved. A group holds up to FramePayloadSize
bytes in RAID 1 or RAID 0, or ErasureDataShardCount * FramePayloadSize bytes in
erasure mode. Flush publishes a partial group. Erasure shards retain their
configured size and pad unused space; logical length removes padding on receipt.
| Mode | Publication threshold | Receiver behavior | Failure behavior |
|---|---|---|---|
Raid1 |
One path write succeeds; other copies continue asynchronously | First valid copy, published in sequence; duplicates discarded | Remaining copies may preserve a group; one path provides no redundancy |
Raid0 |
The assigned path write succeeds | Reorders successive groups distributed across paths | An assigned-path write failure faults the sender; missing groups cannot be reconstructed |
ErasureCode |
k shard writes succeed; other shards continue asynchronously |
Any k valid shards reconstruct a group |
Fewer than k successful shard writes faults the sender; reconstruction requires receipt of k shards |
Erasure mode requires at least k + r locally active paths, where k and r
are the data and parity counts. Each shard uses a distinct path. With fewer
paths, subsequent groups use RAID 1 while desired mode remains erasure coding.
Adding enough paths restores erasure coding. Existing groups retain their
original metadata; new paths do not replay old groups.
sender.Status captures desired/effective mode, path count, shard counts,
membership epoch, lifecycle state, and protection under one lock. Individual
properties remain available, but separate reads may observe different moments.
Status.Protection |
Local meaning |
|---|---|
Unavailable |
No paths, or the sender is completed, faulted, or disposed |
Unprotected |
One mirrored path, or RAID 0 with any path count |
Mirrored |
At least two paths available for mirrored groups |
ErasureProtected |
Enough paths for all configured data and parity shards |
Lifecycle states are Open, Completing, Completed, Faulted, and
Disposed. Protection describes capacity for further publication, including
pending data during completion. It does not confirm redundant delivery of a
particular group. Underlying write success, path count, and path observations
are not remote delivery acknowledgements.
Operation contract
| Operation | Contract |
|---|---|
Sender WriteAsync / Write |
Buffers partial groups and publishes full groups. Success can leave buffered bytes and redundant path writes running. |
Sender FlushAsync / Flush |
Publishes buffered bytes, then waits for all current path flushes in RAID 0 or at least one successful path flush in other modes. It does not confirm receiver consumption. |
ChangeModeAsync |
Publishes the previous partial group with the previous configuration, changes desired mode, and advances the epoch. The sender is authoritative. |
CompleteAsync |
Publishes pending data, mirrors an EOF marker naming the next sequence, and waits for at least one successful path flush. Concurrent calls serialize; repeated successful calls are harmless. Join paths before completion, which freezes further additions. |
Receiver ReadAsync / Read |
Returns ordered bytes. A nonempty read returns zero only after logical EOF and all preceding groups have been consumed. An empty read returns zero immediately. |
Receiver FlushAsync / Flush |
No-op while undisposed. |
Sender RemovePathAsync |
Removes a path at a publication boundary and sends retirement after queued writes. It has no cancellation parameter and can wait on a stalled path. |
Receiver RemovePathAsync |
Locally stops a pump without asking the sender to retire it. Queued frames remain available; unread or staged bytes on the detached path may be lost. |
| Disposal | Aborts the endpoint. Sender disposal without completion may discard buffered bytes. Receiver disposal wakes pending logical reads and releases queued/group buffers. |
Sender writes, flushes, mode changes, completion, and graceful removal serialize with publication. Receiver reads serialize with each other. Paths can be added while an ordinary data operation waits for connectivity. Synchronous calls block on the asynchronous implementation; prefer async calls for network paths.
Cancellation, outages, and errors
Cancelled or timed-out receiver reads leave frames available for the next read. The receiver availability timeout runs only while no paths exist: joining an idle path ends it, and losing the last path starts it. It is not a deadline for a stalled transport or an incomplete group. Use a cancellation token for an overall deadline. Synchronous calls use no cancellation token.
Sender operations without paths wait subject to cancellation and
PathAvailabilityTimeout. An outage timeout is an IOException with a
TimeoutException inner exception. The default is infinite. An unrecoverable
gap can therefore wait indefinitely unless the application sets a timeout or
cancels. New paths do not replay missing groups.
Cancelling a sender operation can leave a prefix buffered or published. There is no rollback or accepted-byte count. Cancellation during group publication faults the sender and removes interrupted write paths because a transport may contain a partial frame. Start a new session after that fault. Cancellation while merely waiting for an operation gate publishes no data. Do not blindly resend an entire buffer after any interrupted write. Failure after completion enters its publication phase faults the sender because EOF may be in flight.
Malformed frames fail their path; other valid copies or shards can still supply the group. Inconsistent group metadata, conflicting duplicate payloads, invalid completion sequences, and exceeded reorder limits fault the receiver. The current RAID-0 receiver policy is conservative: after observing any RAID-0 data, an unexpected path failure processed before logical EOF faults the session, even after a later mode change. In-band retirement avoids that policy. Transport EOF alone is not logical EOF.
Ownership
Ownership transfers only after AddPathAsync succeeds. After a failed or
cancelled initializer/hello, the caller retains responsibility for a stream
whose framing position may have changed. Do not retry a partial hello without
an application-defined reset. A receiver can bind to its first path's session
or require an expected session ID. Session IDs are not authentication.
After addition, the endpoint exclusively uses the supplied stream's bytes.
With LeaveOpen = false, removal, detected failure, and disposal close owned
paths; receiver pumps also close them at completion or retirement. With
LeaveOpen = true, streams stay open, but cancellation cannot immediately stop
a transport that ignores tokens. Coordinate transport shutdown before reusing
those bytes. Receiver-local detach is not graceful sender-side retirement.
Options and resource bounds
MultipathStreamOptions remains shared for convenient paired construction.
Sender settings do not constrain received metadata. Receiver limits are
explicit and need not match the sender's initial mode.
| Setting | Endpoint | Default and range |
|---|---|---|
Mode |
Sender | Raid1; a defined mode |
FramePayloadSize |
Sender | 16 KiB; 1 byte through 1 MiB per frame/shard |
ErasureDataShardCount / ErasureParityShardCount |
Sender | 4 / 2; data >= 2, parity >= 1, total <= 255 |
PathQueueCapacity |
Sender | 8 positive slots per path, including its active write |
MaximumReorderGroups |
Receiver | 1024; sequence distance must be strictly less than this positive value |
ReceiveQueueCapacity |
Receiver | 64 positive event slots shared by pumps |
MaximumReceiveFramePayloadSize |
Receiver | 1 MiB; 1 byte through 1 MiB; checked before allocating a frame body |
MaximumReceiveShardCount |
Receiver | 255; 1 through 255 total shards |
MaximumReorderBytes |
Receiver | 64 MiB; positive reservation for retained groups |
PathAvailabilityTimeout |
Both | Infinite when omitted; otherwise positive or Timeout.InfiniteTimeSpan |
LeaveOpen |
Both | false |
The bounded receiver queue waits when full and never drops frames or failure events to make room. Each independent pump can stage one additional frame. Application reads resume pumps and propagate backpressure through transports.
The reorder byte budget reserves one payload per RAID-1/RAID-0 group. An erasure
group reserves (k + r) * shardSize + logicalLength, including missing-shard
reconstruction and decoded output, before retaining the group. Consumption
releases the reservation. Exceeding the budget faults the receiver instead of
waiting for space that an earlier missing group might itself need.
This is not a process-wide heap limit. Queue payloads occupy at most
ReceiveQueueCapacity * MaximumReceiveFramePayloadSize. Each attached path
additionally needs staging/parsing storage, temporarily up to two payload-sized
arrays plus a header. Group metadata, codec matrices, and transport buffers are
additional. The application controls the number of attached paths.
The sender evicts a path when its send queue has no free slot. It does not silently drop the only RAID-0 copy. A slow redundant path can finish after an operation returns or be evicted as later groups fill its queue.
Optional control channel
MultipathControlChannel frames messages over a separate reliable stream,
readable, writable, or both. One send and one receive can run concurrently;
same-direction calls serialize. Clean EOF returns null. Its leaveOpen
argument controls ownership independently of data options. Finish or cancel
control operations before disposal. Cancellation midway through a control frame
does not reset framing; recreate the channel/transport.
Only these messages are implemented:
| Kind | Checked accessor | Meaning |
|---|---|---|
PathReceivingValidFrames |
GetPathReceivingValidFrames() |
Returns the ID of a path observed receiving valid frames |
ModeChangeRequest |
GetModeChangeRequest() |
Returns typed mode and shard counts for authorization |
EndpointAdvertisement |
GetEndpointAdvertisement() |
Returns typed UTF-8 scheme and opaque data |
Accessors throw InvalidOperationException for the wrong kind. Payload data is
exposed only through these checked accessors. CreatePathReceivingValidFrames
constructs the path observation message, whose wire value is zero.
The channel does not automatically change modes, connect endpoints, or
acknowledge delivery. The application authorizes a request and calls
ChangeModeAsync; no acceptance handshake is required by the data plane.
Non-erasure request shard counts are zero: omit those arguments when calling
ChangeModeAsync to preserve its configured erasure geometry. Authenticate and
authorize endpoint hints before connecting and calling AddPathAsync.
Version-1 wire format
A four-byte big-endian body length excludes its own prefix. Every body begins
with four-byte magic, one-byte version, one-byte kind, and two zero reserved
bytes. Integers and GUIDs use big-endian order. Data magic is 0x54464D50,
control magic is 0x54464D43, and version is 1.
Data body offsets exclude the length prefix:
| Kind | Body bytes | Fields after the 8-byte common header |
|---|---|---|
| Hello (1) | 40 | Session GUID at 8; path GUID at 24 |
| Data (2) | 60 + payload | Session GUID at 8; epoch u64 at 24; sequence u64 at 32; mode, shard index, data count, parity count at 40..43; logical length i32 at 44; payload length i32 at 48; payload XxHash64 u64 at 52; payload at 60 |
| Complete (3) | 32 | Session GUID at 8; exclusive final sequence u64 at 24 |
| Retire (4) | 32 | Session GUID at 8; retirement epoch u64 at 24 |
Modes encode as RAID 1 = 0, RAID 0 = 1, erasure = 2. Sequences start at zero. Membership and mode changes advance the epoch. Old groups remain decodable from their original metadata. XxHash64 covers payload bytes only and provides no authentication. Data frames carry no write-call boundary.
Control kinds are observation = 0, mode request = 1, endpoint advertisement = 2. After the common header, payloads are respectively: a 16-byte path GUID; four bytes containing mode, data count, parity count, and zero; or a one-byte UTF-8 scheme length, scheme bytes, two-byte big-endian data length, and opaque data. Scheme length is at most 255 bytes and endpoint data at most 65,535 bytes.
Deferred work and verification
Acknowledgements, selective missing-group reports, retransmission, automatic remote health tracking, mode acceptance/rejection messages, endpoint withdrawal, and UDP adapters are proposals. Reliable UDP additionally requires replay buffers, timers, flow/congestion control, and an explicit reliability contract. Framing and erasure coding alone cannot provide reliable datagram delivery.
Tests cover the three modes, path churn, reconstruction, fallback/restoration, mode boundaries, initializers, control round trips and checked payloads, timeout/cancellation recovery, bounded queue backpressure, queued failures, receive limits, memory reservation/release, disposal of pending reads, status transitions, concurrent completion, and interrupted publication. Randomized transport faults, partial-frame cancellation, and long-running multipath stress remain necessary before declaring the wire format stable.