RioSerialLink: stop-and-wait command delivery (supersedes NAK-race resend)
Bench falsified the v1 retransmit (testlogs/riomash-patched-62500-retx): resends tracked NAKs 1:1 (321/321) yet lamps still stuck/missed — under mash bursts the NAK arrives after a newer command is already "latest", so the wrong packet was resent; and total shreds never NAK at all. Now commands are stop-and-wait: ONE in flight (_commandGate), resolved by ACK, NAK, or AckTimeout (50ms); NAK/timeout retransmits THE SAME packet up to CommandRetransmitLimit (2), then drops (idempotent - the next state update supersedes). Attribution is exact by construction and timeouts catch silent shreds. Control-byte replies bypass the gate so board traffic is never delayed; a request's own reply (analog/version/ check) also resolves the wait, so request/reply exchanges never burn the timeout even if the board sends no explicit ACK. 7 tests (same-packet resend, timeout retry+drop, ACK completion, serialization, reply-resolves-request, stray-NAK no-op, disable); 282 green. Mash summary now reports NAK/timeout resends. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@@ -0,0 +1,38 @@
|
|||||||
|
|
||||||
|
|
||||||
|
Device : GAL20V8A
|
||||||
|
|
||||||
|
Created By: http://www.autoelectric.cn
|
||||||
|
|
||||||
|
Date : 2023-03-09 19:02
|
||||||
|
|
||||||
|
*QP24
|
||||||
|
|
||||||
|
*QF2706
|
||||||
|
|
||||||
|
*G0
|
||||||
|
|
||||||
|
*F0
|
||||||
|
|
||||||
|
*L00000 11111111111111111111111111111111
|
||||||
|
|
||||||
|
*L00032 11111111111111011111111111111111
|
||||||
|
|
||||||
|
*L00064 11111111110111110000000000000000
|
||||||
|
|
||||||
|
*L00320 11111111111111111111111111111111
|
||||||
|
|
||||||
|
*L00352 11111111111111111111111111111111
|
||||||
|
|
||||||
|
*L00384 11111110111111110000000000000000
|
||||||
|
|
||||||
|
*L00640 11111111111111111111111111111111
|
||||||
|
|
||||||
|
*L00672 11111111100101111011101110111011
|
||||||
|
|
||||||
|
*L00704 10111011101101110000000000000000
|
||||||
|
|
||||||
|
*L00960 11111111111111111111111111111111
|
||||||
|
|
||||||
|
*L00992 11111111111001111111111111111111
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
== RIO mash test :: COM1 @ 31250 8N1, 300s, chip label 'patched-31250' ==
|
||||||
|
lamp echo ON (drives reply/lamp collisions), wedge threshold 2.0s, app auto-recovery DISABLED
|
||||||
|
log: C:\VWE\riojoy\riomash-patched-31250-20260718-003114.log
|
||||||
|
firmware: (no version reply!)
|
||||||
|
counters before: (no status reply — reply path dead?)
|
||||||
|
|
||||||
|
>>> MASH NOW: two hands, 8 lamp buttons, as fast as you can. <<<
|
||||||
|
>>> Test runs 300s. A wedge alarm will beep + banner. <<<
|
||||||
|
>>> Ctrl+C ends the run early and still prints the summary. <<<
|
||||||
|
|
||||||
|
[ 31.90s] progress: presses=188 (188/min) analog=1185 maxGap=0.09s wedges=0
|
||||||
|
[ 61.90s] progress: presses=395 (383/min) analog=2370 maxGap=0.09s wedges=0
|
||||||
|
[ 91.93s] progress: presses=798 (521/min) analog=3559 maxGap=0.09s wedges=0
|
||||||
|
[ 121.96s] progress: presses=932 (459/min) analog=4713 maxGap=0.09s wedges=0
|
||||||
|
[ 126.86s] operator stop (Ctrl+C) — ending run, snapshotting counters...
|
||||||
|
|
||||||
|
== MASH SUMMARY [patched-31250] ==
|
||||||
|
run : 126s on COM1, lamps on, wedge threshold 2.0s
|
||||||
|
firmware : 4.2
|
||||||
|
presses/releases : 946 / 977 (443 presses/min)
|
||||||
|
analog replies : 4927 (~2290 poll slots; 215.2%), sentinels 0
|
||||||
|
framing / NAK : 5655 / 5
|
||||||
|
gap histogram : <100ms:4925 100-250ms:0 250-500ms:0 0.5-1s:1 1-2s:0 2-5s:0 >5s:0
|
||||||
|
longest gaps : 0.54s@126.9s, 0.09s@28.6s, 0.08s@29.5s, 0.08s@121.9s, 0.08s@24.3s, 0.08s@108.9s, 0.08s@21.8s, 0.08s@85.9s, 0.08s@88.9s, 0.08s@83.5s
|
||||||
|
wedge events : 0
|
||||||
|
counters before : (no status reply — reply path dead?)
|
||||||
|
counters after : RestartCount=2 AbandonCount=65 FullBufferCount=0 [board/lamp faults reported!]
|
||||||
|
counter delta : (incomplete — a snapshot got no reply)
|
||||||
|
verdict : PASS — no wedge; compare gap histogram + counter delta to baseline
|
||||||
|
log file : C:\VWE\riojoy\riomash-patched-31250-20260718-003114.log
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
== RIO mash test :: COM1 @ 62500 8N1, 300s, chip label 'patched-62500-retx' ==
|
||||||
|
lamp echo ON (drives reply/lamp collisions), wedge threshold 2.0s, app auto-recovery DISABLED
|
||||||
|
log: C:\VWE\riojoy\riomash-patched-62500-retx-20260719-131037.log
|
||||||
|
firmware: 4.2
|
||||||
|
counters before: RestartCount=0 AbandonCount=0 FullBufferCount=0 [board/lamp faults reported!]
|
||||||
|
|
||||||
|
>>> MASH NOW: two hands, 8 lamp buttons, as fast as you can. <<<
|
||||||
|
>>> Test runs 300s. A wedge alarm will beep + banner. <<<
|
||||||
|
>>> Ctrl+C ends the run early and still prints the summary. <<<
|
||||||
|
|
||||||
|
[ 33.03s] progress: presses=245 (245/min) analog=515 maxGap=0.27s wedges=0
|
||||||
|
[ 63.05s] progress: presses=568 (541/min) analog=992 maxGap=0.27s wedges=0
|
||||||
|
[ 93.11s] progress: presses=709 (457/min) analog=1470 maxGap=0.27s wedges=0
|
||||||
|
[ 95.30s] operator stop (Ctrl+C) — ending run, snapshotting counters...
|
||||||
|
|
||||||
|
== MASH SUMMARY [patched-62500-retx] ==
|
||||||
|
run : 95s on COM1, lamps on, wedge threshold 2.0s
|
||||||
|
firmware : 4.2
|
||||||
|
presses/releases : 709 / 710 (440 presses/min)
|
||||||
|
analog replies : 1522 (~1727 poll slots; 88.1%), sentinels 0
|
||||||
|
framing / NAK : 61 / 321 (NAK-triggered resends: 321)
|
||||||
|
gap histogram : <100ms:1510 100-250ms:9 250-500ms:2 0.5-1s:0 1-2s:0 2-5s:0 >5s:0
|
||||||
|
longest gaps : 0.27s@1.8s, 0.25s@95.3s, 0.13s@77.1s, 0.13s@26.6s, 0.12s@26.3s, 0.12s@57.4s, 0.12s@12.7s, 0.12s@11.5s, 0.12s@18.8s, 0.12s@31.3s
|
||||||
|
wedge events : 0
|
||||||
|
counters before : RestartCount=0 AbandonCount=0 FullBufferCount=0 [board/lamp faults reported!]
|
||||||
|
counters after : RestartCount=0 AbandonCount=0 FullBufferCount=0 [board/lamp faults reported!]
|
||||||
|
counter delta : RestartCount=+0 AbandonCount=+0 FullBufferCount=+0
|
||||||
|
verdict : PASS — no wedge; compare gap histogram + counter delta to baseline
|
||||||
|
log file : C:\VWE\riojoy\riomash-patched-62500-retx-20260719-131037.log
|
||||||
|
After Width: | Height: | Size: 614 KiB |
|
After Width: | Height: | Size: 712 KiB |
|
After Width: | Height: | Size: 844 KiB |
|
After Width: | Height: | Size: 828 KiB |
|
After Width: | Height: | Size: 987 KiB |
|
After Width: | Height: | Size: 858 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 916 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -23,13 +23,14 @@ public sealed class RioSerialLink
|
|||||||
// Time since the last accepted AnalogReply, for the recovery watchdog.
|
// Time since the last accepted AnalogReply, for the recovery watchdog.
|
||||||
private readonly Stopwatch _sinceAnalog = new();
|
private readonly Stopwatch _sinceAnalog = new();
|
||||||
|
|
||||||
// NAK retransmit state: the last COMMAND packet sent (control-byte replies
|
// Stop-and-wait command state: one command in flight at a time
|
||||||
// are excluded — the board never NAKs those) and how many resends it has
|
// (_commandGate), resolved by the board's ACK/NAK or by AckTimeout.
|
||||||
// consumed. Guarded by _retransmitGate; see RioSerialLinkOptions.NakRetransmitLimit.
|
// Control-byte replies (our ACKs) bypass the gate — the board never
|
||||||
private readonly object _retransmitGate = new();
|
// ACK/NAKs those, and they must not queue behind a pending command.
|
||||||
private byte[]? _lastCommand;
|
private readonly SemaphoreSlim _commandGate = new(1, 1);
|
||||||
private int _lastCommandResends;
|
private readonly object _pendingGate = new();
|
||||||
private long _nakRetransmits;
|
private TaskCompletionSource<bool>? _pendingAck; // true = ACK, false = NAK
|
||||||
|
private long _retransmits;
|
||||||
|
|
||||||
public RioSerialLink(IRioTransport transport, RioSerialLinkOptions? options = null)
|
public RioSerialLink(IRioTransport transport, RioSerialLinkOptions? options = null)
|
||||||
{
|
{
|
||||||
@@ -58,8 +59,8 @@ public sealed class RioSerialLink
|
|||||||
/// <summary>The transport's description, surfaced for status/logging.</summary>
|
/// <summary>The transport's description, surfaced for status/logging.</summary>
|
||||||
public string Description => _transport.Description;
|
public string Description => _transport.Description;
|
||||||
|
|
||||||
/// <summary>Total NAK-triggered retransmits since the link was created.</summary>
|
/// <summary>Total command retransmits (NAK- or timeout-triggered) since creation.</summary>
|
||||||
public long NakRetransmits => Interlocked.Read(ref _nakRetransmits);
|
public long Retransmits => Interlocked.Read(ref _retransmits);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Run the receive loop and (if enabled) the analog poll loop until
|
/// Run the receive loop and (if enabled) the analog poll loop until
|
||||||
@@ -89,23 +90,56 @@ public sealed class RioSerialLink
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Send a pre-built packet (see <see cref="PacketBuilder"/>) to the RIO.</summary>
|
/// <summary>
|
||||||
|
/// Send a pre-built packet (see <see cref="PacketBuilder"/>) to the RIO.
|
||||||
|
/// Command packets use stop-and-wait: the call completes once the board
|
||||||
|
/// ACKs, or after <see cref="RioSerialLinkOptions.CommandRetransmitLimit"/>
|
||||||
|
/// retransmits (NAK- or timeout-triggered) go unacknowledged — the command
|
||||||
|
/// is then dropped (all commands are idempotent state-setters, and the
|
||||||
|
/// caller's next update supersedes it). Control bytes bypass the wait.
|
||||||
|
/// </summary>
|
||||||
public async Task SendAsync(byte[] packet, CancellationToken cancellationToken = default)
|
public async Task SendAsync(byte[] packet, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (packet is null) throw new ArgumentNullException(nameof(packet));
|
if (packet is null) throw new ArgumentNullException(nameof(packet));
|
||||||
|
|
||||||
// Only real command packets participate in NAK retransmit; single
|
// Control-byte replies (len 1) are never ACK/NAK'd by the board, and a
|
||||||
// control bytes (our ACK/NAK replies) are never NAK'd by the board.
|
// limit of 0 selects the legacy fire-and-forget behavior.
|
||||||
if (packet.Length > 1 && _options.NakRetransmitLimit > 0)
|
if (packet.Length <= 1 || _options.CommandRetransmitLimit <= 0)
|
||||||
{
|
{
|
||||||
lock (_retransmitGate)
|
await WriteAsync(packet, cancellationToken).ConfigureAwait(false);
|
||||||
{
|
return;
|
||||||
_lastCommand = packet;
|
|
||||||
_lastCommandResends = 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Compat.TaskCompat.WaitAsync(_commandGate, cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (int attempt = 0; ; attempt++)
|
||||||
|
{
|
||||||
|
var pending = new TaskCompletionSource<bool>();
|
||||||
|
lock (_pendingGate) _pendingAck = pending;
|
||||||
|
|
||||||
await WriteAsync(packet, cancellationToken).ConfigureAwait(false);
|
await WriteAsync(packet, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
Task winner = await Compat.TaskCompat.WhenAny(new[]
|
||||||
|
{
|
||||||
|
pending.Task,
|
||||||
|
Compat.TaskCompat.Delay(_options.AckTimeout, cancellationToken),
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (winner == pending.Task && pending.Task.Result)
|
||||||
|
return; // ACK'd
|
||||||
|
|
||||||
|
// 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
|
||||||
|
Interlocked.Increment(ref _retransmits);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
lock (_pendingGate) _pendingAck = null;
|
||||||
|
_commandGate.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task WriteAsync(byte[] packet, CancellationToken cancellationToken)
|
private async Task WriteAsync(byte[] packet, CancellationToken cancellationToken)
|
||||||
@@ -121,24 +155,6 @@ public sealed class RioSerialLink
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The board NAK'd its most recent inbound packet: resend the last command
|
|
||||||
// (idempotent, see RioSerialLinkOptions.NakRetransmitLimit) unless its
|
|
||||||
// retry budget is spent.
|
|
||||||
private async Task RetransmitOnNakAsync(CancellationToken ct)
|
|
||||||
{
|
|
||||||
byte[]? packet;
|
|
||||||
lock (_retransmitGate)
|
|
||||||
{
|
|
||||||
if (_lastCommand is null || _lastCommandResends >= _options.NakRetransmitLimit)
|
|
||||||
return;
|
|
||||||
_lastCommandResends++;
|
|
||||||
packet = _lastCommand;
|
|
||||||
}
|
|
||||||
|
|
||||||
Interlocked.Increment(ref _nakRetransmits);
|
|
||||||
await WriteAsync(packet!, ct).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Request an analog update (<see cref="RioCommand.AnalogRequest"/>).</summary>
|
/// <summary>Request an analog update (<see cref="RioCommand.AnalogRequest"/>).</summary>
|
||||||
public Task RequestAnalogAsync(CancellationToken cancellationToken = default) =>
|
public Task RequestAnalogAsync(CancellationToken cancellationToken = default) =>
|
||||||
SendAsync(PacketBuilder.AnalogRequest(), cancellationToken);
|
SendAsync(PacketBuilder.AnalogRequest(), cancellationToken);
|
||||||
@@ -188,8 +204,14 @@ public sealed class RioSerialLink
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case RioRxEventKind.ControlByte:
|
case RioRxEventKind.ControlByte:
|
||||||
if (ev.Byte == (byte)RioControl.Nak)
|
if (ev.Byte is (byte)RioControl.Ack or (byte)RioControl.Nak)
|
||||||
await RetransmitOnNakAsync(ct).ConfigureAwait(false);
|
{
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
ControlReceived?.Invoke(ev.Byte);
|
ControlReceived?.Invoke(ev.Byte);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -201,6 +223,19 @@ public sealed class RioSerialLink
|
|||||||
|
|
||||||
private void DispatchTyped(RioPacket packet)
|
private void DispatchTyped(RioPacket packet)
|
||||||
{
|
{
|
||||||
|
if (packet.Command is RioCommand.AnalogReply or RioCommand.VersionReply or RioCommand.CheckReply)
|
||||||
|
{
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
switch (packet.Command)
|
switch (packet.Command)
|
||||||
{
|
{
|
||||||
case RioCommand.AnalogReply:
|
case RioCommand.AnalogReply:
|
||||||
|
|||||||
@@ -32,14 +32,22 @@ public sealed record RioSerialLinkOptions
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// How many times a command packet is retransmitted when the board NAKs it
|
/// How many times a command packet is retransmitted when the board NAKs it
|
||||||
/// (0 = fire-and-forget, the pre-2026-07 behavior). The wire has no sequence
|
/// or the ACK never arrives (0 = fire-and-forget, the pre-2026-07 behavior).
|
||||||
/// numbers, but every PC→RIO command is idempotent (lamp state, analog
|
/// Commands use stop-and-wait: ONE command in flight, resolved by
|
||||||
/// request, reset), so resending the most recent packet on NAK is safe even
|
/// ACK / NAK / <see cref="AckTimeout"/> before the next is written. The
|
||||||
/// in the rare race where the NAK belonged to an earlier one. Bounded so a
|
/// first (NAK-race) design resent "the most recent" packet and misfired
|
||||||
/// NAK storm (e.g. a rate the board's RX can't sustain) can't multiply
|
/// under mash bursts — by the time a NAK crossed USB, a newer command was
|
||||||
/// traffic without limit. Bench origin: at 62500 baud ~6.5% of 4-byte lamp
|
/// already the latest, so the corrupted write stayed lost (bench
|
||||||
/// commands arrive corrupt (RX-ISR overrun) — one retry cuts the visible
|
/// 2026-07-19); with one-in-flight the attribution is exact, and the
|
||||||
/// lamp-state error rate to ~0.4%, two to ~0.03%.
|
/// timeout also catches corruptions so complete the board never NAKs.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int NakRetransmitLimit { get; init; } = 2;
|
public int CommandRetransmitLimit { get; init; } = 2;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How long to wait for the board's ACK/NAK of a command before treating it
|
||||||
|
/// as lost (stop-and-wait; see <see cref="CommandRetransmitLimit"/>). Board
|
||||||
|
/// turnaround is ~1-4 ms; 50 ms is generous without stalling the 55 ms
|
||||||
|
/// analog poll cadence on a healthy link.
|
||||||
|
/// </summary>
|
||||||
|
public TimeSpan AckTimeout { get; init; } = TimeSpan.FromMilliseconds(50);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,124 +142,151 @@ public class RioSerialLinkTests
|
|||||||
await run;
|
await run;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- NAK retransmit (RioSerialLinkOptions.NakRetransmitLimit) ------------
|
// --- stop-and-wait command retransmit (CommandRetransmitLimit) -----------
|
||||||
|
|
||||||
[Fact]
|
private static RioSerialLinkOptions StopAndWait(int limit = 2, int timeoutMs = 150) => new()
|
||||||
public async Task Nak_RetransmitsLastCommand()
|
|
||||||
{
|
|
||||||
var fake = new FakeTransport();
|
|
||||||
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
|
|
||||||
|
|
||||||
using var cts = new CancellationTokenSource();
|
|
||||||
Task run = link.RunAsync(cts.Token);
|
|
||||||
|
|
||||||
byte[] lamp = PacketBuilder.LampRequest(0x05, 0x02);
|
|
||||||
await link.SendAsync(lamp);
|
|
||||||
Assert.Equal(lamp, await fake.NextWriteAsync());
|
|
||||||
|
|
||||||
fake.Enqueue((byte)RioControl.Nak); // board: "that arrived corrupt"
|
|
||||||
|
|
||||||
Assert.Equal(lamp, await fake.NextWriteAsync()); // resent
|
|
||||||
Assert.Equal(1, link.NakRetransmits);
|
|
||||||
|
|
||||||
cts.Cancel();
|
|
||||||
await run;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Nak_RetransmitStopsAtLimit()
|
|
||||||
{
|
|
||||||
var fake = new FakeTransport();
|
|
||||||
var link = new RioSerialLink(fake, new RioSerialLinkOptions
|
|
||||||
{
|
{
|
||||||
AutoPollAnalog = false,
|
AutoPollAnalog = false,
|
||||||
NakRetransmitLimit = 2,
|
CommandRetransmitLimit = limit,
|
||||||
});
|
AckTimeout = TimeSpan.FromMilliseconds(timeoutMs),
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Nak_RetransmitsTheSamePacket()
|
||||||
|
{
|
||||||
|
var fake = new FakeTransport();
|
||||||
|
var link = new RioSerialLink(fake, StopAndWait());
|
||||||
|
|
||||||
using var cts = new CancellationTokenSource();
|
using var cts = new CancellationTokenSource();
|
||||||
Task run = link.RunAsync(cts.Token);
|
Task run = link.RunAsync(cts.Token);
|
||||||
|
|
||||||
byte[] lamp = PacketBuilder.LampRequest(0x05, 0x02);
|
byte[] lamp = PacketBuilder.LampRequest(0x05, 0x02);
|
||||||
await link.SendAsync(lamp);
|
Task send = link.SendAsync(lamp);
|
||||||
await fake.NextWriteAsync(); // original
|
|
||||||
|
|
||||||
for (int i = 0; i < 4; i++)
|
Assert.Equal(lamp, await fake.NextWriteAsync());
|
||||||
fake.Enqueue((byte)RioControl.Nak);
|
fake.Enqueue((byte)RioControl.Nak); // board: "that arrived corrupt"
|
||||||
|
Assert.Equal(lamp, await fake.NextWriteAsync()); // exact same packet again
|
||||||
|
fake.Enqueue((byte)RioControl.Ack); // second copy lands
|
||||||
|
|
||||||
Assert.Equal(lamp, await fake.NextWriteAsync()); // retry 1
|
await send.WaitAsync(Timeout);
|
||||||
Assert.Equal(lamp, await fake.NextWriteAsync()); // retry 2
|
Assert.Equal(1, link.Retransmits);
|
||||||
|
|
||||||
// Budget spent: NAKs 3 and 4 must produce no further writes.
|
|
||||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
|
||||||
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
|
|
||||||
Assert.Equal(2, link.NakRetransmits);
|
|
||||||
|
|
||||||
cts.Cancel();
|
cts.Cancel();
|
||||||
await run;
|
await run;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Ack_DoesNotRetransmit()
|
public async Task Timeout_RetransmitsWhenBoardNeverResponds()
|
||||||
{
|
{
|
||||||
var fake = new FakeTransport();
|
var fake = new FakeTransport();
|
||||||
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
|
var link = new RioSerialLink(fake, StopAndWait(limit: 2, timeoutMs: 80));
|
||||||
|
|
||||||
using var cts = new CancellationTokenSource();
|
using var cts = new CancellationTokenSource();
|
||||||
Task run = link.RunAsync(cts.Token);
|
Task run = link.RunAsync(cts.Token);
|
||||||
|
|
||||||
await link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02));
|
byte[] lamp = PacketBuilder.LampRequest(0x05, 0x02);
|
||||||
await fake.NextWriteAsync();
|
Task send = link.SendAsync(lamp);
|
||||||
|
|
||||||
|
// Original + 2 timeout-driven retries, then the command is dropped.
|
||||||
|
Assert.Equal(lamp, await fake.NextWriteAsync());
|
||||||
|
Assert.Equal(lamp, await fake.NextWriteAsync());
|
||||||
|
Assert.Equal(lamp, await fake.NextWriteAsync());
|
||||||
|
await send.WaitAsync(Timeout); // completes (dropped), doesn't hang
|
||||||
|
Assert.Equal(2, link.Retransmits);
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||||
|
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
|
||||||
|
|
||||||
|
cts.Cancel();
|
||||||
|
await run;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Ack_CompletesWithoutRetransmit()
|
||||||
|
{
|
||||||
|
var fake = new FakeTransport();
|
||||||
|
var link = new RioSerialLink(fake, StopAndWait());
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
Task run = link.RunAsync(cts.Token);
|
||||||
|
|
||||||
|
Task send = link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02));
|
||||||
|
await fake.NextWriteAsync();
|
||||||
fake.Enqueue((byte)RioControl.Ack);
|
fake.Enqueue((byte)RioControl.Ack);
|
||||||
|
|
||||||
|
await send.WaitAsync(Timeout);
|
||||||
|
Assert.Equal(0, link.Retransmits);
|
||||||
|
|
||||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||||
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
|
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
|
||||||
Assert.Equal(0, link.NakRetransmits);
|
|
||||||
|
|
||||||
cts.Cancel();
|
cts.Cancel();
|
||||||
await run;
|
await run;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task NewCommand_ResetsRetryBudget()
|
public async Task Commands_AreSerialized_SecondWaitsForFirst()
|
||||||
{
|
{
|
||||||
var fake = new FakeTransport();
|
var fake = new FakeTransport();
|
||||||
var link = new RioSerialLink(fake, new RioSerialLinkOptions
|
var link = new RioSerialLink(fake, StopAndWait());
|
||||||
{
|
|
||||||
AutoPollAnalog = false,
|
|
||||||
NakRetransmitLimit = 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
using var cts = new CancellationTokenSource();
|
using var cts = new CancellationTokenSource();
|
||||||
Task run = link.RunAsync(cts.Token);
|
Task run = link.RunAsync(cts.Token);
|
||||||
|
|
||||||
byte[] first = PacketBuilder.LampRequest(0x05, 0x02);
|
byte[] first = PacketBuilder.LampRequest(0x05, 0x02);
|
||||||
await link.SendAsync(first);
|
|
||||||
await fake.NextWriteAsync();
|
|
||||||
fake.Enqueue((byte)RioControl.Nak);
|
|
||||||
Assert.Equal(first, await fake.NextWriteAsync()); // budget of 'first' spent
|
|
||||||
|
|
||||||
byte[] second = PacketBuilder.LampRequest(0x06, 0x01);
|
byte[] second = PacketBuilder.LampRequest(0x06, 0x01);
|
||||||
await link.SendAsync(second);
|
Task sendA = link.SendAsync(first);
|
||||||
Assert.Equal(second, await fake.NextWriteAsync());
|
Task sendB = link.SendAsync(second);
|
||||||
fake.Enqueue((byte)RioControl.Nak);
|
|
||||||
Assert.Equal(second, await fake.NextWriteAsync()); // fresh budget applies
|
// Only the first is on the wire until it resolves.
|
||||||
|
Assert.Equal(first, await fake.NextWriteAsync());
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||||
|
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(100)));
|
||||||
|
|
||||||
|
fake.Enqueue((byte)RioControl.Ack);
|
||||||
|
Assert.Equal(second, await fake.NextWriteAsync()); // now B goes out
|
||||||
|
fake.Enqueue((byte)RioControl.Ack);
|
||||||
|
|
||||||
|
await sendA.WaitAsync(Timeout);
|
||||||
|
await sendB.WaitAsync(Timeout);
|
||||||
|
|
||||||
cts.Cancel();
|
cts.Cancel();
|
||||||
await run;
|
await run;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OwnControlByteReplies_AreNeverRetransmitted()
|
public async Task RequestReply_ResolvesTheInFlightCommand_WithoutExplicitAck()
|
||||||
{
|
{
|
||||||
var fake = new FakeTransport();
|
var fake = new FakeTransport();
|
||||||
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
|
var link = new RioSerialLink(fake, StopAndWait());
|
||||||
|
|
||||||
using var cts = new CancellationTokenSource();
|
using var cts = new CancellationTokenSource();
|
||||||
Task run = link.RunAsync(cts.Token);
|
Task run = link.RunAsync(cts.Token);
|
||||||
|
|
||||||
// Inbound button packet -> the link replies with a 1-byte ACK. That ACK
|
// An analog request answered by the reply alone (no ACK byte) must not
|
||||||
// must not become "the last command" for retransmit purposes.
|
// burn the ACK timeout — the reply proves the request landed.
|
||||||
|
Task send = link.RequestAnalogAsync();
|
||||||
|
await fake.NextWriteAsync();
|
||||||
|
fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10]));
|
||||||
|
|
||||||
|
await send.WaitAsync(TimeSpan.FromMilliseconds(120)); // well under AckTimeout
|
||||||
|
Assert.Equal(0, link.Retransmits);
|
||||||
|
|
||||||
|
cts.Cancel();
|
||||||
|
await run;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UnsolicitedNak_WithNothingPending_IsIgnored()
|
||||||
|
{
|
||||||
|
var fake = new FakeTransport();
|
||||||
|
var link = new RioSerialLink(fake, StopAndWait());
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
Task run = link.RunAsync(cts.Token);
|
||||||
|
|
||||||
|
// Inbound button packet -> our 1-byte ACK reply (bypasses the command
|
||||||
|
// gate). A stray NAK afterwards must not resend anything.
|
||||||
fake.Enqueue(PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 }));
|
fake.Enqueue(PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 }));
|
||||||
Assert.Equal(new byte[] { (byte)RioControl.Ack }, await fake.NextWriteAsync());
|
Assert.Equal(new byte[] { (byte)RioControl.Ack }, await fake.NextWriteAsync());
|
||||||
|
|
||||||
@@ -267,7 +294,7 @@ public class RioSerialLinkTests
|
|||||||
|
|
||||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||||
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
|
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
|
||||||
Assert.Equal(0, link.NakRetransmits);
|
Assert.Equal(0, link.Retransmits);
|
||||||
|
|
||||||
cts.Cancel();
|
cts.Cancel();
|
||||||
await run;
|
await run;
|
||||||
@@ -277,22 +304,19 @@ public class RioSerialLinkTests
|
|||||||
public async Task Retransmit_Disabled_IsFireAndForget()
|
public async Task Retransmit_Disabled_IsFireAndForget()
|
||||||
{
|
{
|
||||||
var fake = new FakeTransport();
|
var fake = new FakeTransport();
|
||||||
var link = new RioSerialLink(fake, new RioSerialLinkOptions
|
var link = new RioSerialLink(fake, StopAndWait(limit: 0));
|
||||||
{
|
|
||||||
AutoPollAnalog = false,
|
|
||||||
NakRetransmitLimit = 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
using var cts = new CancellationTokenSource();
|
using var cts = new CancellationTokenSource();
|
||||||
Task run = link.RunAsync(cts.Token);
|
Task run = link.RunAsync(cts.Token);
|
||||||
|
|
||||||
await link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02));
|
// Completes immediately (no ACK wait), and a NAK triggers nothing.
|
||||||
|
await link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02)).WaitAsync(Timeout);
|
||||||
await fake.NextWriteAsync();
|
await fake.NextWriteAsync();
|
||||||
fake.Enqueue((byte)RioControl.Nak);
|
fake.Enqueue((byte)RioControl.Nak);
|
||||||
|
|
||||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||||
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
|
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
|
||||||
Assert.Equal(0, link.NakRetransmits);
|
Assert.Equal(0, link.Retransmits);
|
||||||
|
|
||||||
cts.Cancel();
|
cts.Cancel();
|
||||||
await run;
|
await run;
|
||||||
|
|||||||
@@ -18,4 +18,15 @@ internal static class TaskTestExtensions
|
|||||||
cts.Cancel(); // stop the delay timer
|
cts.Cancel(); // stop the delay timer
|
||||||
return await task.ConfigureAwait(false);
|
return await task.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Non-generic counterpart of <see cref="WaitAsync{T}"/>.</summary>
|
||||||
|
public static async Task WaitAsync(this Task task, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
Task completed = await Task.WhenAny(task, Task.Delay(timeout, cts.Token)).ConfigureAwait(false);
|
||||||
|
if (completed != task)
|
||||||
|
throw new TimeoutException($"Task did not complete within {timeout}.");
|
||||||
|
cts.Cancel();
|
||||||
|
await task.ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ internal static class MashTest
|
|||||||
Raw($"firmware : {(version is null ? "(no reply)" : version.ToString())}");
|
Raw($"firmware : {(version is null ? "(no reply)" : version.ToString())}");
|
||||||
Raw($"presses/releases : {presses} / {releases} ({presses / mins:F0} presses/min)");
|
Raw($"presses/releases : {presses} / {releases} ({presses / mins:F0} presses/min)");
|
||||||
Raw($"analog replies : {analogReplies} (~{expectedPolls} poll slots; {100.0 * analogReplies / Math.Max(1, expectedPolls):F1}%), sentinels {sentinels}");
|
Raw($"analog replies : {analogReplies} (~{expectedPolls} poll slots; {100.0 * analogReplies / Math.Max(1, expectedPolls):F1}%), sentinels {sentinels}");
|
||||||
Raw($"framing / NAK : {framing} / {naks} (NAK-triggered resends: {link.NakRetransmits})");
|
Raw($"framing / NAK : {framing} / {naks} (command resends NAK/timeout: {link.Retransmits})");
|
||||||
Raw("gap histogram : " + string.Join(" ", bucketNames.Select((n, i) => $"{n}:{buckets[i]}")));
|
Raw("gap histogram : " + string.Join(" ", bucketNames.Select((n, i) => $"{n}:{buckets[i]}")));
|
||||||
Raw("longest gaps : " + (longestGaps.Count == 0
|
Raw("longest gaps : " + (longestGaps.Count == 0
|
||||||
? "(none)"
|
? "(none)"
|
||||||
|
|||||||