diff --git a/src/CanKit.Pro.Actor/ProtocolActor.cs b/src/CanKit.Pro.Actor/ProtocolActor.cs
index f48a233..5591fe2 100644
--- a/src/CanKit.Pro.Actor/ProtocolActor.cs
+++ b/src/CanKit.Pro.Actor/ProtocolActor.cs
@@ -345,7 +345,25 @@ public IDisposable Schedule(TimeSpan delay, Action callback)
if (callback is null) throw new ArgumentNullException(nameof(callback));
if (delay < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(delay), "Delay must not be negative.");
- var entry = new TimerEntry(DueTimestamp(delay), callback);
+ return Insert(new TimerEntry(DueTimestamp(delay), callback));
+ }
+
+ ///
+ /// As , due at on
+ /// rather than a delay from the reading this call takes. For a
+ /// caller whose deadline is fixed already: a delay computed from its own earlier reading
+ /// lands late by however far the clock moved in between, and on a clock a test moves in
+ /// steps that can be a whole step -- a timer that then never fires (#171). A due instant
+ /// already past fires on the loop's next pass.
+ ///
+ internal IDisposable ScheduleAt(long dueTimestamp, Action callback)
+ {
+ if (callback is null) throw new ArgumentNullException(nameof(callback));
+ return Insert(new TimerEntry(dueTimestamp, callback));
+ }
+
+ private IDisposable Insert(TimerEntry entry)
+ {
lock (_disposeGate)
{
ThrowIfDisposed();
diff --git a/src/CanKit.Pro.Uds/UdsClient.cs b/src/CanKit.Pro.Uds/UdsClient.cs
index 8d8b581..eb0855e 100644
--- a/src/CanKit.Pro.Uds/UdsClient.cs
+++ b/src/CanKit.Pro.Uds/UdsClient.cs
@@ -1,4 +1,5 @@
using System;
+using CanKit.Pro.Actor;
using CanKit.Pro.IsoTp;
namespace CanKit.Pro.Uds;
@@ -34,4 +35,22 @@ public static IUdsClient Create(
if (channel is null) throw new ArgumentNullException(nameof(channel));
return new UdsClientImpl(channel, options ?? new UdsClientOptions(), ownsChannel: !leaveOpen);
}
+
+ ///
+ /// As , measuring and waiting
+ /// out P2, P2* and the suppressed-response windows on ; null is the
+ /// wall clock, as
+ /// the public overload uses. must have been opened on that same
+ /// actor, and its demux must stamp frames with its time source, or a deadline and an
+ /// arrival are not comparable (#171).
+ ///
+ internal static IUdsClient Create(
+ IIsoTpChannel channel,
+ ProtocolActor? clock,
+ UdsClientOptions? options = null,
+ bool leaveOpen = true)
+ {
+ if (channel is null) throw new ArgumentNullException(nameof(channel));
+ return new UdsClientImpl(channel, options ?? new UdsClientOptions(), ownsChannel: !leaveOpen, clock);
+ }
}
diff --git a/src/CanKit.Pro.Uds/UdsClientImpl.cs b/src/CanKit.Pro.Uds/UdsClientImpl.cs
index 81288c8..f464491 100644
--- a/src/CanKit.Pro.Uds/UdsClientImpl.cs
+++ b/src/CanKit.Pro.Uds/UdsClientImpl.cs
@@ -4,6 +4,7 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using CanKit.Pro.Actor;
using CanKit.Pro.IsoTp;
namespace CanKit.Pro.Uds;
@@ -53,6 +54,15 @@ internal sealed class UdsClientImpl : IUdsClient
private readonly SemaphoreSlim _requestLock = new(1, 1);
private readonly CancellationTokenSource _lifetimeCts = new();
+ // Null in production: P2/P2*, the suppressed-response windows and the busy-repeat delay are
+ // measured on Stopwatch.GetTimestamp() and waited out on real timers. A test injects the
+ // actor whose clock the channel it opened shares; then every timestamp this class compares
+ // against a deadline *and* every wait for one is on that clock, so a test advances it
+ // instead of sleeping, and "did the answer come before the window closed" is decided by
+ // the test's order of events rather than by the host's scheduling (#171). Not disposed here.
+ private readonly ProtocolActor? _clock;
+ private readonly ITimeSource _time;
+
// Test hook: fires whenever a caller finds _requestLock already held and starts waiting on
// it -- the observable a queued call is waiting on, standing in for a wall-clock sleep
// timed to land while an earlier call holds the lock (#171). No-op in production; a test
@@ -89,10 +99,26 @@ private async Task AcquireRequestLockAsync(CancellationToken cancellationToken)
private int _disposed;
public UdsClientImpl(IIsoTpChannel channel, UdsClientOptions options, bool ownsChannel)
+ : this(channel, options, ownsChannel, clock: null)
+ {
+ }
+
+ ///
+ /// As the public constructor, measuring and waiting out P2, P2*, the suppressed-response
+ /// windows and the busy-repeat delay on ; null (the public
+ /// constructor's choice) is the wall clock. The
+ /// channel underneath must have been opened on that same actor, and its demux must stamp
+ /// frames with its time source, or a deadline and an arrival are not comparable (#171). The
+ /// actor is not disposed with this client.
+ ///
+ internal UdsClientImpl(IIsoTpChannel channel, UdsClientOptions options, bool ownsChannel,
+ ProtocolActor? clock)
{
_channel = channel;
_options = options;
_ownsChannel = ownsChannel;
+ _clock = clock;
+ _time = clock?.TimeSource ?? MonotonicTimeSource.Instance;
// Every duration here runs a timer, and a timer measures about 49 days at most: one
// beyond that would throw when it is armed, after the request went out (Codex on #150).
@@ -482,7 +508,7 @@ private async Task SendWithoutResponseAsync(byte[] request, CancellationToken ca
// confirmation, the frame is on the bus and may still be answered (Codex on #150).
// Moved out to the transmit stamp afterwards.
bool hadWindow = _suppressedWindows.TryGetDeadline(request[0], out var previousUntil);
- _suppressedWindows.Note(request[0], Stopwatch.GetTimestamp(), _options.P2ClientMax);
+ _suppressedWindows.Note(request[0], Now(), _options.P2ClientMax, _time.Frequency);
IsoTpTransmitStamps stamps;
try
{
@@ -499,11 +525,11 @@ private async Task SendWithoutResponseAsync(byte[] request, CancellationToken ca
// A send that leaves by exception -- cancelled, or a transport fault -- may
// have put the frame on the bus after the provisional window ran out; its P2
// from the transmission is at most P2 from now (Codex on #150).
- _suppressedWindows.Note(request[0], Stopwatch.GetTimestamp(), _options.P2ClientMax);
+ _suppressedWindows.Note(request[0], Now(), _options.P2ClientMax, _time.Frequency);
throw;
}
- var sent = stamps.LastFrameTransmitTimestamp > 0 ? stamps.LastFrameTransmitTimestamp : Stopwatch.GetTimestamp();
- _suppressedWindows.Note(request[0], sent, _options.P2ClientMax);
+ var sent = stamps.LastFrameTransmitTimestamp > 0 ? stamps.LastFrameTransmitTimestamp : Now();
+ _suppressedWindows.Note(request[0], sent, _options.P2ClientMax, _time.Frequency);
}
finally
{
@@ -531,7 +557,7 @@ private async Task WaitOutSuppressedResponseWindowAsync(UdsServiceId serviceId,
{
while (true)
{
- var remaining = SuppressedResponseWindows.Remaining(until);
+ var remaining = SuppressedResponseWindows.Remaining(until, Now(), _time.Frequency);
if (remaining <= TimeSpan.Zero)
{
// The window is over as measured now -- but a 0x78 may be queued already,
@@ -542,7 +568,7 @@ private async Task WaitOutSuppressedResponseWindowAsync(UdsServiceId serviceId,
if (DrainExtends(sid, ref until)) continue;
break;
}
- using var slice = new CancellationTokenSource(remaining);
+ using var slice = CancelAt(until);
using var combined = CancellationTokenSource.CreateLinkedTokenSource(linkedToken, slice.Token);
IsoTpReceivedPdu pdu;
try
@@ -602,7 +628,7 @@ private bool ExtendOnPending(byte sid, in IsoTpReceivedPdu pdu, ref long until)
var data = pdu.Pdu;
if (data.Length < 3 || data[0] != NegativeResponseSid || data[2] != NrcResponsePending)
return false;
- var extendedUntil = pdu.FirstFrameArrivalTimestamp + (long)(_options.P2StarClientMax.TotalSeconds * Stopwatch.Frequency);
+ var extendedUntil = pdu.FirstFrameArrivalTimestamp + Ticks(_options.P2StarClientMax);
if (data[1] != sid)
{
// Another service's: only a window still open when the 0x78 arrived (Codex on #150).
@@ -1052,7 +1078,7 @@ private async Task ExecuteCoreAsync(UdsServiceId serviceId, byte[] reque
when (ex.Code == NrcBusyRepeatRequest && repeats < _options.MaxBusyRepeatRequests)
{
if (_options.BusyRepeatRequestDelay > TimeSpan.Zero)
- await Task.Delay(_options.BusyRepeatRequestDelay, linkedToken).ConfigureAwait(false);
+ await DelayAsync(_options.BusyRepeatRequestDelay, linkedToken).ConfigureAwait(false);
}
}
}
@@ -1082,7 +1108,7 @@ private async Task ExchangeOnceAsync(UdsServiceId serviceId, byte[] requ
// Read before the request is handed to the channel, as the fallback for a channel that
// reports no handoff instant: nothing that reached the wire after this reading can be
// an earlier request's response.
- var requestStarted = Stopwatch.GetTimestamp();
+ var requestStarted = Now();
var stamps = await _channel.SendWithTransmitStampAsync(request, linkedToken)
.ConfigureAwait(false);
var transmitStamp = stamps.LastFrameTransmitTimestamp;
@@ -1090,7 +1116,7 @@ private async Task ExchangeOnceAsync(UdsServiceId serviceId, byte[] requ
// Zero means the channel reported no transmit instant. Falling back to now is the old
// behaviour, which is worse but not broken; treating zero as a timestamp would read as
// infinitely long ago and time out every request.
- var budgetStart = transmitStamp > 0 ? transmitStamp : Stopwatch.GetTimestamp();
+ var budgetStart = transmitStamp > 0 ? transmitStamp : Now();
// A response whose first frame arrived before this is an earlier request's (Codex on
// #143). The bound is the channel's handoff of the request's *last* frame, taken just
// before the driver call: a peer answers only a complete request, so nothing on the
@@ -1234,7 +1260,7 @@ private static bool IsAllZero(byte[] data, int offset, int count)
// among it is routed to its service's window rather than dropped unseen (Codex on #150).
private async Task DiscardStalePdusAsync()
{
- long arrivedBefore = Stopwatch.GetTimestamp();
+ long arrivedBefore = Now();
await SettleAsync().ConfigureAwait(false);
DiscardStalePdus(arrivedBefore);
}
@@ -1292,7 +1318,7 @@ private void RouteStrayPending(in IsoTpReceivedPdu pdu)
// Only a window still open when the 0x78 arrived: one that had run out is not revived
// for a full P2* by a late frame (Codex on #150).
_suppressedWindows.ExtendIfOpenAt(data[1], pdu.FirstFrameArrivalTimestamp,
- pdu.FirstFrameArrivalTimestamp + (long)(_options.P2StarClientMax.TotalSeconds * Stopwatch.Frequency));
+ pdu.FirstFrameArrivalTimestamp + Ticks(_options.P2StarClientMax));
}
///
@@ -1324,7 +1350,7 @@ private async Task ReceiveWithTimeoutAsync(UdsServiceId servic
notBefore, linkedToken).ConfigureAwait(false);
}
- using var timeoutCts = new CancellationTokenSource(remaining);
+ using var timeoutCts = CancelAt(budgetStart + Ticks(budget));
using var combined = CancellationTokenSource.CreateLinkedTokenSource(
linkedToken, timeoutCts.Token);
@@ -1347,7 +1373,9 @@ private async Task ReceiveWithTimeoutAsync(UdsServiceId servic
// is still there. The channel publishes a First Frame when it is read off the bus and
// withdraws it if the actor then refuses the frame -- with nothing put in the inbox -- so
// a wait on it must not be unbounded. The re-check costs nothing when the PDU arrives:
- // completion or abort puts an item in the inbox and the wait returns at once.
+ // completion or abort puts an item in the inbox and the wait returns at once. A real timer
+ // even on an injected clock: it polls the channel's state, it is no protocol deadline, and a
+ // test would otherwise have to advance its clock for a reception it is merely waiting on.
private static readonly TimeSpan InProgressRecheck = TimeSpan.FromMilliseconds(50);
///
@@ -1424,17 +1452,84 @@ private bool ResponseBeganInTime(UdsServiceId serviceId, TimeSpan budget, long b
return false;
}
+ // Now, on _time: Stopwatch.GetTimestamp() in production, the injected actor's clock in a
+ // test (#171).
+ private long Now() => _time.GetTimestamp();
+
+ // window in ticks of _time.Frequency, so a deadline noted from Now() and one computed here
+ // are on the same clock (#171).
+ private long Ticks(TimeSpan window) => SuppressedResponseWindows.Ticks(window, _time.Frequency);
+
+ ///
+ /// A token source cancelled once reaches : a
+ /// real timer in production, the injected actor's
+ /// timer in a test, so a deadline and the wait bounded by it are on one clock (#171). On the
+ /// actor the deadline is armed as the instant it is, not as a delay from a fresh reading --
+ /// a test that moves the clock between this client's reading and the arming would otherwise
+ /// push the timer past the deadline, and it would never fire. The actor's timer fires on its
+ /// loop; the cancellation is handed to the thread pool rather than run there, because
+ /// cancelling runs the channel's registrations, and whatever they resume must not run on --
+ /// and block -- the loop the channel itself needs.
+ ///
+ private ClockTimeout CancelAt(long deadline)
+ => _clock is null
+ ? new ClockTimeout(SuppressedResponseWindows.Remaining(deadline, Now(), _time.Frequency))
+ : new ClockTimeout(_clock, deadline);
+
+ // A wait of delay on _time, as CancelAt measures it.
+ private Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
+ => _clock is null ? Task.Delay(delay, cancellationToken) : WaitOnClockAsync(_clock, delay, cancellationToken);
+
+ private static async Task WaitOnClockAsync(ProtocolActor clock, TimeSpan delay, CancellationToken cancellationToken)
+ {
+ var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var registration = cancellationToken.Register(static state =>
+ ((TaskCompletionSource)state!).TrySetCanceled(), done);
+ using var handle = clock.Schedule(delay, () => done.TrySetResult(true));
+ await done.Task.ConfigureAwait(false);
+ }
+
+ private sealed class ClockTimeout : IDisposable
+ {
+ private readonly CancellationTokenSource _cts;
+ private readonly IDisposable? _timer;
+
+ public ClockTimeout(TimeSpan remaining)
+ => _cts = new CancellationTokenSource(remaining);
+
+ public ClockTimeout(ProtocolActor clock, long deadline)
+ {
+ _cts = new CancellationTokenSource();
+ _timer = clock.ScheduleAt(deadline, () => ThreadPool.QueueUserWorkItem(static state =>
+ ((CancellationTokenSource)state!).Cancel(), _cts));
+ }
+
+ public CancellationToken Token => _cts.Token;
+
+ public bool IsCancellationRequested => _cts.IsCancellationRequested;
+
+ // On the actor's clock only the timer is released: a source without a timer of its own
+ // holds nothing that needs it, and one whose cancellation the timer has already handed
+ // to the thread pool must stay cancellable rather than throw there.
+ public void Dispose()
+ {
+ if (_timer is null) _cts.Dispose();
+ else _timer.Dispose();
+ }
+ }
+
///
- /// Elapsed time between two readings, defaulting the
- /// second to now. Kept in one place so the pre-check (how much budget is left) and the
- /// post-check (was this PDU inside it) can never drift onto different clocks.
+ /// Elapsed time between two readings (or the injected
+ /// clock's equivalent), defaulting the second to now. Kept in one place so the pre-check
+ /// (how much budget is left) and the post-check (was this PDU inside it) can never drift
+ /// onto different clocks.
///
- private static TimeSpan ElapsedSince(long startTimestamp, long? endTimestamp = null)
+ private TimeSpan ElapsedSince(long startTimestamp, long? endTimestamp = null)
{
- var end = endTimestamp ?? Stopwatch.GetTimestamp();
+ var end = endTimestamp ?? Now();
var ticks = end - startTimestamp;
if (ticks <= 0) return TimeSpan.Zero;
- return TimeSpan.FromSeconds((double)ticks / Stopwatch.Frequency);
+ return TimeSpan.FromSeconds((double)ticks / _time.Frequency);
}
private void ThrowIfDisposed()
diff --git a/tests/CanKit.Pro.Tests/TestCases/J1939TpTests.cs b/tests/CanKit.Pro.Tests/TestCases/J1939TpTests.cs
index c9479c4..f9ac91a 100644
--- a/tests/CanKit.Pro.Tests/TestCases/J1939TpTests.cs
+++ b/tests/CanKit.Pro.Tests/TestCases/J1939TpTests.cs
@@ -2862,7 +2862,25 @@ private async IAsyncEnumerable Count(
}
}
- public bool TryRead(out CanFrameEvent frameEvent) => _inner.TryRead(out frameEvent);
+ // The same meaning for a reader that drains with TryRead, as the ISO-TP channel's pump
+ // does: a frame is finished with once the reader asks for the next one -- by then it has
+ // been handed to the actor. Only one reader drains a subscription at a time (the
+ // channel's pump lock), so the frame in hand needs no lock of its own.
+ private CanFrameEvent _taken;
+ private bool _hasTaken;
+
+ public bool TryRead(out CanFrameEvent frameEvent)
+ {
+ if (_hasTaken)
+ {
+ _hasTaken = false;
+ _owner.Consumed(_taken);
+ }
+ if (!_inner.TryRead(out frameEvent)) return false;
+ _taken = frameEvent;
+ _hasTaken = true;
+ return true;
+ }
public ValueTask WaitToReadAsync(CancellationToken cancellationToken = default)
=> _inner.WaitToReadAsync(cancellationToken);
diff --git a/tests/CanKit.Pro.Tests/TestCases/ProtocolActorTimerTests.cs b/tests/CanKit.Pro.Tests/TestCases/ProtocolActorTimerTests.cs
index 965f71f..44dbd86 100644
--- a/tests/CanKit.Pro.Tests/TestCases/ProtocolActorTimerTests.cs
+++ b/tests/CanKit.Pro.Tests/TestCases/ProtocolActorTimerTests.cs
@@ -213,4 +213,34 @@ public async Task A_Clean_Dispose_Reports_Nothing()
observed.Should().BeNull();
}
+
+ // #171: a caller whose deadline is fixed already arms it as that instant. Armed as a delay
+ // from a fresh reading instead, a clock that moved after the caller's own reading would put
+ // the timer late by the whole move -- on a clock a test moves, a timer that never fires.
+ [Fact]
+ public async Task ScheduleAt_Fires_At_The_Instant_Not_A_Delay_From_When_It_Was_Armed()
+ {
+ using var clock = new VirtualClock();
+ var actor = clock.NewActor();
+ var time = actor.TimeSource;
+ long Ms(int ms) => (long)(ms / 1000.0 * time.Frequency);
+
+ var deadline = time.GetTimestamp() + Ms(50); // the caller's reading, and its deadline
+ await clock.AdvanceAsync(TimeSpan.FromMilliseconds(30)); // the clock moves before the arming
+ var fired = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var handle = actor.ScheduleAt(deadline, () => fired.TrySetResult(true));
+
+ await clock.AdvanceAsync(TimeSpan.FromMilliseconds(19));
+ fired.Task.IsCompleted.Should().BeFalse("the deadline is still 1 ms away");
+ await clock.AdvanceAsync(TimeSpan.FromMilliseconds(1));
+ fired.Task.IsCompleted.Should().BeTrue("due 50 ms after the caller's reading, not 50 ms after the arming");
+
+ var late = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var past = actor.ScheduleAt(deadline, () => late.TrySetResult(true));
+ await clock.SettleAsync();
+ late.Task.IsCompleted.Should().BeTrue("an instant already past fires on the loop's next pass");
+
+ Action noCallback = () => actor.ScheduleAt(deadline, null!);
+ noCallback.Should().Throw();
+ }
}
diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/SimulatedUdsEcu.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/SimulatedUdsEcu.cs
index 4d8076f..8d53db1 100644
--- a/tests/CanKit.Pro.Tests/TestCases/Uds/SimulatedUdsEcu.cs
+++ b/tests/CanKit.Pro.Tests/TestCases/Uds/SimulatedUdsEcu.cs
@@ -55,6 +55,15 @@ public sealed class SimulatedUdsEcu : IDisposable
/// Last request the ECU saw (or null).
public byte[]? LastRequest => Volatile.Read(ref _lastRequest);
+ ///
+ /// How the ECU waits out the delays its sentinel exceptions ask for -- the pause before and
+ /// after a pending answer, the gap between 0x78s. Real time by default. A test on a virtual
+ /// clock replaces it with gates it opens itself, so each answer goes out at the virtual
+ /// instant the test has moved the clock to rather than whenever the host gets round to it
+ /// (#171).
+ ///
+ public Func Delay { get; set; } = (delay, ct) => Task.Delay(delay, ct);
+
/// The ECU's own ISO-TP channel, for a handler that puts something on the wire
/// other than the response the loop would build for it.
public IIsoTpChannel Channel => _channel;
@@ -150,7 +159,7 @@ await SendAndCountAsync(new byte[] { 0x7F, sid, nrc.Code }, ct)
// Pending NRCs are intermediate; only the final response counts.
await SendNrcAsync(sid, 0x78, ct).ConfigureAwait(false);
if (pending.DelayBetween > TimeSpan.Zero)
- await Task.Delay(pending.DelayBetween, ct).ConfigureAwait(false);
+ await Delay(pending.DelayBetween, ct).ConfigureAwait(false);
}
if (ct.IsCancellationRequested) return;
@@ -170,14 +179,14 @@ await SendAndCountAsync(new byte[] { 0x7F, sid, nrc.Code }, ct)
try
{
if (pn.DelayBefore > TimeSpan.Zero)
- await Task.Delay(pn.DelayBefore, ct).ConfigureAwait(false);
+ await Delay(pn.DelayBefore, ct).ConfigureAwait(false);
for (int i = 0; i < pn.PendingCount && !ct.IsCancellationRequested; i++)
{
await SendNrcAsync(sid, 0x78, ct).ConfigureAwait(false);
Interlocked.Increment(ref _pendingNrcsSent);
}
if (pn.DelayAfter > TimeSpan.Zero)
- await Task.Delay(pn.DelayAfter, ct).ConfigureAwait(false);
+ await Delay(pn.DelayAfter, ct).ConfigureAwait(false);
if (ct.IsCancellationRequested) return;
await SendAndCountAsync(new byte[] { 0x7F, sid, pn.Nrc }, ct)
.ConfigureAwait(false);
@@ -194,7 +203,7 @@ await SendAndCountAsync(new byte[] { 0x7F, sid, pn.Nrc }, ct)
{
await SendNrcAsync(sid, 0x78, ct).ConfigureAwait(false);
if (pendingSilent.DelayBetween > TimeSpan.Zero)
- await Task.Delay(pendingSilent.DelayBetween, ct).ConfigureAwait(false);
+ await Delay(pendingSilent.DelayBetween, ct).ConfigureAwait(false);
}
// Then silence: the client's restarted P2* must expire (FR-UDS-008).
}
diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs
index efc94b2..49888aa 100644
--- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs
+++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs
@@ -73,6 +73,186 @@ private static (IUdsClient client, SimulatedUdsEcu ecu, IDisposable dispose) Bui
return (client, ecu, dispose);
}
+ ///
+ /// A client whose P2, P2* and suppressed-response windows are measured and waited out
+ /// on a , opposite a on the wall
+ /// clock (#171). Nothing a test asserts about those windows then depends on how the host
+ /// schedules: the client's waits end only when the test moves the clock, every frame is
+ /// stamped with the virtual instant it arrived at, and says when a
+ /// frame from the ECU has been handed to the client's actor.
+ ///
+ private sealed class ClockPair : IDisposable
+ {
+ private readonly IDisposable _stack;
+
+ public ClockPair(Action configure, UdsClientOptions? options = null,
+ IsoTpChannelOptions? clientIsoTp = null)
+ {
+ var session = NewSession();
+ var busClient = OpenClassic(session, 0);
+ var busEcu = OpenClassic(session, 1);
+
+ // A zero stamp means "unstamped" to the channel, so the clock starts off zero.
+ Clock.Advance(TimeSpan.FromMilliseconds(1));
+ Actor = Clock.NewActor();
+ Service = new FrameConsumptionCountingBusService(
+ new CanBusService(busClient, Actor.TimeSource.GetTimestamp));
+ Channel = new IsoTpChannel(Service, IsoTpEndpoint.Normal(txCanId: 0x7E0, rxCanId: 0x7E8),
+ clientIsoTp ?? FastIsoTp(useCanFd: false), ownsService: true, Actor);
+ var ecuChannel = IsoTpFactory.Open(busEcu, IsoTpEndpoint.Normal(txCanId: 0x7E8, rxCanId: 0x7E0),
+ FastIsoTp(useCanFd: false));
+
+ EcuBus = busEcu;
+ Ecu = new SimulatedUdsEcu(ecuChannel);
+ configure(Ecu);
+ Ecu.Start();
+ Client = UdsClient.Create(Channel, Actor, options);
+ _stack = new CompositeDisposable(Client, Ecu, ecuChannel, Channel, busEcu, busClient);
+ }
+
+ public VirtualClock Clock { get; } = new();
+
+ public ProtocolActor Actor { get; }
+
+ public FrameConsumptionCountingBusService Service { get; }
+
+ public IsoTpChannel Channel { get; }
+
+ public SimulatedUdsEcu Ecu { get; }
+
+ /// The ECU's raw bus, for a frame its ISO-TP channel would not send.
+ public ICanBus EcuBus { get; private set; } = null!;
+
+ public IUdsClient Client { get; }
+
+ ///
+ /// Returns once the client has armed a timer exactly away:
+ /// the wait the test is about to end by moving the clock, and the one a client waiting on
+ /// something else would not have armed.
+ ///
+ public Task WaitUntilWaitingAsync(TimeSpan remaining)
+ => Clock.WaitUntilTimerArmedAsync(Actor, remaining, ShortTimeout);
+
+ ///
+ /// Runs -- whatever makes the ECU answer -- and returns once
+ /// the client's actor has taken in the frame it sends, a Single Frame matching
+ /// . Its stamp is then the instant the clock stood at, and the
+ /// clock may be moved again.
+ ///
+ public async Task DeliverAsync(Action release, Func match)
+ {
+ var taken = Service.WaitUntilConsumedAsync(e =>
+ e.Frame.ID == 0x7E8 && match(e.Frame.Data.ToArray()));
+ release();
+ if (await Task.WhenAny(taken, Task.Delay(ShortTimeout)) != taken)
+ throw new TimeoutException("The ECU's frame did not reach the client.");
+ await Clock.SettleAsync();
+ }
+
+ public void Dispose()
+ {
+ _stack.Dispose();
+ Clock.Dispose();
+ }
+ }
+
+ // The task's result, or a timeout rather than a hang when it never comes.
+ private static async Task Within(Task task)
+ {
+ if (await Task.WhenAny(task, Task.Delay(ShortTimeout)) != task)
+ throw new TimeoutException($"No result within {ShortTimeout}.");
+ return await task;
+ }
+
+ // As Within, for a task with no result -- a wait that hangs (a suppressed-window bug
+ // left waiting forever on a clock nobody advances further) surfaces as a timeout here
+ // rather than the test itself hanging.
+ private static async Task Within(Task task)
+ {
+ if (await Task.WhenAny(task, Task.Delay(ShortTimeout)) != task)
+ throw new TimeoutException($"No result within {ShortTimeout}.");
+ await task;
+ }
+
+ // A Single Frame carrying a negative response [0x7F, sid, nrc].
+ private static Func Negative(byte sid, byte nrc)
+ => data => data.Length >= 4 && data[1] == 0x7F && data[2] == sid && data[3] == nrc;
+
+ ///
+ /// Stands in for : each delay the ECU asks for waits until
+ /// the test releases that delay by its length, after moving the clock to where it ends.
+ ///
+ private sealed class EcuSteps
+ {
+ private readonly Dictionary> _gates = new();
+
+ // Honours the ECU's token: a test that fails before releasing a gate still disposes the
+ // ECU, and an ECU loop parked on a gate nobody opens would hang that disposal -- and the
+ // test run with it -- instead of letting the failure be reported.
+ public async Task WaitAsync(TimeSpan delay, CancellationToken cancellationToken)
+ {
+ var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using (cancellationToken.Register(() => cancelled.TrySetCanceled()))
+ await await Task.WhenAny(Gate(delay).Task, cancelled.Task).ConfigureAwait(false);
+ }
+
+ public void Release(TimeSpan delay) => Gate(delay).TrySetResult(true);
+
+ private TaskCompletionSource Gate(TimeSpan delay)
+ {
+ lock (_gates)
+ {
+ if (!_gates.TryGetValue(delay, out var gate))
+ _gates[delay] = gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ return gate;
+ }
+ }
+ }
+
+ ///
+ /// Stands in for like , but gates
+ /// by call order rather than by the delay's length: needed whenever a scenario asks for the
+ /// same duration more than once (two 0x78s spaced by the same gap), where
+ /// would hand both callers the same, already-resolved gate.
+ ///
+ private sealed class OrderedGates
+ {
+ private readonly Queue> _waiters = new();
+ private readonly object _gate = new();
+ private int _pendingReleases;
+
+ // Symmetric with EcuSteps' own race-freedom (see its remarks): a ReleaseNext() that
+ // arrives before the matching WaitAsync -- the ECU has not yet reached its gate on its
+ // own thread -- must not be lost. It is banked instead, so the next WaitAsync call
+ // returns at once.
+ public Task WaitAsync(TimeSpan _, CancellationToken ct)
+ {
+ lock (_gate)
+ {
+ if (_pendingReleases > 0)
+ {
+ _pendingReleases--;
+ return Task.CompletedTask;
+ }
+
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _waiters.Enqueue(tcs);
+ return tcs.Task.WaitAsync(ct);
+ }
+ }
+
+ public void ReleaseNext()
+ {
+ TaskCompletionSource? tcs = null;
+ lock (_gate)
+ {
+ if (_waiters.Count > 0) tcs = _waiters.Dequeue();
+ else _pendingReleases++;
+ }
+ tcs?.TrySetResult(true);
+ }
+ }
+
private static ICanBus OpenCanFd(string session, int channel) => CanBus.Open(
$"virtual://{session}/{channel}",
cfg => cfg.SetProtocolMode(CanProtocolMode.CanFd).Fd(VirtualAdapterFixture.Bitrate, VirtualAdapterFixture.DataBitrate));
@@ -350,6 +530,88 @@ await client.SecurityAccessAsync(
}
}
+ // The same repeat without an injected clock, where the delay is a real Task.Delay: the
+ // request goes out again and succeeds. No timing is asserted -- how long the delay lasts is
+ // what the clock test below proves; this only runs the wall-clock path end to end.
+ [Fact]
+ public async Task A_Busy_Repeat_With_A_Delay_Repeats_On_The_Wall_Clock()
+ {
+ var calls = 0;
+ var (client, _, dispose) = BuildPair(
+ e => e.On(0x22, req => Interlocked.Increment(ref calls) == 1
+ ? throw new EcuNegativeResponse(0x21)
+ : new byte[] { 0xF1, 0x90, 0xAA }),
+ options: new UdsClientOptions { BusyRepeatRequestDelay = TimeSpan.FromMilliseconds(10) });
+ using (dispose)
+ {
+ using var cts = new CancellationTokenSource(ShortTimeout);
+ (await client.ReadDataByIdentifierAsync(0xF190, cts.Token)).Should().Equal(0xAA);
+ Volatile.Read(ref calls).Should().Be(2);
+ }
+ }
+
+ // The clock-injecting overload (#171) guards its channel as the public one does.
+ [Fact]
+ public void Create_On_A_Clock_Rejects_A_Null_Channel()
+ {
+ using var clock = new VirtualClock();
+ Action act = () => UdsClient.Create(null!, clock.NewActor());
+ act.Should().Throw().WithParameterName("channel");
+ }
+
+ // Omitted options default as the public overload's do.
+ [Fact]
+ public void Create_On_A_Clock_Defaults_Omitted_Options()
+ {
+ using var clock = new VirtualClock();
+ var actor = clock.NewActor();
+ using var service = new StarvedReaderBusService();
+ using var channel = new IsoTpChannel(service, IsoTpEndpoint.Normal(0x7E0, 0x7E8),
+ FastIsoTp(useCanFd: false), ownsService: false, actor);
+ using var client = UdsClient.Create(channel, actor);
+ client.Options.P2ClientMax.Should().Be(new UdsClientOptions().P2ClientMax);
+ }
+
+ // #57 on a virtual clock (#171): NRC 0x21 repeats the request after BusyRepeatRequestDelay,
+ // measured on the client's clock like its P2 -- the repeat goes out only once the test has
+ // moved the clock past the delay.
+ [Fact]
+ public async Task A_Busy_Repeat_Waits_Its_Delay_On_The_Clients_Clock()
+ {
+ var calls = 0;
+ using var pair = new ClockPair(
+ e => e.On(0x22, req => Interlocked.Increment(ref calls) == 1
+ ? throw new EcuNegativeResponse(0x21)
+ : new byte[] { 0xF1, 0x90, 0xAA }),
+ options: new UdsClientOptions { BusyRepeatRequestDelay = TimeSpan.FromMilliseconds(100) });
+
+ using var cts = new CancellationTokenSource(ShortTimeout);
+ var read = pair.Client.ReadDataByIdentifierAsync(0xF190, cts.Token);
+ await pair.WaitUntilWaitingAsync(TimeSpan.FromMilliseconds(100)); // the busy delay, not P2
+ Volatile.Read(ref calls).Should().Be(1, "the repeat waits for the delay");
+
+ await pair.Clock.AdvanceAsync(TimeSpan.FromMilliseconds(100));
+ (await Within(read)).Should().Equal(0xAA);
+ Volatile.Read(ref calls).Should().Be(2);
+ }
+
+ // A caller cancelling during the busy delay ends the wait at once, as Task.Delay's token did.
+ [Fact]
+ public async Task A_Busy_Repeat_Delay_On_The_Clients_Clock_Ends_On_Cancellation()
+ {
+ using var pair = new ClockPair(
+ e => e.On(0x22, req => throw new EcuNegativeResponse(0x21)),
+ options: new UdsClientOptions { BusyRepeatRequestDelay = TimeSpan.FromMilliseconds(100) });
+
+ using var cts = new CancellationTokenSource(ShortTimeout);
+ var read = pair.Client.ReadDataByIdentifierAsync(0xF190, cts.Token);
+ await pair.WaitUntilWaitingAsync(TimeSpan.FromMilliseconds(100));
+ cts.Cancel();
+
+ Func act = () => Within(read); // a delay deaf to the token would never end
+ await act.Should().ThrowAsync();
+ }
+
// -----------------------------------------------------------------------------------
// #28 — ISO 14229-2: P2 ends with the *first* frame of the response. A multi-frame
// response whose transfer outlasts P2 (here: paced by the client's own STmin) is not a
@@ -358,15 +620,16 @@ await client.SecurityAccessAsync(
[Fact]
public async Task P2_Ends_With_The_First_Frame_Of_A_MultiFrame_Response()
{
- // 90-byte response = FF (6 data bytes) + 12 CFs. The client advertises STmin = 127 ms,
- // so the ECU cannot deliver the last CF earlier than 12 x 127 ms = 1.5 s after the
- // First Frame -- three times the P2 budget below. The First Frame itself is answered
- // at once; the quantity the host perturbs is that single round trip, against a 500 ms
- // budget the suite's other P2 tests already trust at 80 ms.
+ // 90-byte response = FF (6 data bytes) + 12 CFs, paced by the client's advertised STmin
+ // of 127 ms: the ECU needs 1.5 s of real time to deliver it. On a virtual clock (#171)
+ // the client's P2 is made to expire in the middle of that transfer -- the clock is moved
+ // past P2 once the First Frame is in -- and its N_Cr, on the same clock, never can. On
+ // the wall clock the transfer was a race between 12 real CF gaps and a 500 ms N_Cr,
+ // which macOS CI lost (#185, #188).
var record = Enumerable.Range(0, 87).Select(i => (byte)i).ToArray();
var p2 = TimeSpan.FromMilliseconds(500);
- var (client, _, dispose) = BuildPair(
+ using var pair = new ClockPair(
e => e.On(0x22, req =>
{
var body = new byte[2 + record.Length];
@@ -382,22 +645,26 @@ public async Task P2_Ends_With_The_First_Frame_Of_A_MultiFrame_Response()
UsePadding = true,
NAs = TimeSpan.FromMilliseconds(500),
NBs = TimeSpan.FromMilliseconds(500),
- NCr = TimeSpan.FromMilliseconds(500),
+ NCr = TimeSpan.FromSeconds(10),
LocalStMin = TimeSpan.FromMilliseconds(127),
});
- using (dispose)
- {
- using var cts = new CancellationTokenSource(ShortTimeout);
- var sw = Stopwatch.StartNew();
- var data = await client.ReadDataByIdentifierAsync(0xF190, cts.Token);
- sw.Stop();
+ using var cts = new CancellationTokenSource(ShortTimeout);
+ var read = pair.Client.ReadDataByIdentifierAsync(0xF190, cts.Token);
- data.Should().Equal(record);
- // The transfer really outlasted P2 -- otherwise the assertion above would hold for
- // a client that still measures P2 against the last frame.
- sw.Elapsed.Should().BeGreaterThan(p2);
+ var deadline = DateTime.UtcNow + ShortTimeout;
+ while (pair.Channel.GetReceptionsInProgress().Count == 0)
+ {
+ if (DateTime.UtcNow > deadline) throw new TimeoutException("The First Frame never arrived.");
+ await Task.Delay(1);
}
+ await pair.Clock.AdvanceAsync(p2 + TimeSpan.FromMilliseconds(100)); // P2 runs out mid-transfer
+ // The transfer really outlasted P2: otherwise the result below would hold for a client
+ // that still measures P2 against the last frame. 1.4 s of CF gaps are still to come.
+ pair.Channel.GetReceptionsInProgress().Should().NotBeEmpty();
+
+ var data = await Within(read);
+ data.Should().Equal(record);
}
// -----------------------------------------------------------------------------------
@@ -409,17 +676,23 @@ public async Task P2_Ends_With_The_First_Frame_Of_A_MultiFrame_Response()
public async Task P2_Is_Not_Extended_By_A_MultiFrame_Transfer_For_Another_Service()
{
// The ECU answers the RDBI request with silence, but first starts a 146-byte
- // ReadDTCInformation response (SID 0x59): FF + 20 CFs at the client's STmin of 127 ms,
- // i.e. 2.5 s on the wire. The client must time out at P2 (500 ms), not after the
- // transfer; the bound below leaves 1 s for the host between the two.
- var unrelated = new byte[146];
- unrelated[0] = 0x59;
+ // ReadDTCInformation response (SID 0x59) and never finishes it: only its First Frame goes
+ // on the bus. On a virtual clock (#171) the client's N_Cr cannot end that reception, so
+ // it stays in progress for as long as the test runs. P2 is then moved past on the clock:
+ // the client must time out, because that transfer is somebody else's answer -- the
+ // sibling of P2_Ends_With_The_First_Frame_Of_A_MultiFrame_Response, where the transfer
+ // in progress is this request's own and does extend the wait. A client fooled into
+ // waiting for it never returns, and the test's own token ends it with a cancellation,
+ // not a timeout: no bound on the wall clock is needed to tell the two apart.
var p2 = TimeSpan.FromMilliseconds(500);
+ ClockPair? stack = null;
- var (client, _, dispose) = BuildPair(
+ using var pair = stack = new ClockPair(
e => e.On(0x22, req =>
{
- _ = e.Channel.SendAsync(unrelated);
+ // FF of a 146-byte (0x92) response for SID 0x59; the Consecutive Frames never follow.
+ stack!.EcuBus.Transmit(CanFrame.Classic(0x7E8,
+ new byte[] { 0x10, 0x92, 0x59, 0x00, 0x00, 0x00, 0x00, 0x00 }, isExtendedFrame: false));
throw new EcuSilent();
}),
options: new UdsClientOptions { P2ClientMax = p2, P2StarClientMax = p2 },
@@ -429,21 +702,25 @@ public async Task P2_Is_Not_Extended_By_A_MultiFrame_Transfer_For_Another_Servic
UsePadding = true,
NAs = TimeSpan.FromMilliseconds(500),
NBs = TimeSpan.FromMilliseconds(500),
- NCr = TimeSpan.FromMilliseconds(500),
- LocalStMin = TimeSpan.FromMilliseconds(127),
+ NCr = TimeSpan.FromSeconds(10), // longer than the test: the reception outlives P2
});
- using (dispose)
- {
- using var cts = new CancellationTokenSource(ShortTimeout);
- var sw = Stopwatch.StartNew();
- Func act = () => client.ReadDataByIdentifierAsync(0xF190, cts.Token);
- await act.Should().ThrowAsync();
- sw.Stop();
+ using var cts = new CancellationTokenSource(ShortTimeout);
+ var read = pair.Client.ReadDataByIdentifierAsync(0xF190, cts.Token);
- sw.Elapsed.Should().BeLessThan(TimeSpan.FromMilliseconds(1500),
- "an unrelated transfer must not hold the request past its budget");
+ var deadline = DateTime.UtcNow + ShortTimeout;
+ while (pair.Channel.GetReceptionsInProgress().Count == 0)
+ {
+ if (DateTime.UtcNow > deadline) throw new TimeoutException("The unrelated First Frame never arrived.");
+ await Task.Delay(1);
}
+ await pair.Clock.AdvanceAsync(p2 + TimeSpan.FromMilliseconds(100)); // P2 runs out mid-transfer
+ pair.Channel.GetReceptionsInProgress().Should().NotBeEmpty(
+ "the unrelated transfer is still in progress when P2 runs out");
+
+ Func act = () => read;
+ await act.Should().ThrowAsync(
+ "an unrelated transfer must not hold the request past its budget");
}
// -----------------------------------------------------------------------------------
@@ -512,28 +789,36 @@ public async Task A_Contended_Lock_Needs_No_Subscriber()
[Fact]
public async Task A_Late_Negative_Response_To_A_Suppressed_Send_Is_Not_The_Next_Requests()
{
- var (client, ecu, dispose) = BuildPair(
+ // On a virtual clock (#171): the ECU holds its negative answer until the test lets it go,
+ // so "late" is an instant on the clock rather than a sleep that must land on the right
+ // side of the next request's send.
+ using var answer = new ManualResetEventSlim();
+ using var pair = new ClockPair(
e => e.On(0x3E, req =>
{
if ((req[1] & 0x80) != 0)
{
- Thread.Sleep(100); // late ...
+ answer.Wait(ShortTimeout); // late ...
throw new EcuNegativeResponse(0x12); // ... and negative, to the suppressed one
}
return new byte[] { 0x00 };
}),
options: new UdsClientOptions { P2ClientMax = TimeSpan.FromMilliseconds(300) });
- using (dispose)
- {
- using var cts = new CancellationTokenSource(ShortTimeout);
- await client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token);
-
- // Follows at once; the ECU's negative answer to the suppressed send is still coming.
- Func act = () => client.TesterPresentAsync(suppressPositiveResponse: false, cts.Token);
- await act.Should().NotThrowAsync("the negative response belongs to the suppressed send");
- ecu.RequestsHandled.Should().Be(2);
- }
+ using var cts = new CancellationTokenSource(ShortTimeout);
+ await pair.Client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token); // its window: 300 ms
+ await pair.Clock.AdvanceAsync(TimeSpan.FromMilliseconds(100));
+
+ var next = pair.Client.TesterPresentAsync(suppressPositiveResponse: false, cts.Token);
+ // The next request waits out what is left of the window -- 200 ms -- rather than going
+ // out and arming a fresh P2 of 300.
+ await pair.WaitUntilWaitingAsync(TimeSpan.FromMilliseconds(200));
+ await pair.DeliverAsync(answer.Set, Negative(0x3E, 0x12)); // at 100 ms
+
+ await pair.Clock.AdvanceAsync(TimeSpan.FromMilliseconds(200)); // the window closes
+ Func act = () => next;
+ await act.Should().NotThrowAsync("the negative response belongs to the suppressed send");
+ pair.Ecu.RequestsHandled.Should().Be(2);
}
// Codex and Bugbot on #150: the windows are per service. A suppressed send for another
@@ -541,13 +826,14 @@ public async Task A_Late_Negative_Response_To_A_Suppressed_Send_Is_Not_The_Next_
[Fact]
public async Task Suppressed_Send_Windows_Are_Kept_Per_Service()
{
- var (client, ecu, dispose) = BuildPair(
+ using var answer = new ManualResetEventSlim();
+ using var pair = new ClockPair(
e => e
.On(0x3E, req =>
{
if ((req[1] & 0x80) != 0)
{
- Thread.Sleep(100);
+ answer.Wait(ShortTimeout);
throw new EcuNegativeResponse(0x12);
}
return new byte[] { 0x00 };
@@ -555,16 +841,21 @@ public async Task Suppressed_Send_Windows_Are_Kept_Per_Service()
.On(0x11, req => Array.Empty()),
options: new UdsClientOptions { P2ClientMax = TimeSpan.FromMilliseconds(300) });
- using (dispose)
- {
- using var cts = new CancellationTokenSource(ShortTimeout);
- await client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token); // its window opens
- await client.SendRawAsync(new byte[] { 0x11, 0x81 }, cts.Token); // another service's
-
- Func act = () => client.TesterPresentAsync(suppressPositiveResponse: false, cts.Token);
- await act.Should().NotThrowAsync("the TesterPresent window is still open, whatever came after");
- ecu.RequestsHandled.Should().Be(3);
- }
+ using var cts = new CancellationTokenSource(ShortTimeout);
+ await pair.Client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token); // its window: to 300 ms
+ await pair.Clock.AdvanceAsync(TimeSpan.FromMilliseconds(50));
+ await pair.Client.SendRawAsync(new byte[] { 0x11, 0x81 }, cts.Token); // another service's: to 350 ms
+ await pair.Clock.AdvanceAsync(TimeSpan.FromMilliseconds(50));
+
+ var next = pair.Client.TesterPresentAsync(suppressPositiveResponse: false, cts.Token);
+ // TesterPresent's own window, 200 ms from here -- not the later one's 250, and not none.
+ await pair.WaitUntilWaitingAsync(TimeSpan.FromMilliseconds(200));
+ await pair.DeliverAsync(answer.Set, Negative(0x3E, 0x12));
+
+ await pair.Clock.AdvanceAsync(TimeSpan.FromMilliseconds(200));
+ Func act = () => next;
+ await act.Should().NotThrowAsync("the TesterPresent window is still open, whatever came after");
+ pair.Ecu.RequestsHandled.Should().Be(3);
}
// Codex on #150: NRC 0x78 to a suppressed send says the final answer is still coming, up
@@ -572,88 +863,93 @@ public async Task Suppressed_Send_Windows_Are_Kept_Per_Service()
[Fact]
public async Task A_Pending_Answer_To_A_Suppressed_Send_Extends_Its_Window_By_P2Star()
{
- var (client, ecu, dispose) = BuildPair(
- e => e.On(0x3E, req =>
+ // On a virtual clock (#171): the ECU's 0x78 and its following negative are each held on
+ // a gate the test releases at the virtual instant they belong to.
+ var steps = new EcuSteps();
+ var pendingAt = TimeSpan.FromMilliseconds(50);
+ var negativeAt = TimeSpan.FromMilliseconds(400); // after the 0x78, not from the send
+ using var pair = new ClockPair(
+ e =>
{
- if ((req[1] & 0x80) != 0)
- throw new EcuResponsePendingThenNegative(pendingCount: 1, nrc: 0x12,
- delayBefore: TimeSpan.FromMilliseconds(50), delayAfter: TimeSpan.FromMilliseconds(400));
- return new byte[] { 0x00 };
- }),
+ e.Delay = steps.WaitAsync;
+ e.On(0x3E, req =>
+ {
+ if ((req[1] & 0x80) != 0)
+ throw new EcuResponsePendingThenNegative(pendingCount: 1, nrc: 0x12,
+ delayBefore: pendingAt, delayAfter: negativeAt);
+ return new byte[] { 0x00 };
+ });
+ },
options: new UdsClientOptions
{
P2ClientMax = TimeSpan.FromMilliseconds(300),
P2StarClientMax = TimeSpan.FromMilliseconds(1500),
});
- using (dispose)
- {
- using var cts = new CancellationTokenSource(ShortTimeout);
- await client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token);
+ using var cts = new CancellationTokenSource(ShortTimeout);
+ await pair.Client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token); // its window: 300 ms
- // 0x78 at 50 ms, the negative at 450 ms: past P2, inside P2* from the 0x78.
- Func act = () => client.TesterPresentAsync(suppressPositiveResponse: false, cts.Token);
- await act.Should().NotThrowAsync("the negative answer belongs to the suppressed send");
- ecu.RequestsHandled.Should().Be(2);
- }
+ var next = pair.Client.TesterPresentAsync(suppressPositiveResponse: false, cts.Token);
+ await pair.WaitUntilWaitingAsync(TimeSpan.FromMilliseconds(300));
+
+ await pair.Clock.AdvanceAsync(pendingAt); // 50 ms: past nothing yet, inside the window
+ await pair.DeliverAsync(() => steps.Release(pendingAt), Negative(0x3E, 0x78));
+ // The window moves out to P2* from the 0x78's arrival: 50 + 1500 = 1550 ms, 1500 from here.
+ await pair.WaitUntilWaitingAsync(TimeSpan.FromMilliseconds(1500));
+
+ await pair.Clock.AdvanceAsync(negativeAt); // 450 ms total: past the original P2 (300 ms)
+ await pair.DeliverAsync(() => steps.Release(negativeAt), Negative(0x3E, 0x12));
+
+ await pair.Clock.AdvanceAsync(TimeSpan.FromMilliseconds(1100)); // the window closes at 1550
+ Func act = () => next;
+ await act.Should().NotThrowAsync("the negative answer belongs to the suppressed send");
+ pair.Ecu.RequestsHandled.Should().Be(2);
}
// Bugbot on #150: a 0x78 already queued when the window is over still moves it out.
[Fact]
public async Task A_Queued_Pending_Answer_Still_Extends_A_Window_That_Has_Run_Out()
{
- // P2 is long enough that a loaded host still delivers the 0x78 inside it. The test
- // then waits that P2 out itself, so the client finds the window over and the 0x78
- // already queued. P2* from that arrival reaches past P2; a fixed 300 ms sleep on a
- // 200 ms window did not -- the 0x78 was still on its way, the call returned at once,
- // and the suppressed send had not been counted yet (macOS CI on #153).
+ // On a virtual clock (#171): the 0x78 is delivered -- so it is queued, not merely on its
+ // way -- before the test moves the clock past the window's end, which is the scenario
+ // this test names (Bugbot on #150): a 0x78 already queued when the window is over still
+ // moves it out.
+ var steps = new EcuSteps();
+ var pendingAt = TimeSpan.FromMilliseconds(20);
var p2 = TimeSpan.FromMilliseconds(1500);
var p2Star = TimeSpan.FromMilliseconds(2500);
- var (client, ecu, dispose) = BuildPair(
- e => e.On(0x3E, req =>
+ using var pair = new ClockPair(
+ e =>
{
- if ((req[1] & 0x80) != 0)
- throw new EcuResponsePendingThenNegative(pendingCount: 1, nrc: 0x12,
- delayBefore: TimeSpan.FromMilliseconds(20), delayAfter: TimeSpan.FromSeconds(20));
- return new byte[] { 0x00 };
- }),
+ e.Delay = steps.WaitAsync;
+ e.On(0x3E, req =>
+ {
+ if ((req[1] & 0x80) != 0)
+ throw new EcuResponsePendingThenNegative(pendingCount: 1, nrc: 0x12,
+ delayBefore: pendingAt, delayAfter: TimeSpan.FromSeconds(20));
+ return new byte[] { 0x00 };
+ });
+ },
options: new UdsClientOptions { P2ClientMax = p2, P2StarClientMax = p2Star });
- using (dispose)
- {
- using var cts = new CancellationTokenSource(ShortTimeout);
- await client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token);
- // After the send returns the window has already started, so waiting P2 from here
- // lands past its end.
- long sentAt = Stopwatch.GetTimestamp();
+ using var cts = new CancellationTokenSource(ShortTimeout);
+ await pair.Client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token); // its window: 1500 ms
- var deadline = DateTime.UtcNow + ShortTimeout;
- while (ecu.PendingNrcsSent == 0)
- {
- if (DateTime.UtcNow > deadline) throw new TimeoutException("the 0x78 was not sent");
- await Task.Delay(5);
- }
- long pendingAt = Stopwatch.GetTimestamp();
- var sinceSend = TimeSpan.FromSeconds((pendingAt - sentAt) / (double)Stopwatch.Frequency);
- sinceSend.Should().BeLessThan(p2, "the 0x78 has to arrive while the window is still open");
-
- var windowEnd = sentAt + (long)(p2.TotalSeconds * Stopwatch.Frequency);
- while (Stopwatch.GetTimestamp() < windowEnd)
- await Task.Delay(10);
-
- long callAt = Stopwatch.GetTimestamp();
- var sw = Stopwatch.StartNew();
- await client.TesterPresentAsync(suppressPositiveResponse: false, cts.Token);
- sw.Stop();
-
- // P2* from the 0x78, minus how long we already waited past that arrival. A missed
- // extension returns in a round trip; this bound sits under the shortest extension
- // (a 0x78 that arrived as the window opened) and a loaded host only lengthens it.
- var extensionLeft = p2Star - TimeSpan.FromSeconds((callAt - pendingAt) / (double)Stopwatch.Frequency);
- sw.Elapsed.Should().BeGreaterThanOrEqualTo(extensionLeft - TimeSpan.FromMilliseconds(200),
- "the queued 0x78 moved the window out to P2* from its arrival");
- ecu.LastRequest.Should().BeEquivalentTo(new byte[] { 0x3E, 0x00 });
- }
+ await pair.Clock.AdvanceAsync(pendingAt); // 20 ms: well inside the window
+ await pair.DeliverAsync(() => steps.Release(pendingAt), Negative(0x3E, 0x78));
+
+ await pair.Clock.AdvanceAsync(p2 - pendingAt); // the window is now over, as measured
+
+ var next = pair.Client.TesterPresentAsync(suppressPositiveResponse: false, cts.Token);
+ // The 0x78, already queued, moves the window out to P2* from its arrival: 20 + 2500 =
+ // 2520 ms; the clock stands at p2 (1500 ms), so 1020 ms remain.
+ var remaining = p2Star - p2 + pendingAt;
+ await pair.WaitUntilWaitingAsync(remaining);
+ await pair.Clock.AdvanceAsync(remaining);
+
+ Func act = () => next;
+ await act.Should().NotThrowAsync("the queued 0x78 moved the window out to P2* from its arrival");
+ pair.Ecu.LastRequest.Should().BeEquivalentTo(new byte[] { 0x3E, 0x00 });
}
// Codex on #150: the converse -- a 0x78 queued after the window ran out answers nothing
@@ -661,33 +957,36 @@ public async Task A_Queued_Pending_Answer_Still_Extends_A_Window_That_Has_Run_Ou
[Fact]
public async Task A_Queued_Pending_Answer_From_After_The_Windows_End_Does_Not_Revive_It()
{
- var (client, _, dispose) = BuildPair(
- e => e.On(0x3E, req =>
+ // On a virtual clock (#171): the 0x78 is delivered, and so queued, only once the clock
+ // already stands past the window's end -- late by construction, not by a race against a
+ // sleep.
+ var steps = new EcuSteps();
+ var pendingAt = TimeSpan.FromMilliseconds(500);
+ var p2 = TimeSpan.FromMilliseconds(200);
+ using var pair = new ClockPair(
+ e =>
{
- if ((req[1] & 0x80) != 0)
- throw new EcuResponsePendingThenNegative(pendingCount: 1, nrc: 0x12,
- delayBefore: TimeSpan.FromMilliseconds(500), delayAfter: TimeSpan.FromSeconds(3));
- return new byte[] { 0x00 };
- }),
- options: new UdsClientOptions
- {
- P2ClientMax = TimeSpan.FromMilliseconds(200),
- P2StarClientMax = TimeSpan.FromMilliseconds(2000),
- });
+ e.Delay = steps.WaitAsync;
+ e.On(0x3E, req =>
+ {
+ if ((req[1] & 0x80) != 0)
+ throw new EcuResponsePendingThenNegative(pendingCount: 1, nrc: 0x12,
+ delayBefore: pendingAt, delayAfter: TimeSpan.FromSeconds(3));
+ return new byte[] { 0x00 };
+ });
+ },
+ options: new UdsClientOptions { P2ClientMax = p2, P2StarClientMax = TimeSpan.FromMilliseconds(2000) });
- using (dispose)
- {
- using var cts = new CancellationTokenSource(ShortTimeout);
- await client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token);
- await Task.Delay(700); // the window ran out at 200 ms; the 0x78 at 500 ms is queued
-
- // Revived, the window would reach 2500 ms and this call would wait most of two
- // seconds; a loaded host only makes the call slower, so the bound is wide.
- var sw = Stopwatch.StartNew();
- await client.TesterPresentAsync(suppressPositiveResponse: false, cts.Token);
- sw.Stop();
- sw.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(1), "the queued 0x78 from after the window did not revive it");
- }
+ using var cts = new CancellationTokenSource(ShortTimeout);
+ await pair.Client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token); // its window: 200 ms
+
+ await pair.Clock.AdvanceAsync(pendingAt); // 500 ms: past the window's own end (200 ms)
+ await pair.DeliverAsync(() => steps.Release(pendingAt), Negative(0x3E, 0x78));
+
+ // Revived, the window would reach 2500 ms; not revived, nothing is left to wait out, and
+ // this resolves without the clock moving any further -- the only way it *can* resolve,
+ // since nothing here ever advances it that far.
+ await Within(pair.Client.TesterPresentAsync(suppressPositiveResponse: false, cts.Token));
}
// Codex on #150: a 0x78 stamped inside a suppressed send's window may still be on its way
@@ -696,10 +995,18 @@ public async Task A_Queued_Pending_Answer_From_After_The_Windows_End_Does_Not_Re
[Fact]
public async Task A_Pending_Answer_Still_On_Its_Way_Through_The_Channel_Extends_The_Window()
{
+ // On a virtual clock (#171): the client's channel is opened on a VirtualClock actor, as
+ // ClockPair does, so the property -- a 0x78 stamped inside the window may still be on
+ // its way through the channel's actor when the window is measured as over -- is a fact
+ // about the interval the client arms, not a race against a sleep.
+ using var clock = new VirtualClock();
+ clock.Advance(TimeSpan.FromMilliseconds(1)); // a zero stamp means "unstamped" to the channel
+ using var actor = clock.NewActor();
using var service = new StarvedReaderBusService();
- using var channel = IsoTpFactory.Open(service, IsoTpEndpoint.Normal(0x7E0, 0x7E8), FastIsoTp(useCanFd: false), leaveOpen: true);
+ using var channel = new IsoTpChannel(service, IsoTpEndpoint.Normal(0x7E0, 0x7E8),
+ FastIsoTp(useCanFd: false), ownsService: false, actor);
var pendingBudget = TimeSpan.FromMilliseconds(600);
- using var client = UdsClient.Create(channel, new UdsClientOptions
+ using var client = UdsClient.Create(channel, actor, new UdsClientOptions
{
P2ClientMax = TimeSpan.FromMilliseconds(100),
P2StarClientMax = pendingBudget,
@@ -707,23 +1014,29 @@ public async Task A_Pending_Answer_Still_On_Its_Way_Through_The_Channel_Extends_
using var cts = new CancellationTokenSource(ShortTimeout);
await client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token); // suppressed: window P2 = 100 ms
+
// The ECU's 0x78, stamped at the send -- inside the window by construction, not by a
// timer -- buffered by the demux; the reader task that would take it to the actor is
// starved by construction.
- long arrival = Stopwatch.GetTimestamp();
+ long arrival = clock.Elapsed.Ticks;
byte[] sf = { 0x03, 0x7F, 0x3E, 0x78, 0x00, 0x00, 0x00, 0x00 };
service.Deliver(new CanFrameView(CanFrameType.Can20, 0x7E8, sf, FrameFlags.None), arrival);
- await Task.Delay(150); // the window has run out, as measured
-
- // The next TesterPresent waits the window out: P2* from the 0x78 if the channel was
- // settled before the inbox was read empty, else nothing. It is never answered; what
- // matters is that it did not fail before P2* from the 0x78 had passed -- a lower bound
- // a loaded host only raises.
- Func next = () => client.SendRawAsync(new byte[] { 0x3E, 0x00 }, cts.Token);
- await next.Should().ThrowAsync();
- var sinceArrival = TimeSpan.FromSeconds((Stopwatch.GetTimestamp() - arrival) / (double)Stopwatch.Frequency);
- sinceArrival.Should().BeGreaterThanOrEqualTo(pendingBudget,
- "the 0x78 on its way through the channel moved the window out to P2* from its arrival");
+
+ await clock.AdvanceAsync(TimeSpan.FromMilliseconds(150)); // the window has run out, as measured
+
+ var next = client.SendRawAsync(new byte[] { 0x3E, 0x00 }, cts.Token);
+ // The window moves out to P2* from the 0x78's arrival, settled in before the deadline is
+ // read as empty: 600 ms from the arrival stamp, 450 ms from here.
+ var remaining = TimeSpan.FromTicks(arrival + pendingBudget.Ticks - clock.Elapsed.Ticks);
+ await clock.WaitUntilTimerArmedAsync(actor, remaining, ShortTimeout);
+ await clock.AdvanceAsync(remaining); // the window closes; the real request goes out
+
+ // Nothing answers it either: its own P2 must now run out too.
+ await clock.WaitUntilTimerArmedAsync(actor, TimeSpan.FromMilliseconds(100), ShortTimeout);
+ await clock.AdvanceAsync(TimeSpan.FromMilliseconds(100));
+
+ Func nextFn = () => next;
+ await nextFn.Should().ThrowAsync();
}
// Codex on #150: a suppressed request the channel refuses before transmitting -- here,
@@ -732,26 +1045,21 @@ public async Task A_Pending_Answer_Still_On_Its_Way_Through_The_Channel_Extends_
[Fact]
public async Task A_Suppressed_Send_The_Channel_Refuses_Leaves_No_Window()
{
- var (client, _, dispose) = BuildPair(
+ // On a virtual clock (#171): a window left behind by mistake would have this call wait
+ // out P2 = 2 s on a clock nothing here ever advances, so it can only resolve -- inside
+ // Within's real timeout -- if no window was left at all.
+ using var pair = new ClockPair(
e => e.On(0x3E, req => new byte[] { 0x00 }),
options: new UdsClientOptions { P2ClientMax = TimeSpan.FromSeconds(2), P2StarClientMax = TimeSpan.FromSeconds(2) });
- using (dispose)
- {
- using var cts = new CancellationTokenSource(ShortTimeout);
- var oversized = new byte[4200];
- oversized[0] = 0x3E;
- oversized[1] = 0x80;
- Func refused = () => client.SendRawAsync(oversized, cts.Token);
- await refused.Should().ThrowAsync();
-
- // Left with a window, this call would wait P2 = 2 s before sending; a loaded host
- // only makes the call slower, so the bound is wide.
- var sw = Stopwatch.StartNew();
- await client.TesterPresentAsync(suppressPositiveResponse: false, cts.Token);
- sw.Stop();
- sw.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(1), "nothing was transmitted, so nothing is answered");
- }
+ using var cts = new CancellationTokenSource(ShortTimeout);
+ var oversized = new byte[4200];
+ oversized[0] = 0x3E;
+ oversized[1] = 0x80;
+ Func refused = () => pair.Client.SendRawAsync(oversized, cts.Token);
+ await refused.Should().ThrowAsync();
+
+ await Within(pair.Client.TesterPresentAsync(suppressPositiveResponse: false, cts.Token));
}
// Bugbot on #150: a refused request puts the window back to what it was -- an earlier
@@ -759,28 +1067,26 @@ public async Task A_Suppressed_Send_The_Channel_Refuses_Leaves_No_Window()
[Fact]
public async Task A_Suppressed_Send_The_Channel_Refuses_Leaves_An_Earlier_Window_As_It_Was()
{
- var (client, _, dispose) = BuildPair(
+ // On a virtual clock (#171): the earlier window's survival is proven by the exact
+ // interval the next call arms, not by a real elapsed-time floor.
+ using var pair = new ClockPair(
e => e.On(0x3E, req => new byte[] { 0x00 }),
options: new UdsClientOptions { P2ClientMax = TimeSpan.FromSeconds(2), P2StarClientMax = TimeSpan.FromSeconds(2) });
- using (dispose)
- {
- using var cts = new CancellationTokenSource(ShortTimeout);
- var sw = Stopwatch.StartNew();
- await client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token); // suppressed: a 2 s window, not waited out by the next suppressed send
- var oversized = new byte[4200];
- oversized[0] = 0x3E;
- oversized[1] = 0x80;
- Func refused = () => client.SendRawAsync(oversized, cts.Token);
- await refused.Should().ThrowAsync();
-
- // The unsuppressed TesterPresent waits the first send's window out: at least 2 s
- // after it, a lower bound a loaded host only raises.
- await client.TesterPresentAsync(suppressPositiveResponse: false, cts.Token);
- sw.Stop();
- sw.Elapsed.Should().BeGreaterThanOrEqualTo(TimeSpan.FromMilliseconds(1900),
- "the refused send left the earlier suppressed send's window as it was");
- }
+ using var cts = new CancellationTokenSource(ShortTimeout);
+ await pair.Client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token); // suppressed: a 2 s window, not waited out by the next suppressed send
+ var oversized = new byte[4200];
+ oversized[0] = 0x3E;
+ oversized[1] = 0x80;
+ Func refused = () => pair.Client.SendRawAsync(oversized, cts.Token);
+ await refused.Should().ThrowAsync();
+
+ var next = pair.Client.TesterPresentAsync(suppressPositiveResponse: false, cts.Token);
+ // The unsuppressed TesterPresent waits the first send's window out: 2 s, unshortened by
+ // the refused send in between.
+ await pair.WaitUntilWaitingAsync(TimeSpan.FromSeconds(2));
+ await pair.Clock.AdvanceAsync(TimeSpan.FromSeconds(2));
+ await Within(next);
}
// Codex on #150: the discard on an aborted request -- here, one the caller cancelled --
@@ -789,10 +1095,20 @@ public async Task A_Suppressed_Send_The_Channel_Refuses_Leaves_An_Earlier_Window
[Fact]
public async Task A_Pending_Answer_Still_On_Its_Way_Is_Routed_By_An_Aborted_Requests_Discard()
{
+ // On a virtual clock (#171), as A_Pending_Answer_Still_On_Its_Way_Through_The_Channel_
+ // Extends_The_Window above: only the frame's stamp and the client's own timers need to
+ // be virtual. The race the comment below describes is about *ordering* (the frame must
+ // be delivered before the abort, once the send is visible on the wire) rather than about
+ // *when* on a clock, so the poll for service.Sent.Count stays a bounded real poll of an
+ // observable condition.
+ using var clock = new VirtualClock();
+ clock.Advance(TimeSpan.FromMilliseconds(1));
+ using var actor = clock.NewActor();
using var service = new StarvedReaderBusService();
- using var channel = IsoTpFactory.Open(service, IsoTpEndpoint.Normal(0x7E0, 0x7E8), FastIsoTp(useCanFd: false), leaveOpen: true);
+ using var channel = new IsoTpChannel(service, IsoTpEndpoint.Normal(0x7E0, 0x7E8),
+ FastIsoTp(useCanFd: false), ownsService: false, actor);
var pendingBudget = TimeSpan.FromMilliseconds(600);
- using var client = UdsClient.Create(channel, new UdsClientOptions
+ using var client = UdsClient.Create(channel, actor, new UdsClientOptions
{
P2ClientMax = TimeSpan.FromMilliseconds(100),
P2StarClientMax = pendingBudget,
@@ -805,14 +1121,13 @@ public async Task A_Pending_Answer_Still_On_Its_Way_Is_Routed_By_An_Aborted_Requ
// The 0x78 is stamped right after the request's synchronous start -- after its pre-send
// discard's stamp, which would otherwise drop the frame as older, and inside the window
// by construction rather than by a timer (macOS CI on #150) -- and delivered while the
- // request waits, before its abort, whose discard is the one under test. A 50 ms timer
- // lost that race on macOS: the host resumed after P2 had already elapsed and the wait
- // reported a timeout. Cancelling from here, once the send is visible, does not depend
- // on a timer firing first. (A host that delays the delivery past the abort lets the
- // next call's wait-out route it instead: a pass for the wrong reason, never a failure.)
+ // request waits, before its abort, whose discard is the one under test. Cancelling from
+ // here, once the send is visible, does not depend on a timer firing first. (A host that
+ // delays the delivery past the abort lets the next call's wait-out route it instead: a
+ // pass for the wrong reason, never a failure.)
using var early = new CancellationTokenSource();
var other = client.ReadDataByIdentifierAsync(0xF190, early.Token);
- long arrival = Stopwatch.GetTimestamp();
+ long arrival = clock.Elapsed.Ticks;
var sent = DateTime.UtcNow + ShortTimeout;
while (service.Sent.Count < 2)
{
@@ -825,11 +1140,19 @@ public async Task A_Pending_Answer_Still_On_Its_Way_Is_Routed_By_An_Aborted_Requ
Func cancelled = () => other;
await cancelled.Should().ThrowAsync();
- // The next TesterPresent waits P2* from the 0x78 if the abort routed it, else nothing.
- Func next = () => client.SendRawAsync(new byte[] { 0x3E, 0x00 }, cts.Token);
- await next.Should().ThrowAsync();
- var sinceArrival = TimeSpan.FromSeconds((Stopwatch.GetTimestamp() - arrival) / (double)Stopwatch.Frequency);
- sinceArrival.Should().BeGreaterThanOrEqualTo(pendingBudget,
+ var next = client.SendRawAsync(new byte[] { 0x3E, 0x00 }, cts.Token);
+ // The window moves out to P2* from the 0x78's arrival: 600 ms from the arrival stamp,
+ // and the clock has not moved since, so 600 ms from here too.
+ var remaining = TimeSpan.FromTicks(arrival + pendingBudget.Ticks - clock.Elapsed.Ticks);
+ await clock.WaitUntilTimerArmedAsync(actor, remaining, ShortTimeout);
+ await clock.AdvanceAsync(remaining); // the window closes; the real request goes out
+
+ // Nothing answers it either: its own P2 must now run out too.
+ await clock.WaitUntilTimerArmedAsync(actor, TimeSpan.FromMilliseconds(100), ShortTimeout);
+ await clock.AdvanceAsync(TimeSpan.FromMilliseconds(100));
+
+ Func nextFn = () => next;
+ await nextFn.Should().ThrowAsync(
"the aborted request's discard routed the 0x78 to the suppressed send's window");
}
@@ -839,77 +1162,95 @@ public async Task A_Pending_Answer_Still_On_Its_Way_Is_Routed_By_An_Aborted_Requ
[Fact]
public async Task A_Stale_Pending_Answer_Queued_Before_A_Suppressed_Send_Does_Not_Extend_Its_Window()
{
+ // On a virtual clock (#171): the earlier request's own P2 wait, its cancellation, and
+ // the stale 0x78's delivery are all driven by the test rather than raced against sleeps.
+ var steps = new EcuSteps();
int calls = 0;
- var (client, _, dispose) = BuildPair(
- e => e.On(0x3E, req =>
+ using var pair = new ClockPair(
+ e =>
{
- switch (Interlocked.Increment(ref calls))
+ e.Delay = steps.WaitAsync;
+ e.On(0x3E, req =>
{
- case 1: // the request the caller cancels: its 0x78 at 100 ms is stale by then, its negative never comes in time
- throw new EcuResponsePendingThenNegative(pendingCount: 1, nrc: 0x12,
- delayBefore: TimeSpan.FromMilliseconds(100), delayAfter: TimeSpan.FromSeconds(3));
- case 2: // the suppressed send: no answer
- throw new EcuSilent();
- default:
- return new byte[] { 0x00 };
- }
- }),
+ switch (Interlocked.Increment(ref calls))
+ {
+ case 1: // the request the caller cancels: its 0x78 is stale by the time the suppressed send discards it
+ throw new EcuResponsePendingThenNegative(pendingCount: 1, nrc: 0x12,
+ delayBefore: TimeSpan.FromMilliseconds(100), delayAfter: TimeSpan.FromSeconds(3));
+ case 2: // the suppressed send: no answer
+ throw new EcuSilent();
+ default:
+ return new byte[] { 0x00 };
+ }
+ });
+ },
options: new UdsClientOptions
{
P2ClientMax = TimeSpan.FromMilliseconds(100),
P2StarClientMax = TimeSpan.FromMilliseconds(2000),
});
- using (dispose)
- {
- using var cts = new CancellationTokenSource(ShortTimeout);
- using var early = new CancellationTokenSource(TimeSpan.FromMilliseconds(30));
- Func cancelled = () => client.SendRawAsync(new byte[] { 0x3E, 0x00 }, early.Token);
- await cancelled.Should().ThrowAsync();
- await Task.Delay(150); // the stale 0x78 (100 ms) is queued
-
- await client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token); // suppressed: window P2 = 100 ms
-
- // Read as this send's, the stale 0x78 would move the window out to 2100 ms and this
- // call wait most of two seconds; a loaded host only makes it slower.
- var sw = Stopwatch.StartNew();
- var answer = await client.SendRawAsync(new byte[] { 0x3E, 0x00 }, cts.Token);
- sw.Stop();
- answer.Should().Equal(0x7E, 0x00);
- sw.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(1), "the stale 0x78 was discarded before the suppressed send");
- }
+ using var cts = new CancellationTokenSource(ShortTimeout);
+ using var early = new CancellationTokenSource();
+ var cancelled = pair.Client.SendRawAsync(new byte[] { 0x3E, 0x00 }, early.Token);
+ await pair.WaitUntilWaitingAsync(TimeSpan.FromMilliseconds(100)); // call #1 waiting on its own P2
+ early.Cancel();
+ Func cancelledFn = () => cancelled;
+ await cancelledFn.Should().ThrowAsync();
+
+ // The stale 0x78 (its 100 ms delay) is queued before the suppressed send opens its window.
+ await pair.DeliverAsync(() => steps.Release(TimeSpan.FromMilliseconds(100)), Negative(0x3E, 0x78));
+
+ await pair.Client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token); // suppressed: window P2 = 100 ms
+
+ var next = pair.Client.SendRawAsync(new byte[] { 0x3E, 0x00 }, cts.Token);
+ // Read as this send's, the stale 0x78 would move the window out to 2100 ms; the exact
+ // interval armed here is the suppressed send's own 100 ms, unextended.
+ await pair.WaitUntilWaitingAsync(TimeSpan.FromMilliseconds(100));
+ await pair.Clock.AdvanceAsync(TimeSpan.FromMilliseconds(100));
+
+ var answer = await Within(next);
+ answer.Should().Equal(0x7E, 0x00);
}
// Bugbot on #150: a wait cancelled part-way keeps what remains of the window.
[Fact]
public async Task A_Cancelled_Wait_Keeps_The_Rest_Of_The_Window()
{
- var (client, ecu, dispose) = BuildPair(
+ using var answer = new ManualResetEventSlim();
+ using var pair = new ClockPair(
e => e.On(0x3E, req =>
{
if ((req[1] & 0x80) != 0)
{
- Thread.Sleep(200);
+ answer.Wait(ShortTimeout);
throw new EcuNegativeResponse(0x12);
}
return new byte[] { 0x00 };
}),
options: new UdsClientOptions { P2ClientMax = TimeSpan.FromMilliseconds(400) });
- using (dispose)
- {
- using var cts = new CancellationTokenSource(ShortTimeout);
- await client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token);
-
- using var early = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
- Func cancelled = () => client.TesterPresentAsync(suppressPositiveResponse: false, early.Token);
- await cancelled.Should().ThrowAsync();
+ using var cts = new CancellationTokenSource(ShortTimeout);
+ await pair.Client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token); // its window: 400 ms
+ await pair.Clock.AdvanceAsync(TimeSpan.FromMilliseconds(50));
- // The negative at 200 ms is still coming; the window must still be honoured.
- Func act = () => client.TesterPresentAsync(suppressPositiveResponse: false, cts.Token);
- await act.Should().NotThrowAsync("the remaining window survived the cancelled wait");
- ecu.RequestsHandled.Should().Be(2);
- }
+ using var early = new CancellationTokenSource();
+ var cancelled = pair.Client.TesterPresentAsync(suppressPositiveResponse: false, early.Token);
+ await pair.WaitUntilWaitingAsync(TimeSpan.FromMilliseconds(350)); // waiting out the window
+ early.Cancel();
+ Func cancel = () => cancelled;
+ await cancel.Should().ThrowAsync();
+ await pair.Clock.AdvanceAsync(TimeSpan.FromMilliseconds(50));
+
+ // The negative is still coming; the rest of the window -- 300 ms -- must still be honoured.
+ var next = pair.Client.TesterPresentAsync(suppressPositiveResponse: false, cts.Token);
+ await pair.WaitUntilWaitingAsync(TimeSpan.FromMilliseconds(300));
+ await pair.DeliverAsync(answer.Set, Negative(0x3E, 0x12));
+
+ await pair.Clock.AdvanceAsync(TimeSpan.FromMilliseconds(300));
+ Func act = () => next;
+ await act.Should().NotThrowAsync("the remaining window survived the cancelled wait");
+ pair.Ecu.RequestsHandled.Should().Be(2);
}
// Codex on #150: a 0x78 for service B, heard while waiting out service A's window, moves
@@ -917,39 +1258,58 @@ public async Task A_Cancelled_Wait_Keeps_The_Rest_Of_The_Window()
[Fact]
public async Task A_Pending_Answer_For_Another_Service_Heard_During_A_Wait_Extends_That_Services_Window()
{
- var (client, _, dispose) = BuildPair(
- e => e
- .On(0x3E, req =>
- {
- if ((req[1] & 0x80) != 0) throw new EcuSilent();
- return new byte[] { 0x00 };
- })
- .On(0x11, req =>
- {
- if ((req[1] & 0x80) != 0)
- throw new EcuResponsePendingThenNegative(pendingCount: 1, nrc: 0x12,
- delayBefore: TimeSpan.FromMilliseconds(50), delayAfter: TimeSpan.FromMilliseconds(500));
- Thread.Sleep(250); // slow enough that the stale negative (at 550 ms) would be first in line
- return new byte[] { 0x01 };
- }),
+ // On a virtual clock (#171): B's 0x78 is delivered while A's TesterPresent is actively
+ // waiting out A's window, at the exact instant the test moves the clock to, and B's
+ // window's extension is then proven by the exact interval B's own next request arms --
+ // stronger than the original's implicit race against a slow positive reply, and not
+ // dependent on one.
+ var steps = new EcuSteps();
+ var pendingAt = TimeSpan.FromMilliseconds(50);
+ using var pair = new ClockPair(
+ e =>
+ {
+ e.Delay = steps.WaitAsync;
+ e.On(0x3E, req =>
+ {
+ if ((req[1] & 0x80) != 0) throw new EcuSilent();
+ return new byte[] { 0x00 };
+ })
+ .On(0x11, req =>
+ {
+ if ((req[1] & 0x80) != 0)
+ throw new EcuResponsePendingThenNegative(pendingCount: 1, nrc: 0x12,
+ delayBefore: pendingAt, delayAfter: TimeSpan.FromMilliseconds(500));
+ return new byte[] { 0x01 };
+ });
+ },
options: new UdsClientOptions
{
P2ClientMax = TimeSpan.FromMilliseconds(400),
P2StarClientMax = TimeSpan.FromMilliseconds(1500),
});
- using (dispose)
- {
- using var cts = new CancellationTokenSource(ShortTimeout);
- await client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token); // A, silent
- await client.SendRawAsync(new byte[] { 0x11, 0x81 }, cts.Token); // B: 0x78 at 50 ms, negative at 450 ms
-
- // A's request waits A's window out and hears B's 0x78 meanwhile.
- await client.TesterPresentAsync(suppressPositiveResponse: false, cts.Token);
- // B's request follows at once: B's window must now reach past 450 ms.
- var reset = await client.SendRawAsync(new byte[] { 0x11, 0x01 }, cts.Token);
- reset.Should().Equal(0x51, 0x01);
- }
+ using var cts = new CancellationTokenSource(ShortTimeout);
+ await pair.Client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token); // A, silent: window(0x3E) to 400 ms
+ await pair.Client.SendRawAsync(new byte[] { 0x11, 0x81 }, cts.Token); // B, suppressed: window(0x11) to 400 ms too
+
+ // A's request waits A's window out and hears B's 0x78 meanwhile.
+ var a = pair.Client.TesterPresentAsync(suppressPositiveResponse: false, cts.Token);
+ await pair.WaitUntilWaitingAsync(TimeSpan.FromMilliseconds(400));
+
+ await pair.Clock.AdvanceAsync(pendingAt); // 50 ms
+ await pair.DeliverAsync(() => steps.Release(pendingAt), Negative(0x11, 0x78)); // heard as B's, not A's
+
+ await pair.WaitUntilWaitingAsync(TimeSpan.FromMilliseconds(350)); // A's own window, unaffected
+ await pair.Clock.AdvanceAsync(TimeSpan.FromMilliseconds(350)); // A's window closes at 400 ms
+ await Within(a);
+
+ // B's request follows: B's window now reaches P2* past the 0x78 -- 1550 ms, 1150 from here.
+ var reset = pair.Client.SendRawAsync(new byte[] { 0x11, 0x01 }, cts.Token);
+ await pair.WaitUntilWaitingAsync(TimeSpan.FromMilliseconds(1150));
+ await pair.Clock.AdvanceAsync(TimeSpan.FromMilliseconds(1150));
+
+ var result = await Within(reset);
+ result.Should().Equal(0x51, 0x01);
}
// Codex on #150: a 0x78 for service A, consumed as a stray while service B's request runs
@@ -957,37 +1317,56 @@ public async Task A_Pending_Answer_For_Another_Service_Heard_During_A_Wait_Exten
[Fact]
public async Task A_Pending_Answer_Consumed_As_Another_Requests_Stray_Still_Extends_Its_Window()
{
- var (client, _, dispose) = BuildPair(
- e => e
- .On(0x11, req =>
- {
- if ((req[1] & 0x80) != 0)
- throw new EcuResponsePendingThenNegative(pendingCount: 1, nrc: 0x12,
- delayBefore: TimeSpan.FromMilliseconds(100), delayAfter: TimeSpan.FromMilliseconds(650));
- Thread.Sleep(400); // A's request, out at 600 ms without the routing, is answered at 1000: the stale negative at 750 is first in line
- return new byte[] { 0x01 };
- })
- .On(0x22, req =>
- {
- Thread.Sleep(200); // B's request is on the wire while A's 0x78 arrives
- return new byte[] { 0xF1, 0x90, 0xAA };
- }),
+ // On a virtual clock (#171), each ECU answer released at its instant: the 400 ms and
+ // 200 ms sleeps that placed them before were a 200 ms margin a loaded runner overran.
+ var steps = new EcuSteps();
+ using var answerB = new ManualResetEventSlim();
+ var pendingAt = TimeSpan.FromMilliseconds(100); // A's 0x78, while B's request is out
+ var negativeAt = TimeSpan.FromMilliseconds(650); // A's final negative, 650 ms after it
+ using var pair = new ClockPair(
+ e =>
+ {
+ e.Delay = steps.WaitAsync;
+ e.On(0x11, req =>
+ {
+ if ((req[1] & 0x80) != 0)
+ throw new EcuResponsePendingThenNegative(pendingCount: 1, nrc: 0x12,
+ delayBefore: pendingAt, delayAfter: negativeAt);
+ return new byte[] { 0x01 };
+ })
+ .On(0x22, req =>
+ {
+ answerB.Wait(ShortTimeout); // B's request is on the wire while A's 0x78 arrives
+ return new byte[] { 0xF1, 0x90, 0xAA };
+ });
+ },
options: new UdsClientOptions
{
P2ClientMax = TimeSpan.FromMilliseconds(600),
P2StarClientMax = TimeSpan.FromMilliseconds(2000),
});
- using (dispose)
- {
- using var cts = new CancellationTokenSource(ShortTimeout);
- await client.SendRawAsync(new byte[] { 0x11, 0x81 }, cts.Token); // A, suppressed: 0x78 at 100 ms, negative at 750 ms
- await client.ReadDataByIdentifierAsync(0xF190, cts.Token); // B, another service, consumes A's 0x78 as a stray
-
- // A's request follows: its window must reach past 750 ms.
- var reset = await client.SendRawAsync(new byte[] { 0x11, 0x01 }, cts.Token);
- reset.Should().Equal(0x51, 0x01);
- }
+ using var cts = new CancellationTokenSource(ShortTimeout);
+ await pair.Client.SendRawAsync(new byte[] { 0x11, 0x81 }, cts.Token); // A, suppressed: its window to 600 ms
+ var b = pair.Client.ReadDataByIdentifierAsync(0xF190, cts.Token); // B, another service
+ await pair.WaitUntilWaitingAsync(TimeSpan.FromMilliseconds(600)); // B's P2
+
+ await pair.Clock.AdvanceAsync(pendingAt);
+ await pair.DeliverAsync(() => steps.Release(pendingAt), Negative(0x11, 0x78)); // B consumes it as a stray
+ answerB.Set();
+ await Within(b);
+
+ // A's request follows: its window now reaches P2* past the 0x78 -- 2100 ms, 2000 from here.
+ // Without the routing it would end at 600 ms, before A's negative at 750 is on the wire.
+ var a = pair.Client.SendRawAsync(new byte[] { 0x11, 0x01 }, cts.Token);
+ await pair.WaitUntilWaitingAsync(TimeSpan.FromMilliseconds(2000));
+
+ await pair.Clock.AdvanceAsync(negativeAt);
+ await pair.DeliverAsync(() => steps.Release(negativeAt), Negative(0x11, 0x12)); // at 750 ms
+
+ await pair.Clock.AdvanceAsync(TimeSpan.FromMilliseconds(1350)); // the window closes at 2100
+ var reset = await Within(a);
+ reset.Should().Equal(0x51, 0x01);
}
// Codex on #150: a 0x78 for service A that arrives after A's window has run out answers
@@ -996,38 +1375,57 @@ public async Task A_Pending_Answer_Consumed_As_Another_Requests_Stray_Still_Exte
[Fact]
public async Task A_Pending_Answer_From_After_A_Windows_End_Does_Not_Revive_It()
{
- var (client, _, dispose) = BuildPair(
- e => e
- .On(0x11, req =>
- {
- if ((req[1] & 0x80) != 0)
- throw new EcuResponsePendingThenNegative(pendingCount: 1, nrc: 0x12,
- delayBefore: TimeSpan.FromMilliseconds(500), delayAfter: TimeSpan.FromSeconds(3));
- return new byte[] { 0x01 };
- })
- .On(0x22, req => throw new EcuResponsePending(pendingCount: 1,
- finalResponse: new byte[] { 0xF1, 0x90, 0xAA }, delayBetween: TimeSpan.FromMilliseconds(700))),
+ // On a virtual clock (#171): A's late 0x78 is delivered once B's own receive loop is
+ // waiting for it, at an instant already past A's window's end -- late by construction --
+ // and "not revived" is then proven by A's next request resolving without the clock
+ // moving any further, the only way it can resolve if nothing was revived.
+ var steps = new EcuSteps();
+ var pendingAt = TimeSpan.FromMilliseconds(500);
+ var bGap = TimeSpan.FromMilliseconds(700);
+ using var pair = new ClockPair(
+ e =>
+ {
+ e.Delay = steps.WaitAsync;
+ e.On(0x11, req =>
+ {
+ if ((req[1] & 0x80) != 0)
+ throw new EcuResponsePendingThenNegative(pendingCount: 1, nrc: 0x12,
+ delayBefore: pendingAt, delayAfter: TimeSpan.FromSeconds(3));
+ return new byte[] { 0x01 };
+ })
+ .On(0x22, req => throw new EcuResponsePending(pendingCount: 1,
+ finalResponse: new byte[] { 0xF1, 0x90, 0xAA }, delayBetween: bGap));
+ },
options: new UdsClientOptions
{
P2ClientMax = TimeSpan.FromMilliseconds(200),
P2StarClientMax = TimeSpan.FromMilliseconds(2000),
});
- using (dispose)
- {
- using var cts = new CancellationTokenSource(ShortTimeout);
- await client.SendRawAsync(new byte[] { 0x11, 0x81 }, cts.Token); // A, suppressed: window P2 = 200 ms; its 0x78 comes at 500 ms
- await client.ReadDataByIdentifierAsync(0xF190, cts.Token); // B, waiting in P2* until 700 ms, consumes A's late 0x78 as a stray
-
- // A's next request: the window ran out at 200 ms, and the 0x78 at 500 did not
- // reopen it -- revived, it would reach 2500 ms, and this call would wait most of
- // two seconds. A loaded host only makes the call slower, so the bound is wide.
- var sw = Stopwatch.StartNew();
- var reset = await client.SendRawAsync(new byte[] { 0x11, 0x01 }, cts.Token);
- sw.Stop();
- reset.Should().Equal(0x51, 0x01);
- sw.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(1), "the late 0x78 did not revive the window");
- }
+ using var cts = new CancellationTokenSource(ShortTimeout);
+ await pair.Client.SendRawAsync(new byte[] { 0x11, 0x81 }, cts.Token); // A, suppressed: window(0x11) to 200 ms
+
+ // B's own first 0x78 goes out the instant its request is handled, with nothing pacing
+ // it -- on a fast host that can land before a check for B's initial P2 would even see
+ // it armed, so what is checked directly is the interval B restarts to, which only a
+ // client that has already received and processed that 0x78 could have armed.
+ var b = pair.Client.ReadDataByIdentifierAsync(0xF190, cts.Token);
+ await pair.WaitUntilWaitingAsync(TimeSpan.FromMilliseconds(2000)); // B's P2*, restarted
+ await pair.Clock.AdvanceAsync(pendingAt); // 500 ms: past A's window's end (200 ms)
+ await pair.DeliverAsync(() => steps.Release(pendingAt), Negative(0x11, 0x78)); // A's late 0x78, heard as B's stray
+
+ // B's own wait is unaffected: 2000 ms from its 0x78 at 1 ms, minus the 500 ms elapsed.
+ await pair.WaitUntilWaitingAsync(TimeSpan.FromMilliseconds(1500));
+ await pair.Clock.AdvanceAsync(bGap - pendingAt); // 200 ms further: B's own gap (700 ms) ends
+ await pair.DeliverAsync(() => steps.Release(bGap), data => data.Length >= 2 && data[1] == 0x62); // B's final response
+ var data = await Within(b);
+ data.Should().Equal(0xAA);
+
+ // A's next request: the window ran out at 200 ms, and the late 0x78 did not reopen it --
+ // revived, it would reach 2500 ms, and this resolves without the clock moving any
+ // further, which is only possible if nothing was revived.
+ var reset = await Within(pair.Client.SendRawAsync(new byte[] { 0x11, 0x01 }, cts.Token));
+ reset.Should().Equal(0x51, 0x01);
}
// Codex on #150: the same, heard while another service's wait-out runs rather than by a
@@ -1035,43 +1433,61 @@ public async Task A_Pending_Answer_From_After_A_Windows_End_Does_Not_Revive_It()
[Fact]
public async Task A_Pending_Answer_From_After_A_Windows_End_Heard_In_A_Wait_Out_Does_Not_Revive_It()
{
- var (client, _, dispose) = BuildPair(
- e => e
- .On(0x11, req =>
- {
- if ((req[1] & 0x80) != 0)
- throw new EcuResponsePendingThenNegative(pendingCount: 1, nrc: 0x12,
- delayBefore: TimeSpan.FromMilliseconds(1500), delayAfter: TimeSpan.FromSeconds(3));
- return new byte[] { 0x01 };
- })
- .On(0x3E, req =>
- {
- if ((req[1] & 0x80) != 0)
- throw new EcuResponsePendingThenNegative(pendingCount: 1, nrc: 0x12,
- delayBefore: TimeSpan.FromMilliseconds(100), delayAfter: TimeSpan.FromSeconds(3));
- return new byte[] { 0x00 };
- }),
+ // On a virtual clock (#171): B's late 0x78 is delivered while A is actively waiting out
+ // A's own (self-extended) window -- the "heard in a wait-out" path, as opposed to the
+ // sibling test's "heard by a request's stray branch" -- again proven by exact intervals
+ // rather than a race against a sleep.
+ var steps = new EcuSteps();
+ var aPendingAt = TimeSpan.FromMilliseconds(100);
+ var bPendingAt = TimeSpan.FromMilliseconds(1500);
+ using var pair = new ClockPair(
+ e =>
+ {
+ e.Delay = steps.WaitAsync;
+ e.On(0x11, req =>
+ {
+ if ((req[1] & 0x80) != 0)
+ throw new EcuResponsePendingThenNegative(pendingCount: 1, nrc: 0x12,
+ delayBefore: bPendingAt, delayAfter: TimeSpan.FromSeconds(3));
+ return new byte[] { 0x01 };
+ })
+ .On(0x3E, req =>
+ {
+ if ((req[1] & 0x80) != 0)
+ throw new EcuResponsePendingThenNegative(pendingCount: 1, nrc: 0x12,
+ delayBefore: aPendingAt, delayAfter: TimeSpan.FromSeconds(3));
+ return new byte[] { 0x00 };
+ });
+ },
options: new UdsClientOptions
{
P2ClientMax = TimeSpan.FromMilliseconds(200),
P2StarClientMax = TimeSpan.FromMilliseconds(2000),
});
- using (dispose)
- {
- using var cts = new CancellationTokenSource(ShortTimeout);
- await client.SendRawAsync(new byte[] { 0x11, 0x81 }, cts.Token); // B, suppressed: window to 200 ms; its 0x78 comes at 1500 ms
- await client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token); // A, suppressed: its own 0x78 at 100 ms moves its window to 2100 ms
- await client.SendRawAsync(new byte[] { 0x3E, 0x00 }, cts.Token); // A again: waits its window out until 2100 ms, hearing B's late 0x78 at 1500
-
- // B's next request: revived at 1500 ms, B's window would reach 3500 ms and this
- // call, at ~2100 ms, would wait most of 1.5 s.
- var sw = Stopwatch.StartNew();
- var reset = await client.SendRawAsync(new byte[] { 0x11, 0x01 }, cts.Token);
- sw.Stop();
- reset.Should().Equal(0x51, 0x01);
- sw.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(1), "the late 0x78 heard in the wait-out did not revive the window");
- }
+ using var cts = new CancellationTokenSource(ShortTimeout);
+ await pair.Client.SendRawAsync(new byte[] { 0x11, 0x81 }, cts.Token); // B, suppressed: window(0x11) to 200 ms
+ await pair.Client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token); // A, suppressed: window(0x3E) to 200 ms too
+
+ var again = pair.Client.SendRawAsync(new byte[] { 0x3E, 0x00 }, cts.Token); // A again: waits its own window out
+ await pair.WaitUntilWaitingAsync(TimeSpan.FromMilliseconds(200));
+
+ await pair.Clock.AdvanceAsync(aPendingAt); // 100 ms: A's own 0x78 arrives
+ await pair.DeliverAsync(() => steps.Release(aPendingAt), Negative(0x3E, 0x78));
+ // A's own window moves out to P2* from its 0x78: 100 + 2000 = 2100 ms, 2000 from here.
+ await pair.WaitUntilWaitingAsync(TimeSpan.FromMilliseconds(2000));
+
+ await pair.Clock.AdvanceAsync(bPendingAt - aPendingAt); // 1500 ms total: B's late 0x78 arrives
+ await pair.DeliverAsync(() => steps.Release(bPendingAt), Negative(0x11, 0x78)); // heard in A's own wait-out
+ // A's wait is unaffected: 2100 ms total, 600 ms from here.
+ await pair.WaitUntilWaitingAsync(TimeSpan.FromMilliseconds(600));
+ await pair.Clock.AdvanceAsync(TimeSpan.FromMilliseconds(600)); // A's window closes at 2100 ms
+ await Within(again);
+
+ // B's next request: revived, B's window would reach 3500 ms; unrevived, it resolves
+ // without the clock moving any further.
+ var reset = await Within(pair.Client.SendRawAsync(new byte[] { 0x11, 0x01 }, cts.Token));
+ reset.Should().Equal(0x51, 0x01);
}
// NRC 0x21 asks for a repeat; the client repeats, up to MaxBusyRepeatRequests.
@@ -1462,29 +1878,45 @@ public async Task Client_Times_Out_When_Ecu_Silent_Within_P2()
[Fact]
public async Task Client_Times_Out_With_P2Star_When_Ecu_Sends_Only_ResponsePending()
{
- var (client, _, dispose) = BuildPair(
- e => e.On(0x22, _ => throw new EcuResponsePendingThenSilent(
- pendingCount: 2, delayBetween: TimeSpan.FromMilliseconds(40))),
+ // On a virtual clock (#171): each 0x78 is delivered at the exact virtual instant it
+ // belongs to, via OrderedGates because the two delays share the same 40 ms length --
+ // EcuSteps would hand both callers the same, already-resolved gate. The first 0x78 goes
+ // out the instant the request is handled, with nothing pacing it -- on a fast host that
+ // can land, and be processed, before a check for the initial P2 would even see it armed
+ // -- so what is checked directly is the interval the client restarts to, which only a
+ // client that has already received and processed that 0x78 could have armed.
+ var gates = new OrderedGates();
+ var gap = TimeSpan.FromMilliseconds(40);
+ var p2 = TimeSpan.FromMilliseconds(100);
+ var p2Star = TimeSpan.FromMilliseconds(250);
+ using var pair = new ClockPair(
+ e =>
+ {
+ e.Delay = gates.WaitAsync;
+ e.On(0x22, _ => throw new EcuResponsePendingThenSilent(pendingCount: 2, delayBetween: gap));
+ },
options: new UdsClientOptions
{
- P2ClientMax = TimeSpan.FromMilliseconds(100),
- P2StarClientMax = TimeSpan.FromMilliseconds(250),
+ P2ClientMax = p2,
+ P2StarClientMax = p2Star,
MaxResponsePendingCount = 10,
});
- using (dispose)
- {
- var sw = Stopwatch.StartNew();
- Func act = () => client.ReadDataByIdentifierAsync(0xF190,
- new CancellationTokenSource(ShortTimeout).Token);
- var ex = (await act.Should().ThrowAsync()).Which;
- sw.Stop();
- ex.Timer.Should().Be(UdsTimeoutTimer.P2Star,
- "the ECU answered with NRC 0x78 (response pending), so the running timer is P2* — not P2");
- ex.RequestedService.Should().Be(UdsServiceId.ReadDataByIdentifier);
- sw.Elapsed.Should().BeGreaterThanOrEqualTo(TimeSpan.FromMilliseconds(200),
- "P2* restarts on each 0x78, so the timeout must fire well past the initial P2 budget (100 ms)");
- }
+ using var cts = new CancellationTokenSource(ShortTimeout);
+ var read = pair.Client.ReadDataByIdentifierAsync(0xF190, cts.Token);
+
+ await pair.WaitUntilWaitingAsync(p2Star); // restarted on the first 0x78's arrival
+ await pair.Clock.AdvanceAsync(gap);
+ await pair.DeliverAsync(gates.ReleaseNext, Negative(0x22, 0x78)); // the second 0x78, at 40 ms
+
+ await pair.WaitUntilWaitingAsync(p2Star); // restarted again, from the second 0x78
+ await pair.Clock.AdvanceAsync(p2Star); // then silence: P2* must expire
+
+ Func act = () => read;
+ var ex = (await act.Should().ThrowAsync()).Which;
+ ex.Timer.Should().Be(UdsTimeoutTimer.P2Star,
+ "the ECU answered with NRC 0x78 (response pending), so the running timer is P2* — not P2");
+ ex.RequestedService.Should().Be(UdsServiceId.ReadDataByIdentifier);
}
// -----------------------------------------------------------------------------------
@@ -1494,26 +1926,50 @@ public async Task Client_Times_Out_With_P2Star_When_Ecu_Sends_Only_ResponsePendi
[Fact]
public async Task ResponsePending_Restarts_P2Star_And_Returns_Final_Response()
{
+ // On a virtual clock (#171): the three 0x78s (60 ms apart) and the final response are
+ // each delivered at the exact instant they belong to, via OrderedGates for the same
+ // reason as the P2* timeout test above. The first 0x78 goes out unpaced and can land
+ // before a check for the initial P2 would see it armed, so what is checked directly is
+ // the interval the client restarts to. The exact interval armed after every 0x78 proves
+ // the restart directly, rather than by the client merely finishing with the right
+ // payload despite a short initial P2.
+ var gates = new OrderedGates();
+ var gap = TimeSpan.FromMilliseconds(60);
+ var p2 = TimeSpan.FromMilliseconds(120);
+ var p2Star = TimeSpan.FromSeconds(1);
byte[] finalBody = { 0xF1, 0x90, 0xAA, 0xBB, 0xCC, 0xDD };
- var (client, _, dispose) = BuildPair(
- e => e.On(0x22, _ => throw new EcuResponsePending(
- pendingCount: 3, finalResponse: finalBody,
- delayBetween: TimeSpan.FromMilliseconds(60))),
+ using var pair = new ClockPair(
+ e =>
+ {
+ e.Delay = gates.WaitAsync;
+ e.On(0x22, _ => throw new EcuResponsePending(
+ pendingCount: 3, finalResponse: finalBody, delayBetween: gap));
+ },
options: new UdsClientOptions
{
- // P2 short: proves the client actually restarts on 0x78 rather than living
- // inside the (accidentally) generous initial budget. 120 ms is several times
- // the first-hop cliff measured for #118 (see the P2* timeout test above).
- P2ClientMax = TimeSpan.FromMilliseconds(120),
- P2StarClientMax = TimeSpan.FromSeconds(1),
+ P2ClientMax = p2,
+ P2StarClientMax = p2Star,
MaxResponsePendingCount = 10,
});
- using (dispose)
- {
- var data = await client.ReadDataByIdentifierAsync(0xF190,
- new CancellationTokenSource(ShortTimeout).Token);
- data.Should().Equal(0xAA, 0xBB, 0xCC, 0xDD);
- }
+
+ using var cts = new CancellationTokenSource(ShortTimeout);
+ var read = pair.Client.ReadDataByIdentifierAsync(0xF190, cts.Token);
+
+ await pair.WaitUntilWaitingAsync(p2Star); // restarted on the 1st (unpaced) 0x78's arrival
+ await pair.Clock.AdvanceAsync(gap);
+ await pair.DeliverAsync(gates.ReleaseNext, Negative(0x22, 0x78)); // 2nd 0x78 @ 60 ms
+
+ await pair.WaitUntilWaitingAsync(p2Star);
+ await pair.Clock.AdvanceAsync(gap);
+ await pair.DeliverAsync(gates.ReleaseNext, Negative(0x22, 0x78)); // 3rd 0x78 @ 120 ms
+
+ await pair.WaitUntilWaitingAsync(p2Star);
+ await pair.Clock.AdvanceAsync(gap);
+ // Releasing the 3rd gate lets the final positive response go out right away.
+ await pair.DeliverAsync(gates.ReleaseNext, data => data.Length >= 2 && data[1] == 0x62);
+
+ var data = await Within(read);
+ data.Should().Equal(0xAA, 0xBB, 0xCC, 0xDD);
}
// -----------------------------------------------------------------------------------