Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions tests/CanKit.Pro.Tests/TestCases/BusStateMonitorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,22 @@
[Fact]
public async Task StateChanged_Is_Not_Raised_While_The_State_Is_Unchanged()
{
using var clock = new VirtualClock();
using var bus = OpenBus();
using var actor = new ProtocolActor();
using var monitor = new BusStateMonitor(bus, actor, TimeSpan.FromMilliseconds(20));
var actor = clock.NewActor();
var pollInterval = TimeSpan.FromMilliseconds(20);
using var monitor = new BusStateMonitor(bus, actor, pollInterval);

var changes = 0;
monitor.StateChanged += (_, _) => Interlocked.Increment(ref changes);

// Let many poll ticks run without ever changing the bus state.
await Task.Delay(TimeSpan.FromMilliseconds(200));
// Let ten poll ticks run without ever changing the bus state, each proven armed before the
// clock moves past it.
for (var i = 0; i < 10; i++)
{
await clock.WaitUntilTimerArmedAsync(actor, pollInterval, Bounded);
await clock.AdvanceAsync(pollInterval);
}

Volatile.Read(ref changes).Should().Be(0, "an unchanged state must never raise an edge-triggered event");
}
Expand Down Expand Up @@ -105,19 +112,28 @@
[Fact]
public async Task Dispose_Stops_Further_StateChanged_Events_And_Is_Idempotent()
{
using var clock = new VirtualClock();
using var bus = OpenBus();
using var actor = new ProtocolActor();
var monitor = new BusStateMonitor(bus, actor, TimeSpan.FromMilliseconds(20));
var actor = clock.NewActor();
var pollInterval = TimeSpan.FromMilliseconds(20);
var monitor = new BusStateMonitor(bus, actor, pollInterval);

var changes = 0;
monitor.StateChanged += (_, _) => Interlocked.Increment(ref changes);

// Wait for the first poll to actually be armed before disposing: disposing while the
// constructor's initial RearmPoll post is still in flight would let the race resolve
// either way (a handle Dispose never gets to see and cancel is not what "Dispose stops
// the poll" is claiming).
await clock.WaitUntilTimerArmedAsync(actor, pollInterval, Bounded);

monitor.Dispose();

Check warning

Code scanning / CodeQL

Dispose may not be called if an exception is thrown during execution Warning test

Dispose missed if exception is thrown by
call to method WaitUntilTimerArmedAsync
.
monitor.Dispose(); // idempotent

// Change the state only after disposing: with the poll stopped, no event may arrive.
bus.BusState = BusState.BusOff;
await Task.Delay(TimeSpan.FromMilliseconds(150)); // several poll intervals
await clock.AdvanceAsync(pollInterval + pollInterval + pollInterval); // several poll intervals
await clock.SettleAsync();

Volatile.Read(ref changes).Should().Be(0, "a disposed monitor must stop polling and raising events");
}
Expand Down
97 changes: 65 additions & 32 deletions tests/CanKit.Pro.Tests/TestCases/DeadlineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Threading.Tasks;
using CanKit.Pro.Actor;
using CanKit.Pro.Reliability;
using CanKit.Pro.Tests.Infrastructure;
using FluentAssertions;
using Xunit;

Expand Down Expand Up @@ -37,11 +38,16 @@
[Fact]
public async Task Complete_Before_Expiry_Prevents_OnExpired_And_Is_Idempotent()
{
using var actor = new ProtocolActor();
using var clock = new VirtualClock();
var actor = clock.NewActor();
var scheduler = new DeadlineScheduler(actor);
var fired = false;

var deadline = scheduler.Arm(TimeSpan.FromMilliseconds(200), () => fired = true);
var timeout = TimeSpan.FromMilliseconds(200);
var deadline = scheduler.Arm(timeout, () => fired = true);
// Arm before you advance: prove the deadline really was scheduled for the configured
// timeout before Complete races ahead of it.
await clock.WaitUntilTimerArmedAsync(actor, timeout, Bounded);

deadline.Complete().Should().BeTrue("Complete wins the race well before the deadline would expire");
deadline.IsCompleted.Should().BeTrue();
Expand All @@ -53,54 +59,76 @@
deadline.IsCompleted.Should().BeTrue();
deadline.IsCancelled.Should().BeFalse();

// Let the original timer's due point pass and round-trip through the loop; onExpired must
// never fire for a completed deadline.
await Task.Delay(TimeSpan.FromMilliseconds(300));
await actor.PostAsync(() => 0);
// Move the clock past the original timer's due point and let the loop settle; onExpired
// must never fire for a completed deadline.
await clock.AdvanceAsync(timeout + TimeSpan.FromMilliseconds(100));
await clock.SettleAsync();
fired.Should().BeFalse();
}

