Document list | Previous: .NET System Structure
.NET Stream Connector Public Contract¶
This document is the
.NETprojection of the Stream Connector Common Spec. The target execution environment, transport, wire contract, packet model, connection lifecycle, error meaning, and default value are owned by the common spec. This document only fixes the exact public surface that meaning has in.NET.Usage is owned by the .NET Stream Connector guide.
1. Package And Boundary¶
The public package is Zlink.Stream.Connector. It doesn't
depend on the ASP.NET Core host, Spot, actor, or location runtime.
The exact member list and deployment archive is owned by the fixed snapshot.
This document doesn't repeat listing the
snapshot's member — it fixes the
surface structure and .NET-specific meaning. The verification
procedure is owned by this document §13.
The target it's responsible for is a native build (desktop/server,
Unity, Godot C#). Unity's native build uses the same
Zlink.Stream.Connector NuGet package with no separate
package. It isn't responsible for a web (browser/WASM) build
(Common Spec §2).
2. Entrypoint¶
public static class ZlinkStreamConnectorFactory
{
public static IZlinkStreamConnector Create(ZlinkStreamConnectorOptions options);
}
The implementation type is hidden. The factory returns the public interface.
3. IZlinkStreamConnector¶
public interface IZlinkStreamConnector : IAsyncDisposable
{
bool IsConnected { get; }
ZlinkStreamConnectionState State { get; }
ZlinkStreamCloseReason? CloseReason { get; } // the last close reason; null if never disconnected (§10)
ZlinkStreamConnectorOptions Options { get; }
int PendingDispatchCount { get; }
int ReceivedCount(string name); // the received count per packet name (§8)
IZlinkStreamLifecycleCall Connect { get; }
IZlinkStreamLifecycleCall Close { get; }
IZlinkStreamLifecycleCall Dispatch { get; }
IZlinkStreamSendCall Send(ZlinkStreamEncodedPayload payload);
IZlinkStreamRequestCall Request(ZlinkStreamEncodedPayload payload);
IZlinkStreamWaitCall WaitFor(string name);
IZlinkStreamExpectNoneCall ExpectNone(string name);
IZlinkStreamSequenceCall WaitForSequence(string name);
IDisposable On(string name, Func<ZlinkStreamMessage<ZlinkStreamEncodedPayload>, CancellationToken, ValueTask> handler);
IDisposable OnRequestSending(Action<ZlinkStreamRequestSendingContext> handler);
IDisposable OnReplyReceived(Func<ZlinkStreamReplyReceivedContext, CancellationToken, ValueTask> handler);
IDisposable OnConnectionStateChanged(Func<ZlinkStreamConnectionStateChanged, CancellationToken, ValueTask> handler);
IDisposable OnDisconnected(Func<ZlinkStreamDisconnected, CancellationToken, ValueTask> handler);
IDisposable OnErrorReceived(Func<ZlinkStreamError, CancellationToken, ValueTask> handler);
IReadOnlyList<IZlinkStreamActor> Actors { get; } // the Actor handles bound right now (§3.1)
IZlinkStreamActor? Actor(string actorId); // the open handle of that id, or null
IDisposable OnActorBound(Func<IZlinkStreamActor, CancellationToken, ValueTask> handler);
IDisposable OnActorUnbound(Func<IZlinkStreamActor, CancellationToken, ValueTask> handler);
}
3.1 IZlinkStreamActor¶
The Actor handle of common spec §5.6.
The connector creates it from $zlink.actor.bound and closes it on
$zlink.actor.unbound; the application never creates one.
public interface IZlinkStreamActor
{
string ActorId { get; }
bool IsBound { get; } // false after the unbound announcement
IZlinkStreamSendCall Send(ZlinkStreamEncodedPayload payload); // carries this Actor's slot
IZlinkStreamRequestCall Request(ZlinkStreamEncodedPayload payload);
IDisposable On(string name, Func<ZlinkStreamMessage<ZlinkStreamEncodedPayload>, CancellationToken, ValueTask> handler); // only messages whose counterpart is this Actor
}
- The builders of
Send/Requestare the sameIZlinkStreamSendCall/IZlinkStreamRequestCallas at the connector level (§4).Async()/Submit(...)on a handle withIsBound == falseend inValidationFailed. - The connector-level
On(name, …)receives every message regardless of Actor; a handle'sOnreceives only the messages whose counterpart is that Actor. A handler registered on both runs on both. OnActorBound/OnActorUnboundfollow the sameIDisposablerule as the other registration surfaces.-
The typed surface (§5) provides the same extension methods (
Send<TPayload>,Request<TPayload>,On<TPayload>) onIZlinkStreamActor. -
The connection events are registration methods, not C#
events. Aneventreturns nothing at subscription time, so it does not satisfy common spec §7 — a client that manages subscriptions against the lifetime of one screen would have to keep the delegate it registered. - A handler is called in registration order. A handler
failure doesn't end the connector runtime — it's reported as a
UserCallbackFailederror. PendingDispatchCountis a value for diagnosing dispatch pump status. It isn't used for application flow control.ReceivedCount(name)returns a count per packet name. Counting and reset follow Common Spec §10.- Every registration surface returns an
IDisposable(Common Spec §7).On(...),OnConnectionStateChanged,OnDisconnected, andOnErrorReceivedare alike. CallingDispose()twice isn't treated as an error.
.NET carries an error as a ZlinkStreamError, and a throwing surface
puts that value in a ZlinkStreamException
(Common Spec §9.2).
The caller reads the error code through ZlinkStreamException.Error.Code.
public sealed record ZlinkStreamError(
ZlinkStreamErrorCode Code, // the closed set of 13 codes in common spec §9
string Message,
Exception? Exception = null); // the causing exception; null when there is none
public sealed class ZlinkStreamException(ZlinkStreamError error)
: Exception(error.Message, error.Exception)
{
public ZlinkStreamError Error { get; } = error; // where the code is read
}
4. Call Builder¶
Packet name and metadata are owned by the operation builder, not the payload object.
public interface IZlinkStreamLifecycleCall
{
ValueTask Async(CancellationToken cancellationToken = default);
}
public interface IZlinkStreamSendCall
{
IZlinkStreamSendCall PacketName(string name);
IZlinkStreamSendCall Metadata(string key, string value);
IZlinkStreamSendCall Metadata(ZlinkStreamMetadata metadata);
IZlinkStreamSendCall Compress();
ValueTask Async(CancellationToken cancellationToken = default); // only delivers async completion and failure.
}
public interface IZlinkStreamRequestCall
{
IZlinkStreamRequestCall PacketName(string name);
IZlinkStreamRequestCall Metadata(string key, string value);
IZlinkStreamRequestCall Metadata(ZlinkStreamMetadata metadata);
IZlinkStreamRequestCall Compress();
IZlinkStreamRequestCall Timeout(TimeSpan timeout);
ValueTask<ZlinkStreamEncodedPayload> Async(CancellationToken cancellationToken = default);
void Submit(Action<ZlinkStreamResult<ZlinkStreamEncodedPayload>> callback);
void Submit(Action<ZlinkStreamResult> callback);
}
public interface IZlinkStreamWaitCall
{
// Timeout(...), Where(...) decide this wait's bound and predicate.
ValueTask<ZlinkStreamMessage<ZlinkStreamEncodedPayload>> Async(CancellationToken cancellationToken = default);
}
Sendis a one-way transmission that doesn't wait for a reply.Async()'s completion value has no transport result or admission status — it only delivers async completion and failure (§6). UseRequestif a response is needed.- This .NET implementation creates one frame write queue when the connector is created and keeps up to
4,096 operations in it. When it is full, a one-way
Submitwaits for room and aRequestframe submission fails at once withSendFailed. Timeout(...)only applies to that operation.On(...)is a persistent push handler, andWaitFor(...)is a one-time wait. Production push handling usesOn(...), and sample/CLI/E2E waiting usesWaitFor(...).Metadatais copied as an immutable snapshot at send time.
4.1 Request Hooks¶
The two hooks of Common Spec §5.7 are projected as OnRequestSending/OnReplyReceived (§3) with the contexts below. The sending hook does not follow the dispatch mode, so it is a synchronous Action; the reply hook has the same shape as other receive callbacks.
public sealed class ZlinkStreamRequestSendingContext
{
public string RequestPacketName { get; }
public string? ActorId { get; }
public void SetMetadata(string key, string value);
}
public sealed class ZlinkStreamReplyReceivedContext
{
public string RequestPacketName { get; }
public string? ActorId { get; }
public bool Succeeded { get; }
public ZlinkStreamMessage<ZlinkStreamEncodedPayload>? Reply { get; }
public ZlinkStreamError? Error { get; }
public TimeSpan Elapsed { get; }
}
5. Typed Surface¶
The two name forms of Common Spec §5 appear as an overload that takes a packet name and one that doesn't, on the typed On of the connector and Actor handle and on the connector's WaitFor/ExpectNone/WaitForSequence. Typed Send/Request name the packet explicitly through the returned builder's PacketName(string).
ZlinkStreamTypedConnectorExtensions provides Send<TPayload>,
Request<TPayload>, On<TPayload>, WaitFor<TPayload>,
ExpectNone<TPayload>, WaitForSequence<TPayload>, each returning a
typed builder.
IZlinkStreamPacketNameResolver decides packet identity. The means
of attaching a packet name to a type that
Common Spec §5
requires is an attribute. The default resolver prioritizes this
attribute, and uses the type name if it is absent.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
public sealed class ZlinkStreamPacketNameAttribute(string name) : Attribute
{
public string Name { get; } = name; // the packet name attached to the type
}
- A per-operation
PacketName(...)override is allowed. For an already-encoded raw payload and external protocol interop. This is a different role from the server framework's typed registration descriptor, and isn't a basis for re-exposing packet name at the server handler call site. - Even after typed decode, the connector-internal buffer or mutable transport header isn't exposed.
- A raw header object isn't exposed in the public API.
The codec surface is IZlinkStreamPayloadCodec and
IZlinkStreamCompressionCodec. ZlinkStreamJsonCodec is the default
payload codec, and specifying CompressionCodec uses that
implementation instead of the built-in one.
The two injection points of
Common Spec §5.4 are the
following properties of ZlinkStreamConnectorOptions.
public IZlinkStreamPayloadCodec? PayloadCodec { get; init; } // ZlinkStreamJsonCodec when null
public IZlinkStreamPacketNameResolver NameResolver { get; init; }
= new ZlinkStreamPacketNameResolver(); // the default resolver
If a Framework codec extension must also provide the STREAM header
value, it implements the Stream Connector package's
IZlinkStreamCodecRegistration. This descriptor only owns
STREAM-specific information. The common serializer registry doesn't
reference a STREAM enum or compression package.
public interface IZlinkStreamCodecRegistration
{
string ContentType { get; }
ZlinkStreamCodec Codec { get; }
}
6. Lifecycle And Completion Meaning¶
This is a .NET-specific contract. The state transition itself is
owned by Common Spec §6.
Connect.Async(...)completes once connection and receive-loop preparation finish.- Common Spec §7 owns when
Close.Async(...)completes. - A repeated
CloseandDisposeAsync()share the same terminal result or failure. - Waiting for its own callback's close with
DisposeAsync()inside a callback isn't allowed as a circular wait — it's treated as an immediate error. - A lifecycle waiter's
CancellationTokenonly cancels that waiter. It doesn't cancel an already-started shared close work. - Once a frame write has started, caller cancellation doesn't create a partial frame.
7. Dispatch¶
The behavior of the dispatch modes is defined by common §7. The .NET surface follows.
| Item | Contract |
|---|---|
The pump of Manual (default) |
Dispatch.Async(...). Queued callbacks are processed in the execution context of this call |
| Outbound send queue | An order-preserving queue separate from the dispatch queue. The frame write queue item above defines the result when it is full |
- A send accepted earlier is sent before a request started later. A request waits for the response only after its own frame's actual write finishes.
- A send doesn't route around callback execution on a background thread.
8. Receive Message History¶
A message of the name an On(...) handler is registered for is not
kept in the unread history once dispatch takes over a handler snapshot.
A message of a name with no handler stays in the unread history and
WaitFor(...) consumes it one at a time. Control frames such as
response and heartbeat do not pass through this history.
8.1 Test Wait Surface¶
The contract is owned by
Common Spec §10. The .NET
surface is below.
Push observation — connector method (the same spot as §4's
WaitFor). Each returns a typed builder.
IZlinkStreamWaitCall WaitFor(string name); // waits until it arrives
IZlinkStreamExpectNoneCall ExpectNone(string name); // whether it doesn't arrive during .Within(window)
IZlinkStreamSequenceCall WaitForSequence(string name); // .Expect(p).Expect(p)… in order
The typed surface is an extension method on
ZlinkStreamTypedConnectorExtensions. Each surface carries both
an overload that decides the packet name from TPayload and one where
the caller states it
(Common Spec §10.1.1).
With no name given, Options.NameResolver decides the name from
typeof(TPayload).
public static ZlinkStreamTypedWaitBuilder<TPayload> WaitFor<TPayload>(this IZlinkStreamConnector connector);
public static ZlinkStreamTypedWaitBuilder<TPayload> WaitFor<TPayload>(this IZlinkStreamConnector connector, string name);
public static ZlinkStreamTypedExpectNoneBuilder<TPayload> ExpectNone<TPayload>(this IZlinkStreamConnector connector);
public static ZlinkStreamTypedExpectNoneBuilder<TPayload> ExpectNone<TPayload>(this IZlinkStreamConnector connector, string name);
public static ZlinkStreamTypedSequenceBuilder<TPayload> WaitForSequence<TPayload>(this IZlinkStreamConnector connector);
public static ZlinkStreamTypedSequenceBuilder<TPayload> WaitForSequence<TPayload>(this IZlinkStreamConnector connector, string name);
The typed builder for negative observation and order verification fixes the public interface below.
public sealed class ZlinkStreamTypedExpectNoneBuilder<TPayload>
{
// decides the observation window this packet must not arrive within.
public ZlinkStreamTypedExpectNoneBuilder<TPayload> Within(TimeSpan window);
public ValueTask Async(CancellationToken cancellationToken = default);
}
public sealed class ZlinkStreamTypedSequenceBuilder<TPayload>
{
// adds the next typed predicate to apply in arrival order.
public ZlinkStreamTypedSequenceBuilder<TPayload> Expect(
Func<ZlinkStreamMessage<TPayload>, bool> predicate);
public ZlinkStreamTypedSequenceBuilder<TPayload> Timeout(TimeSpan timeout);
public ValueTask<IReadOnlyList<ZlinkStreamMessage<TPayload>>> Async(
CancellationToken cancellationToken = default);
}
ExpectNone(name).Within(TimeSpan).Async(ct)— throws aZlinkStreamExceptioncarryingValidationFailedif it arrives within the window. The symmetric ofWaitFor.WaitForSequence(name).Expect(p1).Expect(p2)…Timeout(t).Async(ct)returnsIReadOnlyList<ZlinkStreamMessage<TPayload>>; sequence observation and failure follow Common Spec §10.1.- The predicate and the return value handle
ZlinkStreamMessage<TPayload>. The argumentWhere(...)andExpect(...)receive is the message, not the payload. -
A status-only surface isn't provided. Since status is a payload field, it's expressed as
WaitFor<T>(name).Where(p => p.Status == …). The connector doesn't know which field is status. -
Domain REST polling (
GET /deliveries/{id}, etc.) isn't this surface. That'sZLinkHttpClient's job.
9. Transport And TLS¶
The scheme → transport mapping is owned by
Common Spec §3.1. .NET expresses
this as the ZlinkStreamTransport enum (Tcp, Tls, WebSocket,
WebSocketSecure).
- The nullable
Transportoption isn't the path that picks the transport. It's an auxiliary value confirming the URI scheme matches the configuration, and fails withConfigurationErrorif mismatched. - TLS and WSS validate the certificate chain and host name by
default.
SkipServerCertificateValidationdefaults tofalseand is used only for a test's self-signed certificate.
10. Close Reason¶
The value set and meaning is owned by
Common Spec §6.2.
.NET expresses this as the ZlinkStreamCloseReason enum.
The read surface is the IZlinkStreamConnector.CloseReason
property (§3). Its type is ZlinkStreamCloseReason?, and it is null
when the connector has never disconnected. The Disconnected event's
argument ZlinkStreamDisconnected.CloseReason is a surface added on top
of that property.
The session-closing frame's wire value is 1-6, and the .NET
enum's internal ordinal is 0-5. Since the codec explicitly converts
between the two, the enum isn't cast to an integer and used as the
wire value.
Whether a receive bound violation is terminal, the close reason, and
the reconnect condition is owned by
Common Spec §9.
.NET expresses that error as ZlinkStreamErrorCode.FrameTooLarge.
11. Flow¶
The connector never creates, sends, exposes, or propagates flow (Common Spec §5.5). A received message additionally exposes only the Actor identity.
public sealed record ZlinkStreamMessage<TPayload>(
string Name,
ZlinkStreamMetadata Metadata,
TPayload Payload,
string? ActorId = null);
12. Options And Validation¶
The default value is owned by
Common Spec §6.1. .NET
expresses this as a property of ZlinkStreamConnectorOptions
(+ ZlinkStreamHeartbeatOptions, ZlinkStreamReconnectOptions).
The unlimited reconnect that
Common Spec §6
requires is expressed as null on a nullable int.
Validation contract:
The validation timing is owned by
Common Spec §6.3.
In .NET, ZlinkStreamConnectorFactory.Create(options) checks every
option, and on a validation failure it builds no IZlinkStreamConnector
instance and delivers the failure to the caller.
| Violation | Failure |
|---|---|
| No endpoint | ZlinkStreamException's ValidationFailed |
Unsupported scheme, URI scheme/Transport mismatch |
ZlinkStreamException's ConfigurationError |
A CompressionCodec given together with compression turned off |
ZlinkStreamException's ConfigurationError |
| An out-of-range individual timeout, heartbeat, or reconnect value | ZlinkStreamException's ValidationFailed |
Every timeout option must be positive, and the
preview length can't be negative. MaxAttempts must be null or
positive.
13. Regression Test¶
| Test Case | Verification Standard |
|---|---|
StreamConnectorTests.ConnectorImplementationIsHiddenBehindPublicInterface |
The implementation type is hidden, and the factory returns the public interface. |
StreamConnectorTests.ConnectorCallInterfacesMatchTheFrozenSurface |
Fixes the exact member of the lifecycle, send, request, and wait call. |
StreamConnectorTests.ConnectorOptionsMatchTheFrozenDefaults |
Fixes the connector option's default value. |
StreamConnectorTests.ManualDispatchRunsHandlerOnDispatchCaller |
The Manual callback runs on the dispatch caller. |
StreamConnectorTests.ImmediateDispatchRunsHandlerWithoutManualDispatch |
The Immediate callback runs with no separate manual dispatch. |
StreamConnectorTests.ManualCallbackAdmissionDoesNotWaitForDispatchPump |
Registering a Manual callback doesn't wait for the pump, and registered callbacks run in registration order when pumped. |
StreamConnectorTests.RequestTimeoutRemovesPendingRequest |
Removes the pending request after timeout. |
StreamConnectorTests.TcpTypedRequestCorrelatesResponse |
Keeps typed request and response correlation. |
StreamConnectorTests.TypedConnectorUsesJsonByDefaultAndDecodeReply |
The typed default codec is JSON. |
StreamConnectorTests.PacketNameAttributeIsUsedByDefault |
Uses the packet name attribute as the default identity. |
StreamConnectorTests.DisconnectEventCarriesTheFrozenCloseReasonContract |
Fixes the disconnect event's closed close reason. |
StreamConnectorTests.SessionClosingPublishesServerDrainReasonAfterDisconnectedState |
Converts a session-closing frame to the ServerDrain reason. |
StreamConnectorTests.SharedCloseFaultIsObservedByRepeatedCloseAndDispose |
A transport close failure is delivered once as a Disconnected error event, and repeated close and dispose don't fail. |
StreamConnectorTests.OneWayAsync_Waits_For_Bounded_Queue_Admission |
The one-way terminal waits asynchronously up to bounded queue acceptance and completes with no result value. |
StreamConnectorTests.RequestQueueWaitsForEarlierAcceptedOneWaySend |
Preserves the wire send order of an earlier-accepted one-way send and a later request. |
StreamConnectorTests.CallerCancellationDoesNotInterruptAnInProgressFrameWrite |
Once a frame write starts, caller cancellation doesn't create a partial frame. |
StreamConnectorTests.ConnectorOutboundFramesNeverCarryFlowAndOnlyRequestsCarryCorrelation |
Connector outbound frames omit the flow flag and only requests carry correlation IDs. |
StreamConnectorTests.RequestHooksRunInOrderAddWireMetadataAndIsolateFailures |
Checks hook registration order, wire metadata, and callback failure isolation. |
StreamConnectorTests.ReplyHookObservesRemoteFailureTimeoutAndClose |
Checks remote-error, timeout, and close outcomes observed by the reply hook. |
StreamConnectorTests.ManualSendingHookRunsOnRequestCallerAndReplyHookWaitsForDispatch |
In Manual mode the sending hook runs on the request caller and puts metadata on the wire without dispatch; the reply hook waits for dispatch. |
StreamConnectorTests.ActorHandlersIsolateMatchingNamesAndRequestHooksReportActorId |
Actor handlers isolate messages under the same name and hooks receive the Actor ID. |
StreamConnectorTests.HeaderProtocolEnforcesControlPacketContract |
Fixes a control packet's codec/flag/payload contract. |
Release verification confirms with scripts/verify_packaged_contract.sh
whether the source assembly, API snapshot, actual NuGet package, and a
clean consumer all have the same public contract.