Skip to content

Pattern 18: Multi-Step Protocol Sequences

When to Use

Some protocols carry a multi-step obligation on a single request: the response must be acknowledged and the request repeated until the device says it is done, with nothing else touching the wire in between. The canonical shape is a log/history drain:

GetLog → LogData → AckLog → GetLog → LogData → AckLog → … → GetLog → NoData
Scenario Mechanism
Plain request → response Send(request, options, ct)
One response, one trailing no-reply ACK, then advance followUpFactory on Send (COMM-001/COMM-002)
Response → ACK → repeat until the device says "done" Sequence policy (this pattern, COMM-007)
Caller-driven multi-packet operation with branching logic ExclusiveSession(...) (COMM-010)
Drain where interleaved frames are harmless Plain Send loop is fine — no sequence needed

Use a sequence policy when all of these hold:

  • each step is a fresh request (not a multi-frame response to one request),
  • the device requires the ACK/next-request pair to arrive back-to-back — a status poll or command slipping in between desynchronizes or stalls it,
  • the number of steps is unknown up front (the device signals the end).

Declared once per channel via ProtocolChannelOptions<TFrame>.SequencePolicies, the rule becomes a protocol invariant: it is impossible to send the triggering packet without correct drain semantics, regardless of call site. Do not implement the drain as a per-call while loop of Sends — every await Send releases the dispatch slot and queued traffic can interleave (that is compliance rule COMM-007).

Use ExclusiveSession(...) instead when the operation is caller-driven rather than response-driven: discovery scans, configuration reads, and restore flows where adapter code sends different packets, inspects responses, and branches. A sequence policy owns one protocol exchange shape; an exclusive session owns one imperative logical operation.


Lifecycle Walkthrough

Setup (see TCP Transport for the ProtocolChannelOptions<TFrame> API):

channelOptions: new ProtocolChannelOptions<XFrame>
{
    FollowUpQuietTime = TimeSpan.FromMilliseconds(100),   // inter-frame gap after each ACK
    SequencePolicies = [GetLogSequence.Create(BuildFrame)],
}

The caller side is a single plain send:

// drains the whole log internally; returns the final NoData (or error) frame
var final = await Send(BuildFrame(XPacketType.GetLog, []), options, ct);

Wire-level trace for a drain of two log records, with a status poll queued mid-drain:

caller                 channel (one dispatch slot)              device
  │                        │                                       │
  ├─ Send(GetLog) ────────►│                                       │
  │   (status poll queued  ├── TX GetLog ────────────────────────►│
  │    behind the slot)    │◄──────────────────────── RX LogData ──┤
  │                        │   policy(GetLog, LogData) → action    │
  │                        ├── TX AckLog (no-reply) ──────────────►│
  │                        │   wait FollowUpQuietTime              │
  │                        ├── TX GetLog ────────────────────────►│
  │                        │◄──────────────────────── RX LogData ──┤
  │                        ├── TX AckLog ────────────────────────►│
  │                        │   wait FollowUpQuietTime              │
  │                        ├── TX GetLog ────────────────────────►│
  │                        │◄──────────────────────── RX NoData ───┤
  │                        │   policy(GetLog, NoData) → null       │
  │                        │   forward LogData #1, #2 to           │
  │                        │   unmatched-frame → event path        │
  │◄── final = NoData ─────┤                                       │
  │                        ├── TX StatusPoll (next queue item) ───►│

Step by step:

  1. Send(GetLog) enqueues one ordinary queued request. Priority, fairness, and queue bounds apply as usual.
  2. When the dispatch loop picks it up, the request occupies one dispatch slot for the entire drain. Each step is a normal request-response attempt with its own timeout and busy-retry handling.
  3. After every matched response, the registered policies are consulted in order with (request, response); the first non-null action wins. On continuation steps, request is the continuation frame the policy produced, not the original — MatchResponse for each step matches against that step's request.
  4. A returned action makes the channel write the no-reply Acknowledge, wait FollowUpQuietTime, then send Continue as the next step. Continue == null ends the sequence after the ACK; Acknowledge == null skips straight to Continue (sequences without ACK frames work).
  5. When a policy returns null (typically the terminal NoData, but also any error frame), the sequence ends: collected intermediate responses are forwarded to the event path, then the caller's Send task completes with that final frame.
  6. Only now does the dispatch loop return to the queues. Queued items — including high-priority ones — run strictly after the drain; nothing preempts a running sequence, and nothing is cancelled.

If both a followUpFactory and a claiming sequence policy exist for the same request, the policy wins and the factory is ignored (a debug message is logged). Do not configure both.


Intermediate Responses and Event Delivery Timing

Intermediate (non-final) responses — the LogData frames — are not returned to the caller. After the sequence ends they are forwarded, in wire order, to the standard unmatched-frame path: OnUnmatchedFrameTransformToEvents → event router. Your TransformToEvents(XFrame frame) must therefore recognize those frame types and yield the parsed device events:

/// <inheritdoc/>
protected override IEnumerable<XEvent> TransformToEvents(XFrame frame)
{
    if (frame.IsLogData)
        yield return ParseLogRecord(frame.Payload);
}