[Fact]
public async Task Cancel_Before_Expiry_Prevents_OnExpired()
{
using var actor = new ProtocolActor();
using var clock = new VirtualClock();
var actor = clock.NewActor();
var scheduler = new DeadlineScheduler(actor);
var fired = false;

var deadline = scheduler.Arm(TimeSpan.FromMilliseconds(200), () => fired = true);
var timeout = TimeSpan.FromMilliseconds(200);
var deadline = scheduler.Arm(timeout, () => fired = true);
await clock.WaitUntilTimerArmedAsync(actor, timeout, Bounded);

deadline.Dispose(); // Dispose == Cancel

Check warning

Code scanning / CodeQL

Dispose may not be called if an exception is thrown during execution Warning test

Dispose missed if exception is thrown by
call to method WaitUntilTimerArmedAsync
.
deadline.IsCancelled.Should().BeTrue();

await Task.Delay(TimeSpan.FromMilliseconds(300));
await actor.PostAsync(() => 0);
await clock.AdvanceAsync(timeout + TimeSpan.FromMilliseconds(100));
await clock.SettleAsync();
fired.Should().BeFalse();
}

[Fact]
public void Rearm_Before_Original_Expiry_Extends_The_Deadline()
public async Task Rearm_Before_Original_Expiry_Extends_The_Deadline()
{
using var actor = new ProtocolActor();
var scheduler = new DeadlineScheduler(actor);

// #130. Task.Delay completes on the thread pool, and a saturated net48 pool injects
// threads only one or two per second. Delay(850) can still be pending when the rearmed
// 2000 ms deadline fires on the actor's dedicated thread, so WhenAny reports that fire
// and the assertion blames the superseded timer. These waits block the calling thread.
// The windows stay 50 / 850 / 2000 ms. The generation guard itself is
// Previously #130 kept this test on the wall clock: a saturated net48 thread pool injects
// threads only one or two per second, so Task.Delay(850) could still be pending when the
// rearmed 2000 ms deadline fired, and WhenAny would report that fire and blame the
// superseded timer instead. A VirtualClock sidesteps the thread pool race entirely -- the
// deadline's due points are moments this test itself schedules, so there is nothing left
// for the pool to starve. The generation guard itself is covered separately by
// Rearm_Leaves_An_Already_Dispatched_Callback_Unable_To_Expire.
using var fired = new ManualResetEventSlim(false);
using var deadline = scheduler.Arm(TimeSpan.FromMilliseconds(600), () => fired.Set());
using var clock = new VirtualClock();
var actor = clock.NewActor();
var scheduler = new DeadlineScheduler(actor);
var fired = false;

Thread.Sleep(TimeSpan.FromMilliseconds(50));
deadline.Rearm(TimeSpan.FromMilliseconds(2000)).Should().BeTrue("re-arming a still-pending deadline succeeds");
var original = TimeSpan.FromMilliseconds(600);
var extended = TimeSpan.FromMilliseconds(2000);
using var deadline = scheduler.Arm(original, () => fired = true);
await clock.WaitUntilTimerArmedAsync(actor, original, Bounded);

deadline.Rearm(extended).Should().BeTrue("re-arming a still-pending deadline succeeds");
// The rearmed timer must now be armed for the extended interval, not the remainder of the
// original one -- this is the generation guard's counterpart on the happy path.
await clock.WaitUntilTimerArmedAsync(actor, extended, Bounded);

// Bracket from both sides. First: past the original due point, well short of the
// rearmed one.
await clock.AdvanceAsync(original);
await clock.SettleAsync();
fired.Should().BeFalse("the original timeout must have been superseded by Rearm");
deadline.IsExpired.Should().BeFalse();

// Past the original 600 ms, and still more than a second short of the rearmed deadline.
fired.Wait(TimeSpan.FromMilliseconds(850)).Should().BeFalse(
"the original timeout must have been superseded by Rearm");
// One tick short of the rearmed due point: still must not have fired.
var epsilon = TimeSpan.FromMilliseconds(1);
await clock.AdvanceAsync(extended - original - epsilon);
await clock.SettleAsync();
fired.Should().BeFalse("the rearmed deadline has not reached its own due point yet");
deadline.IsExpired.Should().BeFalse();

fired.Wait(Bounded).Should().BeTrue(
"the re-armed timeout must still fire at its new deadline");
// The last tick reaches it.
await clock.AdvanceAsync(epsilon);
await clock.SettleAsync();
fired.Should().BeTrue("the re-armed timeout must still fire at its new deadline");
deadline.IsExpired.Should().BeTrue();
}

