RioSerialLink: type-aware resolution closes the straggler-reply hole

Bench round 3 (testlogs/riomash-patched-62500-sw): lamp glitches
persisted because reply-resolution was type-blind — an analog reply
still crossing USB from the previous poll could falsely confirm the
NEXT command (usually a lamp write) before the board judged it. Now a
reply resolves only its MATCHING pending request (analog/version/
check); ACK/NAK stay type-blind, which is safe because the board's TX
ISR prioritizes ACK/NAK ahead of reply data, so a command's ACK cannot
trail into its successor's window. Budget-exhausted drops settle 10ms
before releasing the gate so late stragglers land on an empty pending.

New test: a stray AnalogReply must not resolve a pending lamp command.
283 green; selftest regression unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-19 13:34:12 -05:00
co-authored by Claude Fable 5
parent f752e2131a
commit 5dd7f78bc2
3 changed files with 106 additions and 23 deletions
+51 -23
View File
@@ -29,9 +29,20 @@ public sealed class RioSerialLink
// ACK/NAKs those, and they must not queue behind a pending command.
private readonly SemaphoreSlim _commandGate = new(1, 1);
private readonly object _pendingGate = new();
private TaskCompletionSource<bool>? _pendingAck; // true = ACK, false = NAK
private PendingCommand? _pending;
private long _retransmits;
// The in-flight command: its command byte gates which inbound events may
// resolve it (a reply only resolves the MATCHING request — an analog reply
// still in USB transit from the previous poll must never "confirm" a lamp
// command that follows it; bench 2026-07-19).
private sealed class PendingCommand
{
public PendingCommand(byte command) => Command = command;
public byte Command { get; }
public TaskCompletionSource<bool> Tcs { get; } = new();
}
public RioSerialLink(IRioTransport transport, RioSerialLinkOptions? options = null)
{
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
@@ -115,29 +126,37 @@ public sealed class RioSerialLink
{
for (int attempt = 0; ; attempt++)
{
var pending = new TaskCompletionSource<bool>();
lock (_pendingGate) _pendingAck = pending;
var pending = new PendingCommand(packet[0]);
lock (_pendingGate) _pending = pending;
await WriteAsync(packet, cancellationToken).ConfigureAwait(false);
Task winner = await Compat.TaskCompat.WhenAny(new[]
{
pending.Task,
pending.Tcs.Task,
Compat.TaskCompat.Delay(_options.AckTimeout, cancellationToken),
}).ConfigureAwait(false);
if (winner == pending.Task && pending.Task.Result)
return; // ACK'd
if (winner == pending.Tcs.Task && pending.Tcs.Task.Result)
return; // delivered (ACK, or the request's own reply)
// NAK'd or timed out (a shred so complete the board never NAK'd).
if (attempt >= _options.CommandRetransmitLimit)
return; // budget spent — drop; idempotent, next update supersedes
{
// Budget spent — drop (idempotent; the next update supersedes).
// Brief settle so a late straggler response can't land on the
// NEXT command's wait (strays with nothing pending are ignored).
lock (_pendingGate) _pending = null;
await Compat.TaskCompat.Delay(TimeSpan.FromMilliseconds(10), cancellationToken)
.ConfigureAwait(false);
return;
}
Interlocked.Increment(ref _retransmits);
}
}
finally
{
lock (_pendingGate) _pendingAck = null;
lock (_pendingGate) _pending = null;
_commandGate.Release();
}
}
@@ -206,11 +225,14 @@ public sealed class RioSerialLink
case RioRxEventKind.ControlByte:
if (ev.Byte is (byte)RioControl.Ack or (byte)RioControl.Nak)
{
// Resolve the in-flight command (stop-and-wait): the board
// only emits ACK/NAK in response to our command packets.
TaskCompletionSource<bool>? pending;
lock (_pendingGate) pending = _pendingAck;
pending?.TrySetResult(ev.Byte == (byte)RioControl.Ack);
// Resolve the in-flight command (stop-and-wait). Safe to be
// type-blind here: the board's TX ISR sends pending ACK/NAK
// ahead of reply data, so a command's ACK cannot arrive
// after its successor starts (unlike replies, see
// DispatchTyped).
PendingCommand? pending;
lock (_pendingGate) pending = _pending;
pending?.Tcs.TrySetResult(ev.Byte == (byte)RioControl.Ack);
}
ControlReceived?.Invoke(ev.Byte);
break;
@@ -223,17 +245,23 @@ public sealed class RioSerialLink
private void DispatchTyped(RioPacket packet)
{
if (packet.Command is RioCommand.AnalogReply or RioCommand.VersionReply or RioCommand.CheckReply)
// A reply proves the MATCHING request landed, whether or not the board
// also sent an explicit ACK — but only the matching one: a reply still
// in transit from an earlier poll must never confirm an unrelated
// command (that type-blindness was the residual lamp-glitch hole).
RioCommand? resolves = packet.Command switch
{
// A reply proves the corresponding request landed, whether or not
// the board also sent an explicit ACK — resolve the in-flight
// command so request/reply exchanges never burn the ACK timeout.
// (One command is in flight at a time, so the only stray case is
// an unsolicited reply resend — harmless: it can only release an
// idempotent command's wait early.)
TaskCompletionSource<bool>? pending;
lock (_pendingGate) pending = _pendingAck;
pending?.TrySetResult(true);
RioCommand.AnalogReply => RioCommand.AnalogRequest,
RioCommand.VersionReply => RioCommand.VersionRequest,
RioCommand.CheckReply => RioCommand.CheckRequest,
_ => null,
};
if (resolves is not null)
{
PendingCommand? pending;
lock (_pendingGate) pending = _pending;
if (pending is not null && pending.Command == (byte)resolves.Value)
pending.Tcs.TrySetResult(true);
}
switch (packet.Command)