From 1fcbfc57a909aac3dcf768648918f23afc8b58a8 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards Date: Tue, 27 Oct 2026 04:01:46 +0100 Subject: [PATCH] test(uds): clock functional confirmation deadlines --- .../Infrastructure/ClockedCanBusService.cs | 19 ++++++-- .../TestCases/ClockedCanBusServiceTests.cs | 45 +++++++++++++++++++ .../TestCases/Uds/UdsFunctionalClientTests.cs | 28 ++++++++++-- 3 files changed, 85 insertions(+), 7 deletions(-) diff --git a/tests/CanKit.Pro.Tests/Infrastructure/ClockedCanBusService.cs b/tests/CanKit.Pro.Tests/Infrastructure/ClockedCanBusService.cs index 5cf02117..3da0282b 100644 --- a/tests/CanKit.Pro.Tests/Infrastructure/ClockedCanBusService.cs +++ b/tests/CanKit.Pro.Tests/Infrastructure/ClockedCanBusService.cs @@ -21,17 +21,28 @@ internal sealed class ClockedCanBusService : ICanBusService private readonly CanBusService _inner; private readonly AsyncLocal _deadline = new(); private readonly object _sendGate; + private readonly bool _ownsTimers; private int _pending; private int _disposed; public ClockedCanBusService(ICanBus bus, ManualTimeSource clock, object? sendGate = null) + : this(bus, new ProtocolActor(ActorExecutionMode.DedicatedThread, null, clock, null), + ownsTimers: true, sendGate) + { } + + /// The caller's clock owns this actor and may keep advancing it after service disposal. + public ClockedCanBusService(ICanBus bus, ProtocolActor timers) + : this(bus, timers, ownsTimers: false, sendGate: null) { } + + private ClockedCanBusService(ICanBus bus, ProtocolActor timers, bool ownsTimers, object? sendGate) { _sendGate = sendGate ?? new object(); - Timers = new ProtocolActor(ActorExecutionMode.DedicatedThread, null, clock, null); - try { _inner = new CanBusService(bus, clock.GetTimestamp); } + _ownsTimers = ownsTimers; + Timers = timers; + try { _inner = new CanBusService(bus, timers.TimeSource.GetTimestamp); } catch { - Timers.Dispose(); + if (_ownsTimers) Timers.Dispose(); throw; } _inner.ArmConfirmTimeout = (source, timeout) => @@ -90,7 +101,7 @@ public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) != 0) return; try { _inner.Dispose(); } - finally { Timers.Dispose(); } + finally { if (_ownsTimers) Timers.Dispose(); } } private sealed class SendDeadline : IDisposable diff --git a/tests/CanKit.Pro.Tests/TestCases/ClockedCanBusServiceTests.cs b/tests/CanKit.Pro.Tests/TestCases/ClockedCanBusServiceTests.cs index bd29bcef..75730ba7 100644 --- a/tests/CanKit.Pro.Tests/TestCases/ClockedCanBusServiceTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/ClockedCanBusServiceTests.cs @@ -93,6 +93,51 @@ public async Task A_Confirmation_Removed_After_Timer_Selection_Cannot_Advance_Pr bench.Now.Should().Be(started, "an already removed confirmation deadline cannot choose a clock step"); } + [Fact] + public async Task Disposing_A_Service_Preserves_Its_Clock_Owned_Actor() + { + using var bus = ControllableBus.DeferredEchoCapable(VirtualAdapterFixture.NewSession("borrowed-confirm")); + using var clock = new VirtualClock(); + var actor = clock.NewActor(); + Task send; + using (var service = new ClockedCanBusService(bus, actor)) + { + send = service.SendConfirmedAsync(CanFrame.Classic(0x123, new byte[] { 1 })); + await bus.DeferredEchoes.WaitForEnqueuedAsync(1, HangGuard); + await ArmedAsync(service, TimeSpan.FromSeconds(1)); + } + await Assert.ThrowsAnyAsync(() => send.WaitAsync(HangGuard)); + (await actor.NextTimerDelayAsync()).Should().BeNull("service disposal removes its own pending deadline"); + bool fired = false; + using var timer = actor.Schedule(TimeSpan.FromMilliseconds(10), () => fired = true); + await clock.AdvanceAsync(TimeSpan.FromMilliseconds(10)); + fired.Should().BeTrue("the external clock still owns and drives its actor"); + } + + [Fact] + public async Task Confirmation_Deadlines_Do_Not_Shadow_The_Protocol_Actors_Timer() + { + using var bus = ControllableBus.DeferredEchoCapable(VirtualAdapterFixture.NewSession("separate-confirm")); + using var clock = new VirtualClock(); + var protocol = clock.NewActor(); + using var service = new ClockedCanBusService(bus, clock.NewActor()); + bool protocolFired = false; + using var timer = protocol.Schedule(TimeSpan.FromSeconds(1), () => protocolFired = true); + var budget = TimeSpan.FromMilliseconds(500); + var send = service.SendConfirmedAsync(CanFrame.Classic(0x123, new byte[] { 1 }), budget); + await bus.DeferredEchoes.WaitForEnqueuedAsync(1, HangGuard); + await ArmedAsync(service, budget); + (await protocol.NextTimerDelayAsync()).Should().Be(TimeSpan.FromSeconds(1)); + await clock.AdvanceAsync(budget - TimeSpan.FromTicks(1)); + send.IsCompleted.Should().BeFalse(); + await clock.AdvanceAsync(TimeSpan.FromTicks(1)); + (await send.WaitAsync(HangGuard)).FailureReason.Should().Be(TxConfirmFailureReason.Timeout); + protocolFired.Should().BeFalse(); + (await protocol.NextTimerDelayAsync()).Should().Be(budget); + await clock.AdvanceAsync(budget); + protocolFired.Should().BeTrue(); + } + private static async Task ArmedAsync(ClockedCanBusService service, TimeSpan delay) { var until = DateTime.UtcNow + HangGuard; diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index 39e11212..bf79507a 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -55,14 +55,17 @@ private sealed class WindowClock : IDisposable { public VirtualClock Clock { get; } = new(); public ProtocolActor Actor { get; } + public ProtocolActor ConfirmationActor { get; } public WindowClock() { Actor = Clock.NewActor(); + ConfirmationActor = Clock.NewActor(); Clock.Advance(TimeSpan.FromMilliseconds(1)); } public long Now => Actor.TimeSource.GetTimestamp(); + public ClockedCanBusService OpenService(ICanBus bus) => new(bus, ConfirmationActor); public void Dispose() => Clock.Dispose(); } @@ -70,7 +73,7 @@ public WindowClock() private static UdsFunctionalClient OpenOnClock(ICanBus bus, WindowClock clock, TimeSpan? responseWindow = null, TimeSpan? responsePendingWindow = null, IsoTpFunctionalOptions? options = null) - => OpenOnClock(new CanBusService(bus, clock.Actor.TimeSource.GetTimestamp), clock, + => OpenOnClock(clock.OpenService(bus), clock, responseWindow, responsePendingWindow, options); private static UdsFunctionalClient OpenOnClock(ICanBusService service, WindowClock clock, @@ -83,6 +86,25 @@ private static UdsFunctionalClient OpenOnClock(ICanBusService service, WindowClo responseWindow: responseWindow, responsePendingWindow: responsePendingWindow); } + [Fact] + public async Task A_Missing_Functional_Echo_Expires_NAs_On_The_Window_Clock() + { + using var clock = new WindowClock(); + using var bus = ControllableBus.DeferredEchoCapable(NewSession()); + var options = FastOptions(); + var p2 = TimeSpan.FromSeconds(1); + using var functional = OpenOnClock(bus, clock, responseWindow: p2, options: options); + var call = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, ShortCollection); + await bus.DeferredEchoes.WaitForEnqueuedAsync(1, ShortTimeout); + await clock.Clock.WaitUntilTimerArmedAsync(clock.ConfirmationActor, options.NAs, ShortTimeout); + await WaitForProvisionalWindow(clock, p2); + await clock.Clock.AdvanceAsync(options.NAs - TimeSpan.FromTicks(1)); + call.IsCompleted.Should().BeFalse("N_As still has one virtual tick left"); + await clock.Clock.AdvanceAsync(TimeSpan.FromTicks(1)); + var failure = await Assert.ThrowsAsync(() => call.WaitAsync(ShortTimeout)); + failure.Timer.Should().Be(IsoTpTimer.NAs); + } + private static async Task WaitUntilArmed(WindowClock clock, TimeSpan expected, TimeSpan? lateBy = null) => await clock.Clock.WaitUntilTimerArmedAsync(clock.Actor, expected, ShortTimeout, lateBy ?? TimeSpan.FromMilliseconds(5)); @@ -937,7 +959,7 @@ public async Task The_Pending_Answers_That_Move_A_Window_Out_Are_Bounded() using var busEcus = OpenClassic(session, 1); var pending = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x3E, 0x78 }); - var iso = new IsoTpFunctionalClient(new CanBusService(busTester, clock.Actor.TimeSource.GetTimestamp), + var iso = new IsoTpFunctionalClient(clock.OpenService(busTester), FunctionalTxId, Ecu1, 0x7EF, FastOptions(), ownsService: true, clock.Actor); using var functional = UdsFunctionalClient.Create(iso, clock.Actor, ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(300), responsePendingWindow: TimeSpan.FromMilliseconds(1500), @@ -1010,7 +1032,7 @@ public async Task A_Pending_Answer_From_Before_The_Handoff_Is_Not_This_Requests( var p2 = TimeSpan.FromMilliseconds(windowMs); using var clock = new WindowClock(); using var bus = ControllableBus.DeferredEchoCapable(NewSession()); - using var inner = new CanBusService(bus, clock.Actor.TimeSource.GetTimestamp); + using var inner = clock.OpenService(bus); var service = new SendEnteredService(inner); using var functional = OpenOnClock(service, clock, responseWindow: p2, responsePendingWindow: TimeSpan.FromMilliseconds(3000));