Expand Down Expand Up @@ -195,19 +223,24 @@
[Fact]
public async Task Disposing_The_Owning_Actor_While_Pending_Never_Fires_The_Deadline_And_Escapes_No_Exception()
{
var actor = new ProtocolActor();
using var clock = new VirtualClock();
var actor = clock.NewActor();
var scheduler = new DeadlineScheduler(actor);
var backgroundFaulted = false;
actor.BackgroundExceptionOccurred += (_, _) => backgroundFaulted = true;
var fired = false;

using var deadline = scheduler.Arm(TimeSpan.FromMilliseconds(200), () => fired = true);
var timeout = TimeSpan.FromMilliseconds(200);
using var deadline = scheduler.Arm(timeout, () => fired = true);
await clock.WaitUntilTimerArmedAsync(actor, timeout, Bounded);

// The actor's FinalDrain discards not-yet-due Schedule callbacks, so a deadline that was
// still Pending simply never resolves (documented best-effort behavior).
// still Pending simply never resolves (documented best-effort behavior). actor.Dispose()
// joins the dedicated thread and only returns once FinalDrain has already run, so the
// outcome is settled the moment this call returns -- no wait, virtual or otherwise, is
// needed to observe it.
actor.Dispose();

Check warning

Code scanning / CodeQL

Dispose may not be called if an exception is thrown during execution Warning test

Dispose missed if exception is thrown by
call to method FromMilliseconds
.
Dispose missed if exception is thrown by
call to method WaitUntilTimerArmedAsync
.

await Task.Delay(TimeSpan.FromMilliseconds(300));
fired.Should().BeFalse("a pending deadline whose actor is disposed must never fire");
deadline.IsExpired.Should().BeFalse();
backgroundFaulted.Should().BeFalse("disposing the actor under a pending deadline must not raise any exception");
Expand Down
15 changes: 13 additions & 2 deletions tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpBusOffTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,24 @@ public async Task Active_MultiFrame_Send_Faults_With_BusOff_Instead_Of_Hanging()

using var sender = IsoTpFactory.Open(bus, IsoTpEndpoint.Normal(0x300, 0x301), FastOptions());

// #171: "give the channel a moment to register the pending FF confirmation" was a
// Task.Delay(100) guessing at a race. OnTransmitting fires synchronously from inside
// Transmit, and its own doc says CanBusService calls it "with the transmitting send
// already registered" -- i.e. once this has fired, the FF's entry is in the pending-send
// list, which is exactly the state the test needs before it drives BusOff.
var ffTransmitted = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
bus.OnTransmitting = frame =>
{
var payload = frame.Data.ToArray();
if (payload.Length > 0 && (payload[0] >> 4) == 0x1) ffTransmitted.TrySetResult(true);
};

// Multi-frame send: the FF goes out and its TX confirmation stays pending behind the
// blocked echo. Driving the bus off while that confirmation is outstanding must abort
// the send (L2 -> L3 propagation per FR-RAW-051), not hang.
var send = sender.SendAsync(Enumerable.Range(0, 30).Select(i => (byte)i).ToArray());

// Give the channel a moment to register the pending FF confirmation.
await Task.Delay(100);
await ffTransmitted.Task.WaitAsync(ShortTimeout);
bus.BusState = BusState.BusOff;
bus.RaiseFault(new InvalidOperationException("simulated bus-off"));

Expand Down
14 changes: 13 additions & 1 deletion tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpCanFdTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ public async Task Channel_With_UseCanFd_Emits_Only_CanFd_Frames_For_Sf_Ff_Cf_And
using var receiver = IsoTpFactory.Open(busB, IsoTpEndpoint.Normal(0x7E8, 0x7E0), FastOptions(useCanFd: true));

var kinds = new List<(uint id, byte pci, CanFrameType kind)>();
// #171: "let the hub deliver the tail frames to the sniffer" was a Task.Delay(100)
// guessing at how long the sniffer's independent subscription takes to catch up with
// the last CF/FC. sniffedAllKinds is set from inside the handler itself once every kind
// this assertion needs has actually arrived, which is what a fixed window could only
// ever approximate.
var sniffedAllKinds = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
bool sawSf = false, sawFf = false, sawCf = false, sawFc = false;
snifferBus.FrameObserved += (_, view) =>
{
var id = view.CanFrame.ID;
Expand All @@ -138,6 +145,11 @@ public async Task Channel_With_UseCanFd_Emits_Only_CanFd_Frames_For_Sf_Ff_Cf_And
lock (kinds)
{
kinds.Add(((uint)id, pci, view.CanFrame.FrameKind));
if (id == 0x7E0 && (pci & 0xF0) == 0x00) sawSf = true;
if (id == 0x7E0 && (pci & 0xF0) == 0x10) sawFf = true;
if (id == 0x7E0 && (pci & 0xF0) == 0x20) sawCf = true;
if (id == 0x7E8 && (pci & 0xF0) == 0x30) sawFc = true;
if (sawSf && sawFf && sawCf && sawFc) sniffedAllKinds.TrySetResult(true);
}
};

Expand All @@ -153,7 +165,7 @@ public async Task Channel_With_UseCanFd_Emits_Only_CanFd_Frames_For_Sf_Ff_Cf_And
await sender.SendAsync(sf);
(await recvSf).Should().Equal(sf);

await Task.Delay(100); // let the hub deliver the tail frames to the sniffer
await sniffedAllKinds.Task.WaitAsync(ShortTimeout);

List<(uint id, byte pci, CanFrameType kind)> observed;
lock (kinds)
Expand Down
Loading
Loading