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 §15.
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; }
ZlinkStreamConnectorOptions Options { get; }
int PendingDispatchCount { get; }
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 ObserveInbound(Func<ZlinkStreamInboundObservation, CancellationToken, ValueTask> observer);
event Func<ZlinkStreamConnectionStateChanged, CancellationToken, ValueTask>? ConnectionStateChanged;
event Func<ZlinkStreamDisconnected, CancellationToken, ValueTask>? Disconnected;
event Func<ZlinkStreamError, CancellationToken, ValueTask>? ErrorReceived;
}
- An event 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.
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.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.
5. Typed Surface¶
ZlinkStreamTypedConnectorExtensions provides Send<TPayload>,
Request<TPayload>, On<TPayload>, WaitFor<TPayload>,
ExpectNone<TPayload>, WaitForSequence<TPayload>, each returning a
typed builder.
IZlinkStreamPacketNameResolver decides packet identity. The
default resolver prioritizes ZlinkStreamPacketNameAttribute, and uses
the type name if the attribute is absent.
- 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.
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.Close.Async(...)outside a callback completes once connection close and terminal callback cleanup finish.Close.Async(...)inside a callback returns immediately after starting close, to avoid a circular wait. Afterward,Close.Async(...)outside a callback, orDisposeAsync(), waits for the shared terminal result.- 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 And Bounded Admission¶
This is a .NET-specific contract.
| Item | Contract |
|---|---|
Manual (default) |
A receive callback/request callback/lifecycle event is processed in the execution context that called Dispatch.Async(...) |
Immediate |
Runs inline on the receive path (no separate dispatch work). A slow handler blocks the receive loop, so backpressure applies as is |
MaxPendingDispatchCallbacks |
Applies only in Manual. This bound includes not just a receive handler, but also the reserved slot preserving the completion callback of an already-accepted request. Immediate bypasses this bounded admission since it doesn't go through the queue |
| Outbound send queue | An order-preserving queue separate from the dispatch bound. Holds at most 4096 sends, and rejects with an immediate error on overflow |
- 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¶
The unread receive history WaitFor(...) uses is bounded by
MaxReceivedMessages. This bound doesn't block processing of a
control frame such as response and heartbeat.
A message of the name an On(...) handler is registered for also
passes through the common receive message queue's admission. Once
dispatch takes over a handler snapshot, it isn't kept in the unread
history. A message of a name with no handler stays in the unread
history and WaitFor(...) consumes it one at a time. So
MaxReceivedMessages bounds both the pre-dispatch wait and the unread
history together. Since the inbound observer is an observation path
separate from this selection, it receives the frame snapshot in both
cases.
If the queue is full, a newly arrived message is rejected and
ReceivedMessageDropped is reported
(Common Spec §10.1).
8.1 Test Wait Surface¶
The contract is owned by
Common Spec §10.2. 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
// typed: ZlinkStreamTypedConnectorExtensions provides WaitFor<T>/ExpectNone<T>/WaitForSequence<T>
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 an error if it arrives within the window. The symmetric ofWaitFor.WaitForSequence(name).Expect(p1).Expect(p2)…Timeout(t).Async(ct)— confirms a push of the same name arrives in predicate order, and returns the payload list. Verifies "arrived in order", not "N arrived."-
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. Inbound Observer¶
Observation meaning and the isolation/overflow rule is owned by
Common Spec §10. The .NET
surface's constraint is below.
ObserveInbound(...)is registered only before connection starts and returnsIDisposable.- Don't call the connector's send/request/wait/dispatch from an observer callback.
- The observer can't drop/transform/reply to a frame.
DisposeAsync()ignores cancellation and waits until a running observer finishes.
10. 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.
11. Close Reason¶
The value set and meaning is owned by
Common Spec §6.3.
.NET expresses this as the ZlinkStreamCloseReason enum and
exposes it as the Disconnected event's argument
ZlinkStreamDisconnected.CloseReason.
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,
and the close reason as ZlinkStreamCloseReason.TransportError.
12. Flow¶
A connector outbound operation generates a UUIDv7 flow_id once,
with no separate public option. A follow-up operation started inside
a callback reuses the current inbound flow, and once the callback
ends, cleans up the ambient flow.
The wire representation is owned by Common Spec §4.2 and flow-correlation.
13. Metric¶
The connector metric follows
Stream Connector Common Contract §6.2's
name and closed label. The .NET connector publishes
zlink.stream.reconnects to the System.Diagnostics.Metrics
provider, and the application and E2E read it with MeterListener.
A metric listener failure doesn't change the send/request result or
connection state.
14. Options And Validation¶
The default value is owned by
Common Spec §6.1. .NET
expresses this as a property of ZlinkStreamConnectorOptions
(+ ZlinkStreamHeartbeatOptions, ZlinkStreamReconnectOptions).
The common contract's MaxInboundObserverPayloadPreviewBytes bounds
the payload preview length in bytes, defaulting to 0. .NET projects
this common option as a property of the same name.
The common contract's diagnostics level (common spec §13) is projected as the following property. Undefined enum values are rejected by validation.
public enum ZlinkStreamDiagnosticsLevel { Off = 0, Errors = 1, Normal = 2, Detailed = 3 }
public ZlinkStreamDiagnosticsLevel DiagnosticsLevel { get; init; } // default Errors
At Off, outbound frames create no flow pair (0x10 not set), and inbound flow fields keep
only the structural length check — value validation and flow scope installs are skipped.
The request correlation is kept regardless of the level.
The live level change from common spec §13 is exposed through the following read/write API on
IZlinkStreamConnector. The connector never needs to be recreated.
public interface IZlinkStreamConnector : IAsyncDisposable
{
ZlinkStreamDiagnosticsLevel DiagnosticsLevel { get; }
void SetDiagnosticsLevel(ZlinkStreamDiagnosticsLevel level);
// ...
}
DiagnosticsLevel reads straight through to Options.DiagnosticsLevel, and it always matches
the level most recently applied by SetDiagnosticsLevel (so does the value Options exposes).
SetDiagnosticsLevel rejects an undefined enum value the same way construction-time option
validation does, with ZlinkStreamErrorCode.ValidationFailed. The value is stored in an atomic
cell (Volatile.Read/Volatile.Write), and every processing point (building an outbound frame,
dispatching an inbound packet, etc.) reads the level exactly once at the start of that
operation and judges the whole operation by that one value — a level change mid-processing never
affects work already under way, only the next processing point that starts after it.
.NET-only option:
| Option | Default | Meaning |
|---|---|---|
MaxPendingDispatchCallbacks |
1024 | The dispatch pending callback bound (§7) |
Validation contract:
| Violation | Failure |
|---|---|
| No endpoint | ArgumentException |
Unsupported scheme, URI scheme/Transport mismatch |
ZlinkStreamException's ConfigurationError before starting connection |
| An invalid timeout/queue size/heartbeat/reconnect combination | ValidationFailed |
Every timeout and queue size option must be positive, and the preview length can't be negative.
15. 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.ManualRequestCallbackAdmission_Is_Bounded_And_Never_Falls_Back_To_A_Background_Thread |
Request callback admission is bounded and doesn't allow a background bypass. |
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 |
Repeated close and dispose observe the same failure. |
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.InboundObserverRegistrationIsRejectedAfterConnectAndStopsAfterDispose |
Fixes the observer registration time and deregistration meaning. |
StreamConnectorTests.Dispose_Waits_For_Cancellation_Ignoring_Inbound_Observer |
Dispose waits for the observer to end, ignoring cancellation. |
StreamConnectorTests.InboundObserverFailureReportsObserverFailedAndMessageStillDispatches |
Reports the observer failure while continuing to process the original message. |
StreamConnectorTests.InboundObserverOverflowReportsObserverDroppedAndRequestStillCompletes |
Observer overflow doesn't block request completion. |
StreamConnectorTests.OutboundFrameCreatesFlowOnceAndCodecRemainsDeterministic |
Generates the outbound flow once and fixes the header codec result. |
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.