diff --git a/GAL20v8a_5764.JED b/GAL20v8a_5764.JED new file mode 100644 index 0000000..fb43e5c --- /dev/null +++ b/GAL20v8a_5764.JED @@ -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 +*L01024 11111111110111110110111111111111 +*L01056 11111111111111111101111110011011 +*L01088 11111111111111111111111111011111 +*L01280 11111111111111111111111111111111 +*L01312 11111111100101101011101110111011 +*L01344 10111011100110010000000000000000 +*L01600 11111111111111111111111111111111 +*L01632 11111111100101101011101110111011 +*L01664 10111011100110100000000000000000 +*L02240 11111111111111111111111111111111 +*L02272 11111111010111111111111111111111 +*L02304 11111111111111110000000000000000 +*L02560 01000000010101010011011100101110 +*L02592 00101110001011100010111000000000 +*L02624 00000000111111111111111111111111 +*L02656 11111111111111111111111111111111 +*L02688 111111111111111111 +*C5764 +*F676 diff --git a/Scans_018-014.pdf b/Scans_018-014.pdf new file mode 100644 index 0000000..be55bab Binary files /dev/null and b/Scans_018-014.pdf differ diff --git a/rio-firmware/testlogs/riomash-patched-31250-20260718-003114.log b/rio-firmware/testlogs/riomash-patched-31250-20260718-003114.log new file mode 100644 index 0000000..6c22c0f --- /dev/null +++ b/rio-firmware/testlogs/riomash-patched-31250-20260718-003114.log @@ -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 diff --git a/rio-firmware/testlogs/riomash-patched-62500-retx-20260719-131037.log b/rio-firmware/testlogs/riomash-patched-62500-retx-20260719-131037.log new file mode 100644 index 0000000..b5e02fe --- /dev/null +++ b/rio-firmware/testlogs/riomash-patched-62500-retx-20260719-131037.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 diff --git a/signal-2026-07-15-093011.jpeg b/signal-2026-07-15-093011.jpeg new file mode 100644 index 0000000..b29bf35 Binary files /dev/null and b/signal-2026-07-15-093011.jpeg differ diff --git a/signal-2026-07-15-093017.jpeg b/signal-2026-07-15-093017.jpeg new file mode 100644 index 0000000..111ad66 Binary files /dev/null and b/signal-2026-07-15-093017.jpeg differ diff --git a/signal-2026-07-15-093023.jpeg b/signal-2026-07-15-093023.jpeg new file mode 100644 index 0000000..bd55569 Binary files /dev/null and b/signal-2026-07-15-093023.jpeg differ diff --git a/signal-2026-07-15-093032.jpeg b/signal-2026-07-15-093032.jpeg new file mode 100644 index 0000000..89dae75 Binary files /dev/null and b/signal-2026-07-15-093032.jpeg differ diff --git a/signal-2026-07-15-093038.jpeg b/signal-2026-07-15-093038.jpeg new file mode 100644 index 0000000..9c7b46c Binary files /dev/null and b/signal-2026-07-15-093038.jpeg differ diff --git a/signal-2026-07-15-093045.jpeg b/signal-2026-07-15-093045.jpeg new file mode 100644 index 0000000..1aead52 Binary files /dev/null and b/signal-2026-07-15-093045.jpeg differ diff --git a/signal-2026-07-15-093051.jpeg b/signal-2026-07-15-093051.jpeg new file mode 100644 index 0000000..90f32c3 Binary files /dev/null and b/signal-2026-07-15-093051.jpeg differ diff --git a/signal-2026-07-15-093058.jpeg b/signal-2026-07-15-093058.jpeg new file mode 100644 index 0000000..3993e1e Binary files /dev/null and b/signal-2026-07-15-093058.jpeg differ diff --git a/signal-2026-07-15-093105.jpeg b/signal-2026-07-15-093105.jpeg new file mode 100644 index 0000000..471dfde Binary files /dev/null and b/signal-2026-07-15-093105.jpeg differ diff --git a/src/RioJoy.Core/Serial/RioSerialLink.cs b/src/RioJoy.Core/Serial/RioSerialLink.cs index 7e2f5ee..0ac9ed3 100644 --- a/src/RioJoy.Core/Serial/RioSerialLink.cs +++ b/src/RioJoy.Core/Serial/RioSerialLink.cs @@ -23,13 +23,14 @@ public sealed class RioSerialLink // Time since the last accepted AnalogReply, for the recovery watchdog. private readonly Stopwatch _sinceAnalog = new(); - // NAK retransmit state: the last COMMAND packet sent (control-byte replies - // are excluded — the board never NAKs those) and how many resends it has - // consumed. Guarded by _retransmitGate; see RioSerialLinkOptions.NakRetransmitLimit. - private readonly object _retransmitGate = new(); - private byte[]? _lastCommand; - private int _lastCommandResends; - private long _nakRetransmits; + // Stop-and-wait command state: one command in flight at a time + // (_commandGate), resolved by the board's ACK/NAK or by AckTimeout. + // Control-byte replies (our ACKs) bypass the gate — the board never + // 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? _pendingAck; // true = ACK, false = NAK + private long _retransmits; public RioSerialLink(IRioTransport transport, RioSerialLinkOptions? options = null) { @@ -58,8 +59,8 @@ public sealed class RioSerialLink /// The transport's description, surfaced for status/logging. public string Description => _transport.Description; - /// Total NAK-triggered retransmits since the link was created. - public long NakRetransmits => Interlocked.Read(ref _nakRetransmits); + /// Total command retransmits (NAK- or timeout-triggered) since creation. + public long Retransmits => Interlocked.Read(ref _retransmits); /// /// Run the receive loop and (if enabled) the analog poll loop until @@ -89,23 +90,56 @@ public sealed class RioSerialLink } } - /// Send a pre-built packet (see ) to the RIO. + /// + /// Send a pre-built packet (see ) to the RIO. + /// Command packets use stop-and-wait: the call completes once the board + /// ACKs, or after + /// 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. + /// public async Task SendAsync(byte[] packet, CancellationToken cancellationToken = default) { if (packet is null) throw new ArgumentNullException(nameof(packet)); - // Only real command packets participate in NAK retransmit; single - // control bytes (our ACK/NAK replies) are never NAK'd by the board. - if (packet.Length > 1 && _options.NakRetransmitLimit > 0) + // Control-byte replies (len 1) are never ACK/NAK'd by the board, and a + // limit of 0 selects the legacy fire-and-forget behavior. + if (packet.Length <= 1 || _options.CommandRetransmitLimit <= 0) { - lock (_retransmitGate) - { - _lastCommand = packet; - _lastCommandResends = 0; - } + await WriteAsync(packet, cancellationToken).ConfigureAwait(false); + return; } - await WriteAsync(packet, cancellationToken).ConfigureAwait(false); + await Compat.TaskCompat.WaitAsync(_commandGate, cancellationToken).ConfigureAwait(false); + try + { + for (int attempt = 0; ; attempt++) + { + var pending = new TaskCompletionSource(); + 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) @@ -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); - } - /// Request an analog update (). public Task RequestAnalogAsync(CancellationToken cancellationToken = default) => SendAsync(PacketBuilder.AnalogRequest(), cancellationToken); @@ -188,8 +204,14 @@ public sealed class RioSerialLink break; case RioRxEventKind.ControlByte: - if (ev.Byte == (byte)RioControl.Nak) - await RetransmitOnNakAsync(ct).ConfigureAwait(false); + 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? pending; + lock (_pendingGate) pending = _pendingAck; + pending?.TrySetResult(ev.Byte == (byte)RioControl.Ack); + } ControlReceived?.Invoke(ev.Byte); break; @@ -201,6 +223,19 @@ 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 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? pending; + lock (_pendingGate) pending = _pendingAck; + pending?.TrySetResult(true); + } + switch (packet.Command) { case RioCommand.AnalogReply: diff --git a/src/RioJoy.Core/Serial/RioSerialLinkOptions.cs b/src/RioJoy.Core/Serial/RioSerialLinkOptions.cs index 994d733..67a88c9 100644 --- a/src/RioJoy.Core/Serial/RioSerialLinkOptions.cs +++ b/src/RioJoy.Core/Serial/RioSerialLinkOptions.cs @@ -32,14 +32,22 @@ public sealed record RioSerialLinkOptions /// /// 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 - /// numbers, but every PC→RIO command is idempotent (lamp state, analog - /// request, reset), so resending the most recent packet on NAK is safe even - /// in the rare race where the NAK belonged to an earlier one. Bounded so a - /// NAK storm (e.g. a rate the board's RX can't sustain) can't multiply - /// traffic without limit. Bench origin: at 62500 baud ~6.5% of 4-byte lamp - /// commands arrive corrupt (RX-ISR overrun) — one retry cuts the visible - /// lamp-state error rate to ~0.4%, two to ~0.03%. + /// or the ACK never arrives (0 = fire-and-forget, the pre-2026-07 behavior). + /// Commands use stop-and-wait: ONE command in flight, resolved by + /// ACK / NAK / before the next is written. The + /// first (NAK-race) design resent "the most recent" packet and misfired + /// under mash bursts — by the time a NAK crossed USB, a newer command was + /// already the latest, so the corrupted write stayed lost (bench + /// 2026-07-19); with one-in-flight the attribution is exact, and the + /// timeout also catches corruptions so complete the board never NAKs. /// - public int NakRetransmitLimit { get; init; } = 2; + public int CommandRetransmitLimit { get; init; } = 2; + + /// + /// How long to wait for the board's ACK/NAK of a command before treating it + /// as lost (stop-and-wait; see ). Board + /// turnaround is ~1-4 ms; 50 ms is generous without stalling the 55 ms + /// analog poll cadence on a healthy link. + /// + public TimeSpan AckTimeout { get; init; } = TimeSpan.FromMilliseconds(50); } diff --git a/tests/RioJoy.Core.Tests/Serial/RioSerialLinkTests.cs b/tests/RioJoy.Core.Tests/Serial/RioSerialLinkTests.cs index 1eb33c0..8d93646 100644 --- a/tests/RioJoy.Core.Tests/Serial/RioSerialLinkTests.cs +++ b/tests/RioJoy.Core.Tests/Serial/RioSerialLinkTests.cs @@ -142,124 +142,151 @@ public class RioSerialLinkTests 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] - public async Task Nak_RetransmitsLastCommand() + public async Task Nak_RetransmitsTheSamePacket() { var fake = new FakeTransport(); - var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false }); + var link = new RioSerialLink(fake, StopAndWait()); using var cts = new CancellationTokenSource(); Task run = link.RunAsync(cts.Token); byte[] lamp = PacketBuilder.LampRequest(0x05, 0x02); - await link.SendAsync(lamp); + Task send = link.SendAsync(lamp); + 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" - - Assert.Equal(lamp, await fake.NextWriteAsync()); // resent - Assert.Equal(1, link.NakRetransmits); + await send.WaitAsync(Timeout); + Assert.Equal(1, link.Retransmits); cts.Cancel(); await run; } [Fact] - public async Task Nak_RetransmitStopsAtLimit() + public async Task Timeout_RetransmitsWhenBoardNeverResponds() { var fake = new FakeTransport(); - var link = new RioSerialLink(fake, new RioSerialLinkOptions - { - AutoPollAnalog = false, - NakRetransmitLimit = 2, - }); + var link = new RioSerialLink(fake, StopAndWait(limit: 2, timeoutMs: 80)); using var cts = new CancellationTokenSource(); Task run = link.RunAsync(cts.Token); byte[] lamp = PacketBuilder.LampRequest(0x05, 0x02); - await link.SendAsync(lamp); - await fake.NextWriteAsync(); // original + Task send = link.SendAsync(lamp); - for (int i = 0; i < 4; i++) - fake.Enqueue((byte)RioControl.Nak); + // 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); - 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( () => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200))); - Assert.Equal(2, link.NakRetransmits); cts.Cancel(); await run; } [Fact] - public async Task Ack_DoesNotRetransmit() + public async Task Ack_CompletesWithoutRetransmit() { var fake = new FakeTransport(); - var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false }); + var link = new RioSerialLink(fake, StopAndWait()); using var cts = new CancellationTokenSource(); Task run = link.RunAsync(cts.Token); - await link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02)); + Task send = link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02)); await fake.NextWriteAsync(); - fake.Enqueue((byte)RioControl.Ack); + await send.WaitAsync(Timeout); + Assert.Equal(0, link.Retransmits); + await Assert.ThrowsAnyAsync( () => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200))); - Assert.Equal(0, link.NakRetransmits); cts.Cancel(); await run; } [Fact] - public async Task NewCommand_ResetsRetryBudget() + public async Task Commands_AreSerialized_SecondWaitsForFirst() { var fake = new FakeTransport(); - var link = new RioSerialLink(fake, new RioSerialLinkOptions - { - AutoPollAnalog = false, - NakRetransmitLimit = 1, - }); + var link = new RioSerialLink(fake, StopAndWait()); using var cts = new CancellationTokenSource(); Task run = link.RunAsync(cts.Token); 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); - await link.SendAsync(second); - Assert.Equal(second, await fake.NextWriteAsync()); - fake.Enqueue((byte)RioControl.Nak); - Assert.Equal(second, await fake.NextWriteAsync()); // fresh budget applies + Task sendA = link.SendAsync(first); + Task sendB = link.SendAsync(second); + + // Only the first is on the wire until it resolves. + Assert.Equal(first, await fake.NextWriteAsync()); + await Assert.ThrowsAnyAsync( + () => 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(); await run; } [Fact] - public async Task OwnControlByteReplies_AreNeverRetransmitted() + public async Task RequestReply_ResolvesTheInFlightCommand_WithoutExplicitAck() { var fake = new FakeTransport(); - var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false }); + var link = new RioSerialLink(fake, StopAndWait()); using var cts = new CancellationTokenSource(); Task run = link.RunAsync(cts.Token); - // Inbound button packet -> the link replies with a 1-byte ACK. That ACK - // must not become "the last command" for retransmit purposes. + // An analog request answered by the reply alone (no ACK byte) must not + // 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 })); Assert.Equal(new byte[] { (byte)RioControl.Ack }, await fake.NextWriteAsync()); @@ -267,7 +294,7 @@ public class RioSerialLinkTests await Assert.ThrowsAnyAsync( () => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200))); - Assert.Equal(0, link.NakRetransmits); + Assert.Equal(0, link.Retransmits); cts.Cancel(); await run; @@ -277,22 +304,19 @@ public class RioSerialLinkTests public async Task Retransmit_Disabled_IsFireAndForget() { var fake = new FakeTransport(); - var link = new RioSerialLink(fake, new RioSerialLinkOptions - { - AutoPollAnalog = false, - NakRetransmitLimit = 0, - }); + var link = new RioSerialLink(fake, StopAndWait(limit: 0)); using var cts = new CancellationTokenSource(); 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(); fake.Enqueue((byte)RioControl.Nak); await Assert.ThrowsAnyAsync( () => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200))); - Assert.Equal(0, link.NakRetransmits); + Assert.Equal(0, link.Retransmits); cts.Cancel(); await run; diff --git a/tests/RioJoy.Core.Tests/TestPolyfills.cs b/tests/RioJoy.Core.Tests/TestPolyfills.cs index 0eca058..017b935 100644 --- a/tests/RioJoy.Core.Tests/TestPolyfills.cs +++ b/tests/RioJoy.Core.Tests/TestPolyfills.cs @@ -18,4 +18,15 @@ internal static class TaskTestExtensions cts.Cancel(); // stop the delay timer return await task.ConfigureAwait(false); } + + /// Non-generic counterpart of . + 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); + } } diff --git a/tools/RioSerialMonitor/MashTest.cs b/tools/RioSerialMonitor/MashTest.cs index 2792e79..5480bc9 100644 --- a/tools/RioSerialMonitor/MashTest.cs +++ b/tools/RioSerialMonitor/MashTest.cs @@ -319,7 +319,7 @@ internal static class MashTest Raw($"firmware : {(version is null ? "(no reply)" : version.ToString())}"); 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($"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("longest gaps : " + (longestGaps.Count == 0 ? "(none)"