Channels
Building on the Duplex Pipes infrastructure, NexNet provides a typed channel interface for streaming structured data between server and client. Channels handle serialization and deserialization automatically.
Channel Type
| Interface | Item types | Thread Safety |
|---|---|---|
INexusDuplexChannel<T> |
Any type NexNet can serialize: built-in types, [NexusObject] types and types with a registered formatter |
Writing is thread safe; reading should be single-threaded |
Each item is written as one MessagePack value preceded by its length, and items are sent back to back on the channel's pipe. An item can be larger than a single pipe frame, and larger than the pipe's window: the reader waits until the whole item has arrived, granting the writer extra credit from the session budget when the item needs it. An item may be at most ConfigBase.MaxMessageSize bytes (4 MiB by default). Writing a larger item throws NexusMessageTooLargeException and sends nothing; receiving one resets the channel's pipe without disconnecting the session.
Raw INexusDuplexPipe users who frame their own data get no such extra credit: an application-level message must fit in the pipe's window (ConfigBase.PipeWindow). See Limits and Flow Control.
For bulk numeric data, send primitive arrays (int[], float[], double[], ...) as items. They are encoded as one compact block of raw little-endian values instead of one MessagePack value per element.
Defining a Channel Method
Add a channel parameter to a nexus interface method:
public interface IServerNexus
{
ValueTask StreamData(INexusDuplexChannel<ComplexMessage> channel);
ValueTask StreamIntegers(INexusDuplexChannel<int> channel);
}
Like duplex pipes, channel methods must return ValueTask and cannot use CancellationToken (channels have built-in cancellation/completion notifications).
Creating Channels
Channels are acquired through the client or session context:
// On the client
var channel = client.CreateChannel<MyMessage>();
// On the server (inside a nexus method)
var channel = Context.CreateChannel<MyMessage>();
If a channel instance is created, it should be disposed to release held resources.
Item Types and NEXNET038
The generator creates formatters for every type that appears in a nexus method signature, so INexusDuplexChannel<ComplexMessage> parameters need no extra work as long as ComplexMessage is a [NexusObject] type.
When a type is only used at runtime, through CreateChannel<T>(), GetChannel<T>(), GetChannelReader<T>() or GetChannelWriter<T>(), the generator may not see it. The analyzer reports NEXNET038 at the call when T has no formatter. Fix it by marking the type with [NexusObject] or by declaring it at assembly level:
[assembly: NexusSerializable<List<MyMessage>>]
Built-in types and non-generic [NexusObject] types are always registered.
Reading with IAsyncEnumerable
The preferred method of reading channels is using IAsyncEnumerable on the INexusChannelReader. This provides efficient buffering and simplifies handling of channel closure, whether graceful or not:
// Given an INexusDuplexPipe from a method argument
var writer = await duplexPipe.GetChannelWriter<int>();
await foreach (var msg in await duplexPipe.GetChannelReader<ComplexMessage>())
{
// Process each message
}
A pipe can carry different item types in each direction, as in this example.
Cost of Writes
Every write call takes the writer's lock, serializes, and flushes the pipe once per call. The flush is where the writer waits for pipe credit, so the number of calls, not the number of items, sets the fixed per-call overhead.
| Call | What it does |
|---|---|
WriteAsync(T item) |
Serializes the item into a pooled scratch buffer, appends its length prefix and the item to the pipe, then flushes once. |
WriteAsync(IEnumerable<T> items) |
Serializes all items with one MessagePack writer into one scratch buffer, copies them to the pipe with their prefixes every 64 KiB serialized, then flushes once at the end. |
WriteAndComplete(items, chunkSize) |
Calls WriteAsync(IEnumerable<T>) for every chunkSize items (10 by default), so one flush per chunk, then completes the channel. |
- Framing. Each item is preceded by its length as an unsigned varint (LEB128: 1 byte up to 127 bytes, 2 bytes up to 16 KiB, 3 bytes up to 2 MiB). Prefix and item are appended together, so a flush never sends a prefix without its item.
- Pipe frames. The pipe writer cuts what is buffered into pipe frames of at most 65,535 bytes, so many small items share one frame and a large item spans several.
- Too large. An item larger than the peer's
MaxMessageSizethrowsNexusMessageTooLargeExceptionbefore any byte of it is written. In a batch, the items before it are still sent with the next flush. - Credit. Items that fit in the pipe window use normal pipe credit. An item whose length plus prefix exceeds the window gets an all-or-nothing extra grant of the difference from the receiver's session budget, released once the item is consumed; if the budget is short, the item waits for it in order. See Limits and Flow Control.
- Transport. Every transport (TCP and UDS over
NetworkStream, TLS, QUIC, HttpSocket and WebSocket) sends through the same send pipe: frames written while a transport write is in progress go out together in the next write, copied into one buffer of up to 1 MiB (one socket send, TLS record run, QUIC send or WebSocket message). Small flushes therefore do not mean small packets on the wire under load.
Batching with WriteAsync(IEnumerable<T>) mainly saves the per-call cost (lock, flush and credit check) and the per-item serializer setup, not wire messages. For many small items, batch them; for large items, the per-call cost is negligible next to serialization and the window, and throughput over a slow link is set by PipeWindow (see Sizing PipeWindow and SessionBudget). A batch is buffered in the pipe writer until its flush, so very large batches hold that much memory on the sending side.
Extension Methods
Several extension methods simplify reading and writing entire collections:
WriteAndComplete
Writes a collection to a channel and signals completion:
// Write to INexusDuplexChannel<T> or INexusChannelWriter<T>
await channel.WriteAndComplete(items, chunkSize: 100);
The optional chunkSize parameter controls how many items are written per batch.
ReadUntilComplete
Reads all items from a channel until the sender signals completion:
// Read from INexusDuplexChannel<T> or INexusChannelReader<T>
var items = await channel.ReadUntilComplete(estimatedSize: 1000);
The optional estimatedSize parameter pre-allocates collection capacity to reduce resizing during reads.
Relaying
ForwardToAsync relays the items one channel receives to another channel without deserializing or reserializing them: it copies the raw bytes of the channel's pipe, parsing only each item's length prefix.
// Inside a server nexus method: pass a client's items on to another client's channel.
public async ValueTask StreamData(INexusDuplexChannel<ComplexMessage> channel)
{
var target = await OpenChannelToOtherClient(); // Your own pairing logic.
await channel.ForwardToAsync(target);
}
- Items stream through as they arrive, so an item larger than the pipe window is not buffered whole on the relay, and flow control works as for pipes: a slow destination stalls the source.
- When the source completes, the destination's output is completed (half-close) and the forward returns. Cancellation throws
OperationCanceledExceptionand completes neither channel. - The destination session may accept smaller items than the source (
MaxMessageSizeis negotiated per session). An item larger than the destination accepts throwsNexusMessageTooLargeExceptionbefore any byte of it is sent: the items before it were delivered, neither channel is completed, and the source's next read returns the oversized item, for you to drop or handle. - Do not write to the destination's channel writer while a forward runs; writing after it ended is fine.
For relaying raw pipes in both directions, see BridgeAsync in Duplex Pipes.
See Also
- Duplex Pipes — Low-level byte streaming
- Serialization — Attributes, built-in types and limits
- Hub Invocations — Method compatibility table for channel arguments