From 3b9546f984b7b38f1bc5635ea921a830f5c1aad2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 04:23:47 +0000 Subject: [PATCH] feat(isotp)!: let IIsoTpChannel.SettleAsync be cancelled with a CancellationToken SettleAsync waits for the channel's actor to take everything queued so far, and had no way to give up on that wait: a caller whose own deadline had passed, or whose operation was cancelled, stayed behind whatever the actor was busy with. It now takes an optional CancellationToken and hands it to the actor's PostAsync, which withdraws the wait while it is still queued. The token ends the wait, not the settling: what the demux had buffered has been handed to the actor by then and is handled as usual. The ADR window before 1.3.0 is the last point where an interface member can be changed without a major version, so the signature is replaced rather than overloaded next to the old one. The UDS client's own calls keep the default token: they settle at a deadline decision, and letting a caller's cancellation abort that would change what it decides. BREAKING CHANGE: IIsoTpChannel.SettleAsync gained a CancellationToken parameter. Callers compile unchanged; implementers of IIsoTpChannel and code compiled against 1.2.x must be rebuilt. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01UWRpkQzKkNDYz3WiNgWvWU --- docs/architecture/arc42-CanKit.Pro.md | 2 +- src/CanKit.Pro.IsoTp/IIsoTpChannel.cs | 9 ++- src/CanKit.Pro.IsoTp/IsoTpChannel.cs | 5 +- src/CanKit.Pro.IsoTp/README.md | 3 +- .../CanKit.Pro.IsoTp.approved.txt | 2 +- .../IsoTp/IsoTpChannelIntegrationTests.cs | 61 +++++++++++++++++++ .../TestCases/Uds/UdsExpiredDeadlineTests.cs | 2 +- 7 files changed, 76 insertions(+), 8 deletions(-) diff --git a/docs/architecture/arc42-CanKit.Pro.md b/docs/architecture/arc42-CanKit.Pro.md index 5c894bb9..594cf4c9 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 c2a83c80..a16fbf66 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 c3dbaf00..d9be5b6d 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 ed1a1505..7ee29e2f 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 c00f507e..d9783b2f 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 972c72d6..6afeb769 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 91e86271..71577ce1 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;