Skip to content

PQC302 — Follow-up factory must be pure

A callback passed as followUpFactory does something other than build the trailing frame.

The factory you hand to Send(request, options, followUpFactory, ct) is invoked by ProtocolChannel inside the dispatch slot — the one slot that serialises every transaction of every device on that channel. It runs exactly once, and only on the final successful match. Its whole job is: look at the response, return the trailing frame, or return null to send nothing.

// reported
private XFrame? ConfirmRecordFollowUp(XFrame response)
{
    Connection.Dispatch(ParseRecord(response), ct);   // calls 'Dispatch'
    return BuildFrame(XPacketType.ConfirmRecord, [1]);
}

Why the build refuses this

Work here stalls every device. The slot is not released until the factory returns. An await, a Task.Delay, a Thread.Sleep or a dispatch that fans out to handlers blocks polls and commands for every other Thing the channel serves.

Work here often never runs at all. The channel calls the factory only on MatchResult.Match, and never on Busy retries, timeouts, errors, cancellation, or NoReply requests. Logic placed here "so it always happens" is exactly the logic that silently stops happening the moment the device misbehaves — which is when it mattered.

A throw here is a logic bug wearing a wire failure's clothes. Throwing faults the originating Send but leaves the channel connected, so the symptom shows up somewhere else entirely.

How to fix it

Return the frame; do everything else after Send returns.

// pure: inspects the response, returns a frame or null
private XFrame? ConfirmRecordFollowUp(XFrame response)
    => response.IsRecordData
        ? BuildFrame(XPacketType.ConfirmRecord, [1])
        : null;

// the poll loop dispatches, outside the slot
var response = await Send(readRecordFrame, options, ConfirmRecordFollowUp, ct);
if (response.IsRecordData)
    Connection.Dispatch(ParseRecord(response), ct);

If the obligation is not "one response, one trailing frame" but "response → ACK → ask again until the device says done", the factory is the wrong mechanism entirely — declare a SequencePolicy instead. See COMM-007.

What the check looks at

Reported inside the factory body:

Shape Reported as
await anywhere awaits
a call to Dispatch or PublishEvent calls '…'
a call returning Task / ValueTask starts asynchronous work in '…'
a call on Thread blocks the channel in '…'
assigning a field, a property, or a variable declared outside the factory writes to '…'

Both shapes of factory are covered — an inline lambda, and a named method passed as a method group. The diagnostic is reported at the call site, so a factory used in three places is reported three times: each of those Send calls is affected.

What is not reported

Locals of the factory itself. Building the frame in steps is ordinary code, not a side effect.

A factory declared in another assembly. There is no body to read. The rule still applies; review covers it.

Passing null. No follow-up, nothing to check.

If you disagree with a report

Do not suppress it. A wrong report is a bug in the check — report it with the code that triggered it.