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>
This commit is contained in:
Cyd
2026-07-19 13:20:30 -05:00
co-authored by Claude Fable 5
parent 1943b7f8eb
commit 47df14cc61
18 changed files with 279 additions and 104 deletions
+38
View File
@@ -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
BIN
View File
Binary file not shown.
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 614 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 828 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 987 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 916 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+74 -39
View File
@@ -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 WriteAsync(packet, cancellationToken).ConfigureAwait(false); 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);
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:
+17 -9
View File
@@ -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) -----------
private static RioSerialLinkOptions StopAndWait(int limit = 2, int timeoutMs = 150) => new()
{
AutoPollAnalog = false,
CommandRetransmitLimit = limit,
AckTimeout = TimeSpan.FromMilliseconds(timeoutMs),
};
[Fact] [Fact]
public async Task Nak_RetransmitsLastCommand() public async Task Nak_RetransmitsTheSamePacket()
{ {
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);
byte[] lamp = PacketBuilder.LampRequest(0x05, 0x02); byte[] lamp = PacketBuilder.LampRequest(0x05, 0x02);
await link.SendAsync(lamp); Task send = link.SendAsync(lamp);
Assert.Equal(lamp, await fake.NextWriteAsync()); Assert.Equal(lamp, await fake.NextWriteAsync());
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
fake.Enqueue((byte)RioControl.Nak); // board: "that arrived corrupt" await send.WaitAsync(Timeout);
Assert.Equal(1, link.Retransmits);
Assert.Equal(lamp, await fake.NextWriteAsync()); // resent
Assert.Equal(1, link.NakRetransmits);
cts.Cancel(); cts.Cancel();
await run; await run;
} }
[Fact] [Fact]
public async Task Nak_RetransmitStopsAtLimit() public async Task Timeout_RetransmitsWhenBoardNeverResponds()
{ {
var fake = new FakeTransport(); var fake = new FakeTransport();
var link = new RioSerialLink(fake, new RioSerialLinkOptions var link = new RioSerialLink(fake, StopAndWait(limit: 2, timeoutMs: 80));
{
AutoPollAnalog = false,
NakRetransmitLimit = 2,
});
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++) // Original + 2 timeout-driven retries, then the command is dropped.
fake.Enqueue((byte)RioControl.Nak); 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);
Assert.Equal(lamp, await fake.NextWriteAsync()); // retry 1
Assert.Equal(lamp, await fake.NextWriteAsync()); // retry 2
// Budget spent: NAKs 3 and 4 must produce no further writes.
await Assert.ThrowsAnyAsync<OperationCanceledException>( await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200))); () => 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 Ack_CompletesWithoutRetransmit()
{ {
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);
await link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02)); Task send = link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02));
await fake.NextWriteAsync(); 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;
+11
View File
@@ -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);
}
} }
+1 -1
View File
@@ -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)"