Communication¶
Rules for ProtocolChannel request-reply usage: serialized transactions, mandatory follow-up frames, and queue ordering on TCP-based adapters.
Part of compliance-rules.
Review Guardrails¶
- Do not report protocol framing, cipher, CRC, or packet-layout defects unless the finding is backed by vendor documentation, SDK source, or supplied protocol source material. If the implementation cites an SDK or supplied source pattern, quote and compare that source before flagging it.
- For methods named with
startIndex/length, do not infer .NET slice semantics. Some vendor SDKs uselengthas an exclusive end index. Treat this as undocumented until verified against source. - Mandatory device ACKs may be sent before application-level event dispatch when the protocol requires the ACK before the next request or when delayed ACK can block the communication queue. In that design, review ordering/queue safety under COMM-001/COMM-002; do not flag audit loss solely because dispatch happens after the ACK.
COMM-001: Mandatory Follow-Up Must Use FollowUpFactory, Not Manual Post-Send¶
Severity: Critical
Description:
When a protocol requires a mandatory trailing frame immediately after a response (e.g. a ConfirmRecord receipt after every ReadRecord response), the adapter must pass a followUpFactory to Send(request, options, followUpFactory, ct). It must not acknowledge by awaiting Send(...) and then issuing a separate Send/SendNoReply.
This rule covers single-shot obligations only: one response, one trailing no-reply frame, exchange complete. If the ACK must be followed by another request of the same exchange (drain-until-empty loops), that is a multi-step sequence — use a SequencePolicy instead, see COMM-007.
By the time await Send(...) returns, ProtocolChannel has already released the dispatch slot. Another queued request (e.g. an access-sync Upload) can be written to the wire before the trailing frame, desynchronizing the device.
Why this matters:
ProtocolChannelserializes one transaction at a time. The follow-up frame is written inside the same transaction, after the finalMatchand before the queue advances (SendFollowUpinProtocolChannel).- A manual post-Send acknowledgment re-enters the queue behind any already-queued item → ordering is not guaranteed.
- For panels that expect the ACK before the next command, an interleaved upload makes the panel reject the next packet or drop the connection.
Detection pattern:
await Send(...)/await SendPacket(...)immediately followed by a separateawait SendNoReply(...)orawait Send(ackFrame, ...)where the second frame acknowledges the first response- a poll loop that reads → processes → sends an ACK as a separate awaited call on the same channel queue shared with other commands
- any "send X, then immediately send ack(X)" pair not expressed through
followUpFactory
Correct pattern:
// ACK is written inside the ReadRecord transaction, before the queue advances
var response = await Send(readRecordFrame, options, ConfirmRecordFollowUp, ct);
private XFrame? ConfirmRecordFollowUp(XFrame response)
=> response.IsRecordData
? BuildFrame(XPacketType.ConfirmRecord, [1])
: null;
ACK-before-dispatch exception:
For protocols where a delayed ACK can block the device/session, it is compliant to ACK inside the protocol transaction and dispatch the parsed event afterwards. Reviewers must not require Dispatch() before ACK. Instead verify that parsing preserves enough context to publish an unknown diagnostic if routing fails. (Under a sequence policy this ordering is structural — see COMM-007.)
Violation example:
// WRONG: ACK queued after Send returns — a queued Upload can slip in before it
var response = await Send(readRecordFrame, options, ct);
if (response.IsRecordData)
await SendNoReply(BuildAck(), ct); // slot already released → race with queued uploads
Verification:
- Enqueue a poll request and a normal-priority command (e.g.
Upload) concurrently. - The trailing frame must always be written before the next queued command on the wire.
- grep the adapter for a
SendNoReply/ secondSendissued immediately after a response in a poll/ack pair.
COMM-002: Follow-Up Factory Must Be Pure, Final-Match-Only, No-Reply¶
Severity: High
Description:
A followUpFactory passed to Send(...) is invoked by ProtocolChannel exactly once, only on the final successful MatchResult.Match, and the frame it returns is written no-reply. The factory must be a pure function of the response: return the follow-up frame, or null to send nothing. No side effects, no await, no blocking, no event dispatch.
Why this matters:
- The factory runs inside the dispatch slot — any blocking/awaiting work there stalls the channel shared by all of the adapter's devices.
- It is not invoked on Busy retries, timeout, error, cancellation, or for
NoReplyrequests (SendFollowUpguards on!NoReply && MatchResult.Match). Logic that must run in those cases does not belong here. - The returned frame is fire-and-forget; expecting a reply to it is unsupported (single follow-up, no multi-step exchange, no follow-up-with-reply).
- Throwing from the factory faults the originating
Sendbut leaves the channel connected — treat it as a logic bug, not a wire failure. The device forces a reconnect itself if it does not receive the expected frame.
Detection pattern:
- a
followUpFactorylambda/method that callsawait,Dispatch,PublishEvent,Task.Delay, or mutates shared state beyond building the frame - a factory whose returned frame's response the caller then tries to read
- a factory used to perform event processing instead of just producing the trailing frame
- code assuming the factory runs on
NoData/error responses (it only runs onMatch)
Correct pattern:
// Pure: inspects the response, returns a frame or null. Dispatch happens later, in the poll loop.
private XFrame? ConfirmRecordFollowUp(XFrame response)
=> response.IsRecordData
? BuildFrame(XPacketType.ConfirmRecord, [1])
: null;
Violation examples:
// WRONG #1: side effects + blocking inside the dispatch slot
private XFrame? ConfirmRecordFollowUp(XFrame response)
{
Connection.Dispatch(ParseRecord(response), ct); // event processing belongs OUTSIDE the slot
Thread.Sleep(100); // blocks the shared channel
return BuildFrame(XPacketType.ConfirmRecord, [1]);
}
// WRONG #2: expecting a reply to the follow-up
var ackReply = await SendExpectingReply(BuildAck(), ct); // follow-up is no-reply only
Verification:
- factory body only inspects the response and returns a frame or
null - event dispatch / NATS publish happens in the poll loop after
Sendreturns, not in the factory - factory is side-effect free; no
await/blocking calls
COMM-003: Enabled or Vendor-Required Retries Must Be Declared¶
Severity: High
Description:
ProtocolChannel defaults to RetryPolicy.None; an operation that relies on that default performs one
attempt and is compliant without an adapter-local declaration. When an operation enables retries, or
vendor evidence requires retry behavior, the adapter must declare the bounded policy and its rationale.
Why this matters:
- Retrying a mutating operation without evidence can duplicate a device-side action.
- Vendor-required retries need bounded, reviewable behavior.
- Declared non-default policy prevents accidental retry regressions.
Detection pattern:
- any enabled retry behavior, including an adapter-level retry wrapper, without an operation-specific rationale and evidence
- vendor documentation requiring retries while the operation relies on
RetryPolicy.None - a retry policy shared across operations without documentation that their vendor retry contract and rationale are identical
Correct pattern:
// Vendor documents retry after Busy; this read is idempotent.
var options = new SendOptions
{
Timeout = TimeSpan.FromSeconds(2),
RetryPolicy = RetryPolicy.Fixed(3, TimeSpan.FromMilliseconds(250)),
};
return await Send(request, options, ct);
Violation examples:
// WRONG: shares a retry policy without showing that both operations have the same retry contract.
return await Send(request, new SendOptions { RetryPolicy = retryPolicy }, ct);
Verification:
- Enumerate all channel operation paths in the adapter.
- Treat absent policy as one attempt (
RetryPolicy.None); do not report it solely for being implicit. - For every enabled or vendor-required retry, including wrapper loops, confirm rationale, per-operation scope, bounds, and evidence.
COMM-004: Retries Must Be Bounded and Cancellation-Aware¶
Severity: Critical
Description:
Any retry loop around ProtocolChannel operations must be bounded (max attempts or bounded duration) and must honor cancellation (ct) on every attempt and wait boundary. Infinite or effectively unbounded retries are prohibited unless a vendor requirement is explicitly documented and wrapped by an upper supervisory bound.
Why this matters:
- Unbounded retries can deadlock shutdown, block failover, and hide hard faults.
- Ignoring cancellation makes adapter stop/reconnect paths unreliable.
- Bounded retries preserve predictable latency and fault recovery.
Detection pattern:
while (true)/ open-ended loop retryingSend*without strict exit bound- retry waits (
Task.Delay, timers) that do not pass cancellation token - catch/retry logic that swallows
OperationCanceledExceptionand continues - vendor-required prolonged retries with no documented external bound
Correct pattern:
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
ct.ThrowIfCancellationRequested();
try
{
return await Send(request, options, ct);
}
catch (OperationCanceledException)
{
throw;
}
catch when (attempt < maxAttempts)
{
await Task.Delay(retryDelay, ct);
}
}
throw new TimeoutException("Retries exhausted.");
Violation examples:
// WRONG #1: unbounded retries
while (true)
{
try { return await Send(request, options, ct); }
catch { }
}
// WRONG #2: delay ignores cancellation
await Task.Delay(retryDelay);
Verification:
- Every retry path has explicit upper bound (attempt count and/or bounded elapsed time).
- Cancellation token is checked/passed before send and during wait.
OperationCanceledExceptionis not converted into normal retry continuation.
COMM-005: Mutating Command Retry Safety Must Be Explicitly Documented¶
Severity: Critical
Description: When retrying mutating protocol commands (state-changing operations such as enroll/delete/update/relay/action writes), the adapter must explicitly document retry safety per command type: idempotent-safe, deduplicated by protocol token, or retry-disabled. If command semantics are undocumented by vendor SDK/protocol docs, the adapter must treat retry safety as unknown and avoid silent automatic retries.
Why this matters:
- Retrying non-idempotent writes can create duplicates, data drift, or repeated physical actions.
- Command-level safety differs even within one protocol; blanket retry rules are unsafe.
- Explicit documentation makes risk visible during reviews and incident analysis.
Detection pattern:
- write/mutate command handlers with automatic retries but no command-specific safety note
- same retry policy applied to both read and mutating commands without distinction
- inferred idempotency not backed by vendor/protocol documentation or explicit "unknown" handling
Correct pattern:
// Mutating command retry policy:
// - UnlockDoor: retry disabled (vendor does not guarantee deduplication)
// - UpsertCredential: safe retry via externalId idempotency key
Violation examples:
// WRONG: generic retry wrapper around all commands, no mutating-safety split.
return await RetryAsync(() => Send(commandFrame, options, ct), ct);
Verification:
- Identify mutating command paths and their retry behavior.
- Confirm each mutating command class has explicit safety posture (
safe,unsafe,unknown) with rationale. - Validate rationale is backed by vendor docs/protocol evidence or explicitly marked as unknown/N/A.
COMM-006: Retry Timing Must Not Starve Polling/Event Flow¶
Severity: High
Description:
Retry timing/backoff for ProtocolChannel operations must not starve poll/event traffic on the same channel queue. Command retry bursts must be paced or deprioritized so that mandatory polling and event-drain paths continue making progress. If protocol architecture isolates channels (e.g., dedicated poll channel), starvation checks may be marked N/A with documentation.
Why this matters:
- Aggressive retries can monopolize queue slots and delay status/history polling.
- Starved polling causes delayed or dropped event visibility and stale state.
- Controlled pacing keeps responsiveness under transient device/network faults.
Detection pattern:
- tight retry loops on shared channel without delay/jitter/priority separation
- command retries scheduled at rate that blocks expected poll cadence
- poll degradation observed in design/tests but left undocumented as acceptable
Correct pattern:
// Shared queue: command retries are paced to preserve poll cadence.
await Task.Delay(commandRetryDelay, ct);
Violation examples:
// WRONG: immediate requeue on shared channel can starve poll traffic.
for (var attempt = 0; attempt < maxAttempts; attempt++)
{
await Send(commandFrame, options, ct);
}
Verification:
- For shared-channel adapters, verify retry timing allows poll/event loops to meet expected cadence.
- Confirm retry strategy includes pacing, prioritization, or equivalent starvation control.
- For isolated-channel designs or vendor exceptions, ensure N/A/exception is documented with scope.
COMM-007: Multi-Step Mandatory Exchanges Must Use a Sequence Policy, Not Per-Call Loops¶
Severity: Critical
Full developer treatment: Pattern 18: Multi-Step Protocol Sequences — lifecycle walkthrough with wire trace, event delivery timing, failure modes table, and testing guidance. This rule keeps the reviewer-facing contract.
Description:
A followUpFactory (COMM-001/COMM-002) covers a single-shot obligation: one response, one trailing no-reply ACK, done. Some protocols instead require a multi-step exchange that must run to completion before anything else touches the wire — a request whose response must be acknowledged and then repeated until the device signals the end. The canonical case is a history/log drain: GetLog → AckLog → GetLog → … → NoData. Each step is a fresh request, and any status poll or command slipping in between an AckLog and the next GetLog desynchronizes the panel.
For these exchanges the adapter must declare a sequence policy on ProtocolChannelOptions<TFrame>.SequencePolicies, not implement a per-call loop in the poll handler that awaits Send and re-enqueues the next request. A per-call loop releases the dispatch slot after every step, so the exclusivity guarantee is lost exactly as in COMM-001 — only worse, because it recurs on every step of the drain.
Sequence policy vs follow-up factory — which to use:
| Obligation | Mechanism |
|---|---|
| One response, one trailing ACK, then advance | followUpFactory on Send (COMM-001/002) |
| Response → ACK → repeat the request until the device says "done" | SequencePolicy on ProtocolChannelOptions<TFrame> |
Why this matters:
- A sequence occupies a single dispatch-queue slot for its whole duration; the channel performs every request→response→ack step internally and does not return to the queues until the sequence ends. Nothing interleaves between an ACK and the next request. Status polls and commands stay queued (nothing is cancelled).
- Externally the exchange is still a plain
Send: the caller receives the final response (NoDataor an error frame).SendOptions.Timeoutapplies per step, so a drain of N steps may legitimately exceed one timeout. - Intermediate (non-final) responses are forwarded, in order, to the standard unmatched-frame → event-transform → router path after the sequence completes but before the caller's
Sendtask returns. Deliver drained records as device events through that path — never via a manual dispatch inside the drain.
Policy purity contract:
A SequencePolicy is (request, response) → next step or null, consulted on the dispatch loop after every matched response. It must be pure decision logic: no blocking, no await, no I/O, no event dispatch, no throwing. Returning null means "this policy does not claim the exchange" — that response becomes the final one returned to the caller. Returning an action tells the channel to write the (no-reply) Acknowledge, honor the configured quiet time, then send Continue; a null Continue ends the sequence. A throwing policy fails the caller's Send but does not disconnect the channel (defensive only — treat a throw as a logic bug).
Colocate the policy with the packet, build it with a factory. Continuation frames must be freshly built with a new packet number, and the packet-number 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:
// next to the GetLog packet definition
internal static class GetLogSequence
{
// build: the protocol's frame builder (owns the packet-number counter)
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;
}
// once, where the channel is created
channelOptions: new ProtocolChannelOptions<XFrame>
{
FollowUpQuietTime = interFrameGap, // honored after each AckLog
SequencePolicies = [GetLogSequence.Create(BuildFrame)],
}
The poll handler then collapses to a single Send(GetLog) whose response is the drain's final NoData.
Unexpected-frame behavior (no state hangs — everything degrades to plain Send):
| Situation | Behavior |
|---|---|
Incoming frame does not match the pending step (MatchResponse = NoMatch) |
Never reaches the policy — routed to the unmatched-frame path as usual; the step keeps waiting for its response (or times out) |
Frame matches, all policies return null (error frame, leftover busy, anything unforeseen) |
Sequence ends; that frame is the final response returned to the caller, handled exactly as a normal Send result |
| No response at all | Per-step timeout → existing disconnect semantics; already-ACKed intermediates are still forwarded to the event path |
Live events vs responses — division of responsibility:
Some protocols interleave unsolicited live events into the reply stream. The policy is not the layer that separates them: MatchResponse says what the frame is (response to the pending request vs live event), and the policy says only what to do next given a genuine response. A live event arriving mid-step falls through to the unmatched-frame path immediately while the step keeps waiting; the policy only ever sees real responses. If a protocol cannot tell a response from a live event by frame content, that is a MatchResponse problem, independent of sequences.
Detection pattern:
- a poll/drain handler with a
while/doloop that awaitsSend(getLog), inspects the response, sendsAckLogas a separate call, then loops to send the nextgetLogon the shared channel - a drain that dispatches parsed records manually inside the loop instead of letting intermediates flow through the unmatched-frame → event path
- a
SequencePolicylambda that callsawait, dispatches events, sleeps, or mutates shared state - both a
followUpFactoryand a claimingSequencePolicyconfigured for the same request packet type (the policy wins; the factory is silently ignored)
Verification:
- Confirm multi-step mandatory exchanges are declared as
SequencePolicies, not per-call loops. - Enqueue a drain and a normal-priority command concurrently: no command frame may appear between an ACK and the following request on the wire until the drain reaches its final response.
- Confirm the policy is a pure function (no
await/dispatch/blocking) and lives next to its packet definition. - Confirm intermediate records are delivered through the event path, not a manual dispatch inside the drain.
COMM-008: Protocol Traffic Must Be Trace-Readable by Packet Name¶
Severity: High
Applicability: all adapters with a device protocol boundary — TCP and SDK/HTTP based. This rule is never N/A just because the adapter does not use ProtocolChannel; only adapters with no device protocol at all (pure in-process simulators) are N/A.
Description:
A Trace-level log must show the protocol flow as a sequence of human-readable packet/message names (e.g. PollRequest, ZoneStatusEvent) readable without decoding hex payloads. Raw hex dumps remain available at the transport layer for deep investigation; this rule is about the protocol layer above them.
For TCP adapters on TcpProtocolBase/FrameBase: the framework logs every frame (TX {Frame} / RX {Frame}) through the frame's ToString(), and FrameBase enforces the override at compile time. The adapter's obligation — and what this rule reviews — is the quality of that rendering:
- the first token is a stable, human-readable packet/message name in the terminology of the vendor protocol documentation — not an invented name, not a bare numeric code;
- followed by key addressing/identity fields (address, index, payload length as appropriate);
- never raw hex only;
- unknown codes render an explicit fallback (e.g.
UnknownCommand(0x9C)), never throw; - the name lookup (enum or dictionary) carries citations to the vendor documentation sections that name the packets, per the citation protocol.
For SDK/HTTP adapters (deriving ProtocolBase directly): the same obligation applies at the protocol boundary — trace-log the vendor-named operation before each SDK/HTTP call and the vendor-named event/callback on receipt. A record event type whose auto-generated ToString() renders vendor-named fields (enum names, vendor string codes) satisfies the receive side.
Why this matters:
- Trace logs are the primary investigation tool for live-site protocol issues; if reading them requires manual hex decoding, investigation cost explodes.
- Stable names allow grepping a flow across sessions ("all
PollResponseafter 14:02") and correlating with vendor documentation during support calls with the manufacturer. - Vendor terminology keeps the log, the code, and the vendor manual speaking one language.
Detection pattern:
- a
FrameBasesubtype whoseToString()renders only bytes/hex or a bare numeric type code - packet names invented by the developer instead of taken from vendor documentation
- a packet-name enum/dictionary without vendor-doc citations
- an SDK/HTTP protocol whose send/receive boundary emits no operation-level trace logging
Correct pattern:
// name from the vendor doc's command list; hex code and length as supplementary fields
public override string ToString()
=> $"{DescribeCommand(Command)}(0x{Command:X2}, {Payload.Length}B)"; // "ArmMode0(0x80, 3B)"
private static string DescribeCommand(byte command)
=> CommandNames.TryGetValue(command, out var name) ? name : "UnknownCommand";
Violation examples:
// WRONG #1: hex-only rendering — trace log stays unreadable
public override string ToString() => Convert.ToHexString(Serialize());
// WRONG #2: bare numeric code — reader must open the vendor doc to decode every line
public override string ToString() => $"Frame type={Type}";
Verification:
- Read (or dry-run) one poll cycle plus one command at Trace level: the request/response/event flow must be readable from packet names alone.
- Spot-check packet names against the vendor documentation's own terminology.
- Confirm the name lookup covers the packet types the adapter actually sends/receives and has a non-throwing fallback for unknown codes.
COMM-009: Serialization Boundaries Must Have Exact Wire Vectors¶
Severity: High
Applicability: every adapter that owns wire serialization — it builds or parses frames, command payloads, memory-write records, or bitfields itself. N/A for adapters where a vendor SDK owns the entire wire layer and the adapter only passes typed SDK objects.
Description: Every adapter-owned or independently testable serialization boundary whose authoritative documentation, capture, protocol example, or supplied SDK implementation exposes the wire layout must have an exact-vector test: expected bytes taken from that independent evidence, compared byte-for-byte against the adapter's serializer/parser output. Cover the complete frame where the adapter owns it, otherwise the complete command payload: lengths, selectors, reserved bytes, address width, byte order, checksums, and bitfields as applicable. Bitfield vectors must use nonzero address and subtype bits so overlapping fields cannot pass accidentally.
Expected bytes must come from the independent evidence, never from the adapter serializer under test. Missing authoritative layout evidence is a documented gap (Open Question / CHECKLIST.md), not permission to test only the adapter's own interpretation.
This is the review-side counterpart of the build-time protocol conformance gate: the gate enforces vectors on new builds; this rule verifies they exist on any adapter under review.
Why this matters:
- A malformed packet can be acknowledged by the device and still carry the wrong meaning — access-sync payloads with a wrong address width report success while the panel never receives the intended data. Only byte-level comparison against independent evidence catches this class of safety-critical failure before hardware.
- Bitfield decode errors (subtype read from the wrong bits) silently turn documented events into
device.unknown, breaking command confirmation and audit attribution.
Detection pattern:
- an adapter with hand-written
Serialize/Build*Payload/frame-construction or byte-level parsing code but no test project, or a test project with no byte-array vector assertions against those paths - vector tests whose expected bytes are produced by calling the adapter's own serializer (self-referential)
- bitfield encode/decode tests that only use zero or single-bit values, leaving overlapping fields unproven
- a protocol command implemented after the conformance gate existed with no corresponding vector and no documented evidence gap
Correct pattern:
// expected bytes transcribed from the vendor SDK reference implementation, cited
[Fact]
public void WriteMemory_AccessLevel_MatchesSdkLayout()
{
// vendor SDK reference implementation, WriteToMemory — 00 00 <addrHi> <addrLo>, two-byte partition field
byte[] expected = [0x60, 0x00, 0x00, 0x03, 0x84, /* … */];
Assert.Equal(expected, Protocol.BuildMemoryAddressPayload(0x0384, partition: 1));
}
Violation examples:
// WRONG: expected value computed by the code under test — proves nothing
var expected = Protocol.BuildMemoryAddressPayload(0x0384, 1);
Assert.Equal(expected, Protocol.BuildMemoryAddressPayload(0x0384, 1));
Verification:
- Enumerate the adapter's serialization boundaries (frame builders, command payload builders, memory-record writers, event/bitfield decoders).
- For each boundary with available authoritative layout evidence, confirm an exact-vector test exists and its expected bytes cite that evidence.
- For each boundary without such evidence, confirm the gap is documented (Open Question or
CHECKLIST.md), not silently untested.
COMM-010: Multi-Packet Logical Operations Must Own the Wire via an Exclusive Session¶
Severity: Critical
Description:
ProtocolChannel serializes individual transactions (one request-reply at a time), but nothing serializes a logical operation made of many packets. When an adapter performs a multi-packet operation on a shared request-reply channel — a device discovery scan, a bulk configuration read, a multi-step panel restore — it must run that whole operation under an exclusive session so no other command type interleaves on the wire between its packets. It must not issue the packets as a plain loop of Send(...) calls on the shared channel, where each await Send releases the dispatch slot and lets a queued status poll or command slip in between two packets of the operation.
An exclusive session takes the dispatch slot as one queued item, then holds it: the channel runs the operation's inner sends inline on the held slot and does not return to the queues until the operation completes. Inner Send(...) calls need no change — an ambient lease token flows through the async chain, so nested read/enumeration code stays unaware of the session.
In scope: any operation whose correctness depends on its packets not being interleaved — discovery enumerations, bulk config reads, multi-step restores.
Explicitly NOT in scope:
- Single request-reply exchanges — one
Send(...)and its response are already serialized by the channel; they need no session. SendBulkaccess sync — bulk is deliberately interruptible (it yields the slot to the queues so commands and polls always take precedence, filling only idle gaps). It is the opposite trade-off and must not be wrapped in a session. Never useSendBulkinside a session — the held dispatch loop would never pull its items.
Why this matters:
- Single-threaded device firmware with one read context degrades when command types alternate packet-by-packet on the wire: it can answer with an error result to a mid-operation read and eventually stop responding, aborting the operation.
- A plain
Sendloop reopens the interleaving window on every packet — precisely the failure exclusivity exists to prevent, recurring for the whole scan. - The channel — not device status, not an adapter convention — is the single source of truth for wire ownership during the operation. Poll schedulers and other unstamped background sends stay queued (nothing is cancelled) and resume after the session releases. Interactive command handlers that reach
Send(...)during a foreign lease fail fast asCommandResult.Busythrough the framework command pipeline, so the UI can prompt the operator to retry later.
Detection pattern:
- a discovery / config-import handler that enumerates devices with a bare loop of
await Send(...)(orawait ReadX(...)) on the shared channel, not wrapped in an exclusive session - a multi-step restore or bulk config read implemented as sequential
Sendcalls with no session holding the slot across them SendBulkinvoked inside an exclusive session- per-block sessions around an operation that should be one session (block boundaries reopen the interleaving window)
Correct pattern:
// in the adapter Protocol subclass: expose a narrow adapter-local wrapper
internal Task<TResult> RunSession<TResult>(Func<CancellationToken, Task<TResult>> operation, CancellationToken ct)
=> ExclusiveSession(operation, ct);
// the whole enumeration runs under one lease; inner Send calls are unchanged
public Task<DiscoveredTree> Discover(Protocol protocol, CancellationToken ct)
=> protocol.RunSession(async leaseCt =>
{
var partitions = await protocol.EnumerateDevices(DeviceType.Partition, MaxPartitions, leaseCt);
var zones = await protocol.EnumerateDevices(DeviceType.Zone, MaxZones, leaseCt);
var modules = await protocol.EnumerateModules(leaseCt);
return BuildTree(partitions, zones, modules);
}, ct);
Violation example:
// WRONG: each await Send releases the slot — a status poll can land between two name reads,
// mixing command types on a single-context panel until it stops answering.
for (var number = 1; number <= maxNumber; number++)
{
var device = await Send(BuildReadNameRequest(number), ct);
if (device is not null)
devices.Add(device);
}
Verification:
- Enumerate the adapter's multi-packet operations (discovery, bulk config read, restore).
- Confirm each runs under one exclusive session, not a plain
Sendloop on the shared channel, and that a single session spans the whole operation (no per-block sessions). - Enqueue a poll and a command concurrently while the operation runs: no foreign frame may appear on the wire between the operation's packets until it completes.
- Confirm
SendBulkis never used inside a session, and that single request-reply exchanges are not needlessly wrapped.
See the ProtocolChannel Exclusive Session documentation for the caller-driven wire-ownership contract and how it relates to the protocol-reactive sequence policy (COMM-007).