diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3cf9186..98af19b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -94,6 +94,11 @@ Every behavioural change needs a test, and tests here are expected to be determi
- **No hardware, no timing luck.** Use the `virtual://` loopback adapter for anything about real
bus behaviour, and `ControllableBus` (`tests/CanKit.Pro.Tests/Infrastructure/`) when the test
needs to control what the bus does — echo frames, bus state, whether a transmit is accepted.
+ `ControllableBus.DeferredEchoCapable(...)` parks each TX echo in a `DeferredEchoQueue` instead
+ of raising it inside `Transmit`, which is the only way to have two sends pending at once: a
+ synchronous echo re-enters `CanBusService`'s pending-send lock on the transmitting thread, so
+ the pending list never holds more than that thread's own entry. Reach for it whenever the
+ behaviour under test is about how several in-flight sends relate to each other.
- **Do not test through an adapter's internals.** If a test needs reflection into another
package's private state, it is testing that package, not ours; drive the scenario through the
double instead.
diff --git a/tests/CanKit.Pro.Tests/Infrastructure/ControllableBus.cs b/tests/CanKit.Pro.Tests/Infrastructure/ControllableBus.cs
index 0d60f8a..28fd20c 100644
--- a/tests/CanKit.Pro.Tests/Infrastructure/ControllableBus.cs
+++ b/tests/CanKit.Pro.Tests/Infrastructure/ControllableBus.cs
@@ -9,6 +9,27 @@
namespace CanKit.Pro.Tests.Infrastructure;
+///
+/// When the TX echo of an accepted transmit reaches .
+///
+public enum EchoDelivery
+{
+ ///
+ /// Raised from inside — on the
+ /// transmitting thread, inside whatever lock the caller holds while transmitting. What a real
+ /// echo-mode adapter (CanKit.Adapter.Virtual in ChannelWorkMode.Echo) does, and what
+ /// makes the reentrancy in CanBusService.SendWithEchoConfirmAsync observable.
+ ///
+ Synchronous,
+
+ ///
+ /// Parked in instead of being raised. The test
+ /// chooses when each echo is delivered, which lets more than one pending send exist at once —
+ /// see for why that is not achievable synchronously.
+ ///
+ Deferred,
+}
+
///
/// An the test drives directly: it decides whether a transmit is accepted,
/// whether (and when) a TX echo comes back, and what the controller reports.
@@ -40,10 +61,12 @@ public sealed class ControllableBus : ICanBus
private readonly IBusRTOptionsConfigurator _options;
private int _disposed;
- private ControllableBus(ICanBus configurationSource)
+ private ControllableBus(ICanBus configurationSource, EchoDelivery echoDelivery)
{
_configurationSource = configurationSource;
_options = new EchoCapableOptions(configurationSource.Options);
+ EchoMode = echoDelivery;
+ DeferredEchoes = new DeferredEchoQueue(frame => RaiseObserved(frame, isEcho: true));
// What a healthy CAN controller reports; tests move it from here.
BusState = BusState.ErrActive;
}
@@ -51,12 +74,26 @@ private ControllableBus(ICanBus configurationSource)
///
/// Creates a double whose report ChannelWorkMode.Echo and the
/// CanFeature.Echo capability — the combination that makes SendConfirmed take
- /// the real-echo-matching path (FR-RAW-031).
+ /// the real-echo-matching path (FR-RAW-031) — and that echoes synchronously from inside
+ /// , exactly as a real echo-mode adapter does.
///
public static ControllableBus EchoCapable(string session)
- => new(VirtualAdapterFixture.Open(session, 0, ChannelWorkMode.Echo));
-
+ => new(VirtualAdapterFixture.Open(session, 0, ChannelWorkMode.Echo), EchoDelivery.Synchronous);
+ ///
+ /// Same echo-capable configuration as , but every accepted transmit's
+ /// echo is parked in until the test releases it.
+ ///
+ ///
+ /// Use this whenever the behaviour under test needs two or more sends to be pending at the
+ /// same time. A synchronous echo makes that impossible — it re-enters
+ /// CanBusService's pending-send lock on the transmitting thread before that thread ever
+ /// leaves Transmit, so the pending list only ever holds the entry belonging to the
+ /// thread currently inside it, no matter how many callers race. See
+ /// .
+ ///
+ public static ControllableBus DeferredEchoCapable(string session)
+ => new(VirtualAdapterFixture.Open(session, 0, ChannelWorkMode.Echo), EchoDelivery.Deferred);
/// Whether reports the frame as accepted.
public bool AcceptTransmit { get; set; } = true;
@@ -64,6 +101,20 @@ public static ControllableBus EchoCapable(string session)
/// Whether an accepted frame is echoed back through .
public bool EchoAcceptedFrames { get; set; } = true;
+ ///
+ /// Whether an accepted frame's echo is raised inside or
+ /// parked in . Settable mid-test so a scenario can, for example,
+ /// let the first send confirm normally and only defer the ones it needs to overlap.
+ ///
+ public EchoDelivery EchoMode { get; set; }
+
+ ///
+ /// Echoes parked by mode, and the handle that releases
+ /// them. Always present; stays empty while is
+ /// .
+ ///
+ public DeferredEchoQueue DeferredEchoes { get; }
+
/// Number of frames handed to .
public int TransmitCount => Volatile.Read(ref _transmitCount);
@@ -92,9 +143,17 @@ public int Transmit(in CanFrame frame)
Interlocked.Increment(ref _transmitCount);
if (!AcceptTransmit) return 0;
- // A real echo-mode adapter delivers the echo synchronously from inside Transmit; matching
- // that is what makes the reentrancy in CanBusService.SendWithEchoConfirmAsync observable.
- if (EchoAcceptedFrames) RaiseObserved(frame, isEcho: true);
+ if (EchoAcceptedFrames)
+ {
+ // Synchronous is the default because that is what a real echo-mode adapter does, and
+ // matching it is what makes the reentrancy in CanBusService.SendWithEchoConfirmAsync
+ // observable. Deferred parks the echo instead, so the caller leaves Transmit — and
+ // releases the service's pending-send lock — with its entry still pending; see
+ // DeferredEchoQueue for why some FR-RAW-031 behaviour is only reachable that way.
+ if (EchoMode == EchoDelivery.Deferred) DeferredEchoes.Park(frame);
+ else RaiseObserved(frame, isEcho: true);
+ }
+
return 1;
}
diff --git a/tests/CanKit.Pro.Tests/Infrastructure/DeferredEchoQueue.cs b/tests/CanKit.Pro.Tests/Infrastructure/DeferredEchoQueue.cs
new file mode 100644
index 0000000..2f334f6
--- /dev/null
+++ b/tests/CanKit.Pro.Tests/Infrastructure/DeferredEchoQueue.cs
@@ -0,0 +1,180 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using CanKit.Abstractions.API.Can.Definitions;
+
+namespace CanKit.Pro.Tests.Infrastructure;
+
+///
+/// The parking lot for TX echoes of a running in
+/// mode: Transmit hands the frame here instead of
+/// echoing it, and the test decides when — and in which order — each echo reaches
+/// FrameObserved.
+///
+///
+/// Why this exists: a real echo-mode adapter delivers the echo synchronously from inside
+/// Transmit, and CanBusService.SendWithEchoConfirmAsync transmits while holding its
+/// pending-send lock. A synchronous echo therefore re-enters that lock on the transmitting
+/// thread, so the pending list can never hold more than the one entry that thread just
+/// registered. Every property that is only observable with two or more entries queued for the same
+/// key — FIFO matching of byte-identical concurrent sends (SRS FR-RAW-031), an expired pending
+/// send poisoning the FIFO for a later one — is therefore untestable against a synchronous echo:
+/// the assertion holds no matter what the matching code does. Deferring the echo is what puts the
+/// second entry in the list.
+///
+///
+///
+/// Everything here is deliberately explicit rather than time-based:
+/// is the only wait, and it waits on a transmit actually having happened rather than on a delay
+/// that "should be long enough". Nothing in this class starts a timer, a thread, or a task — an
+/// echo moves only when the test says so, on the test's own thread.
+///
+///
+///
+/// Reusable beyond FR-RAW-031: any scenario that needs a pending send to still be pending while a
+/// second one is registered (a late echo arriving after its own send already timed out, an echo
+/// that never arrives at all while later ones do — see ) is expressed by
+/// parking, then releasing or discarding, in whatever order the scenario calls for.
+///
+public sealed class DeferredEchoQueue
+{
+ private readonly Action _deliver;
+
+ private readonly object _gate = new();
+ private readonly List _parked = new();
+ private readonly List<(int Threshold, TaskCompletionSource Tcs)> _waiters = new();
+
+ // Monotonic: counts every frame ever parked, so a waiter's threshold cannot be un-met by a
+ // subsequent release. "Two sends have been transmitted" must stay true once it is true.
+ private int _enqueued;
+
+ internal DeferredEchoQueue(Action deliver) => _deliver = deliver;
+
+ /// Echoes parked and not yet released or discarded, oldest first.
+ public int Count
+ {
+ get { lock (_gate) return _parked.Count; }
+ }
+
+ ///
+ /// Total number of echoes ever parked — i.e. accepted transmits observed while in
+ /// mode. Never decreases.
+ ///
+ public int Enqueued
+ {
+ get { lock (_gate) return _enqueued; }
+ }
+
+ ///
+ /// Completes once has reached — the
+ /// deterministic replacement for "sleep a bit and hope the sends got that far".
+ ///
+ ///
+ /// A transmit is parked from inside ICanBus.Transmit, which
+ /// CanBusService.SendWithEchoConfirmAsync calls after it has registered its pending
+ /// entry and while still holding the pending-send lock. So "n echoes parked" is a hard
+ /// guarantee that n pending sends are registered, not an approximation of it.
+ ///
+ /// Number of parked echoes to wait for.
+ /// How long to wait before failing; a bound against a hang, not a
+ /// scheduling assumption.
+ /// Fewer than echoes were parked
+ /// within .
+ public async Task WaitForEnqueuedAsync(int count, TimeSpan timeout)
+ {
+ if (count <= 0) throw new ArgumentOutOfRangeException(nameof(count), count, "Count must be positive.");
+
+ Task wait;
+ lock (_gate)
+ {
+ if (_enqueued >= count) return;
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _waiters.Add((count, tcs));
+ wait = tcs.Task;
+ }
+
+ try
+ {
+ await wait.WaitAsync(timeout).ConfigureAwait(false);
+ }
+ catch (TimeoutException)
+ {
+ throw new TimeoutException(
+ $"Only {Enqueued} of {count} expected echoes were parked within {timeout}.");
+ }
+ }
+
+ ///
+ /// Delivers the oldest parked echo through the bus's FrameObserved event, on the
+ /// calling thread. Returns false when nothing is parked.
+ ///
+ public bool ReleaseNext()
+ {
+ CanFrame frame;
+ lock (_gate)
+ {
+ if (_parked.Count == 0) return false;
+ frame = _parked[0];
+ _parked.RemoveAt(0);
+ }
+
+ // Delivered outside the lock: the echo runs the service's whole match-and-complete path
+ // (and any continuation it resumes) on this thread, and none of that may be serialized
+ // against a concurrent Transmit parking the next echo.
+ _deliver(frame);
+ return true;
+ }
+
+ ///
+ /// Delivers every parked echo, oldest first. Returns how many were delivered.
+ ///
+ public int ReleaseAll()
+ {
+ var released = 0;
+ while (ReleaseNext()) released++;
+ return released;
+ }
+
+ ///
+ /// Drops the oldest parked echo without ever delivering it — the frame reached the wire but
+ /// its echo is lost, while later echoes still arrive normally. Returns false when
+ /// nothing is parked.
+ ///
+ public bool DiscardNext()
+ {
+ lock (_gate)
+ {
+ if (_parked.Count == 0) return false;
+ _parked.RemoveAt(0);
+ return true;
+ }
+ }
+
+ /// Parks ; called by .
+ internal void Park(in CanFrame frame)
+ {
+ (int Threshold, TaskCompletionSource Tcs)[]? satisfied = null;
+ lock (_gate)
+ {
+ _parked.Add(frame);
+ _enqueued++;
+
+ if (_waiters.Count > 0)
+ {
+ var met = _waiters.FindAll(w => w.Threshold <= _enqueued);
+ if (met.Count > 0)
+ {
+ satisfied = met.ToArray();
+ _waiters.RemoveAll(w => w.Threshold <= _enqueued);
+ }
+ }
+ }
+
+ // Never complete a TCS under the lock: the waiter's continuation may call straight back
+ // into ReleaseNext/Count, and Park runs inside the service's pending-send lock.
+ if (satisfied is null) return;
+ foreach (var (_, tcs) in satisfied)
+ tcs.TrySetResult();
+ }
+}
diff --git a/tests/CanKit.Pro.Tests/TestCases/CANopen/CanOpenNodeIntegrationTests.cs b/tests/CanKit.Pro.Tests/TestCases/CANopen/CanOpenNodeIntegrationTests.cs
index 9dede2e..9e5051f 100644
--- a/tests/CanKit.Pro.Tests/TestCases/CANopen/CanOpenNodeIntegrationTests.cs
+++ b/tests/CanKit.Pro.Tests/TestCases/CANopen/CanOpenNodeIntegrationTests.cs
@@ -166,12 +166,20 @@ await master.SdoDownloadAsync(serverNodeId: 0x11, index: 0x2100, subindex: 0x00,
// applied to the abandoned buffer or committed to the OD. Prior to the fix the
// expedited path did not clear _sdoServer, so the stale download session would still
// accept and commit those segment frames.
+ //
+ // Part (2) is observed on the wire rather than by waiting: "0x3000 is still all zeros" is
+ // equally true of a server that rejected the stray segments and of one that never received
+ // them, so the fixed Task.Delay this used to sit on decided which behaviour was being
+ // asserted. A third bus watches the slave's SDO TX, and the test waits for the server's own
+ // responses — the session ack, then one CommandSpecifierInvalid abort per stray segment.
+ // That the aborts arrive is the proof the segments were delivered and refused.
[Fact]
public async Task Sdo_ExpeditedInitiate_ClearsStaleSegmentedServerSession()
{
var session = NewSession();
using var busA = Open(session, 0);
using var busB = Open(session, 1);
+ using var busObserver = Open(session, 2);
using var master = CanOpen.OpenNode(busA, nodeId: 0x01);
using var slave = CanOpen.OpenNode(busB, nodeId: 0x11);
@@ -204,6 +212,36 @@ public async Task Sdo_ExpeditedInitiate_ClearsStaleSegmentedServerSession()
// are rejected with SdoAbortCode.CommandSpecifierInvalid instead.
slave.ObjectDictionary.AddDomain(0x3000, 0x00, new byte[8]);
+ // Watch what the slave itself puts on the wire (COB-ID 0x580 + 0x11 = 0x591) from a
+ // third bus, so the slave's responses are distinguishable from the master's requests.
+ var sessionInstalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var straysRejected = new TaskCompletionSource