Timing guarantees:

  • Forwarding happens after the last wire step but before the caller's Send task completes — "drain reported done" implies "all records handed to event dispatch". The poll handler needs no manual Dispatch calls.
  • Forwarding after the drain (not between steps) keeps the wire tight: the next request beats event handling, which is exactly what ACK-sensitive devices need. The trade-off: an unsolicited live event received mid-drain is dispatched immediately and can therefore be processed before an older drained record. For historical log records this is normally irrelevant.
  • On a mid-sequence failure, records already ACKed on the wire are still forwarded — the device will not re-serve them, so dropping them would lose audit events.

Per-Step Timeout Semantics

SendOptions.Timeout applies per step, not per drain. A drain of N records legitimately takes up to N × (timeout + quiet time). Callers of a sequence-triggering packet must size any outer watchdog accordingly — do not wrap the Send in a timeout tuned for a single exchange. Busy responses are retried per step by the configured RetryPolicy, same as a plain send.


Failure Modes

Failure Caller's Send Channel Already-ACKed intermediates
Step timeout (device goes silent mid-drain) Fails with TimeoutException Disconnected (standard timeout semantics) Forwarded to event path
Busy leftover after retry exhaustion Completes with the busy frame as final response (policies are not consulted for busy outcomes) Stays connected Forwarded
Error/unforeseen response (all policies return null) Completes with that frame as final response — handle it exactly like a plain-send error result Stays connected Forwarded
Caller CancellationToken cancelled mid-step Fails cancelled Disconnected (standard cancellation-after-write semantics) Forwarded
Policy throws Fails with the policy's exception Stays connected (the wire is consistent; a throw is a logic bug, not a wire fault) Forwarded
ACK/continue write fails (socket fault) Fails with the I/O exception Disconnected Forwarded

There is no state in which the sequence machinery "doesn't know" and hangs: everything not claimed by a policy degrades to plain-Send behavior.

Unexpected frames

Situation Behavior
Incoming frame does not match the pending step (MatchResponse = NoMatch) Never reaches the policies — routed to OnUnmatchedFrame immediately; the step keeps waiting for its response (or times out)
Frame matches but every policy returns null Sequence ends; that frame is the final response returned to the caller
No response at all Per-step timeout → disconnect semantics above

Live Events vs Responses

Protocols that mix unsolicited live events into the reply stream need no special sequence handling, because the policy is not the layer that separates them:

  • MatchResponse says what the frame is — response to the pending step, or unrelated frame.
  • The policy says what to do next — given a genuine matched response only.

A live event arriving mid-step (or between steps) falls through to OnUnmatchedFrame at once and is dispatched immediately; the pending step keeps waiting and the sequence is undisturbed. The policy never sees live events. If your protocol cannot distinguish a response from a live event by frame content, fix MatchResponse — that problem exists with or without sequences.


Colocate the Policy with the Packet

Continuation frames must be freshly built (new packet number / counter state), and that counter belongs to the protocol's frame builder — a frame cannot clone itself. Define the rule as a static factory next to the packet definition, parameterized by the builder, and wire it once at channel creation:

/// <summary>
/// Sequence policy for GetLog: every log-data response is ACKed and followed
/// by a fresh GetLog until the device reports NoData.
/// </summary>
internal static class GetLogSequence
{
    /// <summary>Creates the policy bound to the protocol's frame builder.</summary>
    public static SequencePolicy<XFrame> Create(Func<XPacketType, byte[], XFrame> build) =>
        (request, response) =>
            request.PacketType == (byte)XPacketType.GetLog && response.IsLogData
                ? new(build(XPacketType.AckLog, [1]), build(XPacketType.GetLog, []))
                : null;
}

Contract for the policy body:

  • Pure decision logic — no await, no blocking, no I/O, no event dispatch, no throwing. It runs on the dispatch loop shared by every device on the channel.
  • Guard your own packet type, return null for everything else. Multiple sequenced packet types compose as separate policies in the list; first non-null wins. One packet type must be owned by at most one policy.
  • Terminate. There is deliberately no step-count cap (the drain length is unknowable up front); termination comes from the device's end signal, the per-step timeout, and the caller's token. A policy that continues forever against a device that keeps answering is an adapter bug.

Testing Guidance

Sequence behavior is testable without hardware using a loopback socket pair (see Pattern 11: Testing Without Hardware): connect a channel to a local listener, script the device side (assert received bytes, write response frames), and register a minimal drain policy over a toy frame type. Cover at least:

  • Happy drain: N data steps then the end frame; the caller receives the final frame and OnUnmatchedFrame observed exactly N intermediates, in order, before the Send task completed.
  • Exclusivity (the core guarantee): enqueue a plain command — and a high-priority one — while the drain runs; assert on the wire that every ACK/next-request pair completes before the queued items appear, in queue order.
  • Immediate final response: end frame on the first step behaves like a plain send — no intermediates forwarded.
  • Failure forwarding: silence (step timeout) or caller cancellation mid-drain fails the Send, disconnects the channel, and still forwards the already-ACKed intermediates.
  • Policy misbehavior: a throwing policy fails the caller but leaves the channel connected and usable.
  • Live events: an unsolicited frame written mid-step reaches OnUnmatchedFrame immediately and does not complete or disturb the pending step.

See Also