TCP endpoints
Raw-socket listeners and dialers over the same handler/codec seams — framing, runtime endpoint management, TLS, bounded outbound backpressure, deterministic close.
Elarion.Connections.Tcp runs the same handler/codec seams over raw sockets (BCL only, no ASP.NET) —
the shape for devices that speak a proprietary protocol and can't do WebSockets. Because TCP has no
message boundaries, the adapter owns a framing seam — and framing is boundaries only: pick
LengthPrefixedTcpFramer, DelimitedTcpFramer (line/telegram framing — an optional start delimiter
skips serial-bridge line noise), or implement TcpMessageFramer for the vendor's format. Bytes are
bytes on TCP, so every inbound message reaches the codec's OnBinaryAsync as a raw slice; a text
protocol's codec decodes with one Encoding.UTF8.GetString — the string is paid for only where it is
wanted. The pipeline is benchmarked at raw-socket parity in both directions — receive (framer slice
→ codec) and send (SendBinaryAsync frames into a pooled, budget-capped per-connection buffer that is
trimmed after oversized frames, so a proxy that forwards between links stays allocation-light and never
retains its largest frame) — and an armed-but-quiet idle window costs nothing on the hot path. Sockets
default to NoDelay (Nagle stalls serial request/reply telegrams); override per endpoint or per
connection for bulk one-way streams.
Custom framers
When you implement TcpMessageFramer for a vendor format, the contract is payload only on both sides —
the framer owns the header/prefix/delimiter, the codec never sees or supplies it:
TryReadMessagereportsconsumed= the whole wire span it advances past (reserved prologue + payload- any trailer), but hands back
message= the payload slice alone. Don't subtract the header length frommessage— it already excludes the header;consumedis what accounts for it. A transforming framer (a negotiated cipher) returns the decoded payload from its own buffer instead of a slice.
- any trailer), but hands back
- Outbound there is one emit path:
BeginMessageemits/reserves the frame's prologue and returns its length; the payload is then serialized in place;CompleteMessagebackfills the prologue (length, checksum, auth tag), may transform the payload in place (same length), and appends any epilogue. The base class providesWriteMessage(payload, output)as a convenience over the same pair — framing and any cipher logic are written exactly once.
// A 2-byte reserved prologue (e.g. a type tag) before the body:
public sealed class VendorFramer : TcpMessageFramer {
private const int Prologue = 2;
public override bool TryReadMessage(ReadOnlyMemory<byte> buffer, out int consumed,
out ReadOnlyMemory<byte> message) {
consumed = 0; message = default;
if (buffer.Length < Prologue + BodyLength) return false; // need more bytes
consumed = Prologue + BodyLength; // advance past prologue AND body
message = buffer.Slice(Prologue, BodyLength); // payload only — prologue excluded
return true;
}
public override int BeginMessage(IBufferWriter<byte> output) {
ReserveProlog(output); // emit or zero-reserve the prologue...
return Prologue; // ...and report its length for the backfill
}
public override void CompleteMessage(Span<byte> prologue, Span<byte> payload, IBufferWriter<byte> output) {
FillProlog(prologue, payload); // the payload is serialized — backfill length/type/checksum
}
}LengthPrefixedTcpFramer is the shipped reference for exactly this shape (4-byte length prefix as the
prologue, backfilled in CompleteMessage). The codec's OnBinaryAsync then receives that payload
slice — never the framing bytes.
Establishment order is fixed: raw TCP → optional TLS → framer → framed application authentication →
registration → OnOpenedAsync → messages. Frame limits count total unconsumed wire bytes —
prefix, variable header, body, and trailer included — so MaxInboundFrameBytes bounds what a peer can
make the endpoint buffer before a single message completes, and MaxOutboundFrameBytes bounds what a
codec can frame outward; both are validated before allocation and apply to the handshake too. Shared
endpoint framers must be stateless and thread-safe; a framer carrying negotiated state is created per
connection by the session and returned via Settings.
Sessions
The handler you register is a factory: CreateSessionAsync(peer, ct) runs before any byte is
exchanged, does the binding-configuration lookup (or rejects the link by returning null — the socket
closes, nothing is registered), and creates one TcpConnectionSession for the connection. The session
owns everything per-connection: its Settings override the endpoint options (and its framer governs the
handshake itself), AuthenticateAsync runs the framed handshake, and CreateProtocol builds the codec.
Per-connection state — the binding row, a stateful framer, key material derived during authentication —
lives in typed session fields and flows into the codec's constructor; the handler stays a stateless
singleton serving every connection concurrently.
Negotiated framing state (encryption toggles)
A session's framer may carry negotiated state — the classic case is a protocol whose handshake
derives a session key and then switches the link into encrypted framing. The session creates the framer,
returns it through Settings, and hands it (typed) to the codec it creates; the codec flips the state at
the exact protocol point the wire format defines:
sealed class CipherSession : TcpConnectionSession {
private readonly CipherFramer _framer = new();
public override TcpConnectionSettings? Settings => new() { Framer = _framer };
public override ValueTask<ClientConnectionTicket?> AuthenticateAsync(
TcpHandshakeContext handshake, CancellationToken ct) { /* challenge/response */ }
public override IClientConnectionProtocol CreateProtocol(TcpClientConnection connection) =>
new CipherProtocol(connection, _framer);
}
// In the codec:
public async ValueTask OnBinaryAsync(ReadOnlyMemory<byte> message, CancellationToken ct) {
if (TryCompleteKeyExchange(message, out var sessionKey, out var reply)) {
// A completed send means the frame was physically written, so awaiting the plaintext
// mode-switch reply puts the flip exactly between it and the next outbound frame.
await connection.SendBinaryAsync(reply, ct);
framer.EnableEncryption(sessionKey);
return;
}
// ... normal dispatch
}The adapter does not synchronize framer state: mutate inbound-affecting state only on the codec's inbound
path (it runs on the receive loop, so no read is in flight) and outbound-affecting state only after the
mode-switch send completed with no concurrent sends admitted. Two contract points make a cipher framer
implementable: TryReadMessage may return framer-owned memory (the decrypted payload from the
framer's own buffer — a slice of the receive buffer is impossible once bytes are transformed), and
CompleteMessage receives the payload as a writable span for same-length in-place transforms —
encrypt the serialized payload where it lies and backfill the tag into the reserved prologue. Because
WriteMessage is a convenience over the same pair, the cipher is written once and applies on every send
route. In simulated links, give the client side its own framer instance (InMemoryTcpLink.Start(…, clientFramer: new CipherFramer())) — the two ends of a real link never share framing state.
Listeners and dialers
// Devices dial in:
builder.Services.AddElarionTcpConnectionListener<GatewaySocketHandler2>(o => {
o.ListenEndPoint = new IPEndPoint(IPAddress.Any, 7010);
o.Framer = new DelimitedTcpFramer(end: (byte)'>', start: (byte)'<');
o.IdleTimeout = TimeSpan.FromSeconds(60);
});
// Or the gateway dials the device and keeps the link alive (jittered exponential reconnect):
builder.Services.AddElarionTcpConnectionDialer<DeviceLinkHandler>(o => {
o.Host = "10.0.40.17";
o.Port = 2101;
o.Framer = new LengthPrefixedTcpFramer();
});Each Add… call is one endpoint — listen on several ports (one per device channel type) or dial
several devices, each with its own handler, framer, and options. The handler subclasses
TcpConnectionHandler and creates one session per link; device protocols with no credential exchange
ticket straight from what the handler's binding-configuration lookup handed the session (which port,
which peer).
Runtime endpoint management
Bindings that live as data (an admin configures which device is dialed or listened for, on which
port, with which framing) use the runtime manager instead of composition-time registrations: add
AddElarionTcpConnectionEndpoints() and apply named endpoints whenever configuration changes —
Apply… is an upsert that tears the old endpoint down first, so changing a binding means a
reconnect under the new settings, including flipping its direction:
var endpoints = provider.GetRequiredService<TcpConnectionEndpoints>();
// Startup: apply every binding row; on admin change: re-apply just that row.
await endpoints.ApplyDialerAsync<DeviceLinkHandler>("device-7:mgmt", o => {
o.Host = row.Host; o.Port = row.Port;
o.Framer = row.Protocol == "telegram" ? telegramFramer : new LengthPrefixedTcpFramer();
}, ct);
// The same binding later becomes server-based (the device now dials us):
await endpoints.ApplyListenerAsync<GatewayListenHandler>("device-7:mgmt", o => {
o.ListenEndPoint = new IPEndPoint(IPAddress.Any, row.Port);
o.Framer = telegramFramer;
}, ct);
await endpoints.RemoveAsync("device-7:mgmt", ct); // unbind: connections unregisterEvery managed endpoint advertises its binding health: endpoints.Statuses / GetStatus(name)
answer "which bindings are serving, which failed to bind, and why" — a listener whose port couldn't be
bound is Faulted with the reason; a dialer between attempts is Dialing carrying the last failure —
and endpoints.StatusChanged fires on every transition, ready to be projected onto a client event so
an admin UI shows binding state live.
Settings can also vary per connection on one endpoint: the session's Settings returns
TcpConnectionSettings (framer, size caps, send capacity, TLS policy, idle window, transport tag —
nulls inherit the endpoint options). CreateSessionAsync runs before any byte is exchanged, so a
binding-configuration lookup keyed on the peer can pick the wire framing — or the TLS certificate — that
governs the handshake itself — the shape gateways need when differently-speaking device families share
one ingress port.
TLS
Set Tls on any TCP endpoint (or per connection) and the adapter authenticates the stream before
the framer, the application authenticator, the registry, or any observer sees a byte — a TLS failure
rejects the socket with nothing registered, and a TLS-configured dialer never falls back to plaintext.
Policy stays explicit BCL configuration (SslServerAuthenticationOptions /
SslClientAuthenticationOptions, minted fresh per connection); platform certificate validation is
fail-closed and Elarion never bypasses it — a development bypass is test configuration, not a
production convenience. The recommended server setup:
builder.Services.AddElarionTcpConnectionListener<GatewayListenHandler>(o => {
o.ListenEndPoint = new IPEndPoint(IPAddress.Any, 7010);
o.Framer = new LengthPrefixedTcpFramer();
o.Tls = new TcpServerTlsOptions {
CreateAuthenticationOptionsAsync = (peer, ct) => ValueTask.FromResult(
new SslServerAuthenticationOptions { ServerCertificate = gatewayCertificate }),
};
});
// Dialer side: TargetHost names the certificate identity this binding expects.
builder.Services.AddElarionTcpConnectionDialer<DeviceLinkHandler>(o => {
o.Host = "10.0.40.17"; o.Port = 2101;
o.Framer = new LengthPrefixedTcpFramer();
o.Tls = new TcpClientTlsOptions {
CreateAuthenticationOptionsAsync = (peer, ct) => ValueTask.FromResult(
new SslClientAuthenticationOptions { TargetHost = "device-7.gateway.internal" }),
};
});TcpTlsOptions.HandshakeTimeout (10 s default) bounds the TLS exchange separately from the framed
application HandshakeTimeout that follows it.
Outbound backpressure and send completion
Each connection's outbound leg is a bounded FIFO pipeline: up to MaxPendingSends (256 default)
sends may be admitted at once — queued plus in-progress — and at capacity a send fails immediately
with TcpSendQueueFullException before any frame memory is allocated. Admitted sends are never
silently dropped; saturation is always that deterministic fault, so a slow device produces visible
backpressure instead of an unbounded waiter population. A send that completes means its complete
frame was physically written to the stream — never merely queued. Under contention the drainer
coalesces queued frames into one physical write (up to a 64 KiB flush threshold): FIFO order is
unchanged, syscalls amortize, and over TLS a batch costs one record instead of one per frame — the
uncontended send stays inline and allocation-free at raw-socket parity. Cancellation before a frame is
picked up withdraws it (nothing is emitted, the slot frees); a frame the drainer has already activated
completes regardless (the batch write is connection-owned — like bytes already on the wire);
cancellation or an I/O failure during an inline physical write aborts the connection, because a
partial frame may have corrupted stream boundaries — at-most-once covers loss, never corruption.
Deterministic close and endpoint shutdown
One internal lifetime controller owns every connection's Open → Closing → Closed transition: peer
EOF, CloseAsync, codec failures, endpoint reconfiguration, and host shutdown all funnel into one
first-reason-wins close that quiesces I/O, settles every admitted send exactly once (drain on a clean
close, fault on failure), runs the codec's OnClosedAsync once with the first reason and the latest
identity, unregisters once, and disposes the transport once. Endpoint stop — hosted shutdown,
RemoveAsync, or a reconfiguring Apply… — stops accepting/redialing, requests graceful close
everywhere, waits the endpoint's ShutdownGracePeriod (5 s default), force-aborts the stragglers' raw
transports, and then awaits every connection task: no connection is ever silently abandoned, and
the registry holds none of the endpoint's connections when stop returns.
WebSocket endpoint
The ASP.NET Core connection adapter — subclass one handler for the authenticator and codec; accept, framing, lifecycle, and teardown are framework.
Device gateways
Keepalives, codec conversation helpers, and the full device-gateway loop — connections as the carrier, actors as the concurrency gate, handlers as the authorization gate.