diff --git a/docs/architecture/arc42-CanKit.Pro.md b/docs/architecture/arc42-CanKit.Pro.md index 5c894bb..594cf4c 100644 --- a/docs/architecture/arc42-CanKit.Pro.md +++ b/docs/architecture/arc42-CanKit.Pro.md @@ -473,7 +473,7 @@ classDiagram +ReceiveAsync(ct) Task~byte[]~ +ReceiveWithArrivalAsync(ct) Task~IsoTpReceivedPdu~ +ReceiveAllAsync(ct) IAsyncEnumerable~byte[]~ - +SettleAsync() Task + +SettleAsync(ct) Task +event DatagramReceived +event BackgroundExceptionOccurred } diff --git a/src/CanKit.Pro.IsoTp/IIsoTpChannel.cs b/src/CanKit.Pro.IsoTp/IIsoTpChannel.cs index c2a83c8..a16fbf6 100644 --- a/src/CanKit.Pro.IsoTp/IIsoTpChannel.cs +++ b/src/CanKit.Pro.IsoTp/IIsoTpChannel.cs @@ -136,8 +136,15 @@ Task SendWithTransmitStampAsync(ReadOnlyMemory pdu, /// channel's own actor — from a BackgroundExceptionOccurred handler — it returns at /// once and the frames on their way are handled after the current work item: they queue /// behind frames already in the mailbox, and handling them inline would reorder the two. + /// + /// ends the wait, not the settling: what the demux had + /// buffered has been handed to the actor by then and is handled as usual. A token cancelled + /// before the call, or while the wait is still queued behind the actor's other work, cancels + /// the returned task; once the actor has reached the wait it completes normally. + /// /// - Task SettleAsync(); + /// Ends the wait for the actor to catch up. + Task SettleAsync(CancellationToken cancellationToken = default); /// /// Drains every buffered inbox item — both completed PDUs and pending reassembly-abort diff --git a/src/CanKit.Pro.IsoTp/IsoTpChannel.cs b/src/CanKit.Pro.IsoTp/IsoTpChannel.cs index c3dbaf0..d9be5b6 100644 --- a/src/CanKit.Pro.IsoTp/IsoTpChannel.cs +++ b/src/CanKit.Pro.IsoTp/IsoTpChannel.cs @@ -357,8 +357,7 @@ public bool TryReceiveWithArrival(out IsoTpReceivedPdu pdu) } /// - /// - public Task SettleAsync() + public Task SettleAsync(CancellationToken cancellationToken = default) { if (Volatile.Read(ref _disposed) != 0) return Task.CompletedTask; // What the demux has buffered goes to the actor now rather than after the reader's @@ -372,7 +371,7 @@ public Task SettleAsync() if (_actor is ProtocolActor { IsOnCurrentActor: true }) return Task.CompletedTask; try { - return _actor.PostAsync(() => { }); + return _actor.PostAsync(() => { }, cancellationToken); } catch (ObjectDisposedException) { diff --git a/src/CanKit.Pro.IsoTp/README.md b/src/CanKit.Pro.IsoTp/README.md index ed1a150..7ee29e2 100644 --- a/src/CanKit.Pro.IsoTp/README.md +++ b/src/CanKit.Pro.IsoTp/README.md @@ -69,7 +69,8 @@ CAN-FD long-payload cases still get the least coverage of the two halves. `SettleAsync` drains it the same way and completes once the actor has taken everything queued so far, without dropping anything: for a decision taken at a deadline, what the inbox does not hold after it did not arrive before the call — a Single Frame stamped in time can - otherwise still be on its way when the deadline fires. `DiscardPendingPdus(long)` drops what + otherwise still be on its way when the deadline fires. A `CancellationToken` ends the wait, + not the settling: what the demux buffered has been handed to the actor by then. `DiscardPendingPdus(long)` drops what arrived before the caller's own stamp rather than before now, so a caller that reads the inbox after taking the stamp and discards after reading has seen everything it drops, and a frame from between the read and the discard is kept. diff --git a/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.IsoTp.approved.txt b/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.IsoTp.approved.txt index c00f507..d9783b2 100644 --- a/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.IsoTp.approved.txt +++ b/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.IsoTp.approved.txt @@ -20,7 +20,7 @@ namespace CanKit.Pro.IsoTp System.Threading.Tasks.Task ReceiveWithArrivalAsync(System.Threading.CancellationToken cancellationToken = default); System.Threading.Tasks.Task SendAsync(System.ReadOnlyMemory pdu, System.Threading.CancellationToken cancellationToken = default); System.Threading.Tasks.Task SendWithTransmitStampAsync(System.ReadOnlyMemory pdu, System.Threading.CancellationToken cancellationToken = default); - System.Threading.Tasks.Task SettleAsync(); + System.Threading.Tasks.Task SettleAsync(System.Threading.CancellationToken cancellationToken = default); bool TryReceiveWithArrival(out CanKit.Pro.IsoTp.IsoTpReceivedPdu pdu); } public static class IsoTp diff --git a/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpChannelIntegrationTests.cs b/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpChannelIntegrationTests.cs index 972c72d..6afeb76 100644 --- a/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpChannelIntegrationTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpChannelIntegrationTests.cs @@ -496,6 +496,67 @@ public async Task Settle_Takes_A_Buffered_Single_Frame_Through_To_The_Inbox() pdu.FirstFrameArrivalTimestamp.Should().Be(arrival, "the stamp is the demux's, not the settle's"); } + // The token ends the wait for the actor, not the settling: a caller that gives up on the wait + // (its own deadline passed, its operation was cancelled) is not held behind whatever the + // channel's actor is busy with. + [Fact] + public async Task Settle_With_An_Already_Cancelled_Token_Is_Cancelled() + { + var ep = IsoTpEndpoint.Normal(txCanId: 0x7E0, rxCanId: 0x7E8); + using var service = new StarvedReaderBusService(); + using var actor = new ProtocolActor(); + using var channel = new IsoTpChannel(service, ep, FastOptions(), ownsService: false, actor); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var ex = await Record.ExceptionAsync(() => channel.SettleAsync(cts.Token).WaitAsync(ShortTimeout)); + + ex.Should().BeAssignableTo(); + } + + [Fact] + public async Task Settle_Cancelled_While_The_Actor_Is_Busy_Releases_The_Caller_At_Once() + { + var ep = IsoTpEndpoint.Normal(txCanId: 0x7E0, rxCanId: 0x7E8); + using var service = new StarvedReaderBusService(); + using var actor = new ProtocolActor(ActorExecutionMode.DedicatedThread); + using var channel = new IsoTpChannel(service, ep, FastOptions(), ownsService: false, actor); + using var release = new ManualResetEventSlim(); + using var busy = new ManualResetEventSlim(); + actor.Post(() => { busy.Set(); release.Wait(); }); + busy.Wait(ShortTimeout).Should().BeTrue("the actor must be inside the blocking item"); + + using var cts = new CancellationTokenSource(); + var settle = channel.SettleAsync(cts.Token); + settle.IsCompleted.Should().BeFalse("the settle waits behind the item the actor is busy with"); + + cts.Cancel(); + + // Released while the actor is still blocked: not when it eventually gets to the wait. + var ex = await Record.ExceptionAsync(() => settle.WaitAsync(ShortTimeout)); + ex.Should().BeAssignableTo(); + + release.Set(); + } + + [Fact] + public async Task Settle_With_A_Live_Token_Takes_A_Buffered_Frame_Through_Like_Without_One() + { + var ep = IsoTpEndpoint.Normal(txCanId: 0x7E0, rxCanId: 0x7E8); + using var service = new StarvedReaderBusService(); + using var actor = new ProtocolActor(); + using var channel = new IsoTpChannel(service, ep, FastOptions(), ownsService: false, actor); + using var cts = new CancellationTokenSource(); + + byte[] sf = { 0x03, 0x7F, 0x3E, 0x78, 0x00, 0x00, 0x00, 0x00 }; + service.Deliver(new CanFrameView(CanFrameType.Can20, 0x7E8, sf, FrameFlags.None), Stopwatch.GetTimestamp()); + + await channel.SettleAsync(cts.Token).WaitAsync(ShortTimeout); + + channel.TryReceiveWithArrival(out var pdu).Should().BeTrue("settling took the frame through"); + pdu.Pdu.Should().Equal(0x7F, 0x3E, 0x78); + } + // Codex on #150: a discard given the caller's stamp drops what arrived before it and keeps // what arrived since -- so a caller that read the inbox after taking the stamp has seen // everything the discard drops, and a frame from between the read and the discard is not diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs index 91e8627..71577ce 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs @@ -846,7 +846,7 @@ private long Ticks(TimeSpan span) public async Task ReceiveAsync(CancellationToken cancellationToken = default) => (await ReceiveWithArrivalAsync(cancellationToken).ConfigureAwait(false)).Pdu; - public Task SettleAsync() => Task.CompletedTask; // nothing is ever on its way: the stub is its own actor + public Task SettleAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; // nothing is ever on its way: the stub is its own actor public int DiscardPendingPdus() => 0;