Client connections
Long-lived bidirectional links — device gateways and interactive clients — over a transport-neutral kernel; the app writes only the handshake and the codec.
Most realtime needs don't need a connection. A deferred response to one request is request-driven streaming; server→client facts are client events; and producer-owned ordered outputs are streams. A connection is a stateful, bidirectional carrier for the cases those deliberately don't cover — when the conversation itself is stateful or latency-interactive. It does not add replay, ordering, or delivery guarantees to the contract it carries:
- Interactive rate — client→server messages are frequent and per-message latency is part of the UX (collaboration ops, live input, telemetry ingest where batching's flush window is itself too slow).
- The connection is the state — device links where connect/disconnect are business events, or the link fronts a stateful device protocol (the IoT gateway shape).
- Server→client RPC — the server must address one specific connected client and await a result.
If a list-carrying batch command flushed every N items or M milliseconds would do, use that instead: one authorization, one validation pass, one transaction per batch, visible in the schema.
In this section
WebSocket endpoint
The ASP.NET Core adapter — authenticator, codec, per-connection settings.
TCP endpoints
Raw-socket listeners and dialers — framing, TLS, backpressure, deterministic close.
Device gateways
Keepalives, codec conversations, and the full device-gateway loop with actors.
Simulation & testing
Socket-less simulated connections, in-memory TCP links, and the WebSocket test host.
Low-allocation dispatch
The opt-in profile (ADR-0066) that takes a dispatched message to 0 B/op.
The kernel
Elarion.Connections (contracts in Elarion.Abstractions.Connections) is transport-neutral — the
same seams serve a WebSocket adapter today and a raw TCP or datagram adapter tomorrow, and nothing in
them assumes a socket exists at all:
| Piece | Role |
|---|---|
ClientConnection | Stable connection facts (ConnectionId, Transport, ConnectedAt) plus the current immutable, revisioned identity snapshot: principal, PrincipalId (user id for browsers, device id for device links — many connections share one principal), bounded opaque Metadata. Capture the snapshot once at an operation boundary. |
IClientConnectionRegistry | Node-local index and lifecycle broker: adapters register/unregister; consumers look up by id or principal; PromoteAsync performs the one-way anonymous → authenticated identity promotion. Observers run after the index mutation and are failure-isolated. |
IClientConnectionObserver | Connect/disconnect/identity-promotion lifecycle seam — presence projections, device registration, twin wiring hang here. |
IClientConnectionSink | The per-connection outbound port: fire-and-forget SendAsync(name, payload) and request/reply InvokeAsync (completes with the reply, a timeout, or ClientConnectionClosedException — never silently). |
IClientConnectionProtocol | The app-owned codec: complete inbound messages arrive sequentially in receive order; the sink's outbound legs delegate here so wire encoding stays the codec's decision. Legs a codec doesn't speak keep fail-loud defaults. |
ClientConnectionEventBridge | Makes any adapter a peer delivery leg of the SSE endpoint: same topic catalog, same fail-closed subscribe-time authorization, same elarion.connected greeting, same cross-node fan-out. |
Register the kernel once:
builder.Services.AddElarionConnections(); // registry + client-events bridgeIdentity promotion (anonymous → authenticated)
A connection may register anonymously — the shape of device protocols whose authentication is a framed exchange after connect rather than a connect-time credential — and later perform exactly one atomic, one-way promotion to an authenticated identity:
var status = await registry.PromoteAsync(connection.Connection.ConnectionId,
new ClientConnectionIdentity {
Principal = principal,
PrincipalId = deviceId,
Metadata = new Dictionary<string, string> { ["firmware"] = version },
}, ct);
// Promoted | AlreadyAuthenticated | ConnectionNotFound — demotion and user switching are rejected.The rules are deliberate and security-shaped:
- Stable facts never change (
ConnectionId,Transport,ConnectedAt); the principal, principal id, metadata, and revision are replaced together as one immutable snapshot. Inputs are cloned and metadata is defensively copied and bounded — mutating what you passed in cannot alter stored identity. - Only anonymous/no-id → authenticated/non-empty-id is accepted, exactly once — concurrent
promoters get one winner; demotion and principal switching are rejected as
AlreadyAuthenticated. - An in-flight dispatch keeps the snapshot it captured at its boundary; the next dispatch observes the promoted snapshot. A failed validation or a lost race leaves the prior snapshot and revision intact; a promotion observer failure is isolated and never rolls the identity back.
- Client-event subscriptions are disposed on promotion — the peer must resubscribe so subscribe-time
authorization is reevaluated under the new identity. Observers implementing
OnIdentityPromotedAsync(previous, current, …)see both snapshots; disconnect observers and the codec'sOnClosedAsyncalways receive the final (latest) snapshot.
Talking back to one client
Look a connection up and use its sink — from a handler, an observer, or an actor turn:
// All channels of one device (or all tabs of one user):
foreach (var sink in registry.GetForPrincipal(deviceId)) {
await ((WebSocketClientConnection)sink).SendTextAsync(frame, ct); // raw leg, codec-owned format
}
// Request/reply into a client whose codec implements InvokeAsync:
var status = await sink.InvokeAsync<StatusQuery, StatusReply>(
"status.get", new StatusQuery(), new ClientInvokeOptions { Timeout = TimeSpan.FromSeconds(5) }, ct);InvokeAsync is deliberately the simple tier — one request, one reply, bounded by a timeout.
Multi-message protocol conversations (sequence numbers, re-synchronisation, wait-for-any flows) are
codec/actor state; the codec seam is their mounting point, not their replacement — see
device gateways.
Every invoke is bounded by default: a call that carries no ClientInvokeOptions.Timeout gets the
kernel's DefaultInvokeTimeout (30 s out of the box; AddElarionConnections(o => o.DefaultInvokeTimeout = …)), resolved by the sink before the codec sees the options — a client that
never answers surfaces as a TimeoutException, never a silently hung await. The layering is per-call
Timeout > DefaultInvokeTimeout > unbounded: a per-call value always wins (pass
Timeout.InfiniteTimeSpan to make a single call unbounded), and only a default explicitly configured
to null applies none.
Client events over a connection
A connection can serve the same topics the SSE endpoint serves — same catalog, same fail-closed authorization, so a second transport can never fork the rules. The adapter parses its subscribe frame and hands the requests to the bridge; delivery framing is the adapter's choice:
var result = await bridge.SubscribeAsync(connection.Connection, requests,
(envelope, ct) => connection.SendTextAsync($"event:{envelope.Topic}:{envelope.Payload}", ct), ct);Delivery starts with the elarion.connected greeting (the same re-query contract an SSE stream opens
with), and the subscription dies automatically when its connection unregisters. Statuses mirror the
SSE endpoint's HTTP mapping (unauthenticated / invalid / not-found — unknown and denied topics stay
indistinguishable).
Rules of the road
- Facts still travel as client events, even over a connection — the sink is for conversation-shaped traffic (replies, control frames, RPC), not a side channel for state fan-out.
- Guarantees are unchanged: events are at-most-once hints healed by re-query; sends are
at-most-once;
InvokeAsyncfaults explicitly. Commands a device may retransmit belong behind[Idempotent]. - One device, many connections: give every channel the same
PrincipalIdand let a digital-twin actor keyed by device id serialize the shared state — per-connection ordering comes from the receive loop, cross-channel consistency from the actor. - The registry is node-local by design. Multi-node deployments co-locate device ingress with the single-homed twin (the role-holder proxy's prefix list); a replicated connection directory is the point where you adopt a clustered runtime instead.
- Telemetry: register the
Elarion.Connectionsmeter —connection.active,connection.opened/connection.closed(taggedelarion.connection.transport),connection.identity_promotions(taggedelarion.connection.promotion.outcome), andconnection.event_subscriptions.active— and, for TCP endpoints,Elarion.Connections.Tcp:elarion.tcp.tls.handshake.duration(seconds, taggedelarion.tcp.tls.outcome),elarion.tcp.connection.failures(boundedelarion.tcp.failure.stage),elarion.tcp.connection.closed(elarion.tcp.close.mode=graceful/forced),elarion.tcp.outbound.pending/elarion.tcp.outbound.saturated, andelarion.tcp.idle. Tags are fixed vocabularies only — never connection/principal ids, payloads, endpoints, operation names, or certificate data.
Coordination & role leases
Elect one instance for a coarse application role on PostgreSQL, gate work at the holder, and route HTTP traffic to it when needed.
WebSocket endpoint
The ASP.NET Core connection adapter — subclass one handler for the authenticator and codec; accept, framing, lifecycle, and teardown are framework.