From e460d7fa6fd1c5d5a37e9532dbd47055c7d86af2 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:22:33 +0200 Subject: [PATCH 01/55] feat(uds): the collected UDS findings -- busy repeat, functional addressing, and four corrections The six UDS findings collected in #57. DiagnosticSessionControlAsync(byte) masked the session type with 0x7F, so a caller passing 0x83 sent 0x03 without being told; 0x00 and any value with bit 7 set are rejected now, the bit being suppressPosRspMsgIndication. SendRawAsync with that bit set waited P2 for a positive response that was never coming and timed out; it now sends the way a suppressed TesterPresent does and returns empty. NRC 0x21 (busyRepeatRequest) surfaced as a failure; it asks for a repeat, and the client repeats, up to MaxBusyRepeatRequests (new option, default 3) after BusyRepeatRequestDelay (new, default zero), each with a fresh P2. UdsTimeoutException.Elapsed reported the budget on one timeout path and a measurement of how late the client noticed on another; it is the budget on every path now. There was no functional-addressing path: UdsFunctionalClient (new) wraps an IsoTpFunctionalClient -- one request on the functional identifier, every ECU's Single-Frame answer collected within a window and read as UDS in a UdsFunctionalResponse, with TesterPresentAsync as the keep-alive to everyone. Dispose disposed the request lock after a five-second wait while a holder still owned it, turning a slow operation into an ObjectDisposedException on its thread; a holder that outlasts the wait now keeps the semaphore. Each is mutation-checked: the mask, the wait, the repeat, the measurement, the disposal and the functional parse each fail their test when reverted. Closes #57. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/CanKit.Pro.Uds.csproj | 7 + src/CanKit.Pro.Uds/IUdsClient.cs | 11 +- src/CanKit.Pro.Uds/README.md | 16 +++ src/CanKit.Pro.Uds/UdsClientImpl.cs | 114 ++++++++++++---- src/CanKit.Pro.Uds/UdsClientOptions.cs | 14 ++ src/CanKit.Pro.Uds/UdsException.cs | 5 +- src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 121 +++++++++++++++++ src/CanKit.Pro.Uds/UdsFunctionalResponse.cs | 30 +++++ .../ApiApprovals/CanKit.Pro.Uds.approved.txt | 19 +++ .../TestCases/Uds/UdsClientTests.cs | 81 +++++++++++ .../TestCases/Uds/UdsExpiredDeadlineTests.cs | 55 ++++++++ .../TestCases/Uds/UdsFunctionalClientTests.cs | 127 ++++++++++++++++++ 12 files changed, 572 insertions(+), 28 deletions(-) create mode 100644 src/CanKit.Pro.Uds/UdsFunctionalClient.cs create mode 100644 src/CanKit.Pro.Uds/UdsFunctionalResponse.cs create mode 100644 tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs diff --git a/src/CanKit.Pro.Uds/CanKit.Pro.Uds.csproj b/src/CanKit.Pro.Uds/CanKit.Pro.Uds.csproj index 26f20fd..fe0923a 100644 --- a/src/CanKit.Pro.Uds/CanKit.Pro.Uds.csproj +++ b/src/CanKit.Pro.Uds/CanKit.Pro.Uds.csproj @@ -19,4 +19,11 @@ + + + + <_Parameter1>CanKit.Pro.Tests + + + diff --git a/src/CanKit.Pro.Uds/IUdsClient.cs b/src/CanKit.Pro.Uds/IUdsClient.cs index 213c0d7..fb4c03a 100644 --- a/src/CanKit.Pro.Uds/IUdsClient.cs +++ b/src/CanKit.Pro.Uds/IUdsClient.cs @@ -65,6 +65,11 @@ Task DiagnosticSessionControlAsync(UdsSessionType session, /// Sends DiagnosticSessionControl (0x10) with a raw sub-function byte. Convenience for /// vendor-specific session numbers that are not covered by . /// + /// + /// is 0x00 (ISOSAEReserved) or has bit 7 set: that bit is + /// suppressPosRspMsgIndication, not part of the session type, and this method waits for the + /// response (#57). + /// Task DiagnosticSessionControlAsync(byte sessionType, CancellationToken cancellationToken = default); @@ -168,7 +173,11 @@ Task TesterPresentAsync(bool suppressPositiveResponse = true, /// /// Sends the raw bytes verbatim (starting with the SID) and /// returns the ECU's raw positive-response bytes (again including the response SID). The - /// same P2/P2* timing and NRC handling apply as for the strongly-typed methods. + /// same P2/P2* timing and NRC handling apply as for the strongly-typed methods. A request + /// whose sub-function byte has suppressPosRspMsgIndication set (bit 7, on a service that + /// carries a sub-function) is sent without waiting: an empty array comes back once the + /// frame is confirmed, and a negative response the server may still send is not waited for + /// (#57). /// Task SendRawAsync(ReadOnlyMemory request, CancellationToken cancellationToken = default); diff --git a/src/CanKit.Pro.Uds/README.md b/src/CanKit.Pro.Uds/README.md index bde79fb..6bb12f2 100644 --- a/src/CanKit.Pro.Uds/README.md +++ b/src/CanKit.Pro.Uds/README.md @@ -88,6 +88,22 @@ await uds.DownloadAsync( channel's handoff instant, not the transmit stamp — that one is "no later than the driver accepted the frame", and a fast peer can be stamped before it (#146) — and not a reading taken before entering the channel, whose transmission would leave a window. +* NRC 0x21 (busyRepeatRequest) is what it says: the request is repeated, up to + `UdsClientOptions.MaxBusyRepeatRequests` times (default 3, after `BusyRepeatRequestDelay`, + default zero), each with a fresh P2; the negative response surfaces only once the repeats are + used up (#57). +* `UdsTimeoutException.Elapsed` is the budget of the timer that expired (P2 or P2*) on every + path, never a measurement of how late the client noticed (#57). +* `DiagnosticSessionControlAsync(byte)` rejects 0x00 and any value with bit 7 set rather than + masking it; `SendRawAsync` sends a request with suppressPosRspMsgIndication set without + waiting for a response and returns empty (#57). +* Functional addressing: `UdsFunctionalClient` wraps an `IsoTpFunctionalClient` — one request on + the functional identifier, every ECU's Single-Frame answer collected within a window and read + as UDS (`UdsFunctionalResponse` with source identifier, bytes, `IsNegative` and the NRC). The + keep-alive to everyone is `TesterPresentAsync()` (`3E 80`, not collected for) (#57). +* `Dispose` waits up to five seconds for a request in flight to release the request lock; a + holder that outlasts the wait keeps an undisposed semaphore, so its eventual release does not + throw into an operation that was merely slow (#57). * `SecurityAccessAsync` treats a seed of all zeroes — of any length, including zero — as *already unlocked* (ISO 14229-1 §9.4.5.3) and returns without sending a key; the ECU would answer a key for that seed with NRC 0x24. diff --git a/src/CanKit.Pro.Uds/UdsClientImpl.cs b/src/CanKit.Pro.Uds/UdsClientImpl.cs index d21ab9a..593094b 100644 --- a/src/CanKit.Pro.Uds/UdsClientImpl.cs +++ b/src/CanKit.Pro.Uds/UdsClientImpl.cs @@ -37,6 +37,10 @@ internal sealed class UdsClientImpl : IUdsClient private const byte PositiveResponseOffset = 0x40; private const byte NrcResponsePending = 0x78; private const byte SuppressPositiveResponseBit = 0x80; + private const byte NrcBusyRepeatRequest = 0x21; + + /// How long Dispose waits for a request in flight to release the lock. + internal TimeSpan DisposeLockTimeout { get; set; } = TimeSpan.FromSeconds(5); private readonly IIsoTpChannel _channel; private readonly bool _ownsChannel; @@ -79,7 +83,13 @@ public async Task DiagnosticSessionControlAsync(UdsSessionType session, public async Task DiagnosticSessionControlAsync(byte sessionType, CancellationToken cancellationToken = default) { - byte sub = (byte)(sessionType & 0x7F); + // The sub-function byte's bit 7 is suppressPosRspMsgIndication, not part of the session + // type; a caller passing 0x83 meant something this method does not do (it waits for + // the response), and masking it to 0x03 hid that (#57). 0x00 is ISOSAEReserved. + if (sessionType == 0 || (sessionType & SuppressPositiveResponseBit) != 0) + throw new ArgumentOutOfRangeException(nameof(sessionType), sessionType, + "Session type must be 0x01..0x7F; bit 7 is suppressPosRspMsgIndication and is not accepted here."); + byte sub = sessionType; var request = new byte[] { (byte)UdsServiceId.DiagnosticSessionControl, sub }; var response = await ExecuteAsync(UdsServiceId.DiagnosticSessionControl, request, cancellationToken).ConfigureAwait(false); @@ -347,24 +357,7 @@ public async Task TesterPresentAsync(bool suppressPositiveResponse = true, if (suppressPositiveResponse) { - // Fire-and-forget: acquire the request lock so we don't interleave with a real - // request, send the frame, then release. No response is expected. Link the - // lifetime token so Dispose() cancels a WaitAsync/Send still in progress - // (Bugbot 3596586770) — same contract as ExecuteAsync / SecurityAccessAsync. - ThrowIfDisposed(); - using var linked = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken, _lifetimeCts.Token); - var linkedToken = linked.Token; - - await _requestLock.WaitAsync(linkedToken).ConfigureAwait(false); - try - { - await _channel.SendAsync(request, linkedToken).ConfigureAwait(false); - } - finally - { - _requestLock.Release(); - } + await SendWithoutResponseAsync(request, cancellationToken).ConfigureAwait(false); return; } @@ -402,9 +395,52 @@ public async Task SendRawAsync(ReadOnlyMemory request, var sid = (UdsServiceId)request.Span[0]; var copy = new byte[request.Length]; request.Span.CopyTo(copy); + + // A request with suppressPosRspMsgIndication set gets no positive response; waiting P2 + // for one ended in a timeout every time (#57). Sent the way a suppressed TesterPresent + // is, and an empty response returned. A negative response the server may still send is + // not waited for either -- the next request's discard drops it. + if (copy.Length >= 2 && HasSubFunction(sid) && (copy[1] & SuppressPositiveResponseBit) != 0) + { + await SendWithoutResponseAsync(copy, cancellationToken).ConfigureAwait(false); + return Array.Empty(); + } + return await ExecuteAsync(sid, copy, cancellationToken).ConfigureAwait(false); } + /// + /// Fire-and-forget under the request lock: the frame goes out without interleaving a real + /// request, and no response is waited for. The lifetime token is linked so Dispose() + /// cancels a wait or send still in progress (Bugbot 3596586770), as ExecuteAsync does. + /// + private async Task SendWithoutResponseAsync(byte[] request, CancellationToken cancellationToken) + { + ThrowIfDisposed(); + using var linked = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, _lifetimeCts.Token); + var linkedToken = linked.Token; + + await _requestLock.WaitAsync(linkedToken).ConfigureAwait(false); + try + { + await _channel.SendAsync(request, linkedToken).ConfigureAwait(false); + } + finally + { + _requestLock.Release(); + } + } + + // The services whose second byte is a sub-function parameter, and so carry the + // suppressPosRspMsgIndication bit (ISO 14229-1 table 2, "sub-function" column). + private static bool HasSubFunction(UdsServiceId sid) => (byte)sid switch + { + 0x10 or 0x11 or 0x19 or 0x27 or 0x28 or 0x29 or 0x2A or 0x2C or 0x31 or 0x3E + or 0x83 or 0x85 or 0x86 or 0x87 => true, + _ => false, + }; + // --------------------------------------------------------------------------------------- // Upload / Download (SRS FR-UDS-012, ISO 14229-1 §14). // --------------------------------------------------------------------------------------- @@ -781,6 +817,27 @@ private async Task ExecuteAsync(UdsServiceId serviceId, byte[] request, /// private async Task ExecuteCoreAsync(UdsServiceId serviceId, byte[] request, CancellationToken linkedToken) + { + // NRC 0x21 (busyRepeatRequest) asks for exactly that: the request is repeated, up to + // MaxBusyRepeatRequests times, each with a fresh P2 (#57). Anything else the exchange + // produces -- data, another NRC, a timeout -- passes through. + for (int repeats = 0; ; repeats++) + { + try + { + return await ExchangeOnceAsync(serviceId, request, linkedToken).ConfigureAwait(false); + } + catch (UdsNegativeResponseException ex) + when (ex.Code == NrcBusyRepeatRequest && repeats < _options.MaxBusyRepeatRequests) + { + if (_options.BusyRepeatRequestDelay > TimeSpan.Zero) + await Task.Delay(_options.BusyRepeatRequestDelay, linkedToken).ConfigureAwait(false); + } + } + } + + private async Task ExchangeOnceAsync(UdsServiceId serviceId, byte[] request, + CancellationToken linkedToken) { // Drop any late reply left over from a previous aborted/timed-out wait before we put a // new request on the wire. SID correlation alone is insufficient when the next request @@ -977,7 +1034,7 @@ private async Task ReceiveWithTimeoutAsync(UdsServiceId servic // caller judge its stamp; only an empty inbox means nothing arrived in time // (Bugbot on #112). return await TakeQueuedOrInProgressAsync(serviceId, timerKind, budget, budgetStart, - notBefore, elapsedInBudget, linkedToken).ConfigureAwait(false); + notBefore, linkedToken).ConfigureAwait(false); } using var timeoutCts = new CancellationTokenSource(remaining); @@ -995,7 +1052,7 @@ private async Task ReceiveWithTimeoutAsync(UdsServiceId servic // PDU was enqueued just before it fired. Same rule as the zero-remaining exit: look // before declaring a timeout. return await TakeQueuedOrInProgressAsync(serviceId, timerKind, budget, budgetStart, - notBefore, budget, linkedToken).ConfigureAwait(false); + notBefore, linkedToken).ConfigureAwait(false); } } @@ -1014,7 +1071,7 @@ private async Task ReceiveWithTimeoutAsync(UdsServiceId servic /// private async Task TakeQueuedOrInProgressAsync(UdsServiceId serviceId, UdsTimeoutTimer timerKind, TimeSpan budget, long budgetStart, long notBefore, - TimeSpan elapsedReported, CancellationToken linkedToken) + CancellationToken linkedToken) { while (true) { @@ -1027,7 +1084,7 @@ private async Task TakeQueuedOrInProgressAsync(UdsServiceId se if (_channel.TryReceiveWithArrival(out var queued)) return queued; - throw new UdsTimeoutException(serviceId, timerKind, elapsedReported); + throw new UdsTimeoutException(serviceId, timerKind, budget); } using var recheck = new CancellationTokenSource(InProgressRecheck); @@ -1103,10 +1160,15 @@ public void Dispose() // the lock races WaitAsync/Release. try { _lifetimeCts.Cancel(); } catch { /* already disposed */ } + // If the holder does not let go in time -- an operation ignoring the cancellation -- + // the semaphore stays undisposed: its Release on the holder's thread would otherwise + // throw ObjectDisposedException into an operation that was merely slow (#57). A + // SemaphoreSlim without a wait handle holds nothing that needs disposing. + bool lockAcquired = false; try { - if (_requestLock.Wait(TimeSpan.FromSeconds(5))) - _requestLock.Release(); + lockAcquired = _requestLock.Wait(DisposeLockTimeout); + if (lockAcquired) _requestLock.Release(); } catch (ObjectDisposedException) { @@ -1114,7 +1176,7 @@ public void Dispose() } _lifetimeCts.Dispose(); - _requestLock.Dispose(); + if (lockAcquired) _requestLock.Dispose(); if (_ownsChannel) { diff --git a/src/CanKit.Pro.Uds/UdsClientOptions.cs b/src/CanKit.Pro.Uds/UdsClientOptions.cs index 5330dc9..315ad75 100644 --- a/src/CanKit.Pro.Uds/UdsClientOptions.cs +++ b/src/CanKit.Pro.Uds/UdsClientOptions.cs @@ -71,6 +71,20 @@ public sealed class UdsClientOptions /// public bool KeepAliveSuppressPositiveResponse { get; init; } = true; + /// + /// How many times a request is repeated after NRC 0x21 (busyRepeatRequest) before the + /// negative response is surfaced. The NRC exists to ask for a repeat (ISO 14229-1 §A.1), + /// so the default repeats; zero surfaces the first 0x21 as an + /// (#57). Default 3. + /// + public int MaxBusyRepeatRequests { get; init; } = 3; + + /// + /// The pause before a request is repeated after NRC 0x21. Default zero: the server said + /// "repeat", not "wait", and its P2 budget starts again with the repeated request. + /// + public TimeSpan BusyRepeatRequestDelay { get; init; } = TimeSpan.Zero; + /// /// Convenience clone that returns a new instance with the provided overrides. Useful for /// tests that only want to tweak one field of a shared default template. diff --git a/src/CanKit.Pro.Uds/UdsException.cs b/src/CanKit.Pro.Uds/UdsException.cs index fd234df..7c51adf 100644 --- a/src/CanKit.Pro.Uds/UdsException.cs +++ b/src/CanKit.Pro.Uds/UdsException.cs @@ -85,7 +85,10 @@ public sealed class UdsTimeoutException : UdsException /// Service that was awaiting a response. public UdsServiceId RequestedService { get; } - /// Elapsed budget when the timer expired. + /// + /// The budget of the timer that expired (the configured P2 or P2*), on every path. Not a + /// measurement: how late the client noticed says nothing about the server (#57). + /// public TimeSpan Elapsed { get; } /// Creates a P2 or P2* timeout exception. diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs new file mode 100644 index 0000000..0681940 --- /dev/null +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using CanKit.Pro.IsoTp; + +namespace CanKit.Pro.Uds; + +/// +/// UDS over functional addressing (ISO 14229-1 §7.5.4): one request on the functional CAN +/// identifier, every ECU in the response range may answer. Built on +/// , which carries only Single Frames both ways; a request +/// that needs more than one frame, or an ECU whose answer does, is not for this path (#57). +/// +/// +/// The typical uses are the keep-alive to everyone (3E 80) and a session change or +/// short read broadcast to a set of ECUs. A negative response is one ECU's and does not fault +/// the call: it is returned beside the others as a with +/// set. +/// +public sealed class UdsFunctionalClient : IDisposable +{ + private const byte SuppressPositiveResponseBit = 0x80; + + private readonly IsoTpFunctionalClient _client; + private readonly bool _ownsClient; + private int _disposed; + + private UdsFunctionalClient(IsoTpFunctionalClient client, bool ownsClient) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + _ownsClient = ownsClient; + } + + /// + /// Wraps an open . With , + /// disposing this client disposes it. + /// + public static UdsFunctionalClient Create(IsoTpFunctionalClient client, bool ownsClient = false) + => new(client, ownsClient); + + /// The underlying ISO-TP functional client. + public IsoTpFunctionalClient Channel => _client; + + /// + /// Sends on the functional identifier and collects every ECU's + /// answer that arrives within . A request with + /// suppressPosRspMsgIndication set is sent and not collected for: an empty list comes back + /// as soon as the frame is confirmed. + /// + public async Task> SendRawAsync(ReadOnlyMemory request, + TimeSpan window, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + if (request.Length == 0) + throw new ArgumentException("Request must contain at least a SID byte.", nameof(request)); + + if (request.Length >= 2 && HasSubFunction(request.Span[0]) + && (request.Span[1] & SuppressPositiveResponseBit) != 0) + { + await _client.SendAsync(request, cancellationToken).ConfigureAwait(false); + return Array.Empty(); + } + + var raw = await _client.SendAndCollectAsync(request, window, cancellationToken) + .ConfigureAwait(false); + var responses = new UdsFunctionalResponse[raw.Count]; + for (int i = 0; i < raw.Count; i++) + responses[i] = new UdsFunctionalResponse(raw[i].SourceCanId, raw[i].Data); + return responses; + } + + /// + /// TesterPresent to everyone (3E 80): the keep-alive that reaches every ECU with one + /// frame. With the positive response suppressed, the default, nothing is collected. + /// + public async Task> TesterPresentAsync( + bool suppressPositiveResponse = true, TimeSpan? window = null, + CancellationToken cancellationToken = default) + { + byte sub = suppressPositiveResponse ? SuppressPositiveResponseBit : (byte)0x00; + return await SendRawAsync(new byte[] { (byte)UdsServiceId.TesterPresent, sub }, + window ?? TimeSpan.Zero, cancellationToken).ConfigureAwait(false); + } + + /// + /// DiagnosticSessionControl to everyone, collecting each ECU's answer within + /// . Bit 7 of the session type is not accepted, as on + /// . + /// + public async Task> DiagnosticSessionControlAsync( + UdsSessionType session, TimeSpan window, CancellationToken cancellationToken = default) + { + byte sub = (byte)session; + if (sub == 0 || (sub & SuppressPositiveResponseBit) != 0) + throw new ArgumentOutOfRangeException(nameof(session), session, "Session type must be 0x01..0x7F."); + return await SendRawAsync(new byte[] { (byte)UdsServiceId.DiagnosticSessionControl, sub }, + window, cancellationToken).ConfigureAwait(false); + } + + // Mirrors UdsClientImpl.HasSubFunction (ISO 14229-1 table 2). + private static bool HasSubFunction(byte sid) => sid switch + { + 0x10 or 0x11 or 0x19 or 0x27 or 0x28 or 0x29 or 0x2A or 0x2C or 0x31 or 0x3E + or 0x83 or 0x85 or 0x86 or 0x87 => true, + _ => false, + }; + + private void ThrowIfDisposed() + { + if (Volatile.Read(ref _disposed) != 0) + throw new ObjectDisposedException(nameof(UdsFunctionalClient)); + } + + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + if (_ownsClient) _client.Dispose(); + } +} diff --git a/src/CanKit.Pro.Uds/UdsFunctionalResponse.cs b/src/CanKit.Pro.Uds/UdsFunctionalResponse.cs new file mode 100644 index 0000000..f044a2c --- /dev/null +++ b/src/CanKit.Pro.Uds/UdsFunctionalResponse.cs @@ -0,0 +1,30 @@ +using System; + +namespace CanKit.Pro.Uds; + +/// +/// One ECU's answer to a functionally addressed request (see ): +/// where it came from, and the response bytes read as UDS. +/// +public sealed class UdsFunctionalResponse +{ + /// Creates a response; the client is the intended caller. + public UdsFunctionalResponse(uint sourceCanId, byte[] response) + { + SourceCanId = sourceCanId; + Response = response ?? throw new ArgumentNullException(nameof(response)); + } + + /// The CAN identifier the ECU answered on. + public uint SourceCanId { get; } + + /// The response bytes, starting with the response SID (or 0x7F). + public byte[] Response { get; } + + /// Whether this is a negative response (SID 0x7F). + public bool IsNegative => Response.Length >= 1 && Response[0] == 0x7F; + + /// The negative response code, or null for a positive response. + public byte? NegativeResponseCode + => IsNegative && Response.Length >= 3 ? Response[2] : null; +} diff --git a/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Uds.approved.txt b/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Uds.approved.txt index 8a785f1..a2b39e3 100644 --- a/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Uds.approved.txt +++ b/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Uds.approved.txt @@ -33,7 +33,9 @@ namespace CanKit.Pro.Uds public static readonly System.TimeSpan DefaultP2Star; public static readonly System.TimeSpan DefaultTesterPresentPeriod; public UdsClientOptions() { } + public System.TimeSpan BusyRepeatRequestDelay { get; init; } public bool KeepAliveSuppressPositiveResponse { get; init; } + public int MaxBusyRepeatRequests { get; init; } public int MaxResponsePendingCount { get; init; } public System.TimeSpan P2ClientMax { get; init; } public System.TimeSpan P2StarClientMax { get; init; } @@ -60,6 +62,23 @@ namespace CanKit.Pro.Uds protected UdsException(CanKit.Core.Exceptions.CanKitErrorCode errorCode, string message) { } public UdsException(string message, System.Exception innerException) { } } + public sealed class UdsFunctionalClient : System.IDisposable + { + public CanKit.Pro.IsoTp.IsoTpFunctionalClient Channel { get; } + public System.Threading.Tasks.Task> DiagnosticSessionControlAsync(CanKit.Pro.Uds.UdsSessionType session, System.TimeSpan window, System.Threading.CancellationToken cancellationToken = default) { } + public void Dispose() { } + public System.Threading.Tasks.Task> SendRawAsync(System.ReadOnlyMemory request, System.TimeSpan window, System.Threading.CancellationToken cancellationToken = default) { } + public System.Threading.Tasks.Task> TesterPresentAsync(bool suppressPositiveResponse = true, System.TimeSpan? window = default, System.Threading.CancellationToken cancellationToken = default) { } + public static CanKit.Pro.Uds.UdsFunctionalClient Create(CanKit.Pro.IsoTp.IsoTpFunctionalClient client, bool ownsClient = false) { } + } + public sealed class UdsFunctionalResponse + { + public UdsFunctionalResponse(uint sourceCanId, byte[] response) { } + public bool IsNegative { get; } + public byte? NegativeResponseCode { get; } + public byte[] Response { get; } + public uint SourceCanId { get; } + } public enum UdsNegativeResponseCode : byte { GeneralReject = 16, diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs index d7bc475..deb457c 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs @@ -444,6 +444,87 @@ public async Task P2_Is_Not_Extended_By_A_MultiFrame_Transfer_For_Another_Servic } } + // ----------------------------------------------------------------------------------- + // #57 — the collected UDS findings. + // ----------------------------------------------------------------------------------- + + // Bit 7 of the session type is suppressPosRspMsgIndication; masking it silently sent a + // session the caller did not ask for. Rejected before anything is on the wire. + [Theory] + [InlineData(0x83)] + [InlineData(0x00)] + public async Task DiagnosticSessionControl_Rejects_A_Session_Type_Outside_01_To_7F(byte sessionType) + { + var (client, ecu, dispose) = BuildPair(e => e.On(0x10, req => new byte[] { req[1], 0x00, 0x32, 0x01, 0xF4 })); + + using (dispose) + { + Func act = () => client.DiagnosticSessionControlAsync(sessionType, + new CancellationTokenSource(ShortTimeout).Token); + await act.Should().ThrowAsync(); + ecu.RequestsHandled.Should().Be(0, "nothing was sent"); + } + } + + // A raw request with the suppress bit set gets no positive response; waiting P2 for one + // ended in a timeout every time. It is sent and returns empty. + [Fact] + public async Task SendRaw_With_The_Suppress_Bit_Does_Not_Wait_For_A_Response() + { + var (client, ecu, dispose) = BuildPair( + e => e.On(0x3E, req => Array.Empty()), + options: new UdsClientOptions { P2ClientMax = TimeSpan.FromMilliseconds(300) }); + + using (dispose) + { + using var cts = new CancellationTokenSource(ShortTimeout); + var response = await client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token); + response.Should().BeEmpty(); + + // The frame reached the ECU: it counts a suppressed TesterPresent as handled. + var deadline = Stopwatch.StartNew(); + while (ecu.RequestsHandled == 0 && deadline.Elapsed < ShortTimeout) await Task.Delay(5); + ecu.RequestsHandled.Should().Be(1); + } + } + + // NRC 0x21 asks for a repeat; the client repeats, up to MaxBusyRepeatRequests. + [Fact] + public async Task BusyRepeatRequest_Is_Repeated_Until_The_Server_Answers() + { + int calls = 0; + var (client, ecu, dispose) = BuildPair(e => e.On(0x22, req => + { + if (Interlocked.Increment(ref calls) <= 2) throw new EcuNegativeResponse(0x21); + return new byte[] { 0xF1, 0x90, 0xAB }; + })); + + using (dispose) + { + using var cts = new CancellationTokenSource(ShortTimeout); + var data = await client.ReadDataByIdentifierAsync(0xF190, cts.Token); + data.Should().Equal(0xAB); + ecu.RequestsHandled.Should().Be(3, "two busy answers, then the data"); + } + } + + [Fact] + public async Task BusyRepeatRequest_Is_Surfaced_Once_The_Repeats_Are_Used_Up() + { + var (client, ecu, dispose) = BuildPair( + e => e.On(0x22, req => throw new EcuNegativeResponse(0x21)), + options: new UdsClientOptions { MaxBusyRepeatRequests = 1 }); + + using (dispose) + { + using var cts = new CancellationTokenSource(ShortTimeout); + Func act = () => client.ReadDataByIdentifierAsync(0xF190, cts.Token); + var ex = (await act.Should().ThrowAsync()).Which; + ex.Code.Should().Be(0x21); + ecu.RequestsHandled.Should().Be(2, "the request and one repeat"); + } + } + // ----------------------------------------------------------------------------------- // SecurityAccess must hold the request lock across seed + sendKey so TesterPresent // keep-alive cannot interleave and provoke NRC requestSequenceError on real ECUs. diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs index be9d61f..c032a7f 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs @@ -332,6 +332,55 @@ await act.Should().ThrowAsync( "a response that arrived before the request's last frame was handed over is an earlier request's"); } + /// + /// #57 — is the budget of the timer that expired + /// on every path. Here the send's own await returns after the budget is spent and nothing + /// is queued: the zero-remaining exit, which used to report how late the client noticed. + /// + [Fact] + public async Task M_A_Timeout_Reports_The_Budget_Not_How_Late_The_Client_Noticed() + { + using var channel = new StubChannel( + deliverAfter: TimeSpan.FromSeconds(5), + stampArrivalAtDelivery: true) + { + SendObservationDelay = TimeSpan.FromMilliseconds(160), + }; + using var client = NewClient(channel); + + Func act = () => client.ReadDataByIdentifierAsync(0xF190, CancellationToken.None); + var ex = (await act.Should().ThrowAsync()).Which; + + ex.Elapsed.Should().Be(Budget); + } + + /// + /// #57 — Dispose waits for a request in flight to release the lock; when it does not in + /// time, the semaphore is left undisposed, so the holder's eventual Release does not throw + /// ObjectDisposedException into an operation that was merely slow. + /// + [Fact] + public async Task N_Dispose_Leaves_The_Lock_To_A_Holder_That_Outlasts_The_Wait() + { + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var channel = new StubChannel( + deliverAfter: TimeSpan.Zero, + stampArrivalAtDelivery: true) + { Gate = gate }; + var client = NewClient(channel); + ((UdsClientImpl)client).DisposeLockTimeout = TimeSpan.FromMilliseconds(100); + + var inFlight = client.ReadDataByIdentifierAsync(0xF190, CancellationToken.None); + await Task.Delay(50); // let the request take the lock and block in the receive + + client.Dispose(); // returns after its 100 ms wait, the holder still inside + + gate.SetResult(true); + Func act = () => inFlight; + await act.Should().NotThrowAsync( + "the holder's Release must find its semaphore intact"); + } + private sealed class StubChannel : IIsoTpChannel { private static readonly byte[] Response = { 0x62, 0xF1, 0x90, 0xAA }; @@ -405,6 +454,9 @@ public StubChannel(TimeSpan deliverAfter, bool stampArrivalAtDelivery) /// public TimeSpan LastFrameHandoffBeforeTransmit { get; init; } + /// When set, a receive blocks here first, ignoring cancellation (#57). + public TaskCompletionSource? Gate { get; init; } + public async Task SendWithTransmitStampAsync(ReadOnlyMemory pdu, CancellationToken cancellationToken = default) { @@ -430,6 +482,9 @@ public async Task SendAsync(ReadOnlyMemory pdu, public async Task ReceiveWithArrivalAsync( CancellationToken cancellationToken = default) { + if (Gate is not null) + await Gate.Task.ConfigureAwait(false); + // Deliberately not observing the token: this models the write winning the race. if (RespondPendingFirst && !_pendingSent) { diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs new file mode 100644 index 0000000..648d480 --- /dev/null +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -0,0 +1,127 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CanKit.Abstractions.API.Can; +using CanKit.Abstractions.API.Can.Definitions; +using CanKit.Abstractions.API.Common.Definitions; +using CanKit.Core; +using CanKit.Pro.IsoTp; +using CanKit.Pro.Tests.Infrastructure; +using CanKit.Pro.Uds; +using FluentAssertions; +using Xunit; +using IsoTpFactory = CanKit.Pro.IsoTp.IsoTp; + +namespace CanKit.Pro.Tests.TestCases.Uds; + +/// +/// #57 — UDS over functional addressing: one request on the functional identifier, every ECU in +/// the response range may answer, and each answer is read as UDS (positive or negative). +/// +public class UdsFunctionalClientTests : IClassFixture +{ + private static readonly TimeSpan ShortTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan Window = TimeSpan.FromMilliseconds(300); + + private const uint FunctionalTxId = 0x7DF; + private const uint Ecu1 = 0x7E8; + private const uint Ecu2 = 0x7E9; + + private static string NewSession() => $"uds-fa-{Guid.NewGuid():N}"; + + private static ICanBus OpenClassic(string session, int channel) => CanBus.Open( + $"virtual://{session}/{channel}", + cfg => cfg.SetProtocolMode(CanProtocolMode.Can20).Baud(VirtualAdapterFixture.Bitrate)); + + private static IsoTpFunctionalOptions FastOptions() => new() + { + IsExtendedCanId = false, + UseCanFd = false, + UsePadding = true, + NAs = TimeSpan.FromMilliseconds(500), + }; + + private static CanFrame SingleFrameFrom(uint canId, byte[] pdu) + => CanFrame.Classic(unchecked((int)canId), + IsoTpFrameCodec.BuildSingleFrame(IsoTpEndpoint.Normal(canId, 0), pdu, isCanFd: false, padding: true)); + + [Fact] + public async Task A_Functional_Request_Collects_Each_Ecus_Answer_Positive_Or_Negative() + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var busEcus = OpenClassic(session, 1); + + // ECU 1 answers the read; ECU 2 refuses it. + var ecu1Answer = SingleFrameFrom(Ecu1, new byte[] { 0x62, 0xF1, 0x90, 0x01 }); + var ecu2Answer = SingleFrameFrom(Ecu2, new byte[] { 0x7F, 0x22, 0x31 }); + busEcus.FrameObserved += (_, e) => + { + if (e.CanFrame.ID != unchecked((int)FunctionalTxId)) return; + busEcus.Transmit(ecu1Answer); + busEcus.Transmit(ecu2Answer); + }; + + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), ownsClient: true); + + using var cts = new CancellationTokenSource(ShortTimeout); + var responses = await functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, Window, cts.Token); + + responses.Should().HaveCount(2); + var fromEcu1 = responses.Single(r => r.SourceCanId == Ecu1); + fromEcu1.IsNegative.Should().BeFalse(); + fromEcu1.Response.Should().Equal(0x62, 0xF1, 0x90, 0x01); + var fromEcu2 = responses.Single(r => r.SourceCanId == Ecu2); + fromEcu2.IsNegative.Should().BeTrue(); + fromEcu2.NegativeResponseCode.Should().Be(0x31); + } + + [Fact] + public async Task TesterPresent_To_Everyone_Is_One_Suppressed_Frame_And_Collects_Nothing() + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var busEcus = OpenClassic(session, 1); + + var seen = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + busEcus.FrameObserved += (_, e) => + { + if (e.CanFrame.ID == unchecked((int)FunctionalTxId)) seen.TrySetResult(e.CanFrame.Data.ToArray()); + }; + + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), ownsClient: true); + + using var cts = new CancellationTokenSource(ShortTimeout); + var responses = await functional.TesterPresentAsync(cancellationToken: cts.Token); + + responses.Should().BeEmpty("a suppressed request is not collected for"); + var frame = await seen.Task.WaitAsync(ShortTimeout); + frame.Take(3).Should().Equal(0x02, 0x3E, 0x80); + } + + [Fact] + public async Task DiagnosticSessionControl_To_Everyone_Collects_Each_Ecus_Session_Answer() + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var busEcus = OpenClassic(session, 1); + + var ecu1Answer = SingleFrameFrom(Ecu1, new byte[] { 0x50, 0x03, 0x00, 0x32, 0x01, 0xF4 }); + busEcus.FrameObserved += (_, e) => + { + if (e.CanFrame.ID == unchecked((int)FunctionalTxId) && e.CanFrame.Data.Span[1] == 0x10) + busEcus.Transmit(ecu1Answer); + }; + + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), ownsClient: true); + + using var cts = new CancellationTokenSource(ShortTimeout); + var responses = await functional.DiagnosticSessionControlAsync(UdsSessionType.Extended, Window, cts.Token); + + responses.Should().ContainSingle().Which.Response[1].Should().Be(0x03); + } +} From 1cc3af2acbd5c61aef7d1de6b995a0d829da1e85 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:26:54 +0200 Subject: [PATCH 02/55] test(uds): dispose the token source and the client CodeQL flagged on #150 Co-Authored-By: Claude Opus 5 --- tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs | 4 ++-- .../CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs index deb457c..ffab36f 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs @@ -459,8 +459,8 @@ public async Task DiagnosticSessionControl_Rejects_A_Session_Type_Outside_01_To_ using (dispose) { - Func act = () => client.DiagnosticSessionControlAsync(sessionType, - new CancellationTokenSource(ShortTimeout).Token); + using var cts = new CancellationTokenSource(ShortTimeout); + Func act = () => client.DiagnosticSessionControlAsync(sessionType, cts.Token); await act.Should().ThrowAsync(); ecu.RequestsHandled.Should().Be(0, "nothing was sent"); } diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs index c032a7f..baf2db0 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs @@ -367,7 +367,7 @@ public async Task N_Dispose_Leaves_The_Lock_To_A_Holder_That_Outlasts_The_Wait() deliverAfter: TimeSpan.Zero, stampArrivalAtDelivery: true) { Gate = gate }; - var client = NewClient(channel); + using var client = NewClient(channel); // a second Dispose is idempotent ((UdsClientImpl)client).DisposeLockTimeout = TimeSpan.FromMilliseconds(100); var inFlight = client.ReadDataByIdentifierAsync(0xF190, CancellationToken.None); From 7cbebdc6a0f394d5f671fe287a518bfcf7a7f0a3 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:30:52 +0200 Subject: [PATCH 03/55] fix(uds): correlate functional answers to the request, require a window for an unsuppressed TesterPresent, carry the busy-repeat options in With Codex on #150, three times. The functional client wrapped every Single Frame on the response range as an answer; it now keeps only a positive response to the request's service or a negative response naming it, as the physical client correlates. An unsuppressed TesterPresent with no window collected for zero time; the window is required there. And UdsClientOptions.With dropped the two new options on a clone; it carries and accepts them. Each mutation-checked. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsClientOptions.cs | 6 ++- src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 34 +++++++++++---- .../ApiApprovals/CanKit.Pro.Uds.approved.txt | 2 +- .../TestCases/Uds/UdsClientTests.cs | 17 ++++++++ .../TestCases/Uds/UdsFunctionalClientTests.cs | 41 +++++++++++++++++++ 5 files changed, 91 insertions(+), 9 deletions(-) diff --git a/src/CanKit.Pro.Uds/UdsClientOptions.cs b/src/CanKit.Pro.Uds/UdsClientOptions.cs index 315ad75..77bdeee 100644 --- a/src/CanKit.Pro.Uds/UdsClientOptions.cs +++ b/src/CanKit.Pro.Uds/UdsClientOptions.cs @@ -94,7 +94,9 @@ public UdsClientOptions With( TimeSpan? p2StarClientMax = null, int? maxResponsePendingCount = null, TimeSpan? testerPresentPeriod = null, - bool? keepAliveSuppressPositiveResponse = null) + bool? keepAliveSuppressPositiveResponse = null, + int? maxBusyRepeatRequests = null, + TimeSpan? busyRepeatRequestDelay = null) => new() { P2ClientMax = p2ClientMax ?? P2ClientMax, @@ -102,5 +104,7 @@ public UdsClientOptions With( MaxResponsePendingCount = maxResponsePendingCount ?? MaxResponsePendingCount, TesterPresentPeriod = testerPresentPeriod ?? TesterPresentPeriod, KeepAliveSuppressPositiveResponse = keepAliveSuppressPositiveResponse ?? KeepAliveSuppressPositiveResponse, + MaxBusyRepeatRequests = maxBusyRepeatRequests ?? MaxBusyRepeatRequests, + BusyRepeatRequestDelay = busyRepeatRequestDelay ?? BusyRepeatRequestDelay, }; } diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index 0681940..2eea010 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -21,6 +21,7 @@ namespace CanKit.Pro.Uds; public sealed class UdsFunctionalClient : IDisposable { private const byte SuppressPositiveResponseBit = 0x80; + private const byte NegativeResponseSid = 0x7F; private readonly IsoTpFunctionalClient _client; private readonly bool _ownsClient; @@ -44,9 +45,11 @@ public static UdsFunctionalClient Create(IsoTpFunctionalClient client, bool owns /// /// Sends on the functional identifier and collects every ECU's - /// answer that arrives within . A request with - /// suppressPosRspMsgIndication set is sent and not collected for: an empty list comes back - /// as soon as the frame is confirmed. + /// answer to it that arrives within : a positive response + /// to the request's service, or a negative response naming it. Other traffic on the + /// response identifiers -- another tester's answers, a late answer to an earlier request -- + /// is not attributed to this call. A request with suppressPosRspMsgIndication set is sent + /// and not collected for: an empty list comes back as soon as the frame is confirmed. /// public async Task> SendRawAsync(ReadOnlyMemory request, TimeSpan window, CancellationToken cancellationToken = default) @@ -64,20 +67,37 @@ public async Task> SendRawAsync(ReadOnlyMem var raw = await _client.SendAndCollectAsync(request, window, cancellationToken) .ConfigureAwait(false); - var responses = new UdsFunctionalResponse[raw.Count]; - for (int i = 0; i < raw.Count; i++) - responses[i] = new UdsFunctionalResponse(raw[i].SourceCanId, raw[i].Data); + byte sid = request.Span[0]; + byte positiveSid = (byte)(sid + 0x40); + var responses = new List(raw.Count); + foreach (var r in raw) + { + // Correlated to this request the way the physical client correlates: the positive + // response SID, or a negative response echoing the request's SID (Codex on #150). + var data = r.Data; + bool ours = data.Length >= 1 && data[0] == positiveSid + || data.Length >= 3 && data[0] == NegativeResponseSid && data[1] == sid; + if (ours) responses.Add(new UdsFunctionalResponse(r.SourceCanId, data)); + } return responses; } /// /// TesterPresent to everyone (3E 80): the keep-alive that reaches every ECU with one - /// frame. With the positive response suppressed, the default, nothing is collected. + /// frame. With the positive response suppressed, the default, nothing is collected and + /// is ignored; without it, every ECU answers, and a + /// to collect them in is required (Codex on #150). /// + /// + /// is false and no window was given. + /// public async Task> TesterPresentAsync( bool suppressPositiveResponse = true, TimeSpan? window = null, CancellationToken cancellationToken = default) { + if (!suppressPositiveResponse && window is null) + throw new ArgumentNullException(nameof(window), + "An unsuppressed TesterPresent is answered by every ECU; give a window to collect the answers in."); byte sub = suppressPositiveResponse ? SuppressPositiveResponseBit : (byte)0x00; return await SendRawAsync(new byte[] { (byte)UdsServiceId.TesterPresent, sub }, window ?? TimeSpan.Zero, cancellationToken).ConfigureAwait(false); diff --git a/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Uds.approved.txt b/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Uds.approved.txt index a2b39e3..8988db5 100644 --- a/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Uds.approved.txt +++ b/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Uds.approved.txt @@ -40,7 +40,7 @@ namespace CanKit.Pro.Uds public System.TimeSpan P2ClientMax { get; init; } public System.TimeSpan P2StarClientMax { get; init; } public System.TimeSpan TesterPresentPeriod { get; init; } - public CanKit.Pro.Uds.UdsClientOptions With(System.TimeSpan? p2ClientMax = default, System.TimeSpan? p2StarClientMax = default, int? maxResponsePendingCount = default, System.TimeSpan? testerPresentPeriod = default, bool? keepAliveSuppressPositiveResponse = default) { } + public CanKit.Pro.Uds.UdsClientOptions With(System.TimeSpan? p2ClientMax = default, System.TimeSpan? p2StarClientMax = default, int? maxResponsePendingCount = default, System.TimeSpan? testerPresentPeriod = default, bool? keepAliveSuppressPositiveResponse = default, int? maxBusyRepeatRequests = default, System.TimeSpan? busyRepeatRequestDelay = default) { } } public readonly struct UdsDownloadResponse { diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs index ffab36f..94b13a4 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs @@ -508,6 +508,23 @@ public async Task BusyRepeatRequest_Is_Repeated_Until_The_Server_Answers() } } + // Codex on #150: the With(...) clone carries the busy-repeat settings. + [Fact] + public void Options_With_Carries_The_Busy_Repeat_Settings() + { + var options = new UdsClientOptions + { + MaxBusyRepeatRequests = 7, + BusyRepeatRequestDelay = TimeSpan.FromMilliseconds(15), + }; + + var clone = options.With(p2ClientMax: TimeSpan.FromMilliseconds(50)); + + clone.MaxBusyRepeatRequests.Should().Be(7); + clone.BusyRepeatRequestDelay.Should().Be(TimeSpan.FromMilliseconds(15)); + options.With(maxBusyRepeatRequests: 0).MaxBusyRepeatRequests.Should().Be(0); + } + [Fact] public async Task BusyRepeatRequest_Is_Surfaced_Once_The_Repeats_Are_Used_Up() { diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index 648d480..16e48ca 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -78,6 +78,47 @@ public async Task A_Functional_Request_Collects_Each_Ecus_Answer_Positive_Or_Neg fromEcu2.NegativeResponseCode.Should().Be(0x31); } + // Codex on #150: only answers to *this* request are attributed to the call. Another + // tester's answer on a response identifier, or a late answer to an earlier request, is not. + [Fact] + public async Task A_Functional_Request_Does_Not_Attribute_Unrelated_Traffic_To_Itself() + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var busEcus = OpenClassic(session, 1); + + var ours = SingleFrameFrom(Ecu1, new byte[] { 0x62, 0xF1, 0x90, 0x01 }); + var otherService = SingleFrameFrom(Ecu2, new byte[] { 0x50, 0x03, 0x00, 0x32, 0x01, 0xF4 }); + var otherNegative = SingleFrameFrom(Ecu2, new byte[] { 0x7F, 0x10, 0x12 }); + busEcus.FrameObserved += (_, e) => + { + if (e.CanFrame.ID != unchecked((int)FunctionalTxId)) return; + busEcus.Transmit(otherService); + busEcus.Transmit(otherNegative); + busEcus.Transmit(ours); + }; + + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), ownsClient: true); + + using var cts = new CancellationTokenSource(ShortTimeout); + var responses = await functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, Window, cts.Token); + + responses.Should().ContainSingle().Which.SourceCanId.Should().Be(Ecu1); + } + + [Fact] + public async Task An_Unsuppressed_TesterPresent_Needs_A_Window() + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), ownsClient: true); + + Func act = () => functional.TesterPresentAsync(suppressPositiveResponse: false); + await act.Should().ThrowAsync(); + } + [Fact] public async Task TesterPresent_To_Everyone_Is_One_Suppressed_Frame_And_Collects_Nothing() { From 77368d299479fb760859dfd78f5fa966529f008f Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:40:01 +0200 Subject: [PATCH 04/55] fix(uds): serialise functional requests, and reject a negative busy-repeat count Codex on #150, twice more. Two overlapping functional calls with the same SID each subscribed to the response range and collected the other's answers; a request lock now spans the send and its collection window, as the physical client's does. A negative MaxBusyRepeatRequests read as "disabled"; it and a negative BusyRepeatRequestDelay are rejected at construction, beside the other option checks. Both mutation-checked. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsClientImpl.cs | 6 ++++ src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 20 +++++++++++++ .../TestCases/Uds/UdsClientTests.cs | 11 +++++++ .../TestCases/Uds/UdsFunctionalClientTests.cs | 30 +++++++++++++++++++ 4 files changed, 67 insertions(+) diff --git a/src/CanKit.Pro.Uds/UdsClientImpl.cs b/src/CanKit.Pro.Uds/UdsClientImpl.cs index 593094b..42f5798 100644 --- a/src/CanKit.Pro.Uds/UdsClientImpl.cs +++ b/src/CanKit.Pro.Uds/UdsClientImpl.cs @@ -62,6 +62,12 @@ public UdsClientImpl(IIsoTpChannel channel, UdsClientOptions options, bool ownsC throw new ArgumentException("P2ClientMax must be positive.", nameof(options)); if (options.P2StarClientMax <= TimeSpan.Zero) throw new ArgumentException("P2StarClientMax must be positive.", nameof(options)); + if (options.MaxBusyRepeatRequests < 0) + throw new ArgumentOutOfRangeException(nameof(options), + "MaxBusyRepeatRequests must be >= 0 (0 disables the repeat)."); + if (options.BusyRepeatRequestDelay < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(options), + "BusyRepeatRequestDelay must not be negative."); if (options.MaxResponsePendingCount < 0) throw new ArgumentException("MaxResponsePendingCount must be non-negative.", nameof(options)); if (options.TesterPresentPeriod <= TimeSpan.Zero) diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index 2eea010..4e709aa 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -25,6 +25,10 @@ public sealed class UdsFunctionalClient : IDisposable private readonly IsoTpFunctionalClient _client; private readonly bool _ownsClient; + // One request on the wire at a time, its collection window included: overlapping calls + // with the same SID would each collect the other's answers (Codex on #150), as the + // physical client's request lock prevents there. + private readonly SemaphoreSlim _requestLock = new(1, 1); private int _disposed; private UdsFunctionalClient(IsoTpFunctionalClient client, bool ownsClient) @@ -58,6 +62,20 @@ public async Task> SendRawAsync(ReadOnlyMem if (request.Length == 0) throw new ArgumentException("Request must contain at least a SID byte.", nameof(request)); + await _requestLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await SendRawLockedAsync(request, window, cancellationToken).ConfigureAwait(false); + } + finally + { + _requestLock.Release(); + } + } + + private async Task> SendRawLockedAsync(ReadOnlyMemory request, + TimeSpan window, CancellationToken cancellationToken) + { if (request.Length >= 2 && HasSubFunction(request.Span[0]) && (request.Span[1] & SuppressPositiveResponseBit) != 0) { @@ -137,5 +155,7 @@ public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) != 0) return; if (_ownsClient) _client.Dispose(); + // Not disposed: a call still inside its window releases it on the way out, and a + // SemaphoreSlim without a wait handle holds nothing that needs disposing. } } diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs index 94b13a4..2874432 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs @@ -508,6 +508,17 @@ public async Task BusyRepeatRequest_Is_Repeated_Until_The_Server_Answers() } } + // Codex on #150: a negative repeat count is a configuration mistake, not "disabled". + [Fact] + public void A_Negative_Busy_Repeat_Count_Is_Rejected_At_Construction() + { + var session = NewSession(); + using var bus = OpenClassic(session, 0); + using var channel = IsoTpFactory.Open(bus, IsoTpEndpoint.Normal(0x7E0, 0x7E8), FastIsoTp()); + Action act = () => UdsClient.Create(channel, new UdsClientOptions { MaxBusyRepeatRequests = -1 }); + act.Should().Throw(); + } + // Codex on #150: the With(...) clone carries the busy-repeat settings. [Fact] public void Options_With_Carries_The_Busy_Repeat_Settings() diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index 16e48ca..2a2defe 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -107,6 +107,36 @@ public async Task A_Functional_Request_Does_Not_Attribute_Unrelated_Traffic_To_I responses.Should().ContainSingle().Which.SourceCanId.Should().Be(Ecu1); } + // Codex on #150: two overlapping calls with the same SID would each collect the other's + // answers; they run one after the other, the collection window included. + [Fact] + public async Task Overlapping_Functional_Requests_Run_One_After_The_Other() + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var busEcus = OpenClassic(session, 1); + + // The ECU answers the DID it was asked for. + busEcus.FrameObserved += (_, e) => + { + if (e.CanFrame.ID != unchecked((int)FunctionalTxId)) return; + var req = e.CanFrame.Data.ToArray(); + busEcus.Transmit(SingleFrameFrom(Ecu1, new byte[] { 0x62, req[2], req[3], req[3] })); + }; + + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), ownsClient: true); + + using var cts = new CancellationTokenSource(ShortTimeout); + var first = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, Window, cts.Token); + var second = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x91 }, Window, cts.Token); + var firstResponses = await first; + var secondResponses = await second; + + firstResponses.Should().ContainSingle().Which.Response.Should().Equal(0x62, 0xF1, 0x90, 0x90); + secondResponses.Should().ContainSingle().Which.Response.Should().Equal(0x62, 0xF1, 0x91, 0x91); + } + [Fact] public async Task An_Unsuppressed_TesterPresent_Needs_A_Window() { From 2769a4b9be9831b8690c13d721b29991cf3e3247 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:47:04 +0200 Subject: [PATCH 05/55] fix(uds): a functional call queued behind another does not send after dispose, and a positive response must echo the sub-function Codex and Bugbot on #150. A call queued on the functional client's request lock had passed its disposal check before the wait; Dispose now cancels a lifetime token linked into the wait, and the lock holder rechecks disposal before sending. And for a service with a sub-function, a positive response is this request's only if it echoes the sub-function -- a late answer to an Extended-session request no longer passes as the answer to the next one for Default. Both mutation-checked. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 35 ++++++++--- .../TestCases/Uds/UdsFunctionalClientTests.cs | 59 +++++++++++++++++++ 2 files changed, 86 insertions(+), 8 deletions(-) diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index 4e709aa..0caa715 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -29,6 +29,10 @@ public sealed class UdsFunctionalClient : IDisposable // with the same SID would each collect the other's answers (Codex on #150), as the // physical client's request lock prevents there. private readonly SemaphoreSlim _requestLock = new(1, 1); + // Cancels a wait on the lock, and a send or window in progress, when the client is + // disposed: a call queued behind another must not go out on a disposed client (Codex and + // Bugbot on #150). + private readonly CancellationTokenSource _lifetimeCts = new(); private int _disposed; private UdsFunctionalClient(IsoTpFunctionalClient client, bool ownsClient) @@ -62,10 +66,16 @@ public async Task> SendRawAsync(ReadOnlyMem if (request.Length == 0) throw new ArgumentException("Request must contain at least a SID byte.", nameof(request)); - await _requestLock.WaitAsync(cancellationToken).ConfigureAwait(false); + using var linked = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, _lifetimeCts.Token); + var linkedToken = linked.Token; + + await _requestLock.WaitAsync(linkedToken).ConfigureAwait(false); try { - return await SendRawLockedAsync(request, window, cancellationToken).ConfigureAwait(false); + // Disposed while queued behind another call: the lock is released, not used. + ThrowIfDisposed(); + return await SendRawLockedAsync(request, window, linkedToken).ConfigureAwait(false); } finally { @@ -87,15 +97,22 @@ private async Task> SendRawLockedAsync(Read .ConfigureAwait(false); byte sid = request.Span[0]; byte positiveSid = (byte)(sid + 0x40); + // A service with a sub-function echoes it in the positive response (bit 7 cleared), so + // a late answer to an earlier request for another sub-function -- a session change to + // Extended answered during the next one to Default -- is told apart (Codex on #150). + int subFunction = HasSubFunction(sid) && request.Length >= 2 + ? request.Span[1] & ~SuppressPositiveResponseBit + : -1; var responses = new List(raw.Count); foreach (var r in raw) { // Correlated to this request the way the physical client correlates: the positive - // response SID, or a negative response echoing the request's SID (Codex on #150). + // response SID (and sub-function), or a negative response echoing the request's SID. var data = r.Data; - bool ours = data.Length >= 1 && data[0] == positiveSid - || data.Length >= 3 && data[0] == NegativeResponseSid && data[1] == sid; - if (ours) responses.Add(new UdsFunctionalResponse(r.SourceCanId, data)); + bool positive = data.Length >= 1 && data[0] == positiveSid + && (subFunction < 0 || data.Length >= 2 && data[1] == subFunction); + bool negative = data.Length >= 3 && data[0] == NegativeResponseSid && data[1] == sid; + if (positive || negative) responses.Add(new UdsFunctionalResponse(r.SourceCanId, data)); } return responses; } @@ -154,8 +171,10 @@ private void ThrowIfDisposed() public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + try { _lifetimeCts.Cancel(); } catch (ObjectDisposedException) { /* torn down elsewhere */ } if (_ownsClient) _client.Dispose(); - // Not disposed: a call still inside its window releases it on the way out, and a - // SemaphoreSlim without a wait handle holds nothing that needs disposing. + _lifetimeCts.Dispose(); + // The lock is not disposed: a call still inside its window releases it on the way + // out, and a SemaphoreSlim without a wait handle holds nothing that needs disposing. } } diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index 2a2defe..5d2b217 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -137,6 +137,65 @@ public async Task Overlapping_Functional_Requests_Run_One_After_The_Other() secondResponses.Should().ContainSingle().Which.Response.Should().Equal(0x62, 0xF1, 0x91, 0x91); } + // Codex and Bugbot on #150: a call queued behind another's window does not go out on a + // client disposed in the meantime. + [Fact] + public async Task A_Call_Queued_Behind_Another_Does_Not_Send_After_Dispose() + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var busEcus = OpenClassic(session, 1); + + int requestsSeen = 0; + busEcus.FrameObserved += (_, e) => + { + if (e.CanFrame.ID == unchecked((int)FunctionalTxId)) Interlocked.Increment(ref requestsSeen); + }; + + var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), ownsClient: true); + + using var cts = new CancellationTokenSource(ShortTimeout); + var first = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, TimeSpan.FromMilliseconds(300), cts.Token); + var second = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x91 }, TimeSpan.FromMilliseconds(300), cts.Token); + await Task.Delay(50); // the first is in its window, the second queued behind it + + functional.Dispose(); + + Func act = () => second; + await act.Should().ThrowAsync() + .Where(ex => ex is ObjectDisposedException || ex is OperationCanceledException, + "the queued call must not proceed on a disposed client"); + Func firstAct = () => first; + await firstAct.Should().ThrowAsync(); // its window was cut short by the disposal + requestsSeen.Should().Be(1, "only the first request reached the wire"); + } + + // Codex on #150: a service with a sub-function echoes it; a late answer for another + // sub-function is not this request's. + [Fact] + public async Task A_Positive_Response_For_Another_SubFunction_Is_Not_Attributed() + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var busEcus = OpenClassic(session, 1); + + // Asked for Default (0x01), the ECU's late answer says Extended (0x03). + var stale = SingleFrameFrom(Ecu1, new byte[] { 0x50, 0x03, 0x00, 0x32, 0x01, 0xF4 }); + busEcus.FrameObserved += (_, e) => + { + if (e.CanFrame.ID == unchecked((int)FunctionalTxId)) busEcus.Transmit(stale); + }; + + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), ownsClient: true); + + using var cts = new CancellationTokenSource(ShortTimeout); + var responses = await functional.DiagnosticSessionControlAsync(UdsSessionType.Default, Window, cts.Token); + + responses.Should().BeEmpty("an answer for another sub-function is an earlier request's"); + } + [Fact] public async Task An_Unsuppressed_TesterPresent_Needs_A_Window() { From e51479133037ed9b466d55719f25b128f553e241 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:55:26 +0200 Subject: [PATCH 06/55] fix(uds): correlate functional answers on the echoed parameters, and wait out a suppressed send's window before a same-service request Codex on #150, twice more. A positive response echoes the request's leading bytes -- the sub-function, a DID, a routine identifier, a block counter -- and the functional client now requires them to match, so a late answer for DID F190 is not attributed to the request for F191. And a suppressed raw send may still draw a negative response up to P2 after it; the physical client's next request for the same service now waits that window out rather than taking the negative response as its own. Both mutation-checked. The CodeQL note on the dispose test is taken: the client is a using. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/README.md | 11 +++-- src/CanKit.Pro.Uds/UdsClientImpl.cs | 31 +++++++++++++- src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 42 ++++++++++++++----- .../TestCases/Uds/UdsClientTests.cs | 30 +++++++++++++ .../TestCases/Uds/UdsFunctionalClientTests.cs | 26 +++++++++++- 5 files changed, 124 insertions(+), 16 deletions(-) diff --git a/src/CanKit.Pro.Uds/README.md b/src/CanKit.Pro.Uds/README.md index 6bb12f2..45da290 100644 --- a/src/CanKit.Pro.Uds/README.md +++ b/src/CanKit.Pro.Uds/README.md @@ -96,11 +96,16 @@ await uds.DownloadAsync( path, never a measurement of how late the client noticed (#57). * `DiagnosticSessionControlAsync(byte)` rejects 0x00 and any value with bit 7 set rather than masking it; `SendRawAsync` sends a request with suppressPosRspMsgIndication set without - waiting for a response and returns empty (#57). + waiting for a response and returns empty. A suppressed send may still draw a negative + response, up to P2 after it: the next request for the same service waits that window out + rather than taking the negative response as its own (#57). * Functional addressing: `UdsFunctionalClient` wraps an `IsoTpFunctionalClient` — one request on the functional identifier, every ECU's Single-Frame answer collected within a window and read - as UDS (`UdsFunctionalResponse` with source identifier, bytes, `IsNegative` and the NRC). The - keep-alive to everyone is `TesterPresentAsync()` (`3E 80`, not collected for) (#57). + as UDS (`UdsFunctionalResponse` with source identifier, bytes, `IsNegative` and the NRC). Only + answers correlated to the request are attributed: a positive response echoing the request's + leading bytes (sub-function, DID, routine identifier, block counter), or a negative response + naming the service. Calls run one at a time. The keep-alive to everyone is + `TesterPresentAsync()` (`3E 80`, not collected for) (#57). * `Dispose` waits up to five seconds for a request in flight to release the request lock; a holder that outlasts the wait keeps an undisposed semaphore, so its eventual release does not throw into an operation that was merely slow (#57). diff --git a/src/CanKit.Pro.Uds/UdsClientImpl.cs b/src/CanKit.Pro.Uds/UdsClientImpl.cs index 42f5798..d673068 100644 --- a/src/CanKit.Pro.Uds/UdsClientImpl.cs +++ b/src/CanKit.Pro.Uds/UdsClientImpl.cs @@ -42,6 +42,12 @@ internal sealed class UdsClientImpl : IUdsClient /// How long Dispose waits for a request in flight to release the lock. internal TimeSpan DisposeLockTimeout { get; set; } = TimeSpan.FromSeconds(5); + // A suppressed send draws no positive response but may still draw a negative one, up to P2 + // after it went out. A following request for the same service would take that negative + // response as its own; it waits until the window is over instead (Codex on #150). + private int _suppressedSid = -1; + private long _suppressedUntil; + private readonly IIsoTpChannel _channel; private readonly bool _ownsChannel; private readonly UdsClientOptions _options; @@ -430,7 +436,10 @@ private async Task SendWithoutResponseAsync(byte[] request, CancellationToken ca await _requestLock.WaitAsync(linkedToken).ConfigureAwait(false); try { - await _channel.SendAsync(request, linkedToken).ConfigureAwait(false); + var stamps = await _channel.SendWithTransmitStampAsync(request, linkedToken).ConfigureAwait(false); + var sent = stamps.LastFrameTransmitTimestamp > 0 ? stamps.LastFrameTransmitTimestamp : Stopwatch.GetTimestamp(); + _suppressedSid = request[0]; + _suppressedUntil = sent + (long)(_options.P2ClientMax.TotalSeconds * Stopwatch.Frequency); } finally { @@ -438,6 +447,24 @@ private async Task SendWithoutResponseAsync(byte[] request, CancellationToken ca } } + // Under the request lock. A request for the service of the latest suppressed send waits + // out that send's P2, draining what arrives, so a late negative response to the suppressed + // send cannot be taken for this request's. + private async Task WaitOutSuppressedResponseWindowAsync(UdsServiceId serviceId, CancellationToken linkedToken) + { + if (_suppressedSid != (byte)serviceId) return; + var remaining = ElapsedUntil(_suppressedUntil); + if (remaining > TimeSpan.Zero) + await Task.Delay(remaining, linkedToken).ConfigureAwait(false); + _suppressedSid = -1; + } + + private static TimeSpan ElapsedUntil(long timestamp) + { + var ticks = timestamp - Stopwatch.GetTimestamp(); + return ticks <= 0 ? TimeSpan.Zero : TimeSpan.FromSeconds((double)ticks / Stopwatch.Frequency); + } + // The services whose second byte is a sub-function parameter, and so carry the // suppressPosRspMsgIndication bit (ISO 14229-1 table 2, "sub-function" column). private static bool HasSubFunction(UdsServiceId sid) => (byte)sid switch @@ -845,6 +872,8 @@ private async Task ExecuteCoreAsync(UdsServiceId serviceId, byte[] reque private async Task ExchangeOnceAsync(UdsServiceId serviceId, byte[] request, CancellationToken linkedToken) { + await WaitOutSuppressedResponseWindowAsync(serviceId, linkedToken).ConfigureAwait(false); + // Drop any late reply left over from a previous aborted/timed-out wait before we put a // new request on the wire. SID correlation alone is insufficient when the next request // uses the same service (the stale positive response SID would match). diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index 0caa715..d4e4ae2 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -95,22 +95,19 @@ private async Task> SendRawLockedAsync(Read var raw = await _client.SendAndCollectAsync(request, window, cancellationToken) .ConfigureAwait(false); - byte sid = request.Span[0]; + var req = request.Span; + byte sid = req[0]; byte positiveSid = (byte)(sid + 0x40); - // A service with a sub-function echoes it in the positive response (bit 7 cleared), so - // a late answer to an earlier request for another sub-function -- a session change to - // Extended answered during the next one to Default -- is told apart (Codex on #150). - int subFunction = HasSubFunction(sid) && request.Length >= 2 - ? request.Span[1] & ~SuppressPositiveResponseBit - : -1; + // A positive response echoes the request's leading parameter bytes -- the sub-function + // (bit 7 cleared), a DID, a routine identifier, a block counter -- so a late answer to + // an earlier request for another parameter, arriving in this window, is told apart + // (Codex on #150, twice). How many bytes, per service, is in EchoedRequestBytes. + int echoed = Math.Min(EchoedRequestBytes(sid), req.Length - 1); var responses = new List(raw.Count); foreach (var r in raw) { - // Correlated to this request the way the physical client correlates: the positive - // response SID (and sub-function), or a negative response echoing the request's SID. var data = r.Data; - bool positive = data.Length >= 1 && data[0] == positiveSid - && (subFunction < 0 || data.Length >= 2 && data[1] == subFunction); + bool positive = data.Length >= 1 + echoed && data[0] == positiveSid && EchoMatches(req, data, echoed); bool negative = data.Length >= 3 && data[0] == NegativeResponseSid && data[1] == sid; if (positive || negative) responses.Add(new UdsFunctionalResponse(r.SourceCanId, data)); } @@ -153,6 +150,29 @@ public async Task> DiagnosticSessionControl window, cancellationToken).ConfigureAwait(false); } + // How many request bytes after the SID a positive response repeats, for the services + // whose response layout starts with them (ISO 14229-1, the response tables of each + // service): the sub-function where there is one, then a DID (0x22 the first, 0x2E), the + // routine identifier (0x31), the block sequence counter (0x36). + private static int EchoedRequestBytes(byte sid) => sid switch + { + 0x22 or 0x2E => 2, + 0x31 => 3, + 0x36 => 1, + _ => HasSubFunction(sid) ? 1 : 0, + }; + + private static bool EchoMatches(ReadOnlySpan request, byte[] response, int echoed) + { + for (int i = 0; i < echoed; i++) + { + byte expected = request[1 + i]; + if (i == 0 && HasSubFunction(request[0])) expected &= unchecked((byte)~SuppressPositiveResponseBit); + if (response[1 + i] != expected) return false; + } + return true; + } + // Mirrors UdsClientImpl.HasSubFunction (ISO 14229-1 table 2). private static bool HasSubFunction(byte sid) => sid switch { diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs index 2874432..282fe7b 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs @@ -488,6 +488,36 @@ public async Task SendRaw_With_The_Suppress_Bit_Does_Not_Wait_For_A_Response() } } + // Codex on #150: a suppressed send may still draw a negative response, up to P2 after it. + // The next request for the same service waits that window out rather than taking the + // negative response as its own. + [Fact] + public async Task A_Late_Negative_Response_To_A_Suppressed_Send_Is_Not_The_Next_Requests() + { + var (client, ecu, dispose) = BuildPair( + e => e.On(0x3E, req => + { + if ((req[1] & 0x80) != 0) + { + Thread.Sleep(100); // 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); + } + } + // NRC 0x21 asks for a repeat; the client repeats, up to MaxBusyRepeatRequests. [Fact] public async Task BusyRepeatRequest_Is_Repeated_Until_The_Server_Answers() diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index 5d2b217..3b3d6c5 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -152,7 +152,7 @@ public async Task A_Call_Queued_Behind_Another_Does_Not_Send_After_Dispose() if (e.CanFrame.ID == unchecked((int)FunctionalTxId)) Interlocked.Increment(ref requestsSeen); }; - var functional = UdsFunctionalClient.Create( + using var functional = UdsFunctionalClient.Create( // a second Dispose is idempotent IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), ownsClient: true); using var cts = new CancellationTokenSource(ShortTimeout); @@ -196,6 +196,30 @@ public async Task A_Positive_Response_For_Another_SubFunction_Is_Not_Attributed( responses.Should().BeEmpty("an answer for another sub-function is an earlier request's"); } + // Codex on #150: a positive response echoes the request's DID; a late answer for another + // DID arriving in this window is an earlier request's. + [Fact] + public async Task A_Positive_Response_For_Another_Did_Is_Not_Attributed() + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var busEcus = OpenClassic(session, 1); + + var stale = SingleFrameFrom(Ecu1, new byte[] { 0x62, 0xF1, 0x90, 0x01 }); + busEcus.FrameObserved += (_, e) => + { + if (e.CanFrame.ID == unchecked((int)FunctionalTxId)) busEcus.Transmit(stale); + }; + + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), ownsClient: true); + + using var cts = new CancellationTokenSource(ShortTimeout); + var responses = await functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x91 }, Window, cts.Token); + + responses.Should().BeEmpty("the answer names DID F190, the request asked for F191"); + } + [Fact] public async Task An_Unsuppressed_TesterPresent_Needs_A_Window() { From 15882ec1b71cbf215e4cbb455db3aaf8459fff1d Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:05:09 +0200 Subject: [PATCH 07/55] fix(uds): keep a suppressed send's window per service, on both clients, and refuse a functional multi-DID read Codex and Bugbot on #150. The physical client tracked one suppressed send; a suppressed send for another service in between shortened the first's window. Both clients now share a per-service table: a suppressed send notes its service and the instant its P2 ends, and the next call for that service waits the window out -- the functional client too, which released its lock at once before, so a late negative answer to 3E 80 landed in the next TesterPresent's window. And a functional ReadDataByIdentifier for more than one DID is refused: a Single Frame cannot carry the answer, and only one DID would be correlated. Each mutation-checked. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/README.md | 8 +- .../SuppressedResponseWindows.cs | 50 ++++++ src/CanKit.Pro.Uds/UdsClientImpl.cs | 28 +-- src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 30 +++- .../ApiApprovals/CanKit.Pro.Uds.approved.txt | 2 +- .../CanKit.Pro.Uds.received [conflicted].txt | 161 ++++++++++++++++++ .../TestCases/Uds/UdsClientTests.cs | 31 ++++ .../TestCases/Uds/UdsFunctionalClientTests.cs | 45 +++++ 8 files changed, 326 insertions(+), 29 deletions(-) create mode 100644 src/CanKit.Pro.Uds/SuppressedResponseWindows.cs create mode 100644 tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Uds.received [conflicted].txt diff --git a/src/CanKit.Pro.Uds/README.md b/src/CanKit.Pro.Uds/README.md index 45da290..cfed4bf 100644 --- a/src/CanKit.Pro.Uds/README.md +++ b/src/CanKit.Pro.Uds/README.md @@ -98,14 +98,16 @@ await uds.DownloadAsync( masking it; `SendRawAsync` sends a request with suppressPosRspMsgIndication set without waiting for a response and returns empty. A suppressed send may still draw a negative response, up to P2 after it: the next request for the same service waits that window out - rather than taking the negative response as its own (#57). + rather than taking the negative response as its own, one window per service (#57). * Functional addressing: `UdsFunctionalClient` wraps an `IsoTpFunctionalClient` — one request on the functional identifier, every ECU's Single-Frame answer collected within a window and read as UDS (`UdsFunctionalResponse` with source identifier, bytes, `IsNegative` and the NRC). Only answers correlated to the request are attributed: a positive response echoing the request's leading bytes (sub-function, DID, routine identifier, block counter), or a negative response - naming the service. Calls run one at a time. The keep-alive to everyone is - `TesterPresentAsync()` (`3E 80`, not collected for) (#57). + naming the service. Calls run one at a time; a call for a service with a suppressed send + still open waits that window out first (`Create`'s `suppressedResponseWindow`, default P2), + and a read for more than one DID is refused, a Single Frame holding no more. The keep-alive + to everyone is `TesterPresentAsync()` (`3E 80`, not collected for) (#57). * `Dispose` waits up to five seconds for a request in flight to release the request lock; a holder that outlasts the wait keeps an undisposed semaphore, so its eventual release does not throw into an operation that was merely slow (#57). diff --git a/src/CanKit.Pro.Uds/SuppressedResponseWindows.cs b/src/CanKit.Pro.Uds/SuppressedResponseWindows.cs new file mode 100644 index 0000000..c3f9ffd --- /dev/null +++ b/src/CanKit.Pro.Uds/SuppressedResponseWindows.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace CanKit.Pro.Uds; + +/// +/// The response windows still open after suppressed sends, per service. A suppressed send +/// draws no positive response but may still draw a negative one, up to P2 after it went out; +/// the next request for the same service waits that window out rather than taking the +/// negative response as its own (Codex on #150). One entry per service, since a suppressed +/// send for another service in between must not shorten the first's window. +/// +internal sealed class SuppressedResponseWindows +{ + private readonly Dictionary _until = new(); + private readonly object _gate = new(); + + /// Notes that a suppressed send for went out at + /// and may be answered negatively for . + public void Note(byte sid, long sentTimestamp, TimeSpan window) + { + var until = sentTimestamp + (long)(window.TotalSeconds * Stopwatch.Frequency); + lock (_gate) + { + if (!_until.TryGetValue(sid, out var existing) || existing < until) + _until[sid] = until; + } + } + + /// Waits until every window for has closed, then forgets it. + public async Task WaitOutAsync(byte sid, CancellationToken cancellationToken) + { + long until; + lock (_gate) + { + if (!_until.TryGetValue(sid, out until)) return; + } + var ticks = until - Stopwatch.GetTimestamp(); + if (ticks > 0) + await Task.Delay(TimeSpan.FromSeconds((double)ticks / Stopwatch.Frequency), cancellationToken) + .ConfigureAwait(false); + lock (_gate) + { + if (_until.TryGetValue(sid, out var current) && current == until) _until.Remove(sid); + } + } +} diff --git a/src/CanKit.Pro.Uds/UdsClientImpl.cs b/src/CanKit.Pro.Uds/UdsClientImpl.cs index d673068..119fb3f 100644 --- a/src/CanKit.Pro.Uds/UdsClientImpl.cs +++ b/src/CanKit.Pro.Uds/UdsClientImpl.cs @@ -45,8 +45,7 @@ internal sealed class UdsClientImpl : IUdsClient // A suppressed send draws no positive response but may still draw a negative one, up to P2 // after it went out. A following request for the same service would take that negative // response as its own; it waits until the window is over instead (Codex on #150). - private int _suppressedSid = -1; - private long _suppressedUntil; + private readonly SuppressedResponseWindows _suppressedWindows = new(); private readonly IIsoTpChannel _channel; private readonly bool _ownsChannel; @@ -438,8 +437,7 @@ private async Task SendWithoutResponseAsync(byte[] request, CancellationToken ca { var stamps = await _channel.SendWithTransmitStampAsync(request, linkedToken).ConfigureAwait(false); var sent = stamps.LastFrameTransmitTimestamp > 0 ? stamps.LastFrameTransmitTimestamp : Stopwatch.GetTimestamp(); - _suppressedSid = request[0]; - _suppressedUntil = sent + (long)(_options.P2ClientMax.TotalSeconds * Stopwatch.Frequency); + _suppressedWindows.Note(request[0], sent, _options.P2ClientMax); } finally { @@ -447,23 +445,11 @@ private async Task SendWithoutResponseAsync(byte[] request, CancellationToken ca } } - // Under the request lock. A request for the service of the latest suppressed send waits - // out that send's P2, draining what arrives, so a late negative response to the suppressed - // send cannot be taken for this request's. - private async Task WaitOutSuppressedResponseWindowAsync(UdsServiceId serviceId, CancellationToken linkedToken) - { - if (_suppressedSid != (byte)serviceId) return; - var remaining = ElapsedUntil(_suppressedUntil); - if (remaining > TimeSpan.Zero) - await Task.Delay(remaining, linkedToken).ConfigureAwait(false); - _suppressedSid = -1; - } - - private static TimeSpan ElapsedUntil(long timestamp) - { - var ticks = timestamp - Stopwatch.GetTimestamp(); - return ticks <= 0 ? TimeSpan.Zero : TimeSpan.FromSeconds((double)ticks / Stopwatch.Frequency); - } + // Under the request lock. A request for a service with a suppressed send still open waits + // out that send's P2 -- the discard that follows drains what arrived -- so a late negative + // response to the suppressed send cannot be taken for this request's. + private Task WaitOutSuppressedResponseWindowAsync(UdsServiceId serviceId, CancellationToken linkedToken) + => _suppressedWindows.WaitOutAsync((byte)serviceId, linkedToken); // The services whose second byte is a sub-function parameter, and so carry the // suppressPosRspMsgIndication bit (ISO 14229-1 table 2, "sub-function" column). diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index d4e4ae2..1540111 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using CanKit.Pro.IsoTp; @@ -25,6 +26,8 @@ public sealed class UdsFunctionalClient : IDisposable private readonly IsoTpFunctionalClient _client; private readonly bool _ownsClient; + private readonly TimeSpan _suppressedResponseWindow; + private readonly SuppressedResponseWindows _suppressedWindows = new(); // One request on the wire at a time, its collection window included: overlapping calls // with the same SID would each collect the other's answers (Codex on #150), as the // physical client's request lock prevents there. @@ -35,18 +38,25 @@ public sealed class UdsFunctionalClient : IDisposable private readonly CancellationTokenSource _lifetimeCts = new(); private int _disposed; - private UdsFunctionalClient(IsoTpFunctionalClient client, bool ownsClient) + private UdsFunctionalClient(IsoTpFunctionalClient client, bool ownsClient, TimeSpan suppressedResponseWindow) { _client = client ?? throw new ArgumentNullException(nameof(client)); + if (suppressedResponseWindow <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(suppressedResponseWindow), "The window must be positive."); _ownsClient = ownsClient; + _suppressedResponseWindow = suppressedResponseWindow; } /// /// Wraps an open . With , - /// disposing this client disposes it. + /// disposing this client disposes it. is how + /// long after a suppressed send an ECU may still answer it negatively -- P2 -- and so how + /// long the next call for the same service waits before it collects; the default is + /// . /// - public static UdsFunctionalClient Create(IsoTpFunctionalClient client, bool ownsClient = false) - => new(client, ownsClient); + public static UdsFunctionalClient Create(IsoTpFunctionalClient client, bool ownsClient = false, + TimeSpan? suppressedResponseWindow = null) + => new(client, ownsClient, suppressedResponseWindow ?? UdsClientOptions.DefaultP2); /// The underlying ISO-TP functional client. public IsoTpFunctionalClient Channel => _client; @@ -75,6 +85,9 @@ public async Task> SendRawAsync(ReadOnlyMem { // Disposed while queued behind another call: the lock is released, not used. ThrowIfDisposed(); + // A suppressed send for this service may still be answered negatively; that answer + // must not land in this call's window (Codex on #150). + await _suppressedWindows.WaitOutAsync(request.Span[0], linkedToken).ConfigureAwait(false); return await SendRawLockedAsync(request, window, linkedToken).ConfigureAwait(false); } finally @@ -90,9 +103,18 @@ private async Task> SendRawLockedAsync(Read && (request.Span[1] & SuppressPositiveResponseBit) != 0) { await _client.SendAsync(request, cancellationToken).ConfigureAwait(false); + _suppressedWindows.Note(request.Span[0], Stopwatch.GetTimestamp(), _suppressedResponseWindow); return Array.Empty(); } + // A read for more than one DID is answered with all of them in one PDU, which a Single + // Frame cannot hold with their data; and only the first DID would be correlated here, + // so two such reads sharing it could take each other's late answers (Codex on #150). + if (request.Span[0] == (byte)UdsServiceId.ReadDataByIdentifier && request.Length > 3) + throw new ArgumentException( + "A functional ReadDataByIdentifier reads one DID: a Single Frame cannot carry more, and only one is correlated.", + nameof(request)); + var raw = await _client.SendAndCollectAsync(request, window, cancellationToken) .ConfigureAwait(false); var req = request.Span; diff --git a/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Uds.approved.txt b/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Uds.approved.txt index 8988db5..cd84597 100644 --- a/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Uds.approved.txt +++ b/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Uds.approved.txt @@ -69,7 +69,7 @@ namespace CanKit.Pro.Uds public void Dispose() { } public System.Threading.Tasks.Task> SendRawAsync(System.ReadOnlyMemory request, System.TimeSpan window, System.Threading.CancellationToken cancellationToken = default) { } public System.Threading.Tasks.Task> TesterPresentAsync(bool suppressPositiveResponse = true, System.TimeSpan? window = default, System.Threading.CancellationToken cancellationToken = default) { } - public static CanKit.Pro.Uds.UdsFunctionalClient Create(CanKit.Pro.IsoTp.IsoTpFunctionalClient client, bool ownsClient = false) { } + public static CanKit.Pro.Uds.UdsFunctionalClient Create(CanKit.Pro.IsoTp.IsoTpFunctionalClient client, bool ownsClient = false, System.TimeSpan? suppressedResponseWindow = default) { } } public sealed class UdsFunctionalResponse { diff --git a/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Uds.received [conflicted].txt b/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Uds.received [conflicted].txt new file mode 100644 index 0000000..cd84597 --- /dev/null +++ b/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Uds.received [conflicted].txt @@ -0,0 +1,161 @@ +namespace CanKit.Pro.Uds +{ + public interface IUdsClient : System.IDisposable + { + CanKit.Pro.IsoTp.IIsoTpChannel Channel { get; } + byte CurrentSession { get; } + CanKit.Pro.Uds.UdsClientOptions Options { get; } + System.Threading.Tasks.Task DiagnosticSessionControlAsync(CanKit.Pro.Uds.UdsSessionType session, System.Threading.CancellationToken cancellationToken = default); + System.Threading.Tasks.Task DiagnosticSessionControlAsync(byte sessionType, System.Threading.CancellationToken cancellationToken = default); + System.Threading.Tasks.Task DownloadAsync(byte dataFormatIdentifier, byte addressAndLengthFormatIdentifier, System.ReadOnlyMemory memoryAddress, System.ReadOnlyMemory memorySize, System.ReadOnlyMemory data, System.Threading.CancellationToken cancellationToken = default); + System.Threading.Tasks.Task EcuResetAsync(CanKit.Pro.Uds.UdsEcuResetType resetType, System.Threading.CancellationToken cancellationToken = default); + System.Threading.Tasks.Task ReadDataByIdentifierAsync(ushort dataIdentifier, System.Threading.CancellationToken cancellationToken = default); + System.Threading.Tasks.Task> ReadDataByIdentifierAsync(System.Collections.Generic.IReadOnlyList dataIdentifiers, System.Collections.Generic.IReadOnlyDictionary dataRecordLengths, System.Threading.CancellationToken cancellationToken = default); + System.Threading.Tasks.Task RequestDownloadAsync(byte dataFormatIdentifier, byte addressAndLengthFormatIdentifier, System.ReadOnlyMemory memoryAddress, System.ReadOnlyMemory memorySize, System.Threading.CancellationToken cancellationToken = default); + System.Threading.Tasks.Task RequestTransferExitAsync(System.ReadOnlyMemory transferRequestParameterRecord = default, System.Threading.CancellationToken cancellationToken = default); + System.Threading.Tasks.Task RequestUploadAsync(byte dataFormatIdentifier, byte addressAndLengthFormatIdentifier, System.ReadOnlyMemory memoryAddress, System.ReadOnlyMemory memorySize, System.Threading.CancellationToken cancellationToken = default); + System.Threading.Tasks.Task RoutineControlAsync(CanKit.Pro.Uds.UdsRoutineControlType routineType, ushort routineIdentifier, System.ReadOnlyMemory routineControlOptionRecord = default, System.Threading.CancellationToken cancellationToken = default); + System.Threading.Tasks.Task SecurityAccessAsync(byte requestSeedLevel, System.Func computeKey, System.Threading.CancellationToken cancellationToken = default); + System.Threading.Tasks.Task SendRawAsync(System.ReadOnlyMemory request, System.Threading.CancellationToken cancellationToken = default); + System.IDisposable StartTesterPresentKeepAlive(System.TimeSpan? period = default); + System.Threading.Tasks.Task TesterPresentAsync(bool suppressPositiveResponse = true, System.Threading.CancellationToken cancellationToken = default); + System.Threading.Tasks.Task TransferDataAsync(byte blockSequenceCounter, System.ReadOnlyMemory data, System.Threading.CancellationToken cancellationToken = default); + System.Threading.Tasks.Task UploadAsync(byte dataFormatIdentifier, byte addressAndLengthFormatIdentifier, System.ReadOnlyMemory memoryAddress, System.ReadOnlyMemory memorySize, System.Threading.CancellationToken cancellationToken = default); + System.Threading.Tasks.Task WriteDataByIdentifierAsync(ushort dataIdentifier, System.ReadOnlyMemory data, System.Threading.CancellationToken cancellationToken = default); + } + public static class UdsClient + { + public static CanKit.Pro.Uds.IUdsClient Create(CanKit.Pro.IsoTp.IIsoTpChannel channel, CanKit.Pro.Uds.UdsClientOptions? options = null, bool leaveOpen = true) { } + } + public sealed class UdsClientOptions + { + public static readonly System.TimeSpan DefaultP2; + public static readonly System.TimeSpan DefaultP2Star; + public static readonly System.TimeSpan DefaultTesterPresentPeriod; + public UdsClientOptions() { } + public System.TimeSpan BusyRepeatRequestDelay { get; init; } + public bool KeepAliveSuppressPositiveResponse { get; init; } + public int MaxBusyRepeatRequests { get; init; } + public int MaxResponsePendingCount { get; init; } + public System.TimeSpan P2ClientMax { get; init; } + public System.TimeSpan P2StarClientMax { get; init; } + public System.TimeSpan TesterPresentPeriod { get; init; } + public CanKit.Pro.Uds.UdsClientOptions With(System.TimeSpan? p2ClientMax = default, System.TimeSpan? p2StarClientMax = default, int? maxResponsePendingCount = default, System.TimeSpan? testerPresentPeriod = default, bool? keepAliveSuppressPositiveResponse = default, int? maxBusyRepeatRequests = default, System.TimeSpan? busyRepeatRequestDelay = default) { } + } + public readonly struct UdsDownloadResponse + { + public UdsDownloadResponse(byte lengthFormatIdentifier, ulong maxNumberOfBlockLength) { } + public byte LengthFormatIdentifier { get; } + public ulong MaxNumberOfBlockLength { get; } + } + public enum UdsEcuResetType : byte + { + HardReset = 1, + KeyOffOnReset = 2, + SoftReset = 3, + EnableRapidPowerShutDown = 4, + DisableRapidPowerShutDown = 5, + } + public class UdsException : CanKit.Core.Exceptions.CanKitException + { + public UdsException(string message) { } + protected UdsException(CanKit.Core.Exceptions.CanKitErrorCode errorCode, string message) { } + public UdsException(string message, System.Exception innerException) { } + } + public sealed class UdsFunctionalClient : System.IDisposable + { + public CanKit.Pro.IsoTp.IsoTpFunctionalClient Channel { get; } + public System.Threading.Tasks.Task> DiagnosticSessionControlAsync(CanKit.Pro.Uds.UdsSessionType session, System.TimeSpan window, System.Threading.CancellationToken cancellationToken = default) { } + public void Dispose() { } + public System.Threading.Tasks.Task> SendRawAsync(System.ReadOnlyMemory request, System.TimeSpan window, System.Threading.CancellationToken cancellationToken = default) { } + public System.Threading.Tasks.Task> TesterPresentAsync(bool suppressPositiveResponse = true, System.TimeSpan? window = default, System.Threading.CancellationToken cancellationToken = default) { } + public static CanKit.Pro.Uds.UdsFunctionalClient Create(CanKit.Pro.IsoTp.IsoTpFunctionalClient client, bool ownsClient = false, System.TimeSpan? suppressedResponseWindow = default) { } + } + public sealed class UdsFunctionalResponse + { + public UdsFunctionalResponse(uint sourceCanId, byte[] response) { } + public bool IsNegative { get; } + public byte? NegativeResponseCode { get; } + public byte[] Response { get; } + public uint SourceCanId { get; } + } + public enum UdsNegativeResponseCode : byte + { + GeneralReject = 16, + ServiceNotSupported = 17, + SubFunctionNotSupported = 18, + IncorrectMessageLengthOrInvalidFormat = 19, + BusyRepeatRequest = 33, + ConditionsNotCorrect = 34, + RequestSequenceError = 36, + RequestOutOfRange = 49, + SecurityAccessDenied = 51, + InvalidKey = 53, + ExceedNumberOfAttempts = 54, + RequiredTimeDelayNotExpired = 55, + UploadDownloadNotAccepted = 112, + TransferDataSuspended = 113, + GeneralProgrammingFailure = 114, + WrongBlockSequenceCounter = 115, + RequestCorrectlyReceivedResponsePending = 120, + SubFunctionNotSupportedInActiveSession = 126, + ServiceNotSupportedInActiveSession = 127, + } + public sealed class UdsNegativeResponseException : CanKit.Pro.Uds.UdsException + { + public UdsNegativeResponseException(CanKit.Pro.Uds.UdsServiceId requestedService, byte code) { } + public byte Code { get; } + public CanKit.Pro.Uds.UdsNegativeResponseCode? CodeAsEnum { get; } + public string CodeName { get; } + public CanKit.Pro.Uds.UdsServiceId RequestedService { get; } + } + public sealed class UdsProtocolException : CanKit.Pro.Uds.UdsException + { + public UdsProtocolException(string message) { } + } + public enum UdsRoutineControlType : byte + { + StartRoutine = 1, + StopRoutine = 2, + RequestRoutineResults = 3, + } + public enum UdsServiceId : byte + { + DiagnosticSessionControl = 16, + EcuReset = 17, + ReadDataByIdentifier = 34, + SecurityAccess = 39, + WriteDataByIdentifier = 46, + RoutineControl = 49, + RequestDownload = 52, + RequestUpload = 53, + TransferData = 54, + RequestTransferExit = 55, + TesterPresent = 62, + } + public enum UdsSessionType : byte + { + Default = 1, + Programming = 2, + Extended = 3, + SafetySystem = 4, + } + public sealed class UdsTimeoutException : CanKit.Pro.Uds.UdsException + { + public UdsTimeoutException(CanKit.Pro.Uds.UdsServiceId requestedService, CanKit.Pro.Uds.UdsTimeoutTimer timer, System.TimeSpan elapsed) { } + public System.TimeSpan Elapsed { get; } + public CanKit.Pro.Uds.UdsServiceId RequestedService { get; } + public CanKit.Pro.Uds.UdsTimeoutTimer Timer { get; } + } + public enum UdsTimeoutTimer + { + P2 = 0, + P2Star = 1, + } + public readonly struct UdsUploadResponse + { + public UdsUploadResponse(byte lengthFormatIdentifier, ulong maxNumberOfBlockLength) { } + public byte LengthFormatIdentifier { get; } + public ulong MaxNumberOfBlockLength { get; } + } +} \ No newline at end of file diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs index 282fe7b..875b729 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs @@ -518,6 +518,37 @@ public async Task A_Late_Negative_Response_To_A_Suppressed_Send_Is_Not_The_Next_ } } + // Codex and Bugbot on #150: the windows are per service. A suppressed send for another + // service in between must not shorten the first's. + [Fact] + public async Task Suppressed_Send_Windows_Are_Kept_Per_Service() + { + var (client, ecu, dispose) = BuildPair( + e => e + .On(0x3E, req => + { + if ((req[1] & 0x80) != 0) + { + Thread.Sleep(100); + throw new EcuNegativeResponse(0x12); + } + return new byte[] { 0x00 }; + }) + .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); + } + } + // NRC 0x21 asks for a repeat; the client repeats, up to MaxBusyRepeatRequests. [Fact] public async Task BusyRepeatRequest_Is_Repeated_Until_The_Server_Answers() diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index 3b3d6c5..13dcd1a 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -220,6 +220,51 @@ public async Task A_Positive_Response_For_Another_Did_Is_Not_Attributed() responses.Should().BeEmpty("the answer names DID F190, the request asked for F191"); } + // Codex on #150: a suppressed functional send may still be answered negatively; the next + // call for the same service waits that window out before it collects. + [Fact] + public async Task A_Late_Negative_Answer_To_A_Suppressed_Send_Does_Not_Land_In_The_Next_Window() + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var busEcus = OpenClassic(session, 1); + + var negative = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x3E, 0x12 }); + var positive = SingleFrameFrom(Ecu1, new byte[] { 0x7E, 0x00 }); + busEcus.FrameObserved += (_, e) => + { + if (e.CanFrame.ID != unchecked((int)FunctionalTxId)) return; + if (e.CanFrame.Data.Span[2] == 0x80) + _ = Task.Run(async () => { await Task.Delay(100); busEcus.Transmit(negative); }); + else + busEcus.Transmit(positive); + }; + + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), + ownsClient: true, suppressedResponseWindow: TimeSpan.FromMilliseconds(300)); + + using var cts = new CancellationTokenSource(ShortTimeout); + await functional.TesterPresentAsync(cancellationToken: cts.Token); + var responses = await functional.TesterPresentAsync(suppressPositiveResponse: false, Window, cts.Token); + + responses.Should().ContainSingle().Which.IsNegative.Should().BeFalse( + "the late negative answer belongs to the suppressed send and is not collected"); + } + + // Codex on #150: only one DID is correlated, and a Single Frame holds no more anyway. + [Fact] + public async Task A_Functional_Read_For_More_Than_One_Did_Is_Refused() + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), ownsClient: true); + + Func act = () => functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90, 0xF1, 0x91 }, Window); + await act.Should().ThrowAsync(); + } + [Fact] public async Task An_Unsuppressed_TesterPresent_Needs_A_Window() { From 29b5babd939b3a128696993533aea28cb47a6528 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:19:30 +0200 Subject: [PATCH 08/55] fix(uds): extend a suppressed send's window on NRC 0x78, and keep every functional request's window open for the rest of its P2 Codex on #150, twice more, and Bugbot once. NRC 0x78 to a suppressed send says the peer's final answer is still coming, up to P2* later; the window now moves out with it, on both clients, which listen while they wait. And a functional request whose collection window ended before the peer's P2 did could still be answered into the next same-service call's window; every functional send now notes its window, not only a suppressed one. Both mutation-checked. The conflict copy of the approval file a sync tool made and `git add -A` swept in is removed. Co-Authored-By: Claude Opus 5 --- .../SuppressedResponseWindows.cs | 52 +++--- src/CanKit.Pro.Uds/UdsClientImpl.cs | 39 ++++- src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 75 ++++++-- .../ApiApprovals/CanKit.Pro.Uds.approved.txt | 2 +- .../CanKit.Pro.Uds.received [conflicted].txt | 161 ------------------ .../TestCases/Uds/SimulatedUdsEcu.cs | 37 ++++ .../TestCases/Uds/UdsClientTests.cs | 31 ++++ .../TestCases/Uds/UdsFunctionalClientTests.cs | 35 +++- 8 files changed, 225 insertions(+), 207 deletions(-) delete mode 100644 tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Uds.received [conflicted].txt diff --git a/src/CanKit.Pro.Uds/SuppressedResponseWindows.cs b/src/CanKit.Pro.Uds/SuppressedResponseWindows.cs index c3f9ffd..8ae0a12 100644 --- a/src/CanKit.Pro.Uds/SuppressedResponseWindows.cs +++ b/src/CanKit.Pro.Uds/SuppressedResponseWindows.cs @@ -1,28 +1,31 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Threading; -using System.Threading.Tasks; namespace CanKit.Pro.Uds; /// -/// The response windows still open after suppressed sends, per service. A suppressed send -/// draws no positive response but may still draw a negative one, up to P2 after it went out; -/// the next request for the same service waits that window out rather than taking the -/// negative response as its own (Codex on #150). One entry per service, since a suppressed -/// send for another service in between must not shorten the first's window. +/// The response windows still open from earlier sends, per service: how long a peer may still +/// answer a request whose answer nobody is waiting for -- a suppressed send, or a functional +/// request whose collection window ended before the peer's P2 did. The next request for the +/// same service waits such a window out rather than taking the late answer as its own (Codex +/// on #150). One entry per service, since a send for another service in between must not +/// shorten the first's window; a later window for the same service replaces an earlier one +/// only if it ends later. /// internal sealed class SuppressedResponseWindows { private readonly Dictionary _until = new(); private readonly object _gate = new(); - /// Notes that a suppressed send for went out at - /// and may be answered negatively for . + /// Notes that a request for went out at + /// and may be answered for . public void Note(byte sid, long sentTimestamp, TimeSpan window) + => Extend(sid, sentTimestamp + (long)(window.TotalSeconds * Stopwatch.Frequency)); + + /// Moves the window for out to , if later. + public void Extend(byte sid, long until) { - var until = sentTimestamp + (long)(window.TotalSeconds * Stopwatch.Frequency); lock (_gate) { if (!_until.TryGetValue(sid, out var existing) || existing < until) @@ -30,21 +33,22 @@ public void Note(byte sid, long sentTimestamp, TimeSpan window) } } - /// Waits until every window for has closed, then forgets it. - public async Task WaitOutAsync(byte sid, CancellationToken cancellationToken) + /// The instant the window for ends, if one is open. + public bool TryGetDeadline(byte sid, out long until) + { + lock (_gate) return _until.TryGetValue(sid, out until); + } + + /// Closes the window for . + public void Forget(byte sid) + { + lock (_gate) _until.Remove(sid); + } + + /// How long from now until , or zero if it has passed. + public static TimeSpan Remaining(long until) { - long until; - lock (_gate) - { - if (!_until.TryGetValue(sid, out until)) return; - } var ticks = until - Stopwatch.GetTimestamp(); - if (ticks > 0) - await Task.Delay(TimeSpan.FromSeconds((double)ticks / Stopwatch.Frequency), cancellationToken) - .ConfigureAwait(false); - lock (_gate) - { - if (_until.TryGetValue(sid, out var current) && current == until) _until.Remove(sid); - } + return ticks <= 0 ? TimeSpan.Zero : TimeSpan.FromSeconds((double)ticks / Stopwatch.Frequency); } } diff --git a/src/CanKit.Pro.Uds/UdsClientImpl.cs b/src/CanKit.Pro.Uds/UdsClientImpl.cs index 119fb3f..6404dac 100644 --- a/src/CanKit.Pro.Uds/UdsClientImpl.cs +++ b/src/CanKit.Pro.Uds/UdsClientImpl.cs @@ -446,10 +446,41 @@ private async Task SendWithoutResponseAsync(byte[] request, CancellationToken ca } // Under the request lock. A request for a service with a suppressed send still open waits - // out that send's P2 -- the discard that follows drains what arrived -- so a late negative - // response to the suppressed send cannot be taken for this request's. - private Task WaitOutSuppressedResponseWindowAsync(UdsServiceId serviceId, CancellationToken linkedToken) - => _suppressedWindows.WaitOutAsync((byte)serviceId, linkedToken); + // out that send's P2, reading what arrives: it is the suppressed send's and is dropped -- + // except NRC 0x78, which says the peer's final answer is still coming and moves the window + // out by P2* (Codex on #150). So a late negative response to the suppressed send cannot + // be taken for this request's. + private async Task WaitOutSuppressedResponseWindowAsync(UdsServiceId serviceId, CancellationToken linkedToken) + { + byte sid = (byte)serviceId; + if (!_suppressedWindows.TryGetDeadline(sid, out var until)) return; + try + { + while (true) + { + var remaining = SuppressedResponseWindows.Remaining(until); + if (remaining <= TimeSpan.Zero) break; + using var slice = new CancellationTokenSource(remaining); + using var combined = CancellationTokenSource.CreateLinkedTokenSource(linkedToken, slice.Token); + IsoTpReceivedPdu pdu; + try + { + pdu = await _channel.ReceiveWithArrivalAsync(combined.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (slice.IsCancellationRequested && !linkedToken.IsCancellationRequested) + { + break; + } + var data = pdu.Pdu; + if (data.Length >= 3 && data[0] == NegativeResponseSid && data[1] == sid && data[2] == NrcResponsePending) + until = pdu.FirstFrameArrivalTimestamp + (long)(_options.P2StarClientMax.TotalSeconds * Stopwatch.Frequency); + } + } + finally + { + _suppressedWindows.Forget(sid); + } + } // The services whose second byte is a sub-function parameter, and so carry the // suppressPosRspMsgIndication bit (ISO 14229-1 table 2, "sub-function" column). diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index 1540111..2435a58 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -23,11 +23,13 @@ public sealed class UdsFunctionalClient : IDisposable { private const byte SuppressPositiveResponseBit = 0x80; private const byte NegativeResponseSid = 0x7F; + private const byte NrcResponsePending = 0x78; private readonly IsoTpFunctionalClient _client; private readonly bool _ownsClient; - private readonly TimeSpan _suppressedResponseWindow; - private readonly SuppressedResponseWindows _suppressedWindows = new(); + private readonly TimeSpan _responseWindow; + private readonly TimeSpan _responsePendingWindow; + private readonly SuppressedResponseWindows _openWindows = new(); // One request on the wire at a time, its collection window included: overlapping calls // with the same SID would each collect the other's answers (Codex on #150), as the // physical client's request lock prevents there. @@ -38,25 +40,32 @@ public sealed class UdsFunctionalClient : IDisposable private readonly CancellationTokenSource _lifetimeCts = new(); private int _disposed; - private UdsFunctionalClient(IsoTpFunctionalClient client, bool ownsClient, TimeSpan suppressedResponseWindow) + private UdsFunctionalClient(IsoTpFunctionalClient client, bool ownsClient, TimeSpan responseWindow, + TimeSpan responsePendingWindow) { _client = client ?? throw new ArgumentNullException(nameof(client)); - if (suppressedResponseWindow <= TimeSpan.Zero) - throw new ArgumentOutOfRangeException(nameof(suppressedResponseWindow), "The window must be positive."); + if (responseWindow <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(responseWindow), "The window must be positive."); + if (responsePendingWindow <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(responsePendingWindow), "The window must be positive."); _ownsClient = ownsClient; - _suppressedResponseWindow = suppressedResponseWindow; + _responseWindow = responseWindow; + _responsePendingWindow = responsePendingWindow; } /// /// Wraps an open . With , - /// disposing this client disposes it. is how - /// long after a suppressed send an ECU may still answer it negatively -- P2 -- and so how - /// long the next call for the same service waits before it collects; the default is - /// . + /// disposing this client disposes it. is how long after a + /// request an ECU may still answer it -- P2 -- and so how long the next call for the same + /// service waits before it collects, whether the request was suppressed or its collection + /// window simply ended sooner; is what an NRC 0x78 + /// seen in that time extends it by -- P2*. The defaults are + /// and . /// public static UdsFunctionalClient Create(IsoTpFunctionalClient client, bool ownsClient = false, - TimeSpan? suppressedResponseWindow = null) - => new(client, ownsClient, suppressedResponseWindow ?? UdsClientOptions.DefaultP2); + TimeSpan? responseWindow = null, TimeSpan? responsePendingWindow = null) + => new(client, ownsClient, responseWindow ?? UdsClientOptions.DefaultP2, + responsePendingWindow ?? UdsClientOptions.DefaultP2Star); /// The underlying ISO-TP functional client. public IsoTpFunctionalClient Channel => _client; @@ -85,9 +94,10 @@ public async Task> SendRawAsync(ReadOnlyMem { // Disposed while queued behind another call: the lock is released, not used. ThrowIfDisposed(); - // A suppressed send for this service may still be answered negatively; that answer - // must not land in this call's window (Codex on #150). - await _suppressedWindows.WaitOutAsync(request.Span[0], linkedToken).ConfigureAwait(false); + // An earlier request for this service may still be answered -- a suppressed send, + // or one whose collection window ended before the ECU's P2 did; that answer must + // not land in this call's window (Codex on #150). + await WaitOutOpenWindowAsync(request.Span[0], linkedToken).ConfigureAwait(false); return await SendRawLockedAsync(request, window, linkedToken).ConfigureAwait(false); } finally @@ -103,7 +113,7 @@ private async Task> SendRawLockedAsync(Read && (request.Span[1] & SuppressPositiveResponseBit) != 0) { await _client.SendAsync(request, cancellationToken).ConfigureAwait(false); - _suppressedWindows.Note(request.Span[0], Stopwatch.GetTimestamp(), _suppressedResponseWindow); + _openWindows.Note(request.Span[0], Stopwatch.GetTimestamp(), _responseWindow); return Array.Empty(); } @@ -115,8 +125,12 @@ private async Task> SendRawLockedAsync(Read "A functional ReadDataByIdentifier reads one DID: a Single Frame cannot carry more, and only one is correlated.", nameof(request)); + // Noted before the collection: what the ECUs may still send after the window ends is + // the remainder of their P2 from the request, not from now. + var sentAt = Stopwatch.GetTimestamp(); var raw = await _client.SendAndCollectAsync(request, window, cancellationToken) .ConfigureAwait(false); + _openWindows.Note(request.Span[0], sentAt, _responseWindow); var req = request.Span; byte sid = req[0]; byte positiveSid = (byte)(sid + 0x40); @@ -131,11 +145,40 @@ private async Task> SendRawLockedAsync(Read var data = r.Data; bool positive = data.Length >= 1 + echoed && data[0] == positiveSid && EchoMatches(req, data, echoed); bool negative = data.Length >= 3 && data[0] == NegativeResponseSid && data[1] == sid; + if (negative && data[2] == NrcResponsePending) + _openWindows.Extend(sid, Stopwatch.GetTimestamp() + (long)(_responsePendingWindow.TotalSeconds * Stopwatch.Frequency)); if (positive || negative) responses.Add(new UdsFunctionalResponse(r.SourceCanId, data)); } return responses; } + // Under the request lock. Waits out the window still open for this service, listening the + // while: an NRC 0x78 in it says an ECU's final answer is still coming and moves the window + // out by P2* (Codex on #150). Everything heard belongs to the earlier request and is dropped. + private async Task WaitOutOpenWindowAsync(byte sid, CancellationToken cancellationToken) + { + if (!_openWindows.TryGetDeadline(sid, out var until)) return; + try + { + while (true) + { + var remaining = SuppressedResponseWindows.Remaining(until); + if (remaining <= TimeSpan.Zero) break; + var heard = await _client.CollectResponsesAsync(remaining, cancellationToken).ConfigureAwait(false); + foreach (var r in heard) + { + var data = r.Data; + if (data.Length >= 3 && data[0] == NegativeResponseSid && data[1] == sid && data[2] == NrcResponsePending) + until = Math.Max(until, Stopwatch.GetTimestamp() + (long)(_responsePendingWindow.TotalSeconds * Stopwatch.Frequency)); + } + } + } + finally + { + _openWindows.Forget(sid); + } + } + /// /// TesterPresent to everyone (3E 80): the keep-alive that reaches every ECU with one /// frame. With the positive response suppressed, the default, nothing is collected and diff --git a/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Uds.approved.txt b/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Uds.approved.txt index cd84597..4b9b2cc 100644 --- a/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Uds.approved.txt +++ b/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Uds.approved.txt @@ -69,7 +69,7 @@ namespace CanKit.Pro.Uds public void Dispose() { } public System.Threading.Tasks.Task> SendRawAsync(System.ReadOnlyMemory request, System.TimeSpan window, System.Threading.CancellationToken cancellationToken = default) { } public System.Threading.Tasks.Task> TesterPresentAsync(bool suppressPositiveResponse = true, System.TimeSpan? window = default, System.Threading.CancellationToken cancellationToken = default) { } - public static CanKit.Pro.Uds.UdsFunctionalClient Create(CanKit.Pro.IsoTp.IsoTpFunctionalClient client, bool ownsClient = false, System.TimeSpan? suppressedResponseWindow = default) { } + public static CanKit.Pro.Uds.UdsFunctionalClient Create(CanKit.Pro.IsoTp.IsoTpFunctionalClient client, bool ownsClient = false, System.TimeSpan? responseWindow = default, System.TimeSpan? responsePendingWindow = default) { } } public sealed class UdsFunctionalResponse { diff --git a/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Uds.received [conflicted].txt b/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Uds.received [conflicted].txt deleted file mode 100644 index cd84597..0000000 --- a/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Uds.received [conflicted].txt +++ /dev/null @@ -1,161 +0,0 @@ -namespace CanKit.Pro.Uds -{ - public interface IUdsClient : System.IDisposable - { - CanKit.Pro.IsoTp.IIsoTpChannel Channel { get; } - byte CurrentSession { get; } - CanKit.Pro.Uds.UdsClientOptions Options { get; } - System.Threading.Tasks.Task DiagnosticSessionControlAsync(CanKit.Pro.Uds.UdsSessionType session, System.Threading.CancellationToken cancellationToken = default); - System.Threading.Tasks.Task DiagnosticSessionControlAsync(byte sessionType, System.Threading.CancellationToken cancellationToken = default); - System.Threading.Tasks.Task DownloadAsync(byte dataFormatIdentifier, byte addressAndLengthFormatIdentifier, System.ReadOnlyMemory memoryAddress, System.ReadOnlyMemory memorySize, System.ReadOnlyMemory data, System.Threading.CancellationToken cancellationToken = default); - System.Threading.Tasks.Task EcuResetAsync(CanKit.Pro.Uds.UdsEcuResetType resetType, System.Threading.CancellationToken cancellationToken = default); - System.Threading.Tasks.Task ReadDataByIdentifierAsync(ushort dataIdentifier, System.Threading.CancellationToken cancellationToken = default); - System.Threading.Tasks.Task> ReadDataByIdentifierAsync(System.Collections.Generic.IReadOnlyList dataIdentifiers, System.Collections.Generic.IReadOnlyDictionary dataRecordLengths, System.Threading.CancellationToken cancellationToken = default); - System.Threading.Tasks.Task RequestDownloadAsync(byte dataFormatIdentifier, byte addressAndLengthFormatIdentifier, System.ReadOnlyMemory memoryAddress, System.ReadOnlyMemory memorySize, System.Threading.CancellationToken cancellationToken = default); - System.Threading.Tasks.Task RequestTransferExitAsync(System.ReadOnlyMemory transferRequestParameterRecord = default, System.Threading.CancellationToken cancellationToken = default); - System.Threading.Tasks.Task RequestUploadAsync(byte dataFormatIdentifier, byte addressAndLengthFormatIdentifier, System.ReadOnlyMemory memoryAddress, System.ReadOnlyMemory memorySize, System.Threading.CancellationToken cancellationToken = default); - System.Threading.Tasks.Task RoutineControlAsync(CanKit.Pro.Uds.UdsRoutineControlType routineType, ushort routineIdentifier, System.ReadOnlyMemory routineControlOptionRecord = default, System.Threading.CancellationToken cancellationToken = default); - System.Threading.Tasks.Task SecurityAccessAsync(byte requestSeedLevel, System.Func computeKey, System.Threading.CancellationToken cancellationToken = default); - System.Threading.Tasks.Task SendRawAsync(System.ReadOnlyMemory request, System.Threading.CancellationToken cancellationToken = default); - System.IDisposable StartTesterPresentKeepAlive(System.TimeSpan? period = default); - System.Threading.Tasks.Task TesterPresentAsync(bool suppressPositiveResponse = true, System.Threading.CancellationToken cancellationToken = default); - System.Threading.Tasks.Task TransferDataAsync(byte blockSequenceCounter, System.ReadOnlyMemory data, System.Threading.CancellationToken cancellationToken = default); - System.Threading.Tasks.Task UploadAsync(byte dataFormatIdentifier, byte addressAndLengthFormatIdentifier, System.ReadOnlyMemory memoryAddress, System.ReadOnlyMemory memorySize, System.Threading.CancellationToken cancellationToken = default); - System.Threading.Tasks.Task WriteDataByIdentifierAsync(ushort dataIdentifier, System.ReadOnlyMemory data, System.Threading.CancellationToken cancellationToken = default); - } - public static class UdsClient - { - public static CanKit.Pro.Uds.IUdsClient Create(CanKit.Pro.IsoTp.IIsoTpChannel channel, CanKit.Pro.Uds.UdsClientOptions? options = null, bool leaveOpen = true) { } - } - public sealed class UdsClientOptions - { - public static readonly System.TimeSpan DefaultP2; - public static readonly System.TimeSpan DefaultP2Star; - public static readonly System.TimeSpan DefaultTesterPresentPeriod; - public UdsClientOptions() { } - public System.TimeSpan BusyRepeatRequestDelay { get; init; } - public bool KeepAliveSuppressPositiveResponse { get; init; } - public int MaxBusyRepeatRequests { get; init; } - public int MaxResponsePendingCount { get; init; } - public System.TimeSpan P2ClientMax { get; init; } - public System.TimeSpan P2StarClientMax { get; init; } - public System.TimeSpan TesterPresentPeriod { get; init; } - public CanKit.Pro.Uds.UdsClientOptions With(System.TimeSpan? p2ClientMax = default, System.TimeSpan? p2StarClientMax = default, int? maxResponsePendingCount = default, System.TimeSpan? testerPresentPeriod = default, bool? keepAliveSuppressPositiveResponse = default, int? maxBusyRepeatRequests = default, System.TimeSpan? busyRepeatRequestDelay = default) { } - } - public readonly struct UdsDownloadResponse - { - public UdsDownloadResponse(byte lengthFormatIdentifier, ulong maxNumberOfBlockLength) { } - public byte LengthFormatIdentifier { get; } - public ulong MaxNumberOfBlockLength { get; } - } - public enum UdsEcuResetType : byte - { - HardReset = 1, - KeyOffOnReset = 2, - SoftReset = 3, - EnableRapidPowerShutDown = 4, - DisableRapidPowerShutDown = 5, - } - public class UdsException : CanKit.Core.Exceptions.CanKitException - { - public UdsException(string message) { } - protected UdsException(CanKit.Core.Exceptions.CanKitErrorCode errorCode, string message) { } - public UdsException(string message, System.Exception innerException) { } - } - public sealed class UdsFunctionalClient : System.IDisposable - { - public CanKit.Pro.IsoTp.IsoTpFunctionalClient Channel { get; } - public System.Threading.Tasks.Task> DiagnosticSessionControlAsync(CanKit.Pro.Uds.UdsSessionType session, System.TimeSpan window, System.Threading.CancellationToken cancellationToken = default) { } - public void Dispose() { } - public System.Threading.Tasks.Task> SendRawAsync(System.ReadOnlyMemory request, System.TimeSpan window, System.Threading.CancellationToken cancellationToken = default) { } - public System.Threading.Tasks.Task> TesterPresentAsync(bool suppressPositiveResponse = true, System.TimeSpan? window = default, System.Threading.CancellationToken cancellationToken = default) { } - public static CanKit.Pro.Uds.UdsFunctionalClient Create(CanKit.Pro.IsoTp.IsoTpFunctionalClient client, bool ownsClient = false, System.TimeSpan? suppressedResponseWindow = default) { } - } - public sealed class UdsFunctionalResponse - { - public UdsFunctionalResponse(uint sourceCanId, byte[] response) { } - public bool IsNegative { get; } - public byte? NegativeResponseCode { get; } - public byte[] Response { get; } - public uint SourceCanId { get; } - } - public enum UdsNegativeResponseCode : byte - { - GeneralReject = 16, - ServiceNotSupported = 17, - SubFunctionNotSupported = 18, - IncorrectMessageLengthOrInvalidFormat = 19, - BusyRepeatRequest = 33, - ConditionsNotCorrect = 34, - RequestSequenceError = 36, - RequestOutOfRange = 49, - SecurityAccessDenied = 51, - InvalidKey = 53, - ExceedNumberOfAttempts = 54, - RequiredTimeDelayNotExpired = 55, - UploadDownloadNotAccepted = 112, - TransferDataSuspended = 113, - GeneralProgrammingFailure = 114, - WrongBlockSequenceCounter = 115, - RequestCorrectlyReceivedResponsePending = 120, - SubFunctionNotSupportedInActiveSession = 126, - ServiceNotSupportedInActiveSession = 127, - } - public sealed class UdsNegativeResponseException : CanKit.Pro.Uds.UdsException - { - public UdsNegativeResponseException(CanKit.Pro.Uds.UdsServiceId requestedService, byte code) { } - public byte Code { get; } - public CanKit.Pro.Uds.UdsNegativeResponseCode? CodeAsEnum { get; } - public string CodeName { get; } - public CanKit.Pro.Uds.UdsServiceId RequestedService { get; } - } - public sealed class UdsProtocolException : CanKit.Pro.Uds.UdsException - { - public UdsProtocolException(string message) { } - } - public enum UdsRoutineControlType : byte - { - StartRoutine = 1, - StopRoutine = 2, - RequestRoutineResults = 3, - } - public enum UdsServiceId : byte - { - DiagnosticSessionControl = 16, - EcuReset = 17, - ReadDataByIdentifier = 34, - SecurityAccess = 39, - WriteDataByIdentifier = 46, - RoutineControl = 49, - RequestDownload = 52, - RequestUpload = 53, - TransferData = 54, - RequestTransferExit = 55, - TesterPresent = 62, - } - public enum UdsSessionType : byte - { - Default = 1, - Programming = 2, - Extended = 3, - SafetySystem = 4, - } - public sealed class UdsTimeoutException : CanKit.Pro.Uds.UdsException - { - public UdsTimeoutException(CanKit.Pro.Uds.UdsServiceId requestedService, CanKit.Pro.Uds.UdsTimeoutTimer timer, System.TimeSpan elapsed) { } - public System.TimeSpan Elapsed { get; } - public CanKit.Pro.Uds.UdsServiceId RequestedService { get; } - public CanKit.Pro.Uds.UdsTimeoutTimer Timer { get; } - } - public enum UdsTimeoutTimer - { - P2 = 0, - P2Star = 1, - } - public readonly struct UdsUploadResponse - { - public UdsUploadResponse(byte lengthFormatIdentifier, ulong maxNumberOfBlockLength) { } - public byte LengthFormatIdentifier { get; } - public ulong MaxNumberOfBlockLength { get; } - } -} \ No newline at end of file diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/SimulatedUdsEcu.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/SimulatedUdsEcu.cs index 29601d8..b3c486d 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/SimulatedUdsEcu.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/SimulatedUdsEcu.cs @@ -156,6 +156,18 @@ await SendAndCountAsync(new byte[] { 0x7F, sid, nrc.Code }, ct) if (body.Length > 0) Buffer.BlockCopy(body, 0, response, 1, body.Length); await SendAndCountAsync(response, ct).ConfigureAwait(false); } + catch (EcuResponsePendingThenNegative pendingNegative) + { + if (pendingNegative.DelayBefore > TimeSpan.Zero) + await Task.Delay(pendingNegative.DelayBefore, ct).ConfigureAwait(false); + for (int i = 0; i < pendingNegative.PendingCount && !ct.IsCancellationRequested; i++) + await SendNrcAsync(sid, 0x78, ct).ConfigureAwait(false); + if (pendingNegative.DelayAfter > TimeSpan.Zero) + await Task.Delay(pendingNegative.DelayAfter, ct).ConfigureAwait(false); + if (ct.IsCancellationRequested) return; + await SendAndCountAsync(new byte[] { 0x7F, sid, pendingNegative.Nrc }, ct) + .ConfigureAwait(false); + } catch (EcuResponsePendingThenSilent pendingSilent) { for (int i = 0; i < pendingSilent.PendingCount && !ct.IsCancellationRequested; i++) @@ -245,6 +257,31 @@ public EcuResponsePending(int pendingCount, byte[] finalResponse, TimeSpan? dela /// Sentinel thrown by an ECU handler to force N × NRC 0x78 and then go completely /// silent — the client's restarted P2* timer must expire (FR-UDS-008). +/// Send N × NRC 0x78, then a negative response: the final answer is negative (#150). +public sealed class EcuResponsePendingThenNegative : Exception +{ + /// How many NRC 0x78 frames to send. + public int PendingCount { get; } + + /// The final negative response code. + public byte Nrc { get; } + + /// Delay before the first 0x78. + public TimeSpan DelayBefore { get; } + + /// Delay between the last 0x78 and the negative response. + public TimeSpan DelayAfter { get; } + + /// Creates the sentinel. + public EcuResponsePendingThenNegative(int pendingCount, byte nrc, TimeSpan? delayBefore = null, TimeSpan? delayAfter = null) + { + PendingCount = pendingCount; + Nrc = nrc; + DelayBefore = delayBefore ?? TimeSpan.Zero; + DelayAfter = delayAfter ?? TimeSpan.Zero; + } +} + public sealed class EcuResponsePendingThenSilent : Exception { /// How many NRC 0x78 frames to send before going silent. diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs index 875b729..a5e22a3 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs @@ -549,6 +549,37 @@ public async Task Suppressed_Send_Windows_Are_Kept_Per_Service() } } + // Codex on #150: NRC 0x78 to a suppressed send says the final answer is still coming, up + // to P2* later; the window moves out with it. + [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 => + { + if ((req[1] & 0x80) != 0) + throw new EcuResponsePendingThenNegative(pendingCount: 1, nrc: 0x12, + delayBefore: TimeSpan.FromMilliseconds(50), delayAfter: TimeSpan.FromMilliseconds(400)); + 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); + + // 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); + } + } + // NRC 0x21 asks for a repeat; the client repeats, up to MaxBusyRepeatRequests. [Fact] public async Task BusyRepeatRequest_Is_Repeated_Until_The_Server_Answers() diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index 13dcd1a..14d1318 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -242,7 +242,7 @@ public async Task A_Late_Negative_Answer_To_A_Suppressed_Send_Does_Not_Land_In_T using var functional = UdsFunctionalClient.Create( IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), - ownsClient: true, suppressedResponseWindow: TimeSpan.FromMilliseconds(300)); + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(300)); using var cts = new CancellationTokenSource(ShortTimeout); await functional.TesterPresentAsync(cancellationToken: cts.Token); @@ -252,6 +252,39 @@ public async Task A_Late_Negative_Answer_To_A_Suppressed_Send_Does_Not_Land_In_T "the late negative answer belongs to the suppressed send and is not collected"); } + // Codex on #150: a request's collection window may end before the ECU's P2 does; its late + // negative answer must not land in the next same-service call's window either. + [Fact] + public async Task A_Late_Negative_Answer_To_A_Previous_Request_Does_Not_Land_In_The_Next_Window() + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var busEcus = OpenClassic(session, 1); + + var negative = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x22, 0x31 }); + var positive = SingleFrameFrom(Ecu1, new byte[] { 0x62, 0xF1, 0x91, 0x02 }); + busEcus.FrameObserved += (_, e) => + { + if (e.CanFrame.ID != unchecked((int)FunctionalTxId)) return; + if (e.CanFrame.Data.Span[3] == 0x90) + _ = Task.Run(async () => { await Task.Delay(150); busEcus.Transmit(negative); }); + else + busEcus.Transmit(positive); + }; + + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(300)); + + using var cts = new CancellationTokenSource(ShortTimeout); + // A short collection window: the negative answer comes after it. + await functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, TimeSpan.FromMilliseconds(30), cts.Token); + var responses = await functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x91 }, Window, cts.Token); + + responses.Should().ContainSingle().Which.IsNegative.Should().BeFalse( + "F190's late negative answer is the previous request's"); + } + // Codex on #150: only one DID is correlated, and a Single Frame holds no more anyway. [Fact] public async Task A_Functional_Read_For_More_Than_One_Did_Is_Refused() From a09fceb91eb3721925443cf875ac0bc4489a63ef Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:24:14 +0200 Subject: [PATCH 09/55] refactor(uds): fold the pending-response check CodeQL flagged into one predicate Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index 2435a58..d691e8e 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Threading; using System.Threading.Tasks; using CanKit.Pro.IsoTp; @@ -145,7 +146,7 @@ private async Task> SendRawLockedAsync(Read var data = r.Data; bool positive = data.Length >= 1 + echoed && data[0] == positiveSid && EchoMatches(req, data, echoed); bool negative = data.Length >= 3 && data[0] == NegativeResponseSid && data[1] == sid; - if (negative && data[2] == NrcResponsePending) + if (IsResponsePending(data, sid)) _openWindows.Extend(sid, Stopwatch.GetTimestamp() + (long)(_responsePendingWindow.TotalSeconds * Stopwatch.Frequency)); if (positive || negative) responses.Add(new UdsFunctionalResponse(r.SourceCanId, data)); } @@ -165,12 +166,8 @@ private async Task WaitOutOpenWindowAsync(byte sid, CancellationToken cancellati var remaining = SuppressedResponseWindows.Remaining(until); if (remaining <= TimeSpan.Zero) break; var heard = await _client.CollectResponsesAsync(remaining, cancellationToken).ConfigureAwait(false); - foreach (var r in heard) - { - var data = r.Data; - if (data.Length >= 3 && data[0] == NegativeResponseSid && data[1] == sid && data[2] == NrcResponsePending) - until = Math.Max(until, Stopwatch.GetTimestamp() + (long)(_responsePendingWindow.TotalSeconds * Stopwatch.Frequency)); - } + if (heard.Any(r => IsResponsePending(r.Data, sid))) + until = Math.Max(until, Stopwatch.GetTimestamp() + (long)(_responsePendingWindow.TotalSeconds * Stopwatch.Frequency)); } } finally @@ -238,6 +235,9 @@ private static bool EchoMatches(ReadOnlySpan request, byte[] response, int return true; } + private static bool IsResponsePending(byte[] data, byte sid) + => data.Length >= 3 && data[0] == NegativeResponseSid && data[1] == sid && data[2] == NrcResponsePending; + // Mirrors UdsClientImpl.HasSubFunction (ISO 14229-1 table 2). private static bool HasSubFunction(byte sid) => sid switch { From 7269ce3abd4e46d6989b55579ebe076006acd39d Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:34:46 +0200 Subject: [PATCH 10/55] fix(uds): note a functional window before the send, keep a cancelled wait's remainder, drain before ending a wait, and correlate 0x2A on its identifier Bugbot on #150, three times, and Codex once. The functional client noted a request's window only after a successful collection, so a cancelled one left none; it is noted before the send. Both wait-outs forgot the window in a finally, so a cancelled wait dropped what remained; it keeps it, 0x78 extensions included. The physical wait-out ended without reading a 0x78 already queued when the window had run out; it drains the inbox first and only an empty one ends it. And ReadDataByPeriodicIdentifier's transmission mode is a parameter, not a sub-function: it is out of HasSubFunction on both clients, and the functional client correlates its answers on the requested periodic identifier. Each mutation-checked. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsClientImpl.cs | 45 +++++++++++--- src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 39 +++++++++--- .../TestCases/Uds/UdsClientTests.cs | 62 +++++++++++++++++++ .../TestCases/Uds/UdsFunctionalClientTests.cs | 60 ++++++++++++++++++ 4 files changed, 190 insertions(+), 16 deletions(-) diff --git a/src/CanKit.Pro.Uds/UdsClientImpl.cs b/src/CanKit.Pro.Uds/UdsClientImpl.cs index 6404dac..5688bb8 100644 --- a/src/CanKit.Pro.Uds/UdsClientImpl.cs +++ b/src/CanKit.Pro.Uds/UdsClientImpl.cs @@ -454,12 +454,19 @@ private async Task WaitOutSuppressedResponseWindowAsync(UdsServiceId serviceId, { byte sid = (byte)serviceId; if (!_suppressedWindows.TryGetDeadline(sid, out var until)) return; + bool waitedOut = false; try { while (true) { var remaining = SuppressedResponseWindows.Remaining(until); - if (remaining <= TimeSpan.Zero) break; + if (remaining <= TimeSpan.Zero) + { + // The window is over as measured now -- but a 0x78 may be queued already, + // and it moves the window out (Bugbot on #150). Only an empty inbox ends it. + if (DrainExtends(sid, ref until)) continue; + break; + } using var slice = new CancellationTokenSource(remaining); using var combined = CancellationTokenSource.CreateLinkedTokenSource(linkedToken, slice.Token); IsoTpReceivedPdu pdu; @@ -469,24 +476,48 @@ private async Task WaitOutSuppressedResponseWindowAsync(UdsServiceId serviceId, } catch (OperationCanceledException) when (slice.IsCancellationRequested && !linkedToken.IsCancellationRequested) { + if (DrainExtends(sid, ref until)) continue; break; } - var data = pdu.Pdu; - if (data.Length >= 3 && data[0] == NegativeResponseSid && data[1] == sid && data[2] == NrcResponsePending) - until = pdu.FirstFrameArrivalTimestamp + (long)(_options.P2StarClientMax.TotalSeconds * Stopwatch.Frequency); + ExtendOnPending(sid, pdu, ref until); } + waitedOut = true; } finally { - _suppressedWindows.Forget(sid); + // A cancelled wait keeps what remains of the window, extensions included, for the + // next request (Bugbot on #150). + if (waitedOut) _suppressedWindows.Forget(sid); + else _suppressedWindows.Extend(sid, until); } } + // Reads whatever is queued; true when a 0x78 among it moved the window out. + private bool DrainExtends(byte sid, ref long until) + { + bool extended = false; + while (_channel.TryReceiveWithArrival(out var queued)) + extended |= ExtendOnPending(sid, queued, ref until); + return extended; + } + + private bool ExtendOnPending(byte sid, in IsoTpReceivedPdu pdu, ref long until) + { + var data = pdu.Pdu; + if (data.Length < 3 || data[0] != NegativeResponseSid || data[1] != sid || data[2] != NrcResponsePending) + return false; + var extendedUntil = pdu.FirstFrameArrivalTimestamp + (long)(_options.P2StarClientMax.TotalSeconds * Stopwatch.Frequency); + if (extendedUntil <= until) return false; + until = extendedUntil; + return true; + } + // The services whose second byte is a sub-function parameter, and so carry the - // suppressPosRspMsgIndication bit (ISO 14229-1 table 2, "sub-function" column). + // suppressPosRspMsgIndication bit (ISO 14229-1 table 2, "sub-function" column). Not 0x2A: + // its transmissionMode is a plain parameter (Codex on #150). private static bool HasSubFunction(UdsServiceId sid) => (byte)sid switch { - 0x10 or 0x11 or 0x19 or 0x27 or 0x28 or 0x29 or 0x2A or 0x2C or 0x31 or 0x3E + 0x10 or 0x11 or 0x19 or 0x27 or 0x28 or 0x29 or 0x2C or 0x31 or 0x3E or 0x83 or 0x85 or 0x86 or 0x87 => true, _ => false, }; diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index d691e8e..4cde127 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -25,6 +25,7 @@ public sealed class UdsFunctionalClient : IDisposable private const byte SuppressPositiveResponseBit = 0x80; private const byte NegativeResponseSid = 0x7F; private const byte NrcResponsePending = 0x78; + private const byte ReadDataByPeriodicIdentifierSid = 0x2A; private readonly IsoTpFunctionalClient _client; private readonly bool _ownsClient; @@ -126,14 +127,15 @@ private async Task> SendRawLockedAsync(Read "A functional ReadDataByIdentifier reads one DID: a Single Frame cannot carry more, and only one is correlated.", nameof(request)); - // Noted before the collection: what the ECUs may still send after the window ends is - // the remainder of their P2 from the request, not from now. - var sentAt = Stopwatch.GetTimestamp(); + // Noted before the send, so a collection that is cancelled or fails still leaves the + // window in place (Bugbot on #150); what the ECUs may still send after the window ends + // is the remainder of their P2 from the request. A reading taken before the send is a + // lower bound on it; the window is moved out to the send's own instant afterwards. + byte sid = request.Span[0]; + _openWindows.Note(sid, Stopwatch.GetTimestamp(), _responseWindow); var raw = await _client.SendAndCollectAsync(request, window, cancellationToken) .ConfigureAwait(false); - _openWindows.Note(request.Span[0], sentAt, _responseWindow); var req = request.Span; - byte sid = req[0]; byte positiveSid = (byte)(sid + 0x40); // A positive response echoes the request's leading parameter bytes -- the sub-function // (bit 7 cleared), a DID, a routine identifier, a block counter -- so a late answer to @@ -144,7 +146,8 @@ private async Task> SendRawLockedAsync(Read foreach (var r in raw) { var data = r.Data; - bool positive = data.Length >= 1 + echoed && data[0] == positiveSid && EchoMatches(req, data, echoed); + bool positive = data.Length >= 1 + echoed && data[0] == positiveSid && EchoMatches(req, data, echoed) + && (sid != ReadDataByPeriodicIdentifierSid || NamesARequestedPeriodicIdentifier(req, data)); bool negative = data.Length >= 3 && data[0] == NegativeResponseSid && data[1] == sid; if (IsResponsePending(data, sid)) _openWindows.Extend(sid, Stopwatch.GetTimestamp() + (long)(_responsePendingWindow.TotalSeconds * Stopwatch.Frequency)); @@ -159,6 +162,7 @@ private async Task> SendRawLockedAsync(Read private async Task WaitOutOpenWindowAsync(byte sid, CancellationToken cancellationToken) { if (!_openWindows.TryGetDeadline(sid, out var until)) return; + bool waitedOut = false; try { while (true) @@ -169,10 +173,14 @@ private async Task WaitOutOpenWindowAsync(byte sid, CancellationToken cancellati if (heard.Any(r => IsResponsePending(r.Data, sid))) until = Math.Max(until, Stopwatch.GetTimestamp() + (long)(_responsePendingWindow.TotalSeconds * Stopwatch.Frequency)); } + waitedOut = true; } finally { - _openWindows.Forget(sid); + // A cancelled wait keeps what remains of the window, extensions included, for the + // next call (Bugbot on #150). + if (waitedOut) _openWindows.Forget(sid); + else _openWindows.Extend(sid, until); } } @@ -224,6 +232,18 @@ public async Task> DiagnosticSessionControl _ => HasSubFunction(sid) ? 1 : 0, }; + // 0x2A echoes nothing: its positive response starts with the periodic identifier it carries + // data for, which must be one the request asked for (bytes after the transmission mode). + private static bool NamesARequestedPeriodicIdentifier(ReadOnlySpan request, byte[] response) + { + if (response.Length < 2) return false; + for (int i = 2; i < request.Length; i++) + { + if (request[i] == response[1]) return true; + } + return false; + } + private static bool EchoMatches(ReadOnlySpan request, byte[] response, int echoed) { for (int i = 0; i < echoed; i++) @@ -238,10 +258,11 @@ private static bool EchoMatches(ReadOnlySpan request, byte[] response, int private static bool IsResponsePending(byte[] data, byte sid) => data.Length >= 3 && data[0] == NegativeResponseSid && data[1] == sid && data[2] == NrcResponsePending; - // Mirrors UdsClientImpl.HasSubFunction (ISO 14229-1 table 2). + // Mirrors UdsClientImpl.HasSubFunction (ISO 14229-1 table 2). Not 0x2A: its + // transmissionMode is a plain parameter, and its response echoes nothing (Codex on #150). private static bool HasSubFunction(byte sid) => sid switch { - 0x10 or 0x11 or 0x19 or 0x27 or 0x28 or 0x29 or 0x2A or 0x2C or 0x31 or 0x3E + 0x10 or 0x11 or 0x19 or 0x27 or 0x28 or 0x29 or 0x2C or 0x31 or 0x3E or 0x83 or 0x85 or 0x86 or 0x87 => true, _ => false, }; diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs index a5e22a3..3cfe9e3 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs @@ -580,6 +580,68 @@ public async Task A_Pending_Answer_To_A_Suppressed_Send_Extends_Its_Window_By_P2 } } + // 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() + { + var (client, ecu, dispose) = BuildPair( + e => e.On(0x3E, req => + { + if ((req[1] & 0x80) != 0) + throw new EcuResponsePendingThenNegative(pendingCount: 1, nrc: 0x12, + delayBefore: TimeSpan.FromMilliseconds(50), delayAfter: TimeSpan.FromMilliseconds(500)); + return new byte[] { 0x00 }; + }), + options: new UdsClientOptions + { + P2ClientMax = TimeSpan.FromMilliseconds(200), + P2StarClientMax = TimeSpan.FromMilliseconds(1500), + }); + + using (dispose) + { + using var cts = new CancellationTokenSource(ShortTimeout); + await client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token); + await Task.Delay(300); // the 0x78 is queued, the 200 ms window has run out, the negative is at 550 ms + + Func act = () => client.TesterPresentAsync(suppressPositiveResponse: false, cts.Token); + await act.Should().NotThrowAsync("the queued 0x78 moves the window out by P2*"); + ecu.RequestsHandled.Should().Be(2); + } + } + + // 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( + e => e.On(0x3E, req => + { + if ((req[1] & 0x80) != 0) + { + Thread.Sleep(200); + 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(); + + // 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); + } + } + // NRC 0x21 asks for a repeat; the client repeats, up to MaxBusyRepeatRequests. [Fact] public async Task BusyRepeatRequest_Is_Repeated_Until_The_Server_Answers() diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index 14d1318..d7cf03d 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -285,6 +285,66 @@ public async Task A_Late_Negative_Answer_To_A_Previous_Request_Does_Not_Land_In_ "F190's late negative answer is the previous request's"); } + // Bugbot on #150: a collection that is cancelled still leaves the window in place. + [Fact] + public async Task A_Cancelled_Collection_Still_Leaves_Its_Window_For_The_Next_Call() + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var busEcus = OpenClassic(session, 1); + + var negative = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x22, 0x31 }); + var positive = SingleFrameFrom(Ecu1, new byte[] { 0x62, 0xF1, 0x91, 0x02 }); + busEcus.FrameObserved += (_, e) => + { + if (e.CanFrame.ID != unchecked((int)FunctionalTxId)) return; + if (e.CanFrame.Data.Span[3] == 0x90) + _ = Task.Run(async () => { await Task.Delay(150); busEcus.Transmit(negative); }); + else + busEcus.Transmit(positive); + }; + + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(300)); + + using var early = new CancellationTokenSource(TimeSpan.FromMilliseconds(30)); + Func cancelled = () => functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, Window, early.Token); + await cancelled.Should().ThrowAsync(); + + using var cts = new CancellationTokenSource(ShortTimeout); + var responses = await functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x91 }, Window, cts.Token); + responses.Should().ContainSingle().Which.IsNegative.Should().BeFalse( + "the cancelled request's late negative answer is not the next call's"); + } + + // Codex on #150: ReadDataByPeriodicIdentifier echoes nothing; its answer names the + // periodic identifier it carries data for, which must be one the request asked for. + [Fact] + public async Task A_Periodic_Read_Is_Correlated_On_The_Requested_Identifier() + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var busEcus = OpenClassic(session, 1); + + var ours = SingleFrameFrom(Ecu1, new byte[] { 0x6A, 0xF1, 0x11, 0x22 }); + var other = SingleFrameFrom(Ecu2, new byte[] { 0x6A, 0xF2, 0x33 }); + busEcus.FrameObserved += (_, e) => + { + if (e.CanFrame.ID != unchecked((int)FunctionalTxId)) return; + busEcus.Transmit(other); + busEcus.Transmit(ours); + }; + + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), ownsClient: true); + + using var cts = new CancellationTokenSource(ShortTimeout); + var responses = await functional.SendRawAsync(new byte[] { 0x2A, 0x01, 0xF1 }, Window, cts.Token); + + responses.Should().ContainSingle().Which.Response.Should().Equal(0x6A, 0xF1, 0x11, 0x22); + } + // Codex on #150: only one DID is correlated, and a Single Frame holds no more anyway. [Fact] public async Task A_Functional_Read_For_More_Than_One_Did_Is_Refused() From 2da4e3a069c2d4cf28ad9e1aed8477fb257d41b3 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:39:03 +0200 Subject: [PATCH 11/55] fix(uds): anchor a functional request's window at its transmission, however late that was confirmed Codex on #150: a window noted before the send ends early when the transmit confirmation is slow, since the ECU's P2 runs from the transmission. The pre-send note stays as the lower bound a cancelled collection keeps; once the collection is over, the window is moved out to the transmission's instant plus P2, which the collection's own length gives. Mutation-checked with a deferred-echo bus holding the confirmation for 200 ms. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 12 ++++-- .../TestCases/Uds/UdsFunctionalClientTests.cs | 38 +++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index 4cde127..062abd2 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -127,14 +127,16 @@ private async Task> SendRawLockedAsync(Read "A functional ReadDataByIdentifier reads one DID: a Single Frame cannot carry more, and only one is correlated.", nameof(request)); - // Noted before the send, so a collection that is cancelled or fails still leaves the - // window in place (Bugbot on #150); what the ECUs may still send after the window ends - // is the remainder of their P2 from the request. A reading taken before the send is a - // lower bound on it; the window is moved out to the send's own instant afterwards. + // Noted before the send, so a collection that is cancelled or fails still leaves a + // window in place (Bugbot on #150) -- a lower bound, since the send is later. Once the + // collection is over, the window is moved out to the send's own instant plus P2: the + // collection ran for `window` from the transmit confirmation, so that instant is at + // least now less `window`, however long the confirmation took (Codex on #150). byte sid = request.Span[0]; _openWindows.Note(sid, Stopwatch.GetTimestamp(), _responseWindow); var raw = await _client.SendAndCollectAsync(request, window, cancellationToken) .ConfigureAwait(false); + _openWindows.Note(sid, Stopwatch.GetTimestamp() - Ticks(window), _responseWindow); var req = request.Span; byte positiveSid = (byte)(sid + 0x40); // A positive response echoes the request's leading parameter bytes -- the sub-function @@ -255,6 +257,8 @@ private static bool EchoMatches(ReadOnlySpan request, byte[] response, int return true; } + private static long Ticks(TimeSpan span) => (long)(span.TotalSeconds * Stopwatch.Frequency); + private static bool IsResponsePending(byte[] data, byte sid) => data.Length >= 3 && data[0] == NegativeResponseSid && data[1] == sid && data[2] == NrcResponsePending; diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index d7cf03d..3171308 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -7,6 +7,7 @@ using CanKit.Abstractions.API.Common.Definitions; using CanKit.Core; using CanKit.Pro.IsoTp; +using CanKit.Pro.RawCan; using CanKit.Pro.Tests.Infrastructure; using CanKit.Pro.Uds; using FluentAssertions; @@ -345,6 +346,43 @@ public async Task A_Periodic_Read_Is_Correlated_On_The_Requested_Identifier() responses.Should().ContainSingle().Which.Response.Should().Equal(0x6A, 0xF1, 0x11, 0x22); } + // Codex on #150: the window is the ECU's P2 from the *transmission*. With the transmit + // confirmation delayed, a window anchored before the send ends too early; after the + // collection it is moved out to the send's instant, which the collection's length gives. + [Fact] + public async Task A_Window_Is_Anchored_At_The_Transmission_However_Late_It_Was_Confirmed() + { + using var bus = ControllableBus.DeferredEchoCapable(NewSession()); + using var service = new CanBusService(bus); + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(service, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(300)); + + var negative = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x22, 0x31 }); + var positive = SingleFrameFrom(Ecu1, new byte[] { 0x62, 0xF1, 0x91, 0x02 }); + + using var cts = new CancellationTokenSource(ShortTimeout); + // First request: its echo -- the transmit confirmation -- is held for 200 ms; the ECU + // answers negatively 450 ms after the frame went out, inside its P2 of 300 ms from the + // confirmation's point of view as the client sees it... and before that from the wire's. + var first = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, TimeSpan.FromMilliseconds(50), cts.Token); + await bus.DeferredEchoes.WaitForEnqueuedAsync(1, ShortTimeout); + _ = Task.Run(async () => { await Task.Delay(450); bus.RaiseObserved(negative, isEcho: false); }); + await Task.Delay(200); + bus.DeferredEchoes.ReleaseNext(); + await first; + + // The second request, at once: its own echo is released promptly, and the ECU answers it. + var second = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x91 }, Window, cts.Token); + await bus.DeferredEchoes.WaitForEnqueuedAsync(2, ShortTimeout); // the count never decreases + bus.DeferredEchoes.ReleaseNext(); + _ = Task.Run(async () => { await Task.Delay(20); bus.RaiseObserved(positive, isEcho: false); }); + var responses = await second; + + responses.Should().ContainSingle().Which.IsNegative.Should().BeFalse( + "the negative answer came 450 ms after the first request's transmission, inside its window"); + } + // Codex on #150: only one DID is correlated, and a Single Frame holds no more anyway. [Fact] public async Task A_Functional_Read_For_More_Than_One_Did_Is_Refused() From 1dc74ac77b8e20924d030415e5e750cb8950577f Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:06:09 +0200 Subject: [PATCH 12/55] feat(isotp): stamp a functional response with its arrival; fix(uds): note a suppressed send's window before the send, and run P2* from the 0x78's arrival Codex on #150, twice more. A suppressed send cancelled between the driver's acceptance and the confirmation is on the bus and may still be answered, but neither client had noted its window yet; both note it before the send and move it out afterwards. And a 0x78 heard during a functional collection extended the window from the collection's end, and was lost with the collection when the caller cancelled: IsoTpFunctionalResponse now carries the demux's arrival stamp, P2* runs from it, and a cancelled collection takes the conservative reading -- P2* from the cancellation -- since what arrived is lost with it. Each mutation-checked; the stub channel hands its queued response over once, as an inbox does, which the new drain relies on. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.IsoTp/IsoTpFunctionalClient.cs | 12 ++- .../IsoTpFunctionalResponse.cs | 10 ++- src/CanKit.Pro.Uds/UdsClientImpl.cs | 4 + src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 27 +++++-- .../CanKit.Pro.IsoTp.approved.txt | 1 + .../TestCases/Uds/UdsExpiredDeadlineTests.cs | 60 +++++++++++++- .../TestCases/Uds/UdsFunctionalClientTests.cs | 78 ++++++++++++++++++- 7 files changed, 178 insertions(+), 14 deletions(-) diff --git a/src/CanKit.Pro.IsoTp/IsoTpFunctionalClient.cs b/src/CanKit.Pro.IsoTp/IsoTpFunctionalClient.cs index 00a6f3f..6d6ec7d 100644 --- a/src/CanKit.Pro.IsoTp/IsoTpFunctionalClient.cs +++ b/src/CanKit.Pro.IsoTp/IsoTpFunctionalClient.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using CanKit.Abstractions.API.Can.Definitions; @@ -265,7 +266,7 @@ private static async Task> CollectFromSub { await foreach (var frameEvent in sub.Frames.WithCancellation(windowToken).ConfigureAwait(false)) { - if (TryParseFunctionalResponse(frameEvent.Frame, out var response)) + if (TryParseFunctionalResponse(frameEvent, out var response)) responses.Add(response!); } } @@ -285,7 +286,7 @@ private static async Task> CollectFromSub sub.Dispose(); while (sub.TryRead(out var frameEvent)) { - if (TryParseFunctionalResponse(frameEvent.Frame, out var response)) + if (TryParseFunctionalResponse(frameEvent, out var response)) responses.Add(response!); } } @@ -303,9 +304,10 @@ private static void DrainBuffered(ISubscription sub) while (sub.TryRead(out _)) { } } - private static bool TryParseFunctionalResponse(CanFrameView frame, + private static bool TryParseFunctionalResponse(in CanFrameEvent frameEvent, out IsoTpFunctionalResponse? response) { + var frame = frameEvent.Frame; var payload = frame.Data.ToArray(); bool isCanFd = frame.FrameKind == CanFrameType.CanFd; @@ -333,7 +335,9 @@ private static bool TryParseFunctionalResponse(CanFrameView frame, var pdu = new byte[pci.Length]; Array.Copy(payload, pci.DataOffset, pdu, 0, pci.Length); - response = new IsoTpFunctionalResponse((uint)frame.ID, pdu); + // Stamped by the demux at arrival; "now" only for an event built without a stamp. + var arrival = frameEvent.HostArrivalTimestamp > 0 ? frameEvent.HostArrivalTimestamp : Stopwatch.GetTimestamp(); + response = new IsoTpFunctionalResponse((uint)frame.ID, pdu, arrival); return true; } diff --git a/src/CanKit.Pro.IsoTp/IsoTpFunctionalResponse.cs b/src/CanKit.Pro.IsoTp/IsoTpFunctionalResponse.cs index 6773ea3..efc331d 100644 --- a/src/CanKit.Pro.IsoTp/IsoTpFunctionalResponse.cs +++ b/src/CanKit.Pro.IsoTp/IsoTpFunctionalResponse.cs @@ -26,9 +26,17 @@ public sealed class IsoTpFunctionalResponse /// public byte[] Data { get; } - internal IsoTpFunctionalResponse(uint sourceCanId, byte[] data) + /// + /// When the frame arrived, as the demux stamped it (), + /// so a caller's deadline that runs from a response -- UDS P2* from an NRC 0x78 -- runs + /// from its arrival, not from the end of the collection window (#150). + /// + public long HostArrivalTimestamp { get; } + + internal IsoTpFunctionalResponse(uint sourceCanId, byte[] data, long hostArrivalTimestamp = 0) { SourceCanId = sourceCanId; Data = data ?? throw new ArgumentNullException(nameof(data)); + HostArrivalTimestamp = hostArrivalTimestamp; } } diff --git a/src/CanKit.Pro.Uds/UdsClientImpl.cs b/src/CanKit.Pro.Uds/UdsClientImpl.cs index 5688bb8..f683d77 100644 --- a/src/CanKit.Pro.Uds/UdsClientImpl.cs +++ b/src/CanKit.Pro.Uds/UdsClientImpl.cs @@ -435,6 +435,10 @@ private async Task SendWithoutResponseAsync(byte[] request, CancellationToken ca await _requestLock.WaitAsync(linkedToken).ConfigureAwait(false); try { + // Noted before the send as well: cancelled between the driver's acceptance and the + // confirmation, the frame is on the bus and may still be answered (Codex on #150). + // Moved out to the transmit stamp afterwards. + _suppressedWindows.Note(request[0], Stopwatch.GetTimestamp(), _options.P2ClientMax); var stamps = await _channel.SendWithTransmitStampAsync(request, linkedToken).ConfigureAwait(false); var sent = stamps.LastFrameTransmitTimestamp > 0 ? stamps.LastFrameTransmitTimestamp : Stopwatch.GetTimestamp(); _suppressedWindows.Note(request[0], sent, _options.P2ClientMax); diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index 062abd2..ca6fbe4 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -114,6 +114,10 @@ private async Task> SendRawLockedAsync(Read if (request.Length >= 2 && HasSubFunction(request.Span[0]) && (request.Span[1] & SuppressPositiveResponseBit) != 0) { + // Noted before the send as well: cancelled between the driver's acceptance and + // the confirmation, the frame is on the bus and may still be answered (Codex on + // #150). Moved out to the confirmation afterwards. + _openWindows.Note(request.Span[0], Stopwatch.GetTimestamp(), _responseWindow); await _client.SendAsync(request, cancellationToken).ConfigureAwait(false); _openWindows.Note(request.Span[0], Stopwatch.GetTimestamp(), _responseWindow); return Array.Empty(); @@ -134,8 +138,20 @@ private async Task> SendRawLockedAsync(Read // least now less `window`, however long the confirmation took (Codex on #150). byte sid = request.Span[0]; _openWindows.Note(sid, Stopwatch.GetTimestamp(), _responseWindow); - var raw = await _client.SendAndCollectAsync(request, window, cancellationToken) - .ConfigureAwait(false); + IReadOnlyList raw; + try + { + raw = await _client.SendAndCollectAsync(request, window, cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // What arrived before the cancellation is lost with it -- an NRC 0x78 among it + // would have moved the window out by P2*. Not knowing, the window takes that + // reading (Codex on #150): the next call for this service waits P2* from now. + _openWindows.Extend(sid, Stopwatch.GetTimestamp() + Ticks(_responsePendingWindow)); + throw; + } _openWindows.Note(sid, Stopwatch.GetTimestamp() - Ticks(window), _responseWindow); var req = request.Span; byte positiveSid = (byte)(sid + 0x40); @@ -151,8 +167,9 @@ private async Task> SendRawLockedAsync(Read bool positive = data.Length >= 1 + echoed && data[0] == positiveSid && EchoMatches(req, data, echoed) && (sid != ReadDataByPeriodicIdentifierSid || NamesARequestedPeriodicIdentifier(req, data)); bool negative = data.Length >= 3 && data[0] == NegativeResponseSid && data[1] == sid; + // P2* runs from the 0x78's arrival, which the response carries. if (IsResponsePending(data, sid)) - _openWindows.Extend(sid, Stopwatch.GetTimestamp() + (long)(_responsePendingWindow.TotalSeconds * Stopwatch.Frequency)); + _openWindows.Extend(sid, r.HostArrivalTimestamp + Ticks(_responsePendingWindow)); if (positive || negative) responses.Add(new UdsFunctionalResponse(r.SourceCanId, data)); } return responses; @@ -172,8 +189,8 @@ private async Task WaitOutOpenWindowAsync(byte sid, CancellationToken cancellati var remaining = SuppressedResponseWindows.Remaining(until); if (remaining <= TimeSpan.Zero) break; var heard = await _client.CollectResponsesAsync(remaining, cancellationToken).ConfigureAwait(false); - if (heard.Any(r => IsResponsePending(r.Data, sid))) - until = Math.Max(until, Stopwatch.GetTimestamp() + (long)(_responsePendingWindow.TotalSeconds * Stopwatch.Frequency)); + foreach (var pending in heard.Where(r => IsResponsePending(r.Data, sid))) + until = Math.Max(until, pending.HostArrivalTimestamp + Ticks(_responsePendingWindow)); } waitedOut = true; } 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 cffc654..73d45a1 100644 --- a/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.IsoTp.approved.txt +++ b/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.IsoTp.approved.txt @@ -133,6 +133,7 @@ namespace CanKit.Pro.IsoTp public sealed class IsoTpFunctionalResponse { public byte[] Data { get; } + public long HostArrivalTimestamp { get; } public uint SourceCanId { get; } } public sealed class IsoTpOverflowException : CanKit.Pro.IsoTp.IsoTpException diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs index baf2db0..87a4529 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs @@ -381,6 +381,47 @@ await act.Should().NotThrowAsync( "the holder's Release must find its semaphore intact"); } + /// + /// Codex on #150 — a suppressed send cancelled between the driver's acceptance and the + /// confirmation is on the bus and may still be answered: its window is noted before the + /// send, so the next request for the service still waits it out. + /// + [Fact] + public async Task O_A_Suppressed_Send_Cancelled_Before_Confirmation_Still_Opens_Its_Window() + { + using var channel = new StubChannel( + deliverAfter: TimeSpan.FromSeconds(5), + stampArrivalAtDelivery: true) + { + TransmissionTime = TimeSpan.FromMilliseconds(200), + CancellableSend = true, + HonorCancellation = true, + }; + using var client = NewClient(channel); + + using var early = new CancellationTokenSource(TimeSpan.FromMilliseconds(30)); + Func cancelled = () => client.SendRawAsync(new byte[] { 0x3E, 0x80 }, early.Token); + await cancelled.Should().ThrowAsync(); + + // The next TesterPresent must wait the suppressed send's window (P2 = 80 ms from the + // note, taken just before the first send began) before it sends. + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500)); + Func next = () => client.TesterPresentAsync(suppressPositiveResponse: false, cts.Token); + await next.Should().ThrowAsync(); // the stub never answers; what matters is when it sent + + long gapTicks; + lock (channel.Sent) + { + channel.Sent.Should().HaveCount(2); + gapTicks = channel.Sent[1].StartedAt - channel.Sent[0].StartedAt; + } + // The cancelled send started at ~0 ms and was cancelled at 30 ms; without the window the + // second would start right then. 80 ms less a margin for the note preceding the send. + TimeSpan.FromSeconds((double)gapTicks / Stopwatch.Frequency).Should().BeGreaterThanOrEqualTo( + Budget - TimeSpan.FromMilliseconds(5), + "the second send waited out the window the cancelled send opened"); + } + private sealed class StubChannel : IIsoTpChannel { private static readonly byte[] Response = { 0x62, 0xF1, 0x90, 0xAA }; @@ -457,11 +498,18 @@ public StubChannel(TimeSpan deliverAfter, bool stampArrivalAtDelivery) /// When set, a receive blocks here first, ignoring cancellation (#57). public TaskCompletionSource? Gate { get; init; } + /// Requests sent, in order, with the instant each send began (#150). + public List<(byte[] Request, long StartedAt)> Sent { get; } = new(); + + /// When set, a send observes the token while it waits for its confirmation. + public bool CancellableSend { get; init; } + public async Task SendWithTransmitStampAsync(ReadOnlyMemory pdu, CancellationToken cancellationToken = default) { + lock (Sent) Sent.Add((pdu.ToArray(), Stopwatch.GetTimestamp())); if (TransmissionTime > TimeSpan.Zero) - await Task.Delay(TransmissionTime, CancellationToken.None).ConfigureAwait(false); + await Task.Delay(TransmissionTime, CancellableSend ? cancellationToken : CancellationToken.None).ConfigureAwait(false); // The wire instant. Everything the client is entitled to measure is relative to this // and to nothing else; arrival is "now" for the punctual case, inside the budget and @@ -522,17 +570,23 @@ await Task.Delay(remaining, /// a caller past its deadline gets it handed over without waiting, and its stamp — not /// the caller's clock — decides whether it counts. /// + private bool _handedOver; + public bool TryReceiveWithArrival(out IsoTpReceivedPdu pdu) { - if (RespondPendingFirst && _pendingSent) + // An inbox empties: the one queued response is handed over once (#150 -- a drain + // that reads until the inbox is empty would otherwise never end here). + if (!_handedOver && RespondPendingFirst && _pendingSent) { + _handedOver = true; pdu = new IsoTpReceivedPdu(Response, FinalArrivalStamp()); return true; } - if (HonorCancellation && FirstFrameOffsetFromTransmit is null) + if (!_handedOver && HonorCancellation && FirstFrameOffsetFromTransmit is null) { // Stamped at the request: punctual, and waiting in the inbox all along. + _handedOver = true; pdu = new IsoTpReceivedPdu(Response, _arrivalStamp); return true; } diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index 3171308..7728f22 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -305,9 +306,11 @@ public async Task A_Cancelled_Collection_Still_Leaves_Its_Window_For_The_Next_Ca busEcus.Transmit(positive); }; + // A cancelled collection takes the conservative reading (P2* from the cancellation); + // kept short here so the test measures the window, not the default P2*. using var functional = UdsFunctionalClient.Create( IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), - ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(300)); + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(300), responsePendingWindow: TimeSpan.FromMilliseconds(300)); using var early = new CancellationTokenSource(TimeSpan.FromMilliseconds(30)); Func cancelled = () => functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, Window, early.Token); @@ -383,6 +386,79 @@ public async Task A_Window_Is_Anchored_At_The_Transmission_However_Late_It_Was_C "the negative answer came 450 ms after the first request's transmission, inside its window"); } + // Codex on #150: P2* runs from the 0x78's arrival, not from the end of the collection. + // With the 0x78 at the start of a 300 ms window and P2* = 400 ms, the next call may go + // out at 400 ms after the request, not at 700. + [Fact] + public async Task A_Pending_Answer_Extends_The_Window_From_Its_Arrival_Not_From_The_Collections_End() + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var busEcus = OpenClassic(session, 1); + + var pending = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x22, 0x78 }); + var positive = SingleFrameFrom(Ecu1, new byte[] { 0x62, 0xF1, 0x91, 0x02 }); + busEcus.FrameObserved += (_, e) => + { + if (e.CanFrame.ID != unchecked((int)FunctionalTxId)) return; + if (e.CanFrame.Data.Span[3] == 0x90) busEcus.Transmit(pending); else busEcus.Transmit(positive); + }; + + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(100), responsePendingWindow: TimeSpan.FromMilliseconds(400)); + + using var cts = new CancellationTokenSource(ShortTimeout); + var sw = Stopwatch.StartNew(); + await functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, TimeSpan.FromMilliseconds(300), cts.Token); + var responses = await functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x91 }, TimeSpan.FromMilliseconds(50), cts.Token); + sw.Stop(); + + responses.Should().ContainSingle(); + // From the 0x78 (at ~0 ms) plus 400 ms the second call goes out and collects for 50 ms: + // ~450 ms in all. From the collection's end (300 ms) plus 400 ms it could not finish + // before 750 ms. The bound sits between the two. + sw.Elapsed.Should().BeLessThan(TimeSpan.FromMilliseconds(620), + "P2* is counted from the 0x78's arrival"); + } + + // Codex on #150: a cancelled collection cannot show what arrived; the window takes the + // conservative reading and the next call waits P2* from the cancellation. + [Fact] + public async Task A_Cancelled_Collection_Leaves_A_Conservative_Window() + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var busEcus = OpenClassic(session, 1); + + var pending = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x22, 0x78 }); + var negative = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x22, 0x31 }); + var positive = SingleFrameFrom(Ecu1, new byte[] { 0x62, 0xF1, 0x91, 0x02 }); + busEcus.FrameObserved += (_, e) => + { + if (e.CanFrame.ID != unchecked((int)FunctionalTxId)) return; + if (e.CanFrame.Data.Span[3] == 0x90) + { + busEcus.Transmit(pending); + _ = Task.Run(async () => { await Task.Delay(250); busEcus.Transmit(negative); }); + } + else busEcus.Transmit(positive); + }; + + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(100), responsePendingWindow: TimeSpan.FromMilliseconds(400)); + + using var early = new CancellationTokenSource(TimeSpan.FromMilliseconds(30)); + Func cancelled = () => functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, Window, early.Token); + await cancelled.Should().ThrowAsync(); + + using var cts = new CancellationTokenSource(ShortTimeout); + var responses = await functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x91 }, Window, cts.Token); + responses.Should().ContainSingle().Which.IsNegative.Should().BeFalse( + "the final negative answer at 250 ms belongs to the cancelled request, whose 0x78 the cancellation hid"); + } + // Codex on #150: only one DID is correlated, and a Single Frame holds no more anyway. [Fact] public async Task A_Functional_Read_For_More_Than_One_Did_Is_Refused() From 0319c254d50cbc082a9d2c8f92c545beb0d4c8c4 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:13:21 +0200 Subject: [PATCH 13/55] fix(uds): route a heard 0x78 to its own service's window, survive a queued transport fault in the drain, and take the conservative reading on a cancelled functional wait Codex and Bugbot on #150. A 0x78 for service B heard while waiting out service A's window was dropped as A's noise; it moves B's window out, on both clients. The physical drain read the inbox with a take that throws on a queued reassembly abort, so a stale fault failed the next request before it was sent; the fault is dropped as the discard drops it. And a cancelled functional wait-out loses what its slice heard, so its window takes the conservative reading, as a cancelled collection does. Each mutation-checked; the simulated ECU's pending-then-negative sequence runs off its loop, since on the loop it blocked the very request the hazard needs. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsClientImpl.cs | 32 +++++++++++++-- src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 29 ++++++++++++-- .../TestCases/Uds/SimulatedUdsEcu.cs | 31 +++++++++----- .../TestCases/Uds/UdsClientTests.cs | 40 +++++++++++++++++++ .../TestCases/Uds/UdsExpiredDeadlineTests.cs | 37 +++++++++++++++++ .../TestCases/Uds/UdsFunctionalClientTests.cs | 40 +++++++++++++++++++ 6 files changed, 193 insertions(+), 16 deletions(-) diff --git a/src/CanKit.Pro.Uds/UdsClientImpl.cs b/src/CanKit.Pro.Uds/UdsClientImpl.cs index f683d77..3db6cc7 100644 --- a/src/CanKit.Pro.Uds/UdsClientImpl.cs +++ b/src/CanKit.Pro.Uds/UdsClientImpl.cs @@ -483,6 +483,12 @@ private async Task WaitOutSuppressedResponseWindowAsync(UdsServiceId serviceId, if (DrainExtends(sid, ref until)) continue; break; } + catch (IsoTpException) + { + // A queued transport fault -- an aborted reassembly from before -- is stale + // here, as it is in the discard that follows (Bugbot on #150). + continue; + } ExtendOnPending(sid, pdu, ref until); } waitedOut = true; @@ -496,21 +502,41 @@ private async Task WaitOutSuppressedResponseWindowAsync(UdsServiceId serviceId, } } - // Reads whatever is queued; true when a 0x78 among it moved the window out. + // Reads whatever is queued; true when a 0x78 among it moved this service's window out. private bool DrainExtends(byte sid, ref long until) { bool extended = false; - while (_channel.TryReceiveWithArrival(out var queued)) + while (true) + { + IsoTpReceivedPdu queued; + try + { + if (!_channel.TryReceiveWithArrival(out queued)) break; + } + catch (IsoTpException) + { + continue; // a stale transport fault, dropped as the discard would (Bugbot on #150) + } extended |= ExtendOnPending(sid, queued, ref until); + } return extended; } + // A 0x78 heard while waiting out one service's window may be for another service whose + // window is open too; it moves that service's window out in the table, and this + // service's locally (Codex on #150). True when this service's moved. private bool ExtendOnPending(byte sid, in IsoTpReceivedPdu pdu, ref long until) { var data = pdu.Pdu; - if (data.Length < 3 || data[0] != NegativeResponseSid || data[1] != sid || data[2] != NrcResponsePending) + if (data.Length < 3 || data[0] != NegativeResponseSid || data[2] != NrcResponsePending) return false; var extendedUntil = pdu.FirstFrameArrivalTimestamp + (long)(_options.P2StarClientMax.TotalSeconds * Stopwatch.Frequency); + if (data[1] != sid) + { + if (_suppressedWindows.TryGetDeadline(data[1], out _)) + _suppressedWindows.Extend(data[1], extendedUntil); + return false; + } if (extendedUntil <= until) return false; until = extendedUntil; return true; diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index ca6fbe4..81bf908 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -188,9 +188,27 @@ private async Task WaitOutOpenWindowAsync(byte sid, CancellationToken cancellati { var remaining = SuppressedResponseWindows.Remaining(until); if (remaining <= TimeSpan.Zero) break; - var heard = await _client.CollectResponsesAsync(remaining, cancellationToken).ConfigureAwait(false); - foreach (var pending in heard.Where(r => IsResponsePending(r.Data, sid))) - until = Math.Max(until, pending.HostArrivalTimestamp + Ticks(_responsePendingWindow)); + IReadOnlyList heard; + try + { + heard = await _client.CollectResponsesAsync(remaining, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // What this slice heard is lost with it -- a 0x78 among it would have moved + // the window out by P2*; the window takes that reading (Bugbot on #150). + until = Math.Max(until, Stopwatch.GetTimestamp() + Ticks(_responsePendingWindow)); + throw; + } + foreach (var pending in heard.Where(r => IsResponsePending(r.Data))) + { + // A 0x78 for another service with a window open moves that one out too + // (Codex on #150). + var extendedUntil = pending.HostArrivalTimestamp + Ticks(_responsePendingWindow); + byte pendingSid = pending.Data[1]; + if (pendingSid == sid) until = Math.Max(until, extendedUntil); + else if (_openWindows.TryGetDeadline(pendingSid, out _)) _openWindows.Extend(pendingSid, extendedUntil); + } } waitedOut = true; } @@ -277,7 +295,10 @@ private static bool EchoMatches(ReadOnlySpan request, byte[] response, int private static long Ticks(TimeSpan span) => (long)(span.TotalSeconds * Stopwatch.Frequency); private static bool IsResponsePending(byte[] data, byte sid) - => data.Length >= 3 && data[0] == NegativeResponseSid && data[1] == sid && data[2] == NrcResponsePending; + => IsResponsePending(data) && data[1] == sid; + + private static bool IsResponsePending(byte[] data) + => data.Length >= 3 && data[0] == NegativeResponseSid && data[2] == NrcResponsePending; // Mirrors UdsClientImpl.HasSubFunction (ISO 14229-1 table 2). Not 0x2A: its // transmissionMode is a plain parameter, and its response echoes nothing (Codex on #150). diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/SimulatedUdsEcu.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/SimulatedUdsEcu.cs index b3c486d..ece4f43 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/SimulatedUdsEcu.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/SimulatedUdsEcu.cs @@ -158,15 +158,28 @@ await SendAndCountAsync(new byte[] { 0x7F, sid, nrc.Code }, ct) } catch (EcuResponsePendingThenNegative pendingNegative) { - if (pendingNegative.DelayBefore > TimeSpan.Zero) - await Task.Delay(pendingNegative.DelayBefore, ct).ConfigureAwait(false); - for (int i = 0; i < pendingNegative.PendingCount && !ct.IsCancellationRequested; i++) - await SendNrcAsync(sid, 0x78, ct).ConfigureAwait(false); - if (pendingNegative.DelayAfter > TimeSpan.Zero) - await Task.Delay(pendingNegative.DelayAfter, ct).ConfigureAwait(false); - if (ct.IsCancellationRequested) return; - await SendAndCountAsync(new byte[] { 0x7F, sid, pendingNegative.Nrc }, ct) - .ConfigureAwait(false); + // Off the loop: the ECU keeps serving other requests while this one's + // 0x78 and final negative go out on their own schedule (#150). + var pn = pendingNegative; + _ = Task.Run(async () => + { + try + { + if (pn.DelayBefore > TimeSpan.Zero) + await Task.Delay(pn.DelayBefore, ct).ConfigureAwait(false); + for (int i = 0; i < pn.PendingCount && !ct.IsCancellationRequested; i++) + await SendNrcAsync(sid, 0x78, ct).ConfigureAwait(false); + if (pn.DelayAfter > TimeSpan.Zero) + await Task.Delay(pn.DelayAfter, ct).ConfigureAwait(false); + if (ct.IsCancellationRequested) return; + await SendAndCountAsync(new byte[] { 0x7F, sid, pn.Nrc }, ct) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // the ECU was disposed mid-sequence + } + }, ct); } catch (EcuResponsePendingThenSilent pendingSilent) { diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs index 3cfe9e3..8f83381 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs @@ -642,6 +642,46 @@ public async Task A_Cancelled_Wait_Keeps_The_Rest_Of_The_Window() } } + // Codex on #150: a 0x78 for service B, heard while waiting out service A's window, moves + // B's window out; it is not dropped as A's noise. + [Fact] + public async Task A_Pending_Answer_For_Another_Service_Heard_During_A_Wait_Extends_That_Services_Window() + { + var (client, ecu, 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 }; + }), + 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); + } + } + // NRC 0x21 asks for a repeat; the client repeats, up to MaxBusyRepeatRequests. [Fact] public async Task BusyRepeatRequest_Is_Repeated_Until_The_Server_Answers() diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs index 87a4529..1eec9d1 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs @@ -422,6 +422,33 @@ public async Task O_A_Suppressed_Send_Cancelled_Before_Confirmation_Still_Opens_ "the second send waited out the window the cancelled send opened"); } + /// + /// Bugbot on #150 — the drain that ends a wait-out reads the inbox, and a queued reassembly + /// fault throws from that read. It is stale, as it is for the discard that follows, and + /// must not fail the request before it is sent. + /// + [Fact] + public async Task P_A_Stale_Transport_Fault_Queued_Behind_A_Suppressed_Send_Does_Not_Fail_The_Next_Request() + { + using var channel = new StubChannel( + deliverAfter: TimeSpan.FromMilliseconds(5), + stampArrivalAtDelivery: false) + { + QueuedFault = true, + ResponseArrivalOffsetFromTransmit = TimeSpan.FromMilliseconds(1), + LastFrameHandoffBeforeTransmit = TimeSpan.FromMilliseconds(1), + }; + using var client = NewClient(channel); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await client.SendRawAsync(new byte[] { 0x22, 0x80 }, cts.Token); // not a sub-function service: sent and answered + await client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token); // suppressed: opens the window + + // The next TesterPresent waits the window out; the drain at its end meets the fault. + Func act = () => client.SendRawAsync(new byte[] { 0x3E, 0x00 }, cts.Token); + await act.Should().NotThrowAsync("the queued fault is stale and dropped"); + } + private sealed class StubChannel : IIsoTpChannel { private static readonly byte[] Response = { 0x62, 0xF1, 0x90, 0xAA }; @@ -572,8 +599,18 @@ await Task.Delay(remaining, /// private bool _handedOver; + /// A queued reassembly fault, thrown by the first non-blocking take (#150). + public bool QueuedFault { get; init; } + private bool _faultThrown; + public bool TryReceiveWithArrival(out IsoTpReceivedPdu pdu) { + if (QueuedFault && !_faultThrown) + { + _faultThrown = true; + throw new IsoTpTimeoutException(IsoTpTimer.NCr, "a stale reassembly abort, queued"); + } + // An inbox empties: the one queued response is handed over once (#150 -- a drain // that reads until the inbox is empty would otherwise never end here). if (!_handedOver && RespondPendingFirst && _pendingSent) diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index 7728f22..4ac3d92 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -459,6 +459,46 @@ public async Task A_Cancelled_Collection_Leaves_A_Conservative_Window() "the final negative answer at 250 ms belongs to the cancelled request, whose 0x78 the cancellation hid"); } + // Bugbot on #150: a wait-out cancelled mid-slice loses what the slice heard; a 0x78 in it + // would have moved the window out, so the window takes that reading. + [Fact] + public async Task A_Cancelled_Wait_Takes_The_Conservative_Reading_For_What_It_Heard() + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var busEcus = OpenClassic(session, 1); + + var pending = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x3E, 0x78 }); + var negative = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x3E, 0x12 }); + var positive = SingleFrameFrom(Ecu1, new byte[] { 0x7E, 0x00 }); + busEcus.FrameObserved += (_, e) => + { + if (e.CanFrame.ID != unchecked((int)FunctionalTxId)) return; + if (e.CanFrame.Data.Span[2] == 0x80) + _ = Task.Run(async () => + { + await Task.Delay(50); busEcus.Transmit(pending); + await Task.Delay(400); busEcus.Transmit(negative); + }); + else busEcus.Transmit(positive); + }; + + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(300), responsePendingWindow: TimeSpan.FromMilliseconds(400)); + + using var cts = new CancellationTokenSource(ShortTimeout); + await functional.TesterPresentAsync(cancellationToken: cts.Token); // suppressed; 0x78 at 50 ms, negative at 450 ms + + using var early = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + Func cancelled = () => functional.TesterPresentAsync(suppressPositiveResponse: false, Window, early.Token); + await cancelled.Should().ThrowAsync(); // cancelled while waiting; the 0x78 it heard is lost + + var responses = await functional.TesterPresentAsync(suppressPositiveResponse: false, Window, cts.Token); + responses.Should().ContainSingle().Which.IsNegative.Should().BeFalse( + "the negative at 450 ms belongs to the suppressed send; the window must have reached past it"); + } + // Codex on #150: only one DID is correlated, and a Single Frame holds no more anyway. [Fact] public async Task A_Functional_Read_For_More_Than_One_Did_Is_Refused() From 01ece81b51dbe46fbf1930dc430083d287556660 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:19:19 +0200 Subject: [PATCH 14/55] fix(uds): give the functional client one listener per service for the whole window, and validate the collection window before sending Codex on #150, twice more. Between a suppressed send and the next call nobody was collecting, so a 0x78 in that gap went unobserved and the window stayed at P2. Every send now starts a listener for its service, subscribed before the frame goes out and alive until the window has run out; it moves the window out on each 0x78 it hears, for its own service and any other with a window open, and a caller's cancellation neither stops it nor loses what it heard -- which retires the two conservative readings a cancelled collection and a cancelled wait had. And a collection window the collector would reject is refused before anything is transmitted, so a session change does not reach every ECU behind an argument error. Each mutation-checked. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/README.md | 12 +- src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 132 ++++++++++-------- .../TestCases/Uds/UdsFunctionalClientTests.cs | 68 ++++++++- 3 files changed, 144 insertions(+), 68 deletions(-) diff --git a/src/CanKit.Pro.Uds/README.md b/src/CanKit.Pro.Uds/README.md index cfed4bf..50bcca3 100644 --- a/src/CanKit.Pro.Uds/README.md +++ b/src/CanKit.Pro.Uds/README.md @@ -104,10 +104,14 @@ await uds.DownloadAsync( as UDS (`UdsFunctionalResponse` with source identifier, bytes, `IsNegative` and the NRC). Only answers correlated to the request are attributed: a positive response echoing the request's leading bytes (sub-function, DID, routine identifier, block counter), or a negative response - naming the service. Calls run one at a time; a call for a service with a suppressed send - still open waits that window out first (`Create`'s `suppressedResponseWindow`, default P2), - and a read for more than one DID is refused, a Single Frame holding no more. The keep-alive - to everyone is `TesterPresentAsync()` (`3E 80`, not collected for) (#57). + naming the service. Calls run one at a time. Every send starts a listener for its service + that stays up for the ECUs' P2 (`Create`'s `responseWindow`, default P2) and moves the + window out by P2* (`responsePendingWindow`, default P2*) on each NRC 0x78 it hears, so + nothing in the window goes unobserved — a suppressed send included — and a call for that + service waits the window out before it collects, rather than taking a late answer to the + earlier request as its own. A read for more than one DID is refused, a Single Frame holding + no more. The keep-alive to everyone is `TesterPresentAsync()` (`3E 80`, not collected for) + (#57). * `Dispose` waits up to five seconds for a request in flight to release the request lock; a holder that outlasts the wait keeps an undisposed semaphore, so its eventual release does not throw into an operation that was merely slow (#57). diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index 81bf908..f9691cb 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -32,6 +32,7 @@ public sealed class UdsFunctionalClient : IDisposable private readonly TimeSpan _responseWindow; private readonly TimeSpan _responsePendingWindow; private readonly SuppressedResponseWindows _openWindows = new(); + private readonly Dictionary _listeners = new(); // One request on the wire at a time, its collection window included: overlapping calls // with the same SID would each collect the other's answers (Codex on #150), as the // physical client's request lock prevents there. @@ -111,47 +112,43 @@ public async Task> SendRawAsync(ReadOnlyMem private async Task> SendRawLockedAsync(ReadOnlyMemory request, TimeSpan window, CancellationToken cancellationToken) { - if (request.Length >= 2 && HasSubFunction(request.Span[0]) + byte sid = request.Span[0]; + if (request.Length >= 2 && HasSubFunction(sid) && (request.Span[1] & SuppressPositiveResponseBit) != 0) { - // Noted before the send as well: cancelled between the driver's acceptance and - // the confirmation, the frame is on the bus and may still be answered (Codex on - // #150). Moved out to the confirmation afterwards. - _openWindows.Note(request.Span[0], Stopwatch.GetTimestamp(), _responseWindow); + // The listener is up before the frame goes out, so nothing an ECU sends back -- + // a negative answer, a 0x78 -- goes unobserved, and a send cancelled between the + // driver's acceptance and the confirmation is covered (Codex on #150). Its window + // is moved out to the confirmation afterwards. + StartListening(sid, Stopwatch.GetTimestamp()); await _client.SendAsync(request, cancellationToken).ConfigureAwait(false); - _openWindows.Note(request.Span[0], Stopwatch.GetTimestamp(), _responseWindow); + _openWindows.Note(sid, Stopwatch.GetTimestamp(), _responseWindow); return Array.Empty(); } + // Checked before anything is transmitted: a window the collector would reject must + // not leave a session change on every ECU behind an argument error (Codex on #150). + if (window <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(window), window, + "The collection window must be positive for a request that is answered."); + // A read for more than one DID is answered with all of them in one PDU, which a Single // Frame cannot hold with their data; and only the first DID would be correlated here, // so two such reads sharing it could take each other's late answers (Codex on #150). - if (request.Span[0] == (byte)UdsServiceId.ReadDataByIdentifier && request.Length > 3) + if (sid == (byte)UdsServiceId.ReadDataByIdentifier && request.Length > 3) throw new ArgumentException( "A functional ReadDataByIdentifier reads one DID: a Single Frame cannot carry more, and only one is correlated.", nameof(request)); - // Noted before the send, so a collection that is cancelled or fails still leaves a - // window in place (Bugbot on #150) -- a lower bound, since the send is later. Once the - // collection is over, the window is moved out to the send's own instant plus P2: the - // collection ran for `window` from the transmit confirmation, so that instant is at - // least now less `window`, however long the confirmation took (Codex on #150). - byte sid = request.Span[0]; - _openWindows.Note(sid, Stopwatch.GetTimestamp(), _responseWindow); - IReadOnlyList raw; - try - { - raw = await _client.SendAndCollectAsync(request, window, cancellationToken) - .ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // What arrived before the cancellation is lost with it -- an NRC 0x78 among it - // would have moved the window out by P2*. Not knowing, the window takes that - // reading (Codex on #150): the next call for this service waits P2* from now. - _openWindows.Extend(sid, Stopwatch.GetTimestamp() + Ticks(_responsePendingWindow)); - throw; - } + // The listener is up before the send and outlives the collection: what the ECUs may + // still send after the window ends is the remainder of their P2 from the request, and + // a collection the caller cancels loses nothing the listener hears. Once the collection + // is over, the window is moved out to the send's own instant plus P2: the collection + // ran for `window` from the transmit confirmation, so that instant is at least now + // less `window`, however long the confirmation took (Codex on #150). + StartListening(sid, Stopwatch.GetTimestamp()); + var raw = await _client.SendAndCollectAsync(request, window, cancellationToken) + .ConfigureAwait(false); _openWindows.Note(sid, Stopwatch.GetTimestamp() - Ticks(window), _responseWindow); var req = request.Span; byte positiveSid = (byte)(sid + 0x40); @@ -167,7 +164,8 @@ private async Task> SendRawLockedAsync(Read bool positive = data.Length >= 1 + echoed && data[0] == positiveSid && EchoMatches(req, data, echoed) && (sid != ReadDataByPeriodicIdentifierSid || NamesARequestedPeriodicIdentifier(req, data)); bool negative = data.Length >= 3 && data[0] == NegativeResponseSid && data[1] == sid; - // P2* runs from the 0x78's arrival, which the response carries. + // P2* runs from the 0x78's arrival, which the response carries. The listener sees + // the same frame; the earlier of the two to act moves the window, the later is idle. if (IsResponsePending(data, sid)) _openWindows.Extend(sid, r.HostArrivalTimestamp + Ticks(_responsePendingWindow)); if (positive || negative) responses.Add(new UdsFunctionalResponse(r.SourceCanId, data)); @@ -175,49 +173,67 @@ private async Task> SendRawLockedAsync(Read return responses; } - // Under the request lock. Waits out the window still open for this service, listening the - // while: an NRC 0x78 in it says an ECU's final answer is still coming and moves the window - // out by P2* (Codex on #150). Everything heard belongs to the earlier request and is dropped. - private async Task WaitOutOpenWindowAsync(byte sid, CancellationToken cancellationToken) + // One listener per service, alive for the whole window: subscribed before the send so no + // frame in the window goes unobserved, and owning the window so a caller's cancellation + // loses nothing (Codex on #150). It moves the window out on every 0x78 it hears -- for its + // own service in the table and for any other service with a window open -- and ends when + // the window has run out, forgetting it. Started under the request lock. + private void StartListening(byte sid, long from) + { + _openWindows.Note(sid, from, _responseWindow); + lock (_listeners) + { + if (_listeners.ContainsKey(sid)) return; // running already; it reads the moved-out deadline + _listeners[sid] = ListenAsync(sid); + } + } + + private async Task ListenAsync(byte sid) { - if (!_openWindows.TryGetDeadline(sid, out var until)) return; - bool waitedOut = false; try { - while (true) + while (_openWindows.TryGetDeadline(sid, out var until)) { var remaining = SuppressedResponseWindows.Remaining(until); if (remaining <= TimeSpan.Zero) break; - IReadOnlyList heard; - try - { - heard = await _client.CollectResponsesAsync(remaining, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // What this slice heard is lost with it -- a 0x78 among it would have moved - // the window out by P2*; the window takes that reading (Bugbot on #150). - until = Math.Max(until, Stopwatch.GetTimestamp() + Ticks(_responsePendingWindow)); - throw; - } + var heard = await _client.CollectResponsesAsync(remaining, _lifetimeCts.Token).ConfigureAwait(false); foreach (var pending in heard.Where(r => IsResponsePending(r.Data))) { - // A 0x78 for another service with a window open moves that one out too - // (Codex on #150). - var extendedUntil = pending.HostArrivalTimestamp + Ticks(_responsePendingWindow); byte pendingSid = pending.Data[1]; - if (pendingSid == sid) until = Math.Max(until, extendedUntil); - else if (_openWindows.TryGetDeadline(pendingSid, out _)) _openWindows.Extend(pendingSid, extendedUntil); + if (pendingSid == sid || _openWindows.TryGetDeadline(pendingSid, out _)) + _openWindows.Extend(pendingSid, pending.HostArrivalTimestamp + Ticks(_responsePendingWindow)); } } - waitedOut = true; + } + catch (OperationCanceledException) + { + // disposed: the window dies with the client + } + catch (ObjectDisposedException) + { + // likewise } finally { - // A cancelled wait keeps what remains of the window, extensions included, for the - // next call (Bugbot on #150). - if (waitedOut) _openWindows.Forget(sid); - else _openWindows.Extend(sid, until); + _openWindows.Forget(sid); + lock (_listeners) _listeners.Remove(sid); + } + } + + // Under the request lock. Waits for the listener still open for this service, if any: an + // earlier request may still be answered -- a suppressed send, or one whose collection + // window ended before the ECU's P2 did -- and that answer must not land in this call's + // window. The listener is not cancelled with the caller; it keeps the window. + private async Task WaitOutOpenWindowAsync(byte sid, CancellationToken cancellationToken) + { + Task? listener; + lock (_listeners) _listeners.TryGetValue(sid, out listener); + if (listener is null) return; + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using (cancellationToken.Register(static state => ((TaskCompletionSource)state!).TrySetResult(true), cancelled)) + { + if (await Task.WhenAny(listener, cancelled.Task).ConfigureAwait(false) != listener) + cancellationToken.ThrowIfCancellationRequested(); } } diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index 4ac3d92..4b8adf2 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -422,10 +422,10 @@ public async Task A_Pending_Answer_Extends_The_Window_From_Its_Arrival_Not_From_ "P2* is counted from the 0x78's arrival"); } - // Codex on #150: a cancelled collection cannot show what arrived; the window takes the - // conservative reading and the next call waits P2* from the cancellation. + // Codex on #150: a cancelled collection loses what it collected, but the listener that + // owns the window heard the 0x78 too, and the next call waits P2* from it. [Fact] - public async Task A_Cancelled_Collection_Leaves_A_Conservative_Window() + public async Task A_Cancelled_Collection_Does_Not_Lose_The_Pending_Answer_The_Listener_Heard() { var session = NewSession(); using var busTester = OpenClassic(session, 0); @@ -459,10 +459,10 @@ public async Task A_Cancelled_Collection_Leaves_A_Conservative_Window() "the final negative answer at 250 ms belongs to the cancelled request, whose 0x78 the cancellation hid"); } - // Bugbot on #150: a wait-out cancelled mid-slice loses what the slice heard; a 0x78 in it - // would have moved the window out, so the window takes that reading. + // Bugbot on #150: a wait cancelled by the caller does not cancel the listener, which keeps + // the window and hears the 0x78 the caller left behind. [Fact] - public async Task A_Cancelled_Wait_Takes_The_Conservative_Reading_For_What_It_Heard() + public async Task A_Cancelled_Wait_Leaves_The_Listener_To_Hear_The_Pending_Answer() { var session = NewSession(); using var busTester = OpenClassic(session, 0); @@ -499,6 +499,62 @@ public async Task A_Cancelled_Wait_Takes_The_Conservative_Reading_For_What_It_He "the negative at 450 ms belongs to the suppressed send; the window must have reached past it"); } + // Codex on #150: between a suppressed send and the next call nobody was collecting, so a + // 0x78 in that gap went unobserved. The listener is up before the send and stays for the + // window, so the gap is observed and the next call waits P2* from the 0x78. + [Fact] + public async Task A_Pending_Answer_In_The_Gap_After_A_Suppressed_Send_Is_Observed() + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var busEcus = OpenClassic(session, 1); + + var pending = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x3E, 0x78 }); + var negative = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x3E, 0x12 }); + var positive = SingleFrameFrom(Ecu1, new byte[] { 0x7E, 0x00 }); + busEcus.FrameObserved += (_, e) => + { + if (e.CanFrame.ID != unchecked((int)FunctionalTxId)) return; + if (e.CanFrame.Data.Span[2] == 0x80) + _ = Task.Run(async () => + { + await Task.Delay(100); busEcus.Transmit(pending); + await Task.Delay(400); busEcus.Transmit(negative); + }); + else busEcus.Transmit(positive); + }; + + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(300), responsePendingWindow: TimeSpan.FromMilliseconds(600)); + + using var cts = new CancellationTokenSource(ShortTimeout); + await functional.TesterPresentAsync(cancellationToken: cts.Token); // suppressed; 0x78 at 100 ms, negative at 500 ms + await Task.Delay(200); // nobody collecting: the gap + + var responses = await functional.TesterPresentAsync(suppressPositiveResponse: false, Window, cts.Token); + responses.Should().ContainSingle().Which.IsNegative.Should().BeFalse( + "the 0x78 in the gap moved the window past the negative at 500 ms"); + } + + // Codex on #150: a window the collector would reject is checked before anything goes out. + [Fact] + public async Task An_Invalid_Collection_Window_Transmits_Nothing() + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var busEcus = OpenClassic(session, 1); + int seen = 0; + busEcus.FrameObserved += (_, e) => { if (e.CanFrame.ID == unchecked((int)FunctionalTxId)) Interlocked.Increment(ref seen); }; + + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), ownsClient: true); + + Func act = () => functional.DiagnosticSessionControlAsync(UdsSessionType.Extended, TimeSpan.FromMilliseconds(-2)); + await act.Should().ThrowAsync(); + seen.Should().Be(0, "nothing was transmitted"); + } + // Codex on #150: only one DID is correlated, and a Single Frame holds no more anyway. [Fact] public async Task A_Functional_Read_For_More_Than_One_Did_Is_Refused() From 534b1a59f3ce5d0b6861c925764e1d6193c03db3 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:23:04 +0200 Subject: [PATCH 15/55] fix(uds): correlate the DID-echoing services 0x24, 0x2C and 0x2F on the functional path Codex on #150: InputOutputControlByIdentifier's positive response echoes its DID, as do ReadScalingDataByIdentifier's and, after the sub-function, DynamicallyDefineDataIdentifier's; all three are in the echo table now. Mutation-checked on 0x2F. The unused ECU handle CodeQL noted in a test is gone. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 9 ++++--- .../TestCases/Uds/UdsClientTests.cs | 2 +- .../TestCases/Uds/UdsFunctionalClientTests.cs | 27 +++++++++++++++++++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index f9691cb..fda475e 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -275,12 +275,13 @@ public async Task> DiagnosticSessionControl // How many request bytes after the SID a positive response repeats, for the services // whose response layout starts with them (ISO 14229-1, the response tables of each - // service): the sub-function where there is one, then a DID (0x22 the first, 0x2E), the - // routine identifier (0x31), the block sequence counter (0x36). + // service): the sub-function where there is one, then a DID (0x22 the first, 0x24, 0x2E, + // 0x2F -- Codex on #150), the sub-function and DID (0x2C), the routine identifier (0x31), + // the block sequence counter (0x36). private static int EchoedRequestBytes(byte sid) => sid switch { - 0x22 or 0x2E => 2, - 0x31 => 3, + 0x22 or 0x24 or 0x2E or 0x2F => 2, + 0x2C or 0x31 => 3, 0x36 => 1, _ => HasSubFunction(sid) ? 1 : 0, }; diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs index 8f83381..659d2df 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs @@ -647,7 +647,7 @@ 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, ecu, dispose) = BuildPair( + var (client, _, dispose) = BuildPair( e => e .On(0x3E, req => { diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index 4b8adf2..8ae05c7 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -555,6 +555,33 @@ public async Task An_Invalid_Collection_Window_Transmits_Nothing() seen.Should().Be(0, "nothing was transmitted"); } + // Codex on #150: InputOutputControlByIdentifier echoes its DID; another tester's control of + // a different DID is not this call's answer. + [Fact] + public async Task An_IO_Control_Answer_For_Another_Did_Is_Not_Attributed() + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var busEcus = OpenClassic(session, 1); + + var other = SingleFrameFrom(Ecu1, new byte[] { 0x6F, 0xF1, 0x91, 0x03, 0x00 }); + var ours = SingleFrameFrom(Ecu1, new byte[] { 0x6F, 0xF1, 0x90, 0x03, 0x00 }); + busEcus.FrameObserved += (_, e) => + { + if (e.CanFrame.ID != unchecked((int)FunctionalTxId)) return; + busEcus.Transmit(other); + busEcus.Transmit(ours); + }; + + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), ownsClient: true); + + using var cts = new CancellationTokenSource(ShortTimeout); + var responses = await functional.SendRawAsync(new byte[] { 0x2F, 0xF1, 0x90, 0x03, 0x00 }, Window, cts.Token); + + responses.Should().ContainSingle().Which.Response.Should().Equal(0x6F, 0xF1, 0x90, 0x03, 0x00); + } + // Codex on #150: only one DID is correlated, and a Single Frame holds no more anyway. [Fact] public async Task A_Functional_Read_For_More_Than_One_Did_Is_Refused() From db580081be4f927401f93f3d504b3a4b41205660 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:26:39 +0200 Subject: [PATCH 16/55] fix(uds): restart the functional listener when anchoring a window after a confirmation that outlasted it Codex on #150: a transmit confirmation longer than the window lets the pre-send listener retire before the collection begins, and re-anchoring the deadline afterwards left nothing to wait for. Anchoring goes through the same entry that starts a listener when none is running, on both paths. Mutation-checked with a deferred-echo bus holding the confirmation past the window. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 8 +++-- .../TestCases/Uds/UdsFunctionalClientTests.cs | 36 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index fda475e..ed7c24e 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -122,7 +122,9 @@ private async Task> SendRawLockedAsync(Read // is moved out to the confirmation afterwards. StartListening(sid, Stopwatch.GetTimestamp()); await _client.SendAsync(request, cancellationToken).ConfigureAwait(false); - _openWindows.Note(sid, Stopwatch.GetTimestamp(), _responseWindow); + // Through StartListening again: a confirmation that outlasted the window has let + // the listener retire, and the moved-out window needs one (Codex on #150). + StartListening(sid, Stopwatch.GetTimestamp()); return Array.Empty(); } @@ -149,7 +151,9 @@ private async Task> SendRawLockedAsync(Read StartListening(sid, Stopwatch.GetTimestamp()); var raw = await _client.SendAndCollectAsync(request, window, cancellationToken) .ConfigureAwait(false); - _openWindows.Note(sid, Stopwatch.GetTimestamp() - Ticks(window), _responseWindow); + // Through StartListening again: a confirmation that outlasted the window has let the + // listener retire, and the moved-out window needs one (Codex on #150). + StartListening(sid, Stopwatch.GetTimestamp() - Ticks(window)); var req = request.Span; byte positiveSid = (byte)(sid + 0x40); // A positive response echoes the request's leading parameter bytes -- the sub-function diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index 8ae05c7..57071bb 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -582,6 +582,42 @@ public async Task An_IO_Control_Answer_For_Another_Did_Is_Not_Attributed() responses.Should().ContainSingle().Which.Response.Should().Equal(0x6F, 0xF1, 0x90, 0x03, 0x00); } + // Codex on #150: a transmit confirmation that outlasts the window lets the pre-send + // listener retire; anchoring the window afterwards must start one again, or the next call + // has nothing to wait for. + [Fact] + public async Task A_Listener_Is_Restarted_When_The_Confirmation_Outlasted_The_Window() + { + using var bus = ControllableBus.DeferredEchoCapable(NewSession()); + using var service = new CanBusService(bus); + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(service, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(200)); + + var negative = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x22, 0x31 }); + var positive = SingleFrameFrom(Ecu1, new byte[] { 0x62, 0xF1, 0x91, 0x02 }); + + using var cts = new CancellationTokenSource(ShortTimeout); + // The first request's confirmation is held for 300 ms -- longer than the 200 ms window + // the pre-send listener was given; the ECU answers negatively 150 ms after the frame + // is confirmed, inside the window as anchored at the transmission. + var first = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, TimeSpan.FromMilliseconds(30), cts.Token); + await bus.DeferredEchoes.WaitForEnqueuedAsync(1, ShortTimeout); + await Task.Delay(300); + bus.DeferredEchoes.ReleaseNext(); + _ = Task.Run(async () => { await Task.Delay(150); bus.RaiseObserved(negative, isEcho: false); }); + await first; + + var second = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x91 }, Window, cts.Token); + await bus.DeferredEchoes.WaitForEnqueuedAsync(2, ShortTimeout); + bus.DeferredEchoes.ReleaseNext(); + _ = Task.Run(async () => { await Task.Delay(20); bus.RaiseObserved(positive, isEcho: false); }); + var responses = await second; + + responses.Should().ContainSingle().Which.IsNegative.Should().BeFalse( + "the second request waited out the window anchored at the first's late confirmation"); + } + // Codex on #150: only one DID is correlated, and a Single Frame holds no more anyway. [Fact] public async Task A_Functional_Read_For_More_Than_One_Did_Is_Refused() From 79c3ea19b635d45b50e933514889cf88218402fb Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:44:45 +0200 Subject: [PATCH 17/55] fix(uds): route a stray 0x78 to its service's window, start no listener for a window already over, and assert the functional windows as lower bounds Codex and Bugbot on #150. A 0x78 for service A consumed while service B's request ran -- as B's stray, or by B's pre-send discard -- left A's window at P2; both places route it to A's window now. A listener started for a window already over completed before it was registered and stayed as a zombie that blocked every later one; the functional client starts none for such a window and starts the others off the caller's stack. And two of the new functional tests, red on macOS CI, asserted that a late negative lands before a deadline with 50 ms to spare -- a timer the host can delay past the deadline; they now assert when the next request went out, a lower bound a loaded host only raises, and the other new tests' margins are widened. The stub channel hands its queued response over only once the request is out, as an inbox would. Each fix mutation-checked. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsClientImpl.cs | 31 +++++- src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 12 ++- .../TestCases/Uds/UdsClientTests.cs | 38 ++++++++ .../TestCases/Uds/UdsExpiredDeadlineTests.cs | 10 +- .../TestCases/Uds/UdsFunctionalClientTests.cs | 95 +++++++++++++++---- 5 files changed, 162 insertions(+), 24 deletions(-) diff --git a/src/CanKit.Pro.Uds/UdsClientImpl.cs b/src/CanKit.Pro.Uds/UdsClientImpl.cs index 3db6cc7..50fc969 100644 --- a/src/CanKit.Pro.Uds/UdsClientImpl.cs +++ b/src/CanKit.Pro.Uds/UdsClientImpl.cs @@ -1051,7 +1051,10 @@ private async Task ExchangeOnceAsync(UdsServiceId serviceId, byte[] requ if (echoed != serviceId) { // Stray NRC for a different SID — treat as background noise and keep - // waiting inside the same budget. + // waiting inside the same budget. Unless it is a 0x78 for a service + // with a suppressed send still open: that send's final answer is still + // coming, and its window moves out (Codex on #150). + RouteStrayPending(received); continue; } @@ -1113,6 +1116,22 @@ private void DiscardStalePdus() { try { + // Read before the bulk discard: a queued 0x78 for a service with a suppressed send + // still open moves that window out rather than vanishing (Codex on #150). A queued + // transport fault is stale here and dropped like the rest. + while (true) + { + IsoTpReceivedPdu queued; + try + { + if (!_channel.TryReceiveWithArrival(out queued)) break; + } + catch (IsoTpException) + { + continue; + } + RouteStrayPending(queued); + } _channel.DiscardPendingPdus(); } catch (ObjectDisposedException) @@ -1121,6 +1140,16 @@ private void DiscardStalePdus() } } + // A 0x78 not for the request in hand, for a service with a suppressed send still open: + // that send's window moves out by P2* from the 0x78's arrival. + private void RouteStrayPending(in IsoTpReceivedPdu pdu) + { + var data = pdu.Pdu; + if (data.Length < 3 || data[0] != NegativeResponseSid || data[2] != NrcResponsePending) return; + if (_suppressedWindows.TryGetDeadline(data[1], out _)) + _suppressedWindows.Extend(data[1], pdu.FirstFrameArrivalTimestamp + (long)(_options.P2StarClientMax.TotalSeconds * Stopwatch.Frequency)); + } + /// /// Waits on with the currently applicable /// (P2 or P2*) timeout, taking already-elapsed time into account so a single wait budget diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index ed7c24e..2824a7c 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -188,7 +188,17 @@ private void StartListening(byte sid, long from) lock (_listeners) { if (_listeners.ContainsKey(sid)) return; // running already; it reads the moved-out deadline - _listeners[sid] = ListenAsync(sid); + // A window already over -- a collection that outlasted P2 -- needs no listener; one + // started for it would complete before it was registered, remove a key not yet + // there, and be stored as a zombie that blocks every later one (Bugbot on #150). + if (!_openWindows.TryGetDeadline(sid, out var until) + || SuppressedResponseWindows.Remaining(until) <= TimeSpan.Zero) + { + _openWindows.Forget(sid); + return; + } + // Started off this stack: the listener's clean-up runs after the registration. + _listeners[sid] = Task.Run(() => ListenAsync(sid)); } } diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs index 659d2df..5a5483a 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs @@ -682,6 +682,44 @@ public async Task A_Pending_Answer_For_Another_Service_Heard_During_A_Wait_Exten } } + // Codex on #150: a 0x78 for service A, consumed as a stray while service B's request runs + // (or dropped by B's pre-send discard), still moves A's window out. + [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 }; + }), + 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); + } + } + // NRC 0x21 asks for a repeat; the client repeats, up to MaxBusyRepeatRequests. [Fact] public async Task BusyRepeatRequest_Is_Repeated_Until_The_Server_Answers() diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs index 1eec9d1..7c37fb6 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs @@ -535,6 +535,7 @@ public async Task SendWithTransmitStampAsync(ReadOnlyMemory CancellationToken cancellationToken = default) { lock (Sent) Sent.Add((pdu.ToArray(), Stopwatch.GetTimestamp())); + _sent = true; if (TransmissionTime > TimeSpan.Zero) await Task.Delay(TransmissionTime, CancellableSend ? cancellationToken : CancellationToken.None).ConfigureAwait(false); @@ -598,6 +599,7 @@ await Task.Delay(remaining, /// the caller's clock — decides whether it counts. /// private bool _handedOver; + private bool _sent; /// A queued reassembly fault, thrown by the first non-blocking take (#150). public bool QueuedFault { get; init; } @@ -612,7 +614,13 @@ public bool TryReceiveWithArrival(out IsoTpReceivedPdu pdu) } // An inbox empties: the one queued response is handed over once (#150 -- a drain - // that reads until the inbox is empty would otherwise never end here). + // that reads until the inbox is empty would otherwise never end here), and only + // once the request is out -- a response cannot be queued before it. + if (!_sent) + { + pdu = default; + return false; + } if (!_handedOver && RespondPendingFirst && _pendingSent) { _handedOver = true; diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index 57071bb..2b61fd3 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading; @@ -44,6 +45,9 @@ private static ICanBus OpenClassic(string session, int channel) => CanBus.Open( NAs = TimeSpan.FromMilliseconds(500), }; + private static TimeSpan Between(long earlier, long later) + => TimeSpan.FromSeconds((later - earlier) / (double)Stopwatch.Frequency); + private static CanFrame SingleFrameFrom(uint canId, byte[] pdu) => CanFrame.Classic(unchecked((int)canId), IsoTpFrameCodec.BuildSingleFrame(IsoTpEndpoint.Normal(canId, 0), pdu, isCanFd: false, padding: true)); @@ -306,11 +310,12 @@ public async Task A_Cancelled_Collection_Still_Leaves_Its_Window_For_The_Next_Ca busEcus.Transmit(positive); }; - // A cancelled collection takes the conservative reading (P2* from the cancellation); - // kept short here so the test measures the window, not the default P2*. using var functional = UdsFunctionalClient.Create( IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), - ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(300), responsePendingWindow: TimeSpan.FromMilliseconds(300)); + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(300)); + + var sentAt = new List(); + busEcus.FrameObserved += (_, e) => { if (e.CanFrame.ID == unchecked((int)FunctionalTxId)) lock (sentAt) sentAt.Add(Stopwatch.GetTimestamp()); }; using var early = new CancellationTokenSource(TimeSpan.FromMilliseconds(30)); Func cancelled = () => functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, Window, early.Token); @@ -320,6 +325,12 @@ public async Task A_Cancelled_Collection_Still_Leaves_Its_Window_For_The_Next_Ca var responses = await functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x91 }, Window, cts.Token); responses.Should().ContainSingle().Which.IsNegative.Should().BeFalse( "the cancelled request's late negative answer is not the next call's"); + // And the reason it is not: the second request waited the window out. A lower bound, + // which a loaded host only raises. + long gap; + lock (sentAt) gap = sentAt[1] - sentAt[0]; + Between(0, gap).Should().BeGreaterThanOrEqualTo(TimeSpan.FromMilliseconds(250), + "the cancelled request's window was kept for the next call"); } // Codex on #150: ReadDataByPeriodicIdentifier echoes nothing; its answer names the @@ -361,29 +372,33 @@ public async Task A_Window_Is_Anchored_At_The_Transmission_However_Late_It_Was_C IsoTpFactory.OpenFunctional(service, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(300)); - var negative = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x22, 0x31 }); var positive = SingleFrameFrom(Ecu1, new byte[] { 0x62, 0xF1, 0x91, 0x02 }); + var sentAt = new List(); + bus.OnTransmitting = frame => { if (frame.ID == unchecked((int)FunctionalTxId)) lock (sentAt) sentAt.Add(Stopwatch.GetTimestamp()); }; using var cts = new CancellationTokenSource(ShortTimeout); - // First request: its echo -- the transmit confirmation -- is held for 200 ms; the ECU - // answers negatively 450 ms after the frame went out, inside its P2 of 300 ms from the - // confirmation's point of view as the client sees it... and before that from the wire's. + // First request: its echo -- the transmit confirmation -- is held for 200 ms. The window + // is P2 = 300 ms from the transmission as the confirmation places it, so the next call + // for the service may not go out before 300 ms after that confirmation; anchored before + // the send instead, the window would end 200 ms sooner. Asserted as a lower bound on + // when the second request went out -- a loaded host only makes it later. var first = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, TimeSpan.FromMilliseconds(50), cts.Token); await bus.DeferredEchoes.WaitForEnqueuedAsync(1, ShortTimeout); - _ = Task.Run(async () => { await Task.Delay(450); bus.RaiseObserved(negative, isEcho: false); }); await Task.Delay(200); + var confirmedAt = Stopwatch.GetTimestamp(); bus.DeferredEchoes.ReleaseNext(); await first; - // The second request, at once: its own echo is released promptly, and the ECU answers it. - var second = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x91 }, Window, cts.Token); + var second = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x91 }, TimeSpan.FromMilliseconds(50), cts.Token); await bus.DeferredEchoes.WaitForEnqueuedAsync(2, ShortTimeout); // the count never decreases bus.DeferredEchoes.ReleaseNext(); _ = Task.Run(async () => { await Task.Delay(20); bus.RaiseObserved(positive, isEcho: false); }); - var responses = await second; + (await second).Should().ContainSingle(); - responses.Should().ContainSingle().Which.IsNegative.Should().BeFalse( - "the negative answer came 450 ms after the first request's transmission, inside its window"); + long secondSentAt; + lock (sentAt) secondSentAt = sentAt[1]; + Between(confirmedAt, secondSentAt).Should().BeGreaterThanOrEqualTo(TimeSpan.FromMilliseconds(250), + "the window is anchored at the transmission, 300 ms of P2 from the confirmation"); } // Codex on #150: P2* runs from the 0x78's arrival, not from the end of the collection. @@ -478,17 +493,17 @@ public async Task A_Cancelled_Wait_Leaves_The_Listener_To_Hear_The_Pending_Answe _ = Task.Run(async () => { await Task.Delay(50); busEcus.Transmit(pending); - await Task.Delay(400); busEcus.Transmit(negative); + await Task.Delay(200); busEcus.Transmit(negative); }); else busEcus.Transmit(positive); }; using var functional = UdsFunctionalClient.Create( IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), - ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(300), responsePendingWindow: TimeSpan.FromMilliseconds(400)); + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(300), responsePendingWindow: TimeSpan.FromMilliseconds(600)); using var cts = new CancellationTokenSource(ShortTimeout); - await functional.TesterPresentAsync(cancellationToken: cts.Token); // suppressed; 0x78 at 50 ms, negative at 450 ms + await functional.TesterPresentAsync(cancellationToken: cts.Token); // suppressed; 0x78 at 50 ms, negative at 250 ms, window to 650 ms using var early = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); Func cancelled = () => functional.TesterPresentAsync(suppressPositiveResponse: false, Window, early.Token); @@ -496,7 +511,7 @@ public async Task A_Cancelled_Wait_Leaves_The_Listener_To_Hear_The_Pending_Answe var responses = await functional.TesterPresentAsync(suppressPositiveResponse: false, Window, cts.Token); responses.Should().ContainSingle().Which.IsNegative.Should().BeFalse( - "the negative at 450 ms belongs to the suppressed send; the window must have reached past it"); + "the negative at 250 ms belongs to the suppressed send; the window reaches to 650 ms"); } // Codex on #150: between a suppressed send and the next call nobody was collecting, so a @@ -590,20 +605,22 @@ public async Task A_Listener_Is_Restarted_When_The_Confirmation_Outlasted_The_Wi { using var bus = ControllableBus.DeferredEchoCapable(NewSession()); using var service = new CanBusService(bus); + // N_As long enough to hold the confirmation past the window without timing the send out. + var options = new IsoTpFunctionalOptions { IsExtendedCanId = false, UseCanFd = false, UsePadding = true, NAs = TimeSpan.FromSeconds(2) }; using var functional = UdsFunctionalClient.Create( - IsoTpFactory.OpenFunctional(service, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), - ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(200)); + IsoTpFactory.OpenFunctional(service, FunctionalTxId, Ecu1, 0x7EF, options), + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(400)); var negative = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x22, 0x31 }); var positive = SingleFrameFrom(Ecu1, new byte[] { 0x62, 0xF1, 0x91, 0x02 }); using var cts = new CancellationTokenSource(ShortTimeout); - // The first request's confirmation is held for 300 ms -- longer than the 200 ms window + // The first request's confirmation is held for 500 ms -- longer than the 400 ms window // the pre-send listener was given; the ECU answers negatively 150 ms after the frame // is confirmed, inside the window as anchored at the transmission. var first = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, TimeSpan.FromMilliseconds(30), cts.Token); await bus.DeferredEchoes.WaitForEnqueuedAsync(1, ShortTimeout); - await Task.Delay(300); + await Task.Delay(500); bus.DeferredEchoes.ReleaseNext(); _ = Task.Run(async () => { await Task.Delay(150); bus.RaiseObserved(negative, isEcho: false); }); await first; @@ -618,6 +635,42 @@ public async Task A_Listener_Is_Restarted_When_The_Confirmation_Outlasted_The_Wi "the second request waited out the window anchored at the first's late confirmation"); } + // Bugbot on #150: a collection that outlasts P2 anchors a window already over; that must + // not leave a completed listener behind that blocks the next window's real one. + [Fact] + public async Task A_Collection_That_Outlasts_The_Window_Does_Not_Leave_A_Zombie_Listener() + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var busEcus = OpenClassic(session, 1); + + var negative = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x22, 0x31 }); + var positive = SingleFrameFrom(Ecu1, new byte[] { 0x62, 0xF1, 0x92, 0x03 }); + int seen = 0; + busEcus.FrameObserved += (_, e) => + { + if (e.CanFrame.ID != unchecked((int)FunctionalTxId)) return; + int n = Interlocked.Increment(ref seen); + if (n == 2) _ = Task.Run(async () => { await Task.Delay(150); busEcus.Transmit(negative); }); + if (n == 3) busEcus.Transmit(positive); + }; + + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(400)); + + using var cts = new CancellationTokenSource(ShortTimeout); + // A collection longer than the window: the pre-send listener retires; the anchor after + // it is already over and must start nothing. + await functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, TimeSpan.FromMilliseconds(500), cts.Token); + // A short collection; its late negative, at 150 ms, needs a living listener's window. + await functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x91 }, TimeSpan.FromMilliseconds(30), cts.Token); + var responses = await functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x92 }, Window, cts.Token); + + responses.Should().ContainSingle().Which.IsNegative.Should().BeFalse( + "the second request's window was honoured by a real listener, not blocked by a zombie"); + } + // Codex on #150: only one DID is correlated, and a Single Frame holds no more anyway. [Fact] public async Task A_Functional_Read_For_More_Than_One_Did_Is_Refused() From 37283877c1069d42fa23d16618c17d9bc6f96e89 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:04:34 +0200 Subject: [PATCH 18/55] feat(isotp): let a functional client listen on one subscription across collections IsoTpFunctionalClient.Listen() subscribes to the response range once and returns an IsoTpFunctionalListener whose CollectAsync(window) collects from that standing subscription. CollectResponsesAsync subscribes per call, so a response arriving between two calls, or between a SendAsync and the first call, is missed; obtained before the send, the listener hears the fastest reply, and what arrives between two collections is buffered for the next. The UDS functional client needs exactly that for its per-service listeners (Codex and Bugbot on #150). Mutation-checked: a drain at the start of CollectAsync fails the new test. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.IsoTp/IsoTpFunctionalClient.cs | 15 +++- .../IsoTpFunctionalListener.cs | 81 +++++++++++++++++++ src/CanKit.Pro.IsoTp/README.md | 6 ++ .../CanKit.Pro.IsoTp.approved.txt | 6 ++ .../IsoTp/IsoTpFunctionalClientTests.cs | 53 ++++++++++++ 5 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 src/CanKit.Pro.IsoTp/IsoTpFunctionalListener.cs diff --git a/src/CanKit.Pro.IsoTp/IsoTpFunctionalClient.cs b/src/CanKit.Pro.IsoTp/IsoTpFunctionalClient.cs index 6d6ec7d..e7eff1c 100644 --- a/src/CanKit.Pro.IsoTp/IsoTpFunctionalClient.cs +++ b/src/CanKit.Pro.IsoTp/IsoTpFunctionalClient.cs @@ -194,6 +194,19 @@ public async Task> CollectResponsesAsync( return await CollectFromSubscriptionAsync(sub, window, cancellationToken).ConfigureAwait(false); } + /// + /// Subscribes to the response range now and returns a listener that keeps the subscription + /// across collections. Obtained before a , it closes the gap between + /// send and subscribe that leaves, and a response that + /// arrives between two of its collections is buffered for the next one. + /// + /// The client was disposed. + public IsoTpFunctionalListener Listen() + { + ThrowIfDisposed(); + return new IsoTpFunctionalListener(_service.Subscribe(_responseFilter, includeEcho: true)); + } + /// public void Dispose() { @@ -304,7 +317,7 @@ private static void DrainBuffered(ISubscription sub) while (sub.TryRead(out _)) { } } - private static bool TryParseFunctionalResponse(in CanFrameEvent frameEvent, + internal static bool TryParseFunctionalResponse(in CanFrameEvent frameEvent, out IsoTpFunctionalResponse? response) { var frame = frameEvent.Frame; diff --git a/src/CanKit.Pro.IsoTp/IsoTpFunctionalListener.cs b/src/CanKit.Pro.IsoTp/IsoTpFunctionalListener.cs new file mode 100644 index 0000000..72d18e3 --- /dev/null +++ b/src/CanKit.Pro.IsoTp/IsoTpFunctionalListener.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using CanKit.Pro.RawCan; + +namespace CanKit.Pro.IsoTp; + +/// +/// A standing subscription to a functional client's response range, from which Single-Frame +/// responses are collected window by window. Unlike +/// , which subscribes for each call, +/// the subscription is made once, by , and kept until +/// the listener is disposed: a response that arrives between two collections is buffered and +/// returned by the next one, and one that arrives before the first collection begins is not +/// missed. Obtain it before the request goes out, and no fast reply is lost. +/// +/// +/// Collections are sequential: call again only after the previous +/// call completed. The subscription buffer is bounded and drops the oldest frame when full, so +/// a listener left uncollected for long may lose the earliest of what arrived. +/// +public sealed class IsoTpFunctionalListener : IDisposable +{ + private readonly ISubscription _subscription; + private int _disposed; + + internal IsoTpFunctionalListener(ISubscription subscription) + { + _subscription = subscription; + } + + /// + /// Collects the Single-Frame responses that arrive on the subscription within + /// , together with those buffered since the previous collection. + /// + /// Duration to collect responses. + /// Cancels the collection; what was collected is lost. + /// The responses, in arrival order; empty if none arrived. + /// The listener was disposed. + public async Task> CollectAsync( + TimeSpan window, CancellationToken cancellationToken = default) + { + if (Volatile.Read(ref _disposed) != 0) + throw new ObjectDisposedException(nameof(IsoTpFunctionalListener)); + var responses = new List(); + using var windowCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + windowCts.CancelAfter(window); + try + { + // Ends when the window runs out, or when the subscription is completed underneath + // -- the service disposed -- in which case what is buffered is still read. + while (await _subscription.WaitToReadAsync(windowCts.Token).ConfigureAwait(false)) + TakeBuffered(responses); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // The window expired: a frame that arrived on the subscription before the deadline + // may still sit unread, and belongs to this collection. The subscription stays + // open, so this is a read of the buffer, not a drain of a closed one. + TakeBuffered(responses); + } + return responses.AsReadOnly(); + } + + private void TakeBuffered(List responses) + { + while (_subscription.TryRead(out var frameEvent)) + { + if (IsoTpFunctionalClient.TryParseFunctionalResponse(frameEvent, out var response)) + responses.Add(response!); + } + } + + /// Ends the subscription. A collection in progress ends with it. + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + _subscription.Dispose(); + } +} diff --git a/src/CanKit.Pro.IsoTp/README.md b/src/CanKit.Pro.IsoTp/README.md index 05ecd7a..dc4b2ec 100644 --- a/src/CanKit.Pro.IsoTp/README.md +++ b/src/CanKit.Pro.IsoTp/README.md @@ -128,6 +128,12 @@ foreach (var r in responses) collected in arrival order. - `IsoTpFunctionalOptions` configures `IsExtendedCanId`, `UseCanFd`, `UsePadding`, `PaddingByte`, and `NAs` (TX-confirm timeout). +- **Listening across collections**: `CollectResponsesAsync` subscribes per call, so a response + that arrives between two calls — or between a `SendAsync` and the first call — is missed. + `client.Listen()` subscribes once and returns an `IsoTpFunctionalListener` whose + `CollectAsync(window)` collects from that standing subscription: obtained before the send, + it hears the fastest reply, and what arrives between two collections is buffered for the + next. Dispose it to end the subscription. ## Non-scope (yet) 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 73d45a1..4f84f43 100644 --- a/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.IsoTp.approved.txt +++ b/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.IsoTp.approved.txt @@ -117,9 +117,15 @@ namespace CanKit.Pro.IsoTp public CanKit.Pro.IsoTp.IsoTpFunctionalOptions Options { get; } public System.Threading.Tasks.Task> CollectResponsesAsync(System.TimeSpan window, System.Threading.CancellationToken cancellationToken = default) { } public void Dispose() { } + public CanKit.Pro.IsoTp.IsoTpFunctionalListener Listen() { } public System.Threading.Tasks.Task> SendAndCollectAsync(System.ReadOnlyMemory pdu, System.TimeSpan window, System.Threading.CancellationToken cancellationToken = default) { } public System.Threading.Tasks.Task SendAsync(System.ReadOnlyMemory pdu, System.Threading.CancellationToken cancellationToken = default) { } } + public sealed class IsoTpFunctionalListener : System.IDisposable + { + public System.Threading.Tasks.Task> CollectAsync(System.TimeSpan window, System.Threading.CancellationToken cancellationToken = default) { } + public void Dispose() { } + } public sealed class IsoTpFunctionalOptions { public static readonly System.TimeSpan DefaultTimeout; diff --git a/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs index ca8ade0..bdedd5d 100644 --- a/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs @@ -542,4 +542,57 @@ public async Task Functional_Client_LeaveOpen_Does_Not_Dispose_Service() await peer.SendAsync(new byte[] { 0x11, 0x22 }).WaitAsync(ShortTimeout); (await recvTask).Should().Equal(0x11, 0x22); } + + // ───────────────────────────────────────────────────────────────────────────────────────── + // Listen(): one subscription across collections (#150). A response that arrives between + // two collections -- here, before the first one begins -- is buffered for the next, where + // CollectResponsesAsync, subscribing per call, would not have seen it. + // ───────────────────────────────────────────────────────────────────────────────────────── + [Fact] + public async Task Functional_Listener_Keeps_What_Arrives_Between_Collections() + { + var session = NewSession(); + using var busA = OpenClassic(session, 0); // tester bus + using var busB = OpenClassic(session, 1); // ECU bus + + const uint FunctionalTxId = 0x7DF; + const uint EcuResponseId = 0x7E8; + + byte[] pdu = { 0x62, 0xF1, 0x90, 0x01 }; + var frame = CanFrame.Classic( + unchecked((int)EcuResponseId), + IsoTpFrameCodec.BuildSingleFrame(IsoTpEndpoint.Normal(EcuResponseId, 0), pdu, isCanFd: false, padding: true)); + using var client = IsoTpFactory.OpenFunctional(busA, FunctionalTxId, 0x7E8, 0x7EF, FastOptions()); + + // Delivered to the subscription, and confirmed as delivered, before any collection. + var delivered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + busA.FrameObserved += (_, e) => { if (e.CanFrame.ID == unchecked((int)EcuResponseId)) delivered.TrySetResult(true); }; + using var listener = client.Listen(); + busB.Transmit(frame); + await delivered.Task.WaitAsync(ShortTimeout); + + var responses = await listener.CollectAsync(TimeSpan.FromMilliseconds(50)).WaitAsync(ShortTimeout); + responses.Should().ContainSingle().Which.Data.Should().Equal(pdu); + + // Not returned twice: the next collection starts from an empty buffer. + (await listener.CollectAsync(TimeSpan.FromMilliseconds(50)).WaitAsync(ShortTimeout)).Should().BeEmpty(); + } + + [Fact] + public async Task Functional_Listener_Disposal_Ends_A_Collection_In_Progress() + { + var session = NewSession(); + using var busA = OpenClassic(session, 0); + using var busB = OpenClassic(session, 1); // joined so the hub forwards frames, but silent + using var client = IsoTpFactory.OpenFunctional(busA, 0x7DF, 0x7E8, 0x7EF, FastOptions()); + + var listener = client.Listen(); + var collecting = listener.CollectAsync(ShortTimeout); + listener.Dispose(); + // Disposal completes the subscription; a collection in progress ends with it. + (await collecting.WaitAsync(ShortTimeout)).Should().BeEmpty(); + + Func act = () => listener.CollectAsync(TimeSpan.FromMilliseconds(10)); + await act.Should().ThrowAsync(); + } } From 9c4a976e0c3cc9e2c12d3e96a381ef68fa18f7f7 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:04:34 +0200 Subject: [PATCH 19/55] fix(uds): subscribe the functional listener before the send, retire it under the windows lock, and route a stray 0x78 from before the handoff Codex and Bugbot on #150, and two macOS CI failures of the same cause. The functional listener's subscription was made by the worker Task.Run scheduled, i.e. after the send: a 0x78 an ECU answers the instant the request is on the bus was lost to it, the window stayed at P2, and the next same-service request took the earlier one's late answer. The listener now holds one IsoTpFunctionalListener, obtained in StartListening on the caller's stack before the send, for its whole life. Noting a window, starting a listener and retiring one happen under one lock, so a note that moves the window out either finds the listener reading, and it re-reads the deadline before retiring, or starts one; and a window left without a listener is given one by the next request's wait-out. In the physical client, a stray 0x78 stamped before the request's handoff is routed to its service's window before it is dropped. The functional test that asserted a timed answer inside a 50 ms window now collects for P2. Mutation-checked: a lazy subscription in a worker 50 ms late fails the cancelled-collection test, the late worker alone with the eager subscription passes; the stub test fails at P2 without the routing. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsClientImpl.cs | 6 + src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 111 +++++++++++++----- .../TestCases/Uds/UdsExpiredDeadlineTests.cs | 67 +++++++++++ .../TestCases/Uds/UdsFunctionalClientTests.cs | 4 +- 4 files changed, 158 insertions(+), 30 deletions(-) diff --git a/src/CanKit.Pro.Uds/UdsClientImpl.cs b/src/CanKit.Pro.Uds/UdsClientImpl.cs index 50fc969..88092b5 100644 --- a/src/CanKit.Pro.Uds/UdsClientImpl.cs +++ b/src/CanKit.Pro.Uds/UdsClientImpl.cs @@ -1029,8 +1029,14 @@ private async Task ExchangeOnceAsync(UdsServiceId serviceId, byte[] requ // at all -- it began before the request was handed to the channel, so it answers // an earlier one (Codex on #143). ElapsedSince clamps a negative interval to // zero, which would read as "punctual"; it is a stray, and the wait goes on. + // A stray that is a 0x78 for a suppressed send still open -- answered while + // this request was being handed over -- still moves that send's window out + // (Bugbot on #150); the branch below does the same for a later one. if (received.FirstFrameArrivalTimestamp < notBefore) + { + RouteStrayPending(received); continue; + } var arrival = ElapsedSince(budgetStart, received.FirstFrameArrivalTimestamp); if (arrival > timeout) throw new UdsTimeoutException(serviceId, timerKind, timeout); diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index 2824a7c..4282bfb 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -32,7 +32,8 @@ public sealed class UdsFunctionalClient : IDisposable private readonly TimeSpan _responseWindow; private readonly TimeSpan _responsePendingWindow; private readonly SuppressedResponseWindows _openWindows = new(); - private readonly Dictionary _listeners = new(); + // Per service: the standing subscription that hears the window out, and the task reading it. + private readonly Dictionary _listeners = new(); // One request on the wire at a time, its collection window included: overlapping calls // with the same SID would each collect the other's answers (Codex on #150), as the // physical client's request lock prevents there. @@ -151,9 +152,6 @@ private async Task> SendRawLockedAsync(Read StartListening(sid, Stopwatch.GetTimestamp()); var raw = await _client.SendAndCollectAsync(request, window, cancellationToken) .ConfigureAwait(false); - // Through StartListening again: a confirmation that outlasted the window has let the - // listener retire, and the moved-out window needs one (Codex on #150). - StartListening(sid, Stopwatch.GetTimestamp() - Ticks(window)); var req = request.Span; byte positiveSid = (byte)(sid + 0x40); // A positive response echoes the request's leading parameter bytes -- the sub-function @@ -171,9 +169,13 @@ private async Task> SendRawLockedAsync(Read // P2* runs from the 0x78's arrival, which the response carries. The listener sees // the same frame; the earlier of the two to act moves the window, the later is idle. if (IsResponsePending(data, sid)) - _openWindows.Extend(sid, r.HostArrivalTimestamp + Ticks(_responsePendingWindow)); + Extend(sid, r.HostArrivalTimestamp + Ticks(_responsePendingWindow)); if (positive || negative) responses.Add(new UdsFunctionalResponse(r.SourceCanId, data)); } + // Through StartListening again, after the 0x78s above moved the window: a confirmation + // that outlasted the window has let the listener retire, and the moved-out window + // needs one (Codex on #150). + StartListening(sid, Stopwatch.GetTimestamp() - Ticks(window)); return responses; } @@ -182,66 +184,117 @@ private async Task> SendRawLockedAsync(Read // loses nothing (Codex on #150). It moves the window out on every 0x78 it hears -- for its // own service in the table and for any other service with a window open -- and ends when // the window has run out, forgetting it. Started under the request lock. + // + // The subscription is made here, on the caller's stack, before StartListening returns and + // so before the send: a 0x78 an ECU answers the instant the request is on the bus is + // buffered for the listener's first collection, not lost to a listener still being + // scheduled (Codex and Bugbot on #150). And it is one subscription for the listener's whole + // life, so nothing arriving between two of its collections is lost either. + // + // Noting the window, starting a listener and retiring one happen under the listeners lock, + // so a note that moves the window out either finds the listener still reading -- and it + // re-reads the deadline before retiring -- or finds none and starts one; a listener cannot + // retire, forgetting the window, between the note and the check (Codex on #150). private void StartListening(byte sid, long from) { - _openWindows.Note(sid, from, _responseWindow); lock (_listeners) { - if (_listeners.ContainsKey(sid)) return; // running already; it reads the moved-out deadline - // A window already over -- a collection that outlasted P2 -- needs no listener; one - // started for it would complete before it was registered, remove a key not yet - // there, and be stored as a zombie that blocks every later one (Bugbot on #150). - if (!_openWindows.TryGetDeadline(sid, out var until) - || SuppressedResponseWindows.Remaining(until) <= TimeSpan.Zero) - { - _openWindows.Forget(sid); - return; - } - // Started off this stack: the listener's clean-up runs after the registration. - _listeners[sid] = Task.Run(() => ListenAsync(sid)); + _openWindows.Note(sid, from, _responseWindow); + EnsureListener(sid); } } - private async Task ListenAsync(byte sid) + // Under the listeners lock. Starts a listener for the service's window if the window is + // open and none is reading it; forgets a window already over -- a collection that outlasted + // P2 -- because a listener for it would only retire on its first read (Bugbot on #150). + private void EnsureListener(byte sid) + { + if (_listeners.ContainsKey(sid)) return; + if (!_openWindows.TryGetDeadline(sid, out var until) + || SuppressedResponseWindows.Remaining(until) <= TimeSpan.Zero) + { + _openWindows.Forget(sid); + return; + } + var ears = _client.Listen(); + // Started off this stack: its retirement takes this lock, so it runs after the entry. + _listeners[sid] = (ears, Task.Run(() => ListenAsync(sid, ears))); + } + + private void Extend(byte sid, long until) + { + lock (_listeners) _openWindows.Extend(sid, until); + } + + private async Task ListenAsync(byte sid, IsoTpFunctionalListener ears) { try { - while (_openWindows.TryGetDeadline(sid, out var until)) + while (true) { - var remaining = SuppressedResponseWindows.Remaining(until); - if (remaining <= TimeSpan.Zero) break; - var heard = await _client.CollectResponsesAsync(remaining, _lifetimeCts.Token).ConfigureAwait(false); + TimeSpan remaining; + lock (_listeners) + { + // Retirement is decided under the lock that notes and moves the window, + // and the entry goes with it: a note after this reads no listener and + // starts one, a note before it moved the deadline this reads. + if (!_openWindows.TryGetDeadline(sid, out var until) + || (remaining = SuppressedResponseWindows.Remaining(until)) <= TimeSpan.Zero) + { + Retire(sid, ears); + return; + } + } + var heard = await ears.CollectAsync(remaining, _lifetimeCts.Token).ConfigureAwait(false); foreach (var pending in heard.Where(r => IsResponsePending(r.Data))) { byte pendingSid = pending.Data[1]; - if (pendingSid == sid || _openWindows.TryGetDeadline(pendingSid, out _)) - _openWindows.Extend(pendingSid, pending.HostArrivalTimestamp + Ticks(_responsePendingWindow)); + lock (_listeners) + { + if (pendingSid == sid || _openWindows.TryGetDeadline(pendingSid, out _)) + _openWindows.Extend(pendingSid, pending.HostArrivalTimestamp + Ticks(_responsePendingWindow)); + } } } } catch (OperationCanceledException) { // disposed: the window dies with the client + lock (_listeners) Retire(sid, ears); } catch (ObjectDisposedException) { // likewise + lock (_listeners) Retire(sid, ears); } - finally + } + + // Under the listeners lock. Removes this listener's entry -- not a successor's -- and the + // window with it, and ends its subscription. + private void Retire(byte sid, IsoTpFunctionalListener ears) + { + if (_listeners.TryGetValue(sid, out var entry) && ReferenceEquals(entry.Ears, ears)) { + _listeners.Remove(sid); _openWindows.Forget(sid); - lock (_listeners) _listeners.Remove(sid); } + ears.Dispose(); } // Under the request lock. Waits for the listener still open for this service, if any: an // earlier request may still be answered -- a suppressed send, or one whose collection // window ended before the ECU's P2 did -- and that answer must not land in this call's - // window. The listener is not cancelled with the caller; it keeps the window. + // window. The listener is not cancelled with the caller; it keeps the window. A window + // moved out by a 0x78 another service's listener heard after this service's own retired + // has no listener yet; it gets one here, and is waited out the same. private async Task WaitOutOpenWindowAsync(byte sid, CancellationToken cancellationToken) { Task? listener; - lock (_listeners) _listeners.TryGetValue(sid, out listener); + lock (_listeners) + { + EnsureListener(sid); + listener = _listeners.TryGetValue(sid, out var entry) ? entry.Run : null; + } if (listener is null) return; var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using (cancellationToken.Register(static state => ((TaskCompletionSource)state!).TrySetResult(true), cancelled)) diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs index 7c37fb6..a4e6a34 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs @@ -449,6 +449,50 @@ public async Task P_A_Stale_Transport_Fault_Queued_Behind_A_Suppressed_Send_Does await act.Should().NotThrowAsync("the queued fault is stale and dropped"); } + /// + /// Bugbot on #150 — a 0x78 for a service with a suppressed send open, answered while the + /// next request was being handed to the driver, predates that request's handoff and is + /// dropped as a stray; it must still move the suppressed send's window out by P2*. + /// + [Fact] + public async Task Q_A_Stray_Pending_From_Before_The_Handoff_Still_Moves_Its_Services_Window() + { + using var channel = new StubChannel( + deliverAfter: TimeSpan.FromMilliseconds(5), + stampArrivalAtDelivery: false) + { + StrayPendingBeforeHandoffFor = 0x3E, + ResponseArrivalOffsetFromTransmit = TimeSpan.FromMilliseconds(1), + LastFrameHandoffBeforeTransmit = TimeSpan.FromMilliseconds(1), + }; + // P2* well apart from P2, so which of the two the third send waited is measurable. + var pendingBudget = TimeSpan.FromMilliseconds(300); + using var client = UdsClient.Create(channel, new UdsClientOptions + { + P2ClientMax = Budget, + P2StarClientMax = pendingBudget, + }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token); // suppressed: opens the window, P2 from the send + await client.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, cts.Token); // its stray is the 0x78, stamped before its handoff + + // The next TesterPresent waits the window out: P2* from the 0x78 if it was routed, else + // P2 from the first send -- which the second send followed within a few milliseconds. + Func next = () => client.SendRawAsync(new byte[] { 0x3E, 0x00 }, cts.Token); + await next.Should().ThrowAsync(); // never answered; what matters is when it sent + + long gapTicks; + lock (channel.Sent) + { + channel.Sent.Should().HaveCount(3); + gapTicks = channel.Sent[2].StartedAt - channel.Sent[1].StartedAt; + } + TimeSpan.FromSeconds((double)gapTicks / Stopwatch.Frequency).Should().BeGreaterThanOrEqualTo( + pendingBudget - TimeSpan.FromMilliseconds(5), + "the 0x78 heard before the second request's handoff moved the suppressed send's window out by P2*"); + } + private sealed class StubChannel : IIsoTpChannel { private static readonly byte[] Response = { 0x62, 0xF1, 0x90, 0xAA }; @@ -531,6 +575,14 @@ public StubChannel(TimeSpan deliverAfter, bool stampArrivalAtDelivery) /// When set, a send observes the token while it waits for its confirmation. public bool CancellableSend { get; init; } + /// + /// A 7F sid 78 handed over first, once a request is out, stamped a millisecond + /// before that request's last-frame handoff: answered to an earlier send while this + /// one was being handed to the driver (Bugbot on #150). + /// + public byte? StrayPendingBeforeHandoffFor { get; init; } + private bool _straySent; + public async Task SendWithTransmitStampAsync(ReadOnlyMemory pdu, CancellationToken cancellationToken = default) { @@ -561,6 +613,21 @@ public async Task ReceiveWithArrivalAsync( if (Gate is not null) await Gate.Task.ConfigureAwait(false); + if (StrayPendingBeforeHandoffFor is { } straySid && _sent && !_straySent) + { + _straySent = true; + return new IsoTpReceivedPdu(new byte[] { 0x7F, straySid, 0x78 }, + _arrivalStamp - Ticks(LastFrameHandoffBeforeTransmit) - Ticks(TimeSpan.FromMilliseconds(1))); + } + + // An inbox empties: the one queued response, once delivered, is not delivered + // again -- a later read waits, as it would on an empty inbox, until cancelled. + if (_delivered) + { + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + throw new OperationCanceledException(cancellationToken); + } + // Deliberately not observing the token: this models the write winning the race. if (RespondPendingFirst && !_pendingSent) { diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index 2b61fd3..26f277e 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -389,7 +389,9 @@ public async Task A_Window_Is_Anchored_At_The_Transmission_However_Late_It_Was_C bus.DeferredEchoes.ReleaseNext(); await first; - var second = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x91 }, TimeSpan.FromMilliseconds(50), cts.Token); + // The second collects for the whole P2: its answer is timed, and a host that delays the + // timer past a short window would leave the collection empty (macOS CI on #150). + var second = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x91 }, Window, cts.Token); await bus.DeferredEchoes.WaitForEnqueuedAsync(2, ShortTimeout); // the count never decreases bus.DeferredEchoes.ReleaseNext(); _ = Task.Run(async () => { await Task.Delay(20); bus.RaiseObserved(positive, isEcho: false); }); From da778719513bbb9a54ebc26da8abd8cb3266d44a Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:08:42 +0200 Subject: [PATCH 20/55] test(isotp): dispose the listener under test by using, for a throw before its explicit disposal CodeQL on #150. Co-Authored-By: Claude Opus 5 --- .../TestCases/IsoTp/IsoTpFunctionalClientTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs index bdedd5d..e977d5f 100644 --- a/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs @@ -586,7 +586,7 @@ public async Task Functional_Listener_Disposal_Ends_A_Collection_In_Progress() using var busB = OpenClassic(session, 1); // joined so the hub forwards frames, but silent using var client = IsoTpFactory.OpenFunctional(busA, 0x7DF, 0x7E8, 0x7EF, FastOptions()); - var listener = client.Listen(); + using var listener = client.Listen(); // disposed below; the using covers a throw before that (CodeQL) var collecting = listener.CollectAsync(ShortTimeout); listener.Dispose(); // Disposal completes the subscription; a collection in progress ends with it. From 2bb7a006d8db0cd3532f6120d5e09cb9fbf57d92 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:23:59 +0200 Subject: [PATCH 21/55] fix(isotp): keep a frame from after the deadline for the next collection, and report an ended subscription instead of returning empty at once Codex and Bugbot on #150, on the listener added in 3728387. A frame that arrives between the window's timer firing and the drain reading the buffer was returned by the expired collection; it is the next collection's, and is kept for it, decided by its arrival stamp against the deadline. And a subscription completed underneath -- the service disposed -- ended every collection at once with an empty list, which a loop collecting until a deadline spins on; the collection in progress still returns what it has, and every one after it throws ObjectDisposedException. Mutation-checked: without the flag the new test sees no exception. Co-Authored-By: Claude Opus 5 --- .../IsoTpFunctionalListener.cs | 44 ++++++++++++++----- .../IsoTp/IsoTpFunctionalClientTests.cs | 24 ++++++++++ 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/src/CanKit.Pro.IsoTp/IsoTpFunctionalListener.cs b/src/CanKit.Pro.IsoTp/IsoTpFunctionalListener.cs index 72d18e3..effb466 100644 --- a/src/CanKit.Pro.IsoTp/IsoTpFunctionalListener.cs +++ b/src/CanKit.Pro.IsoTp/IsoTpFunctionalListener.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using CanKit.Pro.RawCan; @@ -23,6 +24,10 @@ namespace CanKit.Pro.IsoTp; public sealed class IsoTpFunctionalListener : IDisposable { private readonly ISubscription _subscription; + // Arrived after a collection's deadline but before its drain read the buffer: kept for the + // next collection, whose window they are in (Codex on #150). + private readonly Queue _carried = new(); + private bool _ended; private int _disposed; internal IsoTpFunctionalListener(ISubscription subscription) @@ -33,42 +38,57 @@ internal IsoTpFunctionalListener(ISubscription subscription) /// /// Collects the Single-Frame responses that arrive on the subscription within /// , together with those buffered since the previous collection. + /// A response that arrives after the window ends but before this call returns is kept for + /// the next collection, not returned by this one. /// /// Duration to collect responses. /// Cancels the collection; what was collected is lost. /// The responses, in arrival order; empty if none arrived. - /// The listener was disposed. + /// + /// The listener was disposed, or its subscription ended underneath it -- the service was + /// disposed -- which a collection in progress reports by returning what was buffered, and + /// every collection after it by this exception. + /// public async Task> CollectAsync( TimeSpan window, CancellationToken cancellationToken = default) { - if (Volatile.Read(ref _disposed) != 0) - throw new ObjectDisposedException(nameof(IsoTpFunctionalListener)); - var responses = new List(); + if (Volatile.Read(ref _disposed) != 0 || _ended) + throw new ObjectDisposedException(nameof(IsoTpFunctionalListener), + _ended ? "The subscription ended: the service was disposed." : null); + long deadline = Stopwatch.GetTimestamp() + (long)(window.TotalSeconds * Stopwatch.Frequency); + var responses = new List(_carried); + _carried.Clear(); using var windowCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); windowCts.CancelAfter(window); try { // Ends when the window runs out, or when the subscription is completed underneath - // -- the service disposed -- in which case what is buffered is still read. + // -- the service disposed -- in which case what is buffered is still read, and + // the next collection throws rather than return empty at once: a loop that + // collects while a deadline lasts would otherwise spin on it (Bugbot on #150). while (await _subscription.WaitToReadAsync(windowCts.Token).ConfigureAwait(false)) - TakeBuffered(responses); + TakeBuffered(responses, deadline); + _ended = true; } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { // The window expired: a frame that arrived on the subscription before the deadline - // may still sit unread, and belongs to this collection. The subscription stays - // open, so this is a read of the buffer, not a drain of a closed one. - TakeBuffered(responses); + // may still sit unread, and belongs to this collection; one that arrived after it, + // in the instant between the timer and this read, is the next collection's. The + // subscription stays open, so this is a read of the buffer, not a drain of a + // closed one. + TakeBuffered(responses, deadline); } return responses.AsReadOnly(); } - private void TakeBuffered(List responses) + private void TakeBuffered(List responses, long deadline) { while (_subscription.TryRead(out var frameEvent)) { - if (IsoTpFunctionalClient.TryParseFunctionalResponse(frameEvent, out var response)) - responses.Add(response!); + if (!IsoTpFunctionalClient.TryParseFunctionalResponse(frameEvent, out var response)) continue; + if (response!.HostArrivalTimestamp <= deadline) responses.Add(response); + else _carried.Enqueue(response); } } diff --git a/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs index e977d5f..fa6129a 100644 --- a/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -578,6 +579,29 @@ public async Task Functional_Listener_Keeps_What_Arrives_Between_Collections() (await listener.CollectAsync(TimeSpan.FromMilliseconds(50)).WaitAsync(ShortTimeout)).Should().BeEmpty(); } + // Bugbot on #150: a subscription completed underneath -- the service disposed -- ends the + // collection in progress with what it has, and the next collection throws rather than + // return empty at once, which a loop collecting until a deadline would spin on. + [Fact] + public async Task Functional_Listener_Reports_A_Service_Disposed_Underneath_On_The_Next_Collection() + { + var session = NewSession(); + using var busA = OpenClassic(session, 0); + using var busB = OpenClassic(session, 1); + var service = new CanBusService(busA); + using var client = IsoTpFactory.OpenFunctional(service, 0x7DF, 0x7E8, 0x7EF, FastOptions(), leaveOpen: true); + + using var listener = client.Listen(); + var collecting = listener.CollectAsync(ShortTimeout); + service.Dispose(); + (await collecting.WaitAsync(ShortTimeout)).Should().BeEmpty("the subscription ended with nothing buffered"); + + var sw = Stopwatch.StartNew(); + Func next = () => listener.CollectAsync(ShortTimeout); + await next.Should().ThrowAsync(); + sw.Elapsed.Should().BeLessThan(ShortTimeout, "it throws instead of waiting the window"); + } + [Fact] public async Task Functional_Listener_Disposal_Ends_A_Collection_In_Progress() { From d7cefc1f41192fc85bf07cce64fe795352d9f91e Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:23:59 +0200 Subject: [PATCH 22/55] fix(uds): anchor the functional window when the collection leaves by exception Codex on #150. With the transmit confirmation held past the window, the pre-send listener has retired by the time the collection runs; a collection the caller then cancelled, or a transport fault, left by exception before the post-collection anchoring, and the request that went out had no window -- the next same-service call could take its late answer. The window is now noted from the instant the exception leaves, which is at most P2 from the transmission. Mutation-checked: without it the new test collects the cancelled request's negative answer. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 17 +++++++- .../TestCases/Uds/UdsFunctionalClientTests.cs | 41 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index 4282bfb..a7febfb 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -150,8 +150,21 @@ private async Task> SendRawLockedAsync(Read // ran for `window` from the transmit confirmation, so that instant is at least now // less `window`, however long the confirmation took (Codex on #150). StartListening(sid, Stopwatch.GetTimestamp()); - var raw = await _client.SendAndCollectAsync(request, window, cancellationToken) - .ConfigureAwait(false); + IReadOnlyList raw; + try + { + raw = await _client.SendAndCollectAsync(request, window, cancellationToken) + .ConfigureAwait(false); + } + catch + { + // A collection that ends in a cancellation or a transport fault may still have put + // the request on the bus, and a confirmation that outlasted the window has let the + // pre-send listener retire; the ECUs' P2 from the transmission is at most P2 from + // now, so that window is noted before the exception leaves (Codex on #150). + StartListening(sid, Stopwatch.GetTimestamp()); + throw; + } var req = request.Span; byte positiveSid = (byte)(sid + 0x40); // A positive response echoes the request's leading parameter bytes -- the sub-function diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index 26f277e..c8b3aab 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -637,6 +637,47 @@ public async Task A_Listener_Is_Restarted_When_The_Confirmation_Outlasted_The_Wi "the second request waited out the window anchored at the first's late confirmation"); } + // Codex on #150: with the confirmation held past the window, the pre-send listener has + // retired by the time the collection runs; a collection the caller then cancels leaves by + // exception, and the window must still be anchored for the request that went out. + [Fact] + public async Task A_Cancelled_Collection_After_A_Late_Confirmation_Still_Anchors_Its_Window() + { + using var bus = ControllableBus.DeferredEchoCapable(NewSession()); + using var service = new CanBusService(bus); + var options = new IsoTpFunctionalOptions { IsExtendedCanId = false, UseCanFd = false, UsePadding = true, NAs = TimeSpan.FromSeconds(2) }; + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(service, FunctionalTxId, Ecu1, 0x7EF, options), + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(400)); + + var negative = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x22, 0x31 }); + var positive = SingleFrameFrom(Ecu1, new byte[] { 0x62, 0xF1, 0x91, 0x02 }); + + // The confirmation is held for 500 ms, past the 400 ms window of the pre-send listener; + // the collection is then cancelled 50 ms in, and the ECU answers negatively after that, + // 150 ms after the confirmation -- inside its P2 from the transmission. + using var early = new CancellationTokenSource(); + var first = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, Window, early.Token); + await bus.DeferredEchoes.WaitForEnqueuedAsync(1, ShortTimeout); + await Task.Delay(500); + bus.DeferredEchoes.ReleaseNext(); + await Task.Delay(50); + early.Cancel(); + Func cancelled = () => first; + await cancelled.Should().ThrowAsync(); + _ = Task.Run(async () => { await Task.Delay(100); bus.RaiseObserved(negative, isEcho: false); }); + + using var cts = new CancellationTokenSource(ShortTimeout); + var second = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x91 }, Window, cts.Token); + await bus.DeferredEchoes.WaitForEnqueuedAsync(2, ShortTimeout); + bus.DeferredEchoes.ReleaseNext(); + _ = Task.Run(async () => { await Task.Delay(20); bus.RaiseObserved(positive, isEcho: false); }); + var responses = await second; + + responses.Should().ContainSingle().Which.IsNegative.Should().BeFalse( + "the cancelled request's late negative answer fell in the window anchored when its collection was cancelled"); + } + // Bugbot on #150: a collection that outlasts P2 anchors a window already over; that must // not leave a completed listener behind that blocks the next window's real one. [Fact] From c59b9aac50af8026bb0d27bd0422a8bdfbb58b4a Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:28:28 +0200 Subject: [PATCH 23/55] test(isotp): dispose the service under test by using, for a throw before its explicit disposal CodeQL on #150. Co-Authored-By: Claude Opus 5 --- .../TestCases/IsoTp/IsoTpFunctionalClientTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs index fa6129a..9d4b877 100644 --- a/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs @@ -588,7 +588,7 @@ public async Task Functional_Listener_Reports_A_Service_Disposed_Underneath_On_T var session = NewSession(); using var busA = OpenClassic(session, 0); using var busB = OpenClassic(session, 1); - var service = new CanBusService(busA); + using var service = new CanBusService(busA); // disposed below; the using covers a throw before that (CodeQL) using var client = IsoTpFactory.OpenFunctional(service, 0x7DF, 0x7E8, 0x7EF, FastOptions(), leaveOpen: true); using var listener = client.Listen(); From ef55548e65b421ad750574c216c3a8f77c55f2ee Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:43:19 +0200 Subject: [PATCH 24/55] fix(uds): revive no window for a 0x78 from after its end, and correlate WriteMemoryByAddress on its address and size Codex on #150. A 0x78 for a service whose window had already run out -- its entry still in the table until the next request waits it out, or its listener retires -- moved that window out by a full P2*, and the next request for the service waited seconds for nothing; both clients now extend a window only if it was still open when the 0x78 arrived, by its arrival stamp. And a functional 0x3D was correlated on nothing: the 0x7D response echoes the addressAndLengthFormatIdentifier with the address and size it sizes, whose lengths the identifier's nibbles give. Mutation- checked: the unconditional extension makes the new physical-client test wait 1.8 s; without the 0x3D entry the functional test attributes another address's answer. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/README.md | 4 +- .../SuppressedResponseWindows.cs | 15 +++++++ src/CanKit.Pro.Uds/UdsClientImpl.cs | 6 ++- src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 35 ++++++++++------ .../TestCases/Uds/UdsClientTests.cs | 40 +++++++++++++++++++ .../TestCases/Uds/UdsFunctionalClientTests.cs | 28 +++++++++++++ 6 files changed, 112 insertions(+), 16 deletions(-) diff --git a/src/CanKit.Pro.Uds/README.md b/src/CanKit.Pro.Uds/README.md index 50bcca3..2263696 100644 --- a/src/CanKit.Pro.Uds/README.md +++ b/src/CanKit.Pro.Uds/README.md @@ -103,8 +103,8 @@ await uds.DownloadAsync( the functional identifier, every ECU's Single-Frame answer collected within a window and read as UDS (`UdsFunctionalResponse` with source identifier, bytes, `IsNegative` and the NRC). Only answers correlated to the request are attributed: a positive response echoing the request's - leading bytes (sub-function, DID, routine identifier, block counter), or a negative response - naming the service. Calls run one at a time. Every send starts a listener for its service + leading bytes (sub-function, DID, routine identifier, block counter, memory address and + size), or a negative response naming the service. Calls run one at a time. Every send starts a listener for its service that stays up for the ECUs' P2 (`Create`'s `responseWindow`, default P2) and moves the window out by P2* (`responsePendingWindow`, default P2*) on each NRC 0x78 it hears, so nothing in the window goes unobserved — a suppressed send included — and a call for that diff --git a/src/CanKit.Pro.Uds/SuppressedResponseWindows.cs b/src/CanKit.Pro.Uds/SuppressedResponseWindows.cs index 8ae0a12..a2f37de 100644 --- a/src/CanKit.Pro.Uds/SuppressedResponseWindows.cs +++ b/src/CanKit.Pro.Uds/SuppressedResponseWindows.cs @@ -34,6 +34,21 @@ public void Extend(byte sid, long until) } /// The instant the window for ends, if one is open. + /// + /// Extends the window for to only if one is + /// noted and was still open at : a 0x78 that arrived after a + /// window's deadline answers nothing the window still covers, and must not revive it + /// (Codex on #150). + /// + public void ExtendIfOpenAt(byte sid, long arrival, long until) + { + lock (_gate) + { + if (_until.TryGetValue(sid, out var existing) && arrival <= existing && existing < until) + _until[sid] = until; + } + } + public bool TryGetDeadline(byte sid, out long until) { lock (_gate) return _until.TryGetValue(sid, out until); diff --git a/src/CanKit.Pro.Uds/UdsClientImpl.cs b/src/CanKit.Pro.Uds/UdsClientImpl.cs index 88092b5..f5ed23b 100644 --- a/src/CanKit.Pro.Uds/UdsClientImpl.cs +++ b/src/CanKit.Pro.Uds/UdsClientImpl.cs @@ -1152,8 +1152,10 @@ private void RouteStrayPending(in IsoTpReceivedPdu pdu) { var data = pdu.Pdu; if (data.Length < 3 || data[0] != NegativeResponseSid || data[2] != NrcResponsePending) return; - if (_suppressedWindows.TryGetDeadline(data[1], out _)) - _suppressedWindows.Extend(data[1], pdu.FirstFrameArrivalTimestamp + (long)(_options.P2StarClientMax.TotalSeconds * Stopwatch.Frequency)); + // 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)); } /// diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index a7febfb..1692977 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -168,10 +168,11 @@ private async Task> SendRawLockedAsync(Read var req = request.Span; byte positiveSid = (byte)(sid + 0x40); // A positive response echoes the request's leading parameter bytes -- the sub-function - // (bit 7 cleared), a DID, a routine identifier, a block counter -- so a late answer to - // an earlier request for another parameter, arriving in this window, is told apart + // (bit 7 cleared), a DID, a routine identifier, a block counter, an address and size -- + // so a late answer to an earlier request for another parameter, arriving in this + // window, is told apart // (Codex on #150, twice). How many bytes, per service, is in EchoedRequestBytes. - int echoed = Math.Min(EchoedRequestBytes(sid), req.Length - 1); + int echoed = Math.Min(EchoedRequestBytes(req), req.Length - 1); var responses = new List(raw.Count); foreach (var r in raw) { @@ -262,10 +263,13 @@ private async Task ListenAsync(byte sid, IsoTpFunctionalListener ears) foreach (var pending in heard.Where(r => IsResponsePending(r.Data))) { byte pendingSid = pending.Data[1]; + // Its own window or another service's, but only one still open when the + // 0x78 arrived: a window that had run out, whose listener has not retired + // yet, is not revived for a full P2* by a late frame (Codex on #150). lock (_listeners) { - if (pendingSid == sid || _openWindows.TryGetDeadline(pendingSid, out _)) - _openWindows.Extend(pendingSid, pending.HostArrivalTimestamp + Ticks(_responsePendingWindow)); + _openWindows.ExtendIfOpenAt(pendingSid, pending.HostArrivalTimestamp, + pending.HostArrivalTimestamp + Ticks(_responsePendingWindow)); } } } @@ -357,14 +361,21 @@ public async Task> DiagnosticSessionControl // whose response layout starts with them (ISO 14229-1, the response tables of each // service): the sub-function where there is one, then a DID (0x22 the first, 0x24, 0x2E, // 0x2F -- Codex on #150), the sub-function and DID (0x2C), the routine identifier (0x31), - // the block sequence counter (0x36). - private static int EchoedRequestBytes(byte sid) => sid switch + // the block sequence counter (0x36), and for WriteMemoryByAddress (0x3D) the + // addressAndLengthFormatIdentifier with the address and size it sizes -- its low nibble + // the address's bytes, its high nibble the size's (Codex on #150). + private static int EchoedRequestBytes(ReadOnlySpan request) { - 0x22 or 0x24 or 0x2E or 0x2F => 2, - 0x2C or 0x31 => 3, - 0x36 => 1, - _ => HasSubFunction(sid) ? 1 : 0, - }; + byte sid = request[0]; + return sid switch + { + 0x22 or 0x24 or 0x2E or 0x2F => 2, + 0x2C or 0x31 => 3, + 0x36 => 1, + 0x3D => request.Length < 2 ? 0 : 1 + (request[1] & 0x0F) + (request[1] >> 4), + _ => HasSubFunction(sid) ? 1 : 0, + }; + } // 0x2A echoes nothing: its positive response starts with the periodic identifier it carries // data for, which must be one the request asked for (bytes after the transmission mode). diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs index 5a5483a..1ccfaa2 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs @@ -720,6 +720,46 @@ public async Task A_Pending_Answer_Consumed_As_Another_Requests_Stray_Still_Exte } } + // Codex on #150: a 0x78 for service A that arrives after A's window has run out answers + // nothing the window still covers; routed from B's wait, it must not revive A's window + // for a full P2*, or A's next request waits seconds for nothing. + [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))), + 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"); + } + } + // NRC 0x21 asks for a repeat; the client repeats, up to MaxBusyRepeatRequests. [Fact] public async Task BusyRepeatRequest_Is_Repeated_Until_The_Server_Answers() diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index c8b3aab..4de4129 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -333,6 +333,34 @@ public async Task A_Cancelled_Collection_Still_Leaves_Its_Window_For_The_Next_Ca "the cancelled request's window was kept for the next call"); } + // Codex on #150: WriteMemoryByAddress echoes the addressAndLengthFormatIdentifier and the + // address and size it sizes; an answer for another address is not this request's. + [Fact] + public async Task A_Memory_Write_Is_Correlated_On_Its_Address_And_Size() + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var busEcus = OpenClassic(session, 1); + + // ALFID 0x11: a one-byte address (0x34) and a one-byte size (2), then two data bytes. + var ours = SingleFrameFrom(Ecu1, new byte[] { 0x7D, 0x11, 0x34, 0x02 }); + var other = SingleFrameFrom(Ecu2, new byte[] { 0x7D, 0x11, 0x35, 0x02 }); + busEcus.FrameObserved += (_, e) => + { + if (e.CanFrame.ID != unchecked((int)FunctionalTxId)) return; + busEcus.Transmit(other); + busEcus.Transmit(ours); + }; + + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), ownsClient: true); + + using var cts = new CancellationTokenSource(ShortTimeout); + var responses = await functional.SendRawAsync(new byte[] { 0x3D, 0x11, 0x34, 0x02, 0xAA, 0xBB }, Window, cts.Token); + + responses.Should().ContainSingle().Which.Response.Should().Equal(0x7D, 0x11, 0x34, 0x02); + } + // Codex on #150: ReadDataByPeriodicIdentifier echoes nothing; its answer names the // periodic identifier it carries data for, which must be one the request asked for. [Fact] From 675e29b0fe40d1910d43f7546b1a7a30500f7751 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:53:31 +0200 Subject: [PATCH 25/55] fix(uds): revive no window for a late 0x78 heard while another service's wait-out runs Codex on #150, the third place the rule of ef55548 applies: the peer branch of ExtendOnPending extended another service's window on its entry alone. It now goes through ExtendIfOpenAt, by the 0x78's arrival stamp. Mutation-checked: the entry-only extension makes the new test's next request wait 1.4 s. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsClientImpl.cs | 4 +- .../TestCases/Uds/UdsClientTests.cs | 44 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/CanKit.Pro.Uds/UdsClientImpl.cs b/src/CanKit.Pro.Uds/UdsClientImpl.cs index f5ed23b..3a3cf4a 100644 --- a/src/CanKit.Pro.Uds/UdsClientImpl.cs +++ b/src/CanKit.Pro.Uds/UdsClientImpl.cs @@ -533,8 +533,8 @@ private bool ExtendOnPending(byte sid, in IsoTpReceivedPdu pdu, ref long until) var extendedUntil = pdu.FirstFrameArrivalTimestamp + (long)(_options.P2StarClientMax.TotalSeconds * Stopwatch.Frequency); if (data[1] != sid) { - if (_suppressedWindows.TryGetDeadline(data[1], out _)) - _suppressedWindows.Extend(data[1], extendedUntil); + // Another service's: only a window still open when the 0x78 arrived (Codex on #150). + _suppressedWindows.ExtendIfOpenAt(data[1], pdu.FirstFrameArrivalTimestamp, extendedUntil); return false; } if (extendedUntil <= until) return false; diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs index 1ccfaa2..69dfe43 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs @@ -760,6 +760,50 @@ public async Task A_Pending_Answer_From_After_A_Windows_End_Does_Not_Revive_It() } } + // Codex on #150: the same, heard while another service's wait-out runs rather than by a + // request's stray branch. + [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 }; + }), + 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"); + } + } + // NRC 0x21 asks for a repeat; the client repeats, up to MaxBusyRepeatRequests. [Fact] public async Task BusyRepeatRequest_Is_Repeated_Until_The_Server_Answers() From 0d7266b80321e1979801b0f3f16b18f9f0b38484 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:03:23 +0200 Subject: [PATCH 26/55] fix(uds): revive no window for a 0x78 of the waited service itself from after its end Codex on #150, the fourth place: the same-service branch of ExtendOnPending moved the local deadline out for any 0x78, including one queued after the window had run out, which the drain at the wait-out's entry consumes. It is skipped unless the 0x78 arrived while the window was open. Mutation-checked: without the check the new test's next request waits 1.8 s. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsClientImpl.cs | 4 ++- .../TestCases/Uds/UdsClientTests.cs | 34 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/CanKit.Pro.Uds/UdsClientImpl.cs b/src/CanKit.Pro.Uds/UdsClientImpl.cs index 3a3cf4a..f0c4087 100644 --- a/src/CanKit.Pro.Uds/UdsClientImpl.cs +++ b/src/CanKit.Pro.Uds/UdsClientImpl.cs @@ -537,7 +537,9 @@ private bool ExtendOnPending(byte sid, in IsoTpReceivedPdu pdu, ref long until) _suppressedWindows.ExtendIfOpenAt(data[1], pdu.FirstFrameArrivalTimestamp, extendedUntil); return false; } - if (extendedUntil <= until) return false; + // This service's: likewise only if the window was still open when it arrived -- a + // 0x78 queued after P2 ran out answers nothing the window covers (Codex on #150). + if (pdu.FirstFrameArrivalTimestamp > until || extendedUntil <= until) return false; until = extendedUntil; return true; } diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs index 69dfe43..3d6dc51 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs @@ -610,6 +610,40 @@ public async Task A_Queued_Pending_Answer_Still_Extends_A_Window_That_Has_Run_Ou } } + // Codex on #150: the converse -- a 0x78 queued after the window ran out answers nothing + // the window covers, and does not revive it for a full P2*. + [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 => + { + 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), + }); + + 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"); + } + } + // 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() From 1d0b046b44a43d42846930ccf8717e4bf2524641 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:14:56 +0200 Subject: [PATCH 27/55] fix(uds): revive no functional window for a 0x78 collected after the request's P2 Codex on #150, the fifth place: a collection window longer than P2 may hold a 0x78 from after it, and the collection's own extension was unconditional. The window is now noted as anchored at the transmission before the collected 0x78s are read, each extends it only if it was still open at the 0x78's arrival, and the listener is ensured after. Mutation- checked: the unconditional extension makes the new test's next request wait 1.9 s. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 19 +++++++--- .../TestCases/Uds/UdsFunctionalClientTests.cs | 36 +++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index 1692977..450b9c3 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -173,6 +173,13 @@ private async Task> SendRawLockedAsync(Read // window, is told apart // (Codex on #150, twice). How many bytes, per service, is in EchoedRequestBytes. int echoed = Math.Min(EchoedRequestBytes(req), req.Length - 1); + // The window as anchored at the transmission -- the collection ran for `window` from + // the transmit confirmation, so the send was at least `window` ago -- noted before the + // 0x78s are read: a listener that retired while the confirmation outlasted the + // provisional window has forgotten it, and a 0x78 inside the ECU's P2 must still move + // it out (Codex on #150). + long transmitted = Stopwatch.GetTimestamp() - Ticks(window); + lock (_listeners) _openWindows.Note(sid, transmitted, _responseWindow); var responses = new List(raw.Count); foreach (var r in raw) { @@ -181,15 +188,17 @@ private async Task> SendRawLockedAsync(Read && (sid != ReadDataByPeriodicIdentifierSid || NamesARequestedPeriodicIdentifier(req, data)); bool negative = data.Length >= 3 && data[0] == NegativeResponseSid && data[1] == sid; // P2* runs from the 0x78's arrival, which the response carries. The listener sees - // the same frame; the earlier of the two to act moves the window, the later is idle. + // the same frame; the earlier of the two to act moves the window, the later is + // idle. Only a 0x78 that arrived while the window was open: a collection window + // longer than P2 may hold one from after it, which revives nothing (Codex on #150). if (IsResponsePending(data, sid)) - Extend(sid, r.HostArrivalTimestamp + Ticks(_responsePendingWindow)); + Extend(sid, r.HostArrivalTimestamp, r.HostArrivalTimestamp + Ticks(_responsePendingWindow)); if (positive || negative) responses.Add(new UdsFunctionalResponse(r.SourceCanId, data)); } // Through StartListening again, after the 0x78s above moved the window: a confirmation // that outlasted the window has let the listener retire, and the moved-out window // needs one (Codex on #150). - StartListening(sid, Stopwatch.GetTimestamp() - Ticks(window)); + StartListening(sid, transmitted); return responses; } @@ -235,9 +244,9 @@ private void EnsureListener(byte sid) _listeners[sid] = (ears, Task.Run(() => ListenAsync(sid, ears))); } - private void Extend(byte sid, long until) + private void Extend(byte sid, long arrival, long until) { - lock (_listeners) _openWindows.Extend(sid, until); + lock (_listeners) _openWindows.ExtendIfOpenAt(sid, arrival, until); } private async Task ListenAsync(byte sid, IsoTpFunctionalListener ears) diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index 4de4129..7b69188 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -467,6 +467,42 @@ public async Task A_Pending_Answer_Extends_The_Window_From_Its_Arrival_Not_From_ "P2* is counted from the 0x78's arrival"); } + // Codex on #150: a collection window longer than P2 may hold a 0x78 from after the + // request's P2; it answers nothing the window still covers and does not revive it. + [Fact] + public async Task A_Pending_Answer_Collected_After_P2_Does_Not_Revive_The_Window() + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var busEcus = OpenClassic(session, 1); + + var pending = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x22, 0x78 }); + var positive = SingleFrameFrom(Ecu1, new byte[] { 0x62, 0xF1, 0x91, 0x02 }); + busEcus.FrameObserved += (_, e) => + { + if (e.CanFrame.ID != unchecked((int)FunctionalTxId)) return; + if (e.CanFrame.Data.Span[3] == 0x90) + _ = Task.Run(async () => { await Task.Delay(250); busEcus.Transmit(pending); }); + else busEcus.Transmit(positive); + }; + + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(100), responsePendingWindow: TimeSpan.FromMilliseconds(2000)); + + using var cts = new CancellationTokenSource(ShortTimeout); + // P2 = 100 ms; the 0x78 at 250 ms is inside the 400 ms collection but after P2. + await functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, TimeSpan.FromMilliseconds(400), cts.Token); + + // Revived, the window would reach 2250 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 responses = await functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x91 }, TimeSpan.FromMilliseconds(50), cts.Token); + sw.Stop(); + responses.Should().ContainSingle(); + sw.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(1), "the 0x78 from after P2 did not revive the window"); + } + // Codex on #150: a cancelled collection loses what it collected, but the listener that // owns the window heard the 0x78 too, and the next call waits P2* from it. [Fact] From 36d801770adbbb5bfeb52ebc0632eef12be5298d Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:28:05 +0200 Subject: [PATCH 28/55] fix(uds): read what the functional listener's subscription buffered before retiring it Codex on #150. The subscription is made before the send, but the worker reading it may be scheduled after the window has run out; it retired on that alone and abandoned what the subscription had buffered meanwhile -- a punctual 0x78 among it. The worker now reads the buffer first and decides again. An internal, test-settable start delay stands in for the late scheduling, as DisposeLockTimeout does for the physical client's dispose. Mutation-checked: retiring without the read makes the new test take the earlier request's late negative answer. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 20 +++++++++- .../TestCases/Uds/UdsFunctionalClientTests.cs | 37 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index 450b9c3..36a6098 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -42,6 +42,10 @@ public sealed class UdsFunctionalClient : IDisposable // disposed: a call queued behind another must not go out on a disposed client (Codex and // Bugbot on #150). private readonly CancellationTokenSource _lifetimeCts = new(); + + // Test hook: how late the listener's worker starts reading its subscription, standing in + // for a thread pool that schedules it after the window has run out (Codex on #150). + internal TimeSpan ListenerStartDelay { get; set; } private int _disposed; private UdsFunctionalClient(IsoTpFunctionalClient client, bool ownsClient, TimeSpan responseWindow, @@ -253,6 +257,9 @@ private async Task ListenAsync(byte sid, IsoTpFunctionalListener ears) { try { + if (ListenerStartDelay > TimeSpan.Zero) + await Task.Delay(ListenerStartDelay, _lifetimeCts.Token).ConfigureAwait(false); + bool drained = false; while (true) { TimeSpan remaining; @@ -264,11 +271,20 @@ private async Task ListenAsync(byte sid, IsoTpFunctionalListener ears) if (!_openWindows.TryGetDeadline(sid, out var until) || (remaining = SuppressedResponseWindows.Remaining(until)) <= TimeSpan.Zero) { - Retire(sid, ears); - return; + if (drained) + { + Retire(sid, ears); + return; + } + // Not before what the subscription buffered is read: it was made + // before the send, and holds what arrived while this worker was + // still being scheduled -- a punctual 0x78 among it moves the window + // out, and the decision is taken again (Codex on #150). + remaining = TimeSpan.Zero; } } var heard = await ears.CollectAsync(remaining, _lifetimeCts.Token).ConfigureAwait(false); + drained = remaining == TimeSpan.Zero; foreach (var pending in heard.Where(r => IsResponsePending(r.Data))) { byte pendingSid = pending.Data[1]; diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index 7b69188..6ba03ab 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -503,6 +503,43 @@ public async Task A_Pending_Answer_Collected_After_P2_Does_Not_Revive_The_Window sw.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(1), "the 0x78 from after P2 did not revive the window"); } + // Codex on #150: the listener's subscription is made before the send, but its worker may + // start after the window has run out; what the subscription buffered meanwhile -- here + // the immediate 0x78 -- is read before the worker retires, not abandoned with it. + [Fact] + public async Task A_Listener_Starting_After_The_Window_Still_Hears_What_It_Buffered() + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var busEcus = OpenClassic(session, 1); + + var pending = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x10, 0x78 }); + var negative = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x10, 0x12 }); + var positive = SingleFrameFrom(Ecu1, new byte[] { 0x50, 0x03, 0x00, 0x32, 0x01, 0xF4 }); + busEcus.FrameObserved += (_, e) => + { + if (e.CanFrame.ID != unchecked((int)FunctionalTxId)) return; + if ((e.CanFrame.Data.Span[2] & 0x80) != 0) + { + busEcus.Transmit(pending); + _ = Task.Run(async () => { await Task.Delay(250); busEcus.Transmit(negative); }); + } + else busEcus.Transmit(positive); + }; + + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(100), responsePendingWindow: TimeSpan.FromMilliseconds(400)); + functional.ListenerStartDelay = TimeSpan.FromMilliseconds(150); // past the 100 ms window + + using var cts = new CancellationTokenSource(ShortTimeout); + await functional.SendRawAsync(new byte[] { 0x10, 0x83 }, Window, cts.Token); // suppressed: the 0x78 is buffered before the worker runs + + var responses = await functional.DiagnosticSessionControlAsync(UdsSessionType.Extended, Window, cts.Token); + responses.Should().ContainSingle().Which.IsNegative.Should().BeFalse( + "the late worker read the buffered 0x78 and kept the window to 400 ms, past the negative at 250"); + } + // Codex on #150: a cancelled collection loses what it collected, but the listener that // owns the window heard the 0x78 too, and the next call waits P2* from it. [Fact] From e9ce1fc79597aaba3e4f8bea7011b474559332b5 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:42:55 +0200 Subject: [PATCH 29/55] fix(isotp): read the listener's buffer without a timer for a zero window macOS CI on #150, on 36d8017: the functional listener's read of its buffer before retiring was a zero-length collection, which armed a 0 ms timer; a timer's callback needs a thread-pool thread, and a starved pool grants one about once a second, so the retirement the next call awaited took a second (A_Pending_Answer_Extends_The_Window_From_Its_Arrival_Not_ From_The_Collections_End: 1474 ms against a 620 ms bound). A window of zero or less now returns what is buffered without arming anything. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.IsoTp/IsoTpFunctionalListener.cs | 11 ++++++++++- .../TestCases/IsoTp/IsoTpFunctionalClientTests.cs | 5 ++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/CanKit.Pro.IsoTp/IsoTpFunctionalListener.cs b/src/CanKit.Pro.IsoTp/IsoTpFunctionalListener.cs index effb466..f7d9b13 100644 --- a/src/CanKit.Pro.IsoTp/IsoTpFunctionalListener.cs +++ b/src/CanKit.Pro.IsoTp/IsoTpFunctionalListener.cs @@ -41,7 +41,9 @@ internal IsoTpFunctionalListener(ISubscription subscription) /// A response that arrives after the window ends but before this call returns is kept for /// the next collection, not returned by this one. /// - /// Duration to collect responses. + /// + /// Duration to collect responses. Zero or less returns what is buffered without waiting. + /// /// Cancels the collection; what was collected is lost. /// The responses, in arrival order; empty if none arrived. /// @@ -58,6 +60,13 @@ public async Task> CollectAsync( long deadline = Stopwatch.GetTimestamp() + (long)(window.TotalSeconds * Stopwatch.Frequency); var responses = new List(_carried); _carried.Clear(); + if (window <= TimeSpan.Zero) + { + // No timer for a read of the buffer: its callback would need a thread-pool thread, + // which a starved pool grants about once a second (macOS CI on #150). + TakeBuffered(responses, deadline); + return responses.AsReadOnly(); + } using var windowCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); windowCts.CancelAfter(window); try diff --git a/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs index 9d4b877..b058217 100644 --- a/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs @@ -572,11 +572,14 @@ public async Task Functional_Listener_Keeps_What_Arrives_Between_Collections() busB.Transmit(frame); await delivered.Task.WaitAsync(ShortTimeout); - var responses = await listener.CollectAsync(TimeSpan.FromMilliseconds(50)).WaitAsync(ShortTimeout); + // A zero window reads the buffer without waiting (and without a timer, which a starved + // thread pool serves late -- #150). + var responses = await listener.CollectAsync(TimeSpan.Zero).WaitAsync(ShortTimeout); responses.Should().ContainSingle().Which.Data.Should().Equal(pdu); // Not returned twice: the next collection starts from an empty buffer. (await listener.CollectAsync(TimeSpan.FromMilliseconds(50)).WaitAsync(ShortTimeout)).Should().BeEmpty(); + (await listener.CollectAsync(TimeSpan.Zero).WaitAsync(ShortTimeout)).Should().BeEmpty(); } // Bugbot on #150: a subscription completed underneath -- the service disposed -- ends the From 0d6fa8b204c144f1a9e00082d0c48ce1f7f48448 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:42:55 +0200 Subject: [PATCH 30/55] fix(uds): correlate a functional RequestFileTransfer on its modeOfOperation Codex on #150: the 0x78 positive response echoes the request's modeOfOperation, on which the table fell through to nothing. Mutation-checked: without the entry the new test attributes another operation's answer. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/README.md | 4 +-- src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 15 +++++----- .../TestCases/Uds/UdsFunctionalClientTests.cs | 29 +++++++++++++++++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/CanKit.Pro.Uds/README.md b/src/CanKit.Pro.Uds/README.md index 2263696..907428f 100644 --- a/src/CanKit.Pro.Uds/README.md +++ b/src/CanKit.Pro.Uds/README.md @@ -103,8 +103,8 @@ await uds.DownloadAsync( the functional identifier, every ECU's Single-Frame answer collected within a window and read as UDS (`UdsFunctionalResponse` with source identifier, bytes, `IsNegative` and the NRC). Only answers correlated to the request are attributed: a positive response echoing the request's - leading bytes (sub-function, DID, routine identifier, block counter, memory address and - size), or a negative response naming the service. Calls run one at a time. Every send starts a listener for its service + leading bytes (sub-function, DID, routine identifier, block counter, mode of operation, + memory address and size), or a negative response naming the service. Calls run one at a time. Every send starts a listener for its service that stays up for the ECUs' P2 (`Create`'s `responseWindow`, default P2) and moves the window out by P2* (`responsePendingWindow`, default P2*) on each NRC 0x78 it hears, so nothing in the window goes unobserved — a suppressed send included — and a call for that diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index 36a6098..fa3d77d 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -172,9 +172,9 @@ private async Task> SendRawLockedAsync(Read var req = request.Span; byte positiveSid = (byte)(sid + 0x40); // A positive response echoes the request's leading parameter bytes -- the sub-function - // (bit 7 cleared), a DID, a routine identifier, a block counter, an address and size -- - // so a late answer to an earlier request for another parameter, arriving in this - // window, is told apart + // (bit 7 cleared), a DID, a routine identifier, a block counter, a mode of operation, + // an address and size -- so a late answer to an earlier request for another + // parameter, arriving in this window, is told apart // (Codex on #150, twice). How many bytes, per service, is in EchoedRequestBytes. int echoed = Math.Min(EchoedRequestBytes(req), req.Length - 1); // The window as anchored at the transmission -- the collection ran for `window` from @@ -386,9 +386,10 @@ public async Task> DiagnosticSessionControl // whose response layout starts with them (ISO 14229-1, the response tables of each // service): the sub-function where there is one, then a DID (0x22 the first, 0x24, 0x2E, // 0x2F -- Codex on #150), the sub-function and DID (0x2C), the routine identifier (0x31), - // the block sequence counter (0x36), and for WriteMemoryByAddress (0x3D) the - // addressAndLengthFormatIdentifier with the address and size it sizes -- its low nibble - // the address's bytes, its high nibble the size's (Codex on #150). + // the block sequence counter (0x36), the modeOfOperation of RequestFileTransfer (0x38 -- + // Codex on #150), and for WriteMemoryByAddress (0x3D) the addressAndLengthFormatIdentifier + // with the address and size it sizes -- its low nibble the address's bytes, its high + // nibble the size's (Codex on #150). private static int EchoedRequestBytes(ReadOnlySpan request) { byte sid = request[0]; @@ -396,7 +397,7 @@ private static int EchoedRequestBytes(ReadOnlySpan request) { 0x22 or 0x24 or 0x2E or 0x2F => 2, 0x2C or 0x31 => 3, - 0x36 => 1, + 0x36 or 0x38 => 1, 0x3D => request.Length < 2 ? 0 : 1 + (request[1] & 0x0F) + (request[1] >> 4), _ => HasSubFunction(sid) ? 1 : 0, }; diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index 6ba03ab..22202b4 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -361,6 +361,35 @@ public async Task A_Memory_Write_Is_Correlated_On_Its_Address_And_Size() responses.Should().ContainSingle().Which.Response.Should().Equal(0x7D, 0x11, 0x34, 0x02); } + // Codex on #150: RequestFileTransfer echoes its modeOfOperation; an answer for another + // operation is not this request's. (Its positive SID is 0x78 -- not the NRC, which is + // the third byte of a 0x7F frame.) + [Fact] + public async Task A_File_Transfer_Request_Is_Correlated_On_Its_Mode_Of_Operation() + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var busEcus = OpenClassic(session, 1); + + var ours = SingleFrameFrom(Ecu1, new byte[] { 0x78, 0x01, 0x01, 0x10 }); + var other = SingleFrameFrom(Ecu2, new byte[] { 0x78, 0x02, 0x01, 0x10 }); + busEcus.FrameObserved += (_, e) => + { + if (e.CanFrame.ID != unchecked((int)FunctionalTxId)) return; + busEcus.Transmit(other); + busEcus.Transmit(ours); + }; + + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), ownsClient: true); + + using var cts = new CancellationTokenSource(ShortTimeout); + // AddFile (0x01), a one-byte path "A". + var responses = await functional.SendRawAsync(new byte[] { 0x38, 0x01, 0x00, 0x01, 0x41 }, Window, cts.Token); + + responses.Should().ContainSingle().Which.Response.Should().Equal(0x78, 0x01, 0x01, 0x10); + } + // Codex on #150: ReadDataByPeriodicIdentifier echoes nothing; its answer names the // periodic identifier it carries data for, which must be one the request asked for. [Fact] From edba9e4abd141f8979aa0e7089d4f2da28e28119 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:04:57 +0200 Subject: [PATCH 31/55] fix(uds): keep the functional listener through a send whose confirmation outlasts the window Codex on #150. With the transmit confirmation held past the provisional window, the pre-send listener retired while the frame was on the bus, and until the send returned there was no subscription at all: a 0x78 answered at the transmission was lost, and with it the P2* covering the request's final answer. A service with a send in flight now keeps its listener, and the 0x78s heard meanwhile are recorded and applied, in arrival order, against the window as anchored once the send returns -- one at the transmission moves it out, one from after P2 does not. Mutation-checked twice over: without the keep-alive, and with the recorded arrivals dropped, the new test takes the earlier request's negative answer. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 88 ++++++++++++++----- .../TestCases/Uds/UdsFunctionalClientTests.cs | 43 +++++++++ 2 files changed, 110 insertions(+), 21 deletions(-) diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index fa3d77d..ffe5375 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -34,6 +34,10 @@ public sealed class UdsFunctionalClient : IDisposable private readonly SuppressedResponseWindows _openWindows = new(); // Per service: the standing subscription that hears the window out, and the task reading it. private readonly Dictionary _listeners = new(); + // The services with a send in flight, with the 0x78 arrivals heard for them meanwhile: + // their listener does not retire, and whether such a 0x78 was punctual is decided against + // the window as anchored once the send returns (Codex on #150). Under the same lock. + private readonly Dictionary> _inFlight = new(); // One request on the wire at a time, its collection window included: overlapping calls // with the same SID would each collect the other's answers (Codex on #150), as the // physical client's request lock prevents there. @@ -125,11 +129,17 @@ private async Task> SendRawLockedAsync(Read // a negative answer, a 0x78 -- goes unobserved, and a send cancelled between the // driver's acceptance and the confirmation is covered (Codex on #150). Its window // is moved out to the confirmation afterwards. - StartListening(sid, Stopwatch.GetTimestamp()); - await _client.SendAsync(request, cancellationToken).ConfigureAwait(false); - // Through StartListening again: a confirmation that outlasted the window has let - // the listener retire, and the moved-out window needs one (Codex on #150). - StartListening(sid, Stopwatch.GetTimestamp()); + StartListening(sid, Stopwatch.GetTimestamp(), inFlight: true); + try + { + await _client.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + finally + { + // Through StartListening again: the window is moved out to the confirmation, + // and the listener, kept through the send, reads it (Codex on #150). + StartListening(sid, Stopwatch.GetTimestamp(), inFlight: false); + } return Array.Empty(); } @@ -153,7 +163,7 @@ private async Task> SendRawLockedAsync(Read // is over, the window is moved out to the send's own instant plus P2: the collection // ran for `window` from the transmit confirmation, so that instant is at least now // less `window`, however long the confirmation took (Codex on #150). - StartListening(sid, Stopwatch.GetTimestamp()); + StartListening(sid, Stopwatch.GetTimestamp(), inFlight: true); IReadOnlyList raw; try { @@ -163,10 +173,10 @@ private async Task> SendRawLockedAsync(Read catch { // A collection that ends in a cancellation or a transport fault may still have put - // the request on the bus, and a confirmation that outlasted the window has let the - // pre-send listener retire; the ECUs' P2 from the transmission is at most P2 from - // now, so that window is noted before the exception leaves (Codex on #150). - StartListening(sid, Stopwatch.GetTimestamp()); + // the request on the bus; the ECUs' P2 from the transmission is at most P2 from + // now, so that window is noted before the exception leaves, and the listener, + // kept through the send, has heard any 0x78 the collection lost (Codex on #150). + StartListening(sid, Stopwatch.GetTimestamp(), inFlight: false); throw; } var req = request.Span; @@ -183,7 +193,7 @@ private async Task> SendRawLockedAsync(Read // provisional window has forgotten it, and a 0x78 inside the ECU's P2 must still move // it out (Codex on #150). long transmitted = Stopwatch.GetTimestamp() - Ticks(window); - lock (_listeners) _openWindows.Note(sid, transmitted, _responseWindow); + lock (_listeners) NoteAnchored(sid, transmitted); var responses = new List(raw.Count); foreach (var r in raw) { @@ -202,7 +212,7 @@ private async Task> SendRawLockedAsync(Read // Through StartListening again, after the 0x78s above moved the window: a confirmation // that outlasted the window has let the listener retire, and the moved-out window // needs one (Codex on #150). - StartListening(sid, transmitted); + StartListening(sid, transmitted, inFlight: false); return responses; } @@ -222,15 +232,36 @@ private async Task> SendRawLockedAsync(Read // so a note that moves the window out either finds the listener still reading -- and it // re-reads the deadline before retiring -- or finds none and starts one; a listener cannot // retire, forgetting the window, between the note and the check (Codex on #150). - private void StartListening(byte sid, long from) + private void StartListening(byte sid, long from, bool inFlight) { lock (_listeners) { - _openWindows.Note(sid, from, _responseWindow); + if (inFlight) + { + _inFlight[sid] = new List(); + _openWindows.Note(sid, from, _responseWindow); + } + else + { + NoteAnchored(sid, from); + } EnsureListener(sid); } } + // Under the listeners lock. Ends the send in flight for the service: notes its window + // from the anchor the send gave, then applies the 0x78s heard meanwhile in arrival order, + // each only if the window was open when it arrived -- one at the transmission moves it + // out, one from after P2 does not (Codex on #150). + private void NoteAnchored(byte sid, long from) + { + _openWindows.Note(sid, from, _responseWindow); + if (!_inFlight.TryGetValue(sid, out var heard)) return; + _inFlight.Remove(sid); + foreach (var arrival in heard) + _openWindows.ExtendIfOpenAt(sid, arrival, arrival + Ticks(_responsePendingWindow)); + } + // Under the listeners lock. Starts a listener for the service's window if the window is // open and none is reading it; forgets a window already over -- a collection that outlasted // P2 -- because a listener for it would only retire on its first read (Bugbot on #150). @@ -253,6 +284,9 @@ private void Extend(byte sid, long arrival, long until) lock (_listeners) _openWindows.ExtendIfOpenAt(sid, arrival, until); } + // How long a listener kept alive by a send in flight collects before it looks again. + private static readonly TimeSpan InFlightSlice = TimeSpan.FromMilliseconds(20); + private async Task ListenAsync(byte sid, IsoTpFunctionalListener ears) { try @@ -271,16 +305,27 @@ private async Task ListenAsync(byte sid, IsoTpFunctionalListener ears) if (!_openWindows.TryGetDeadline(sid, out var until) || (remaining = SuppressedResponseWindows.Remaining(until)) <= TimeSpan.Zero) { - if (drained) + if (_inFlight.ContainsKey(sid)) + { + // A send still in flight -- its confirmation outlasting the + // provisional window -- keeps the listener: the window is moved + // out once the send returns, and nothing answered at the + // transmission goes unheard meanwhile (Codex on #150). + remaining = InFlightSlice; + } + else if (drained) { Retire(sid, ears); return; } - // Not before what the subscription buffered is read: it was made - // before the send, and holds what arrived while this worker was - // still being scheduled -- a punctual 0x78 among it moves the window - // out, and the decision is taken again (Codex on #150). - remaining = TimeSpan.Zero; + else + { + // Not before what the subscription buffered is read: it was made + // before the send, and holds what arrived while this worker was + // still being scheduled -- a punctual 0x78 among it moves the + // window out, and the decision is taken again (Codex on #150). + remaining = TimeSpan.Zero; + } } } var heard = await ears.CollectAsync(remaining, _lifetimeCts.Token).ConfigureAwait(false); @@ -293,7 +338,8 @@ private async Task ListenAsync(byte sid, IsoTpFunctionalListener ears) // yet, is not revived for a full P2* by a late frame (Codex on #150). lock (_listeners) { - _openWindows.ExtendIfOpenAt(pendingSid, pending.HostArrivalTimestamp, + if (_inFlight.TryGetValue(pendingSid, out var inFlight)) inFlight.Add(pending.HostArrivalTimestamp); + else _openWindows.ExtendIfOpenAt(pendingSid, pending.HostArrivalTimestamp, pending.HostArrivalTimestamp + Ticks(_responsePendingWindow)); } } diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index 22202b4..5fc8370 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -808,6 +808,49 @@ public async Task A_Cancelled_Collection_After_A_Late_Confirmation_Still_Anchors "the cancelled request's late negative answer fell in the window anchored when its collection was cancelled"); } + // Codex on #150: a suppressed send whose confirmation outlasts the window must not be + // left without a listener between the transmission and the send's return -- a 0x78 + // answered at the transmission would be lost, and with it the P2* that covers the + // request's final answer. + [Fact] + public async Task A_Listener_Is_Kept_Through_A_Send_Whose_Confirmation_Outlasts_The_Window() + { + using var bus = ControllableBus.DeferredEchoCapable(NewSession()); + using var service = new CanBusService(bus); + var options = new IsoTpFunctionalOptions { IsExtendedCanId = false, UseCanFd = false, UsePadding = true, NAs = TimeSpan.FromSeconds(2) }; + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(service, FunctionalTxId, Ecu1, 0x7EF, options), + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(400), responsePendingWindow: TimeSpan.FromMilliseconds(1000)); + + var pending = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x10, 0x78 }); + var negative = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x10, 0x12 }); + var positive = SingleFrameFrom(Ecu1, new byte[] { 0x50, 0x03, 0x00, 0x32, 0x01, 0xF4 }); + + using var cts = new CancellationTokenSource(ShortTimeout); + // The confirmation is held for 600 ms, past the 400 ms provisional window; the frame is + // on the bus meanwhile, and the ECU's 0x78 arrives at 500 ms, 100 ms before the + // confirmation is released -- heard by the listener while the send is still in + // flight. The negative answer follows 600 ms after the confirmation: past the P2 + // window anchored there (1000 ms), inside P2* = 1000 ms from the 0x78 (1500 ms). + var suppressed = functional.SendRawAsync(new byte[] { 0x10, 0x83 }, Window, cts.Token); + await bus.DeferredEchoes.WaitForEnqueuedAsync(1, ShortTimeout); + await Task.Delay(500); + bus.RaiseObserved(pending, isEcho: false); + await Task.Delay(100); + bus.DeferredEchoes.ReleaseNext(); + await suppressed; + _ = Task.Run(async () => { await Task.Delay(600); bus.RaiseObserved(negative, isEcho: false); }); + + var second = functional.DiagnosticSessionControlAsync(UdsSessionType.Extended, Window, cts.Token); + await bus.DeferredEchoes.WaitForEnqueuedAsync(2, ShortTimeout); + bus.DeferredEchoes.ReleaseNext(); + _ = Task.Run(async () => { await Task.Delay(20); bus.RaiseObserved(positive, isEcho: false); }); + var responses = await second; + + responses.Should().ContainSingle().Which.IsNegative.Should().BeFalse( + "the listener kept through the send heard the 0x78 and held the window past the negative answer"); + } + // Bugbot on #150: a collection that outlasts P2 anchors a window already over; that must // not leave a completed listener behind that blocks the next window's real one. [Fact] From c3c025be307ead9b29fc550fc947e3995276beee Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:25:51 +0200 Subject: [PATCH 32/55] feat(isotp)!: let a channel be settled, so a look at the inbox at a deadline sees every frame that arrived before it Codex on #150. A Single Frame the demux has buffered is not in the inbox until the reader task and the actor have taken it, and neither runs on the caller's schedule: at a deadline, a 0x78 stamped in time can still be on its way. IIsoTpChannel.SettleAsync drains the demux buffer as DiscardPendingPdus does and completes once the actor has taken everything queued so far, dropping nothing. The starved-reader bus service moves to the shared test infrastructure, with an arrival stamp, for the tests that need a frame stamped in time but not yet taken. Mutation-checked: without the pump the new test's frame is not in the inbox after settling. BREAKING CHANGE: IIsoTpChannel gains SettleAsync. Source-breaking for external implementers of the interface; consumers are unaffected. Per docs/decisions/0001-versioning-and-api-stability.md this is a minor bump before v1.3.0 and no shim is introduced. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.IsoTp/IIsoTpChannel.cs | 10 ++ src/CanKit.Pro.IsoTp/IsoTpChannel.cs | 20 ++++ src/CanKit.Pro.IsoTp/README.md | 4 + .../CanKit.Pro.IsoTp.approved.txt | 1 + .../Infrastructure/StarvedReaderBusService.cs | 101 ++++++++++++++++ .../IsoTp/IsoTpChannelIntegrationTests.cs | 112 ++++-------------- 6 files changed, 162 insertions(+), 86 deletions(-) create mode 100644 tests/CanKit.Pro.Tests/Infrastructure/StarvedReaderBusService.cs diff --git a/src/CanKit.Pro.IsoTp/IIsoTpChannel.cs b/src/CanKit.Pro.IsoTp/IIsoTpChannel.cs index bfc7b5a..9fa65ec 100644 --- a/src/CanKit.Pro.IsoTp/IIsoTpChannel.cs +++ b/src/CanKit.Pro.IsoTp/IIsoTpChannel.cs @@ -126,6 +126,16 @@ Task SendWithTransmitStampAsync(ReadOnlyMemory pdu, /// IReadOnlyList GetReceptionsInProgress(); + /// + /// Completes once every frame the bus had delivered before the call has been taken through + /// the channel: a PDU it completed is in the inbox, a reception it began is in + /// . For a decision taken at a deadline — is a + /// response there, was there a 0x78 — what the inbox does not hold after this did not + /// arrive before the call; without it, a frame stamped in time can still be on its way + /// through the channel's actor when the deadline fires (Codex on #150). + /// + Task SettleAsync(); + /// /// Drains every buffered inbox item — both completed PDUs and pending reassembly-abort /// faults enqueued by AbortRx — and returns how many were dropped. Also silently diff --git a/src/CanKit.Pro.IsoTp/IsoTpChannel.cs b/src/CanKit.Pro.IsoTp/IsoTpChannel.cs index c23be42..219fc19 100644 --- a/src/CanKit.Pro.IsoTp/IsoTpChannel.cs +++ b/src/CanKit.Pro.IsoTp/IsoTpChannel.cs @@ -344,6 +344,26 @@ public bool TryReceiveWithArrival(out IsoTpReceivedPdu pdu) } /// + /// + public Task SettleAsync() + { + 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 + // next scheduling; then a no-op posted behind it completes once the actor has taken + // everything queued so far. Called from the actor itself, the post would wait for the + // loop it is on: there is nothing ahead of the caller then. + PumpSubscription(); + if (_actor is ProtocolActor { IsOnCurrentActor: true }) return Task.CompletedTask; + try + { + return _actor.PostAsync(() => { }); + } + catch (ObjectDisposedException) + { + return Task.CompletedTask; // channel tearing down: nothing left on its way + } + } + public int DiscardPendingPdus() { // Everything that arrived up to now is pending. The stamp goes first, so a frame the diff --git a/src/CanKit.Pro.IsoTp/README.md b/src/CanKit.Pro.IsoTp/README.md index dc4b2ec..7610e10 100644 --- a/src/CanKit.Pro.IsoTp/README.md +++ b/src/CanKit.Pro.IsoTp/README.md @@ -63,6 +63,10 @@ CAN-FD long-payload cases still get the least coverage of the two halves. answer does not wait for the reader task's scheduling either. A caller waiting on one re-checks rather than waiting unboundedly. `DiscardPendingPdus` drains the demux buffer the same way before clearing, so a frame buffered at discard time is part of what it drops. + `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. - Timings: `IsoTpChannelOptions.NAs` (TX-confirm), `NBs` (peer-FC wait), `NCr` (next CF wait) and `WftMax` (max consecutive `Wait` FCs) are configurable; defaults are conservative 1 s / 10. - Reception limits: a First Frame announcing more than `MaxReceivePduLength` (default 65 535 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 4f84f43..4175a4b 100644 --- a/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.IsoTp.approved.txt +++ b/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.IsoTp.approved.txt @@ -19,6 +19,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(); bool TryReceiveWithArrival(out CanKit.Pro.IsoTp.IsoTpReceivedPdu pdu); } public static class IsoTp diff --git a/tests/CanKit.Pro.Tests/Infrastructure/StarvedReaderBusService.cs b/tests/CanKit.Pro.Tests/Infrastructure/StarvedReaderBusService.cs new file mode 100644 index 0000000..35e0820 --- /dev/null +++ b/tests/CanKit.Pro.Tests/Infrastructure/StarvedReaderBusService.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using CanKit.Abstractions.API.Can; +using CanKit.Abstractions.API.Can.Definitions; +using CanKit.Pro.RawCan; + +namespace CanKit.Pro.Tests.Infrastructure; + +/// +/// A bus service whose subscription's WaitToReadAsync stays pending until +/// , so the channel's reader task is starved by construction and +/// only a caller-side pump sees what buffered. +/// +internal sealed class StarvedReaderBusService : ICanBusService +{ + private readonly Channel _frames = Channel.CreateUnbounded(); + private readonly TaskCompletionSource _wake = new(TaskCreationOptions.RunContinuationsAsynchronously); + private TaskCompletionSource _drained = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _wakesServed; + + /// + /// Buffers a frame as the demux would. Unstamped by default -- the channel stamps it when + /// it is pumped; makes it arrive at a given + /// instant, as a frame stamped in time but still on its way through the channel (#150). + /// + public void Deliver(CanFrameView frame, long hostArrivalTimestamp = 0) => _frames.Writer.TryWrite( + new CanFrameEvent(frame, isEcho: false, TimeSpan.Zero, hostArrivalTimestamp)); + + /// Lets the reader task's wait complete once; every later wait stays pending. + public void WakeReader() => _wake.TrySetResult(true); + + /// Completes when a pump has emptied the buffer (a TryRead returned false). + public Task PumpDrained => _drained.Task; + + public void ResetDrained() => _drained = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public ICanBus Bus => throw new NotSupportedException(); + + public int SubscriptionCount => 1; + + public event EventHandler? BackgroundExceptionOccurred + { + add { } + remove { } + } + + public ISubscription Subscribe(Func? predicate = null, + int? bufferCapacity = null, bool includeEcho = false) + => new Sub(this); + + public ISubscription Subscribe(CanIdFilter filter, int? bufferCapacity = null, + bool includeEcho = false) + => new Sub(this); + + /// Every frame the channel put on the wire, in order. + public List Sent { get; } = new(); + + public Task SendConfirmed(CanFrame frame, TimeSpan? timeout = null, + CancellationToken cancellationToken = default) + { + lock (Sent) Sent.Add(frame.Data.ToArray()); + return Task.FromResult(new TxConfirmation { Confirmed = true }); + } + + public IReadOnlyList FindOverlappingFilterSubscriptions() + => Array.Empty(); + + public void Dispose() => _frames.Writer.TryComplete(); + + private sealed class Sub : ISubscription + { + private readonly StarvedReaderBusService _owner; + public Sub(StarvedReaderBusService owner) => _owner = owner; + + public IAsyncEnumerable Frames => _owner._frames.Reader.ReadAllAsync(); + + public bool TryRead(out CanFrameEvent frameEvent) + { + bool ok = _owner._frames.Reader.TryRead(out frameEvent); + if (!ok) _owner._drained.TrySetResult(true); + return ok; + } + + public async ValueTask WaitToReadAsync(CancellationToken cancellationToken = default) + { + if (Interlocked.Exchange(ref _owner._wakesServed, 1) == 0) + return await _owner._wake.Task.WaitAsync(cancellationToken); + await Task.Delay(Timeout.Infinite, cancellationToken); + return false; + } + + public void Reconfigure(CanIdFilter filter) { } + + public void Reconfigure(Func? predicate) { } + + public void Dispose() { } + } +} diff --git a/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpChannelIntegrationTests.cs b/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpChannelIntegrationTests.cs index 1a80995..5736f75 100644 --- a/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpChannelIntegrationTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpChannelIntegrationTests.cs @@ -417,6 +417,32 @@ public async Task A_First_Frame_That_Fits_A_Single_Frame_Is_Ignored(int announce channel.GetReceptionsInProgress().Should().BeEmpty(); } + // Codex on #150: a Single Frame the demux has buffered is not in the inbox until the reader + // task and the actor have taken it, and neither runs on the caller's schedule; SettleAsync + // takes it through on the caller's, so a look at the inbox after it is a look at every + // frame that arrived before the call. + [Fact] + public async Task Settle_Takes_A_Buffered_Single_Frame_Through_To_The_Inbox() + { + 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); + + byte[] sf = { 0x03, 0x7F, 0x3E, 0x78, 0x00, 0x00, 0x00, 0x00 }; + long arrival = Stopwatch.GetTimestamp(); + service.Deliver(new CanFrameView(CanFrameType.Can20, 0x7E8, sf, FrameFlags.None), arrival); + // Buffered, but nothing has taken it yet -- the reader task is starved by construction. + await Task.Delay(50); + channel.TryReceiveWithArrival(out _).Should().BeFalse("the reader task has not run"); + + await channel.SettleAsync().WaitAsync(ShortTimeout); + + channel.TryReceiveWithArrival(out var pdu).Should().BeTrue("settling took the frame through"); + pdu.Pdu.Should().Equal(0x7F, 0x3E, 0x78); + pdu.FirstFrameArrivalTimestamp.Should().Be(arrival, "the stamp is the demux's, not the settle's"); + } + // Bugbot on #147: ignored means no effect at all -- a reassembly in flight survives a // stray First Frame that fits a Single Frame, and a short one (#27). [Theory] @@ -2444,92 +2470,6 @@ public async Task A_First_Frame_Buffered_At_Discard_Time_Gets_No_Flow_Control() service.Sent.Should().BeEmpty("a dropped First Frame is not answered with Flow Control"); } - /// - /// A bus service whose subscription's WaitToReadAsync stays pending until - /// , so the channel's reader task is starved by construction and - /// only a caller-side pump sees what buffered. - /// - private sealed class StarvedReaderBusService : ICanBusService - { - private readonly Channel _frames = Channel.CreateUnbounded(); - private readonly TaskCompletionSource _wake = new(TaskCreationOptions.RunContinuationsAsynchronously); - private TaskCompletionSource _drained = new(TaskCreationOptions.RunContinuationsAsynchronously); - private int _wakesServed; - - public void Deliver(CanFrameView frame) => _frames.Writer.TryWrite( - new CanFrameEvent(frame, isEcho: false, TimeSpan.Zero)); - - /// Lets the reader task's wait complete once; every later wait stays pending. - public void WakeReader() => _wake.TrySetResult(true); - - /// Completes when a pump has emptied the buffer (a TryRead returned false). - public Task PumpDrained => _drained.Task; - - public void ResetDrained() => _drained = new(TaskCreationOptions.RunContinuationsAsynchronously); - - public ICanBus Bus => throw new NotSupportedException(); - - public int SubscriptionCount => 1; - - public event EventHandler? BackgroundExceptionOccurred - { - add { } - remove { } - } - - public ISubscription Subscribe(Func? predicate = null, - int? bufferCapacity = null, bool includeEcho = false) - => new Sub(this); - - public ISubscription Subscribe(CanIdFilter filter, int? bufferCapacity = null, - bool includeEcho = false) - => new Sub(this); - - /// Every frame the channel put on the wire, in order. - public List Sent { get; } = new(); - - public Task SendConfirmed(CanFrame frame, TimeSpan? timeout = null, - CancellationToken cancellationToken = default) - { - lock (Sent) Sent.Add(frame.Data.ToArray()); - return Task.FromResult(new TxConfirmation { Confirmed = true }); - } - - public IReadOnlyList FindOverlappingFilterSubscriptions() - => Array.Empty(); - - public void Dispose() => _frames.Writer.TryComplete(); - - private sealed class Sub : ISubscription - { - private readonly StarvedReaderBusService _owner; - public Sub(StarvedReaderBusService owner) => _owner = owner; - - public IAsyncEnumerable Frames => _owner._frames.Reader.ReadAllAsync(); - - public bool TryRead(out CanFrameEvent frameEvent) - { - bool ok = _owner._frames.Reader.TryRead(out frameEvent); - if (!ok) _owner._drained.TrySetResult(true); - return ok; - } - - public async ValueTask WaitToReadAsync(CancellationToken cancellationToken = default) - { - if (Interlocked.Exchange(ref _owner._wakesServed, 1) == 0) - return await _owner._wake.Task.WaitAsync(cancellationToken); - await Task.Delay(Timeout.Infinite, cancellationToken); - return false; - } - - public void Reconfigure(CanIdFilter filter) { } - - public void Reconfigure(Func? predicate) { } - - public void Dispose() { } - } - } - private static async Task> WaitForReceptionsAsync( IIsoTpChannel channel, int count) { From 2a697de4f8417d568e6ded0a65c3092f8924595e Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:25:51 +0200 Subject: [PATCH 33/55] fix(uds): settle the channel before reading the inbox empty at a deadline Codex on #150. The wait-out of a suppressed send's window read the inbox empty when the window was measured as over, and forgot the window; a 0x78 stamped inside it but still on its way through the channel's actor was then dropped by the pre-send discard, and the next same-service request took the suppressed send's late answer. The wait-out, the over-budget take of the P2 wait and the pre-send discard settle the channel first. Mutation-checked: without the settle the new test's next request goes out at P2 instead of P2*. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsClientImpl.cs | 32 +++++++++++++++-- .../TestCases/Uds/UdsClientTests.cs | 35 +++++++++++++++++++ .../TestCases/Uds/UdsExpiredDeadlineTests.cs | 2 ++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/CanKit.Pro.Uds/UdsClientImpl.cs b/src/CanKit.Pro.Uds/UdsClientImpl.cs index f0c4087..36b70ac 100644 --- a/src/CanKit.Pro.Uds/UdsClientImpl.cs +++ b/src/CanKit.Pro.Uds/UdsClientImpl.cs @@ -467,7 +467,10 @@ private async Task WaitOutSuppressedResponseWindowAsync(UdsServiceId serviceId, if (remaining <= TimeSpan.Zero) { // The window is over as measured now -- but a 0x78 may be queued already, - // and it moves the window out (Bugbot on #150). Only an empty inbox ends it. + // and it moves the window out (Bugbot on #150). Only an empty inbox ends + // it, and only once the channel has settled: a 0x78 stamped inside the + // window may still be on its way through its actor (Codex on #150). + await SettleAsync().ConfigureAwait(false); if (DrainExtends(sid, ref until)) continue; break; } @@ -480,6 +483,7 @@ private async Task WaitOutSuppressedResponseWindowAsync(UdsServiceId serviceId, } catch (OperationCanceledException) when (slice.IsCancellationRequested && !linkedToken.IsCancellationRequested) { + await SettleAsync().ConfigureAwait(false); if (DrainExtends(sid, ref until)) continue; break; } @@ -957,7 +961,7 @@ private async Task ExchangeOnceAsync(UdsServiceId serviceId, byte[] requ // Drop any late reply left over from a previous aborted/timed-out wait before we put a // new request on the wire. SID correlation alone is insufficient when the next request // uses the same service (the stale positive response SID would match). - DiscardStalePdus(); + await DiscardStalePdusAsync().ConfigureAwait(false); // The stamp the channel took as the request's last frame went to the bus -- not a reading // taken here. P2 starts when the request was transmitted, and this continuation resumes @@ -1120,6 +1124,26 @@ private static bool IsAllZero(byte[] data, int offset, int count) return true; } + // The pre-send discard: what is on its way through the channel is settled first, so a 0x78 + // among it is routed to its service's window rather than dropped unseen (Codex on #150). + private async Task DiscardStalePdusAsync() + { + await SettleAsync().ConfigureAwait(false); + DiscardStalePdus(); + } + + private async Task SettleAsync() + { + try + { + await _channel.SettleAsync().ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + // channel gone: nothing on its way + } + } + private void DiscardStalePdus() { try @@ -1227,6 +1251,10 @@ private async Task TakeQueuedOrInProgressAsync(UdsServiceId se { while (true) { + // Settled first: a Single Frame stamped inside the budget may still be on its way + // through the channel's actor when the deadline fires; after this it is in the + // inbox, or its reception is on record (Codex on #150). + await SettleAsync().ConfigureAwait(false); // The in-progress check goes first: the channel withdraws the record only after the // completed PDU (or the abort's error item) is in the inbox, so a reception seen in // progress here is found by the wait below, and one not seen is either absent or diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs index 3d6dc51..d256188 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs @@ -644,6 +644,41 @@ public async Task A_Queued_Pending_Answer_From_After_The_Windows_End_Does_Not_Re } } + // Codex on #150: a 0x78 stamped inside a suppressed send's window may still be on its way + // through the channel's actor when the window is measured as over; the next request for + // the service waits for the channel to settle before it reads the inbox empty. + [Fact] + public async Task A_Pending_Answer_Still_On_Its_Way_Through_The_Channel_Extends_The_Window() + { + using var service = new StarvedReaderBusService(); + using var channel = IsoTpFactory.Open(service, IsoTpEndpoint.Normal(0x7E0, 0x7E8), FastIsoTp(useCanFd: false), leaveOpen: true); + var pendingBudget = TimeSpan.FromMilliseconds(600); + using var client = UdsClient.Create(channel, new UdsClientOptions + { + P2ClientMax = TimeSpan.FromMilliseconds(100), + P2StarClientMax = pendingBudget, + }); + + 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 20 ms in, buffered by the demux -- the reader task that would + // take it to the actor is starved by construction. + await Task.Delay(20); + byte[] sf = { 0x03, 0x7F, 0x3E, 0x78, 0x00, 0x00, 0x00, 0x00 }; + service.Deliver(new CanFrameView(CanFrameType.Can20, 0x7E8, sf, FrameFlags.None), Stopwatch.GetTimestamp()); + 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 how long it waited before it sent, a lower bound a loaded host only raises. + var sw = Stopwatch.StartNew(); + Func next = () => client.SendRawAsync(new byte[] { 0x3E, 0x00 }, cts.Token); + await next.Should().ThrowAsync(); + sw.Stop(); + sw.Elapsed.Should().BeGreaterThanOrEqualTo(TimeSpan.FromMilliseconds(400), + "the 0x78 on its way through the channel moved the window out to 620 ms"); + } + // 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() diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs index a4e6a34..cce0912 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs @@ -720,6 +720,8 @@ private static 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 int DiscardPendingPdus() => 0; public IAsyncEnumerable ReceiveAllAsync(CancellationToken cancellationToken = default) From 808e9c3ef9fea7ab834593e460bac6568c772311 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:25:51 +0200 Subject: [PATCH 34/55] fix(isotp): bound a functional collection by its frames' arrival stamps, and widen three functional tests' margins Codex on #150: the collection's timer and this method's continuations are scheduling, and a frame that arrived after the window's end but before they ran was returned as the window's; each frame is now held against the window's end by its arrival stamp. Closed by construction: the instant between the timer and the continuation is not one a test can open reliably. And three of the branch's functional tests raised an ECU's late answer by a timer that had to fire before the next request went out, with 250 to 400 ms to spare; the net48 leg on Windows exceeded 300. Their windows are widened so the margin is 850 ms or more. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.IsoTp/IsoTpFunctionalClient.cs | 12 +++++--- .../TestCases/Uds/UdsFunctionalClientTests.cs | 29 +++++++++++-------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/CanKit.Pro.IsoTp/IsoTpFunctionalClient.cs b/src/CanKit.Pro.IsoTp/IsoTpFunctionalClient.cs index e7eff1c..299f05e 100644 --- a/src/CanKit.Pro.IsoTp/IsoTpFunctionalClient.cs +++ b/src/CanKit.Pro.IsoTp/IsoTpFunctionalClient.cs @@ -271,6 +271,10 @@ private static async Task> CollectFromSub var responses = new List(); // Combine the caller's token with a deadline token so the window bounds the collection. + // The window's end is also held as an arrival stamp: the timer's callback and this + // method's continuations are scheduling, and a frame that arrived after the deadline + // but before they ran is not the window's (Codex on #150). + long deadline = Stopwatch.GetTimestamp() + (long)(window.TotalSeconds * Stopwatch.Frequency); using var windowCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); windowCts.CancelAfter(window); var windowToken = windowCts.Token; @@ -279,8 +283,8 @@ private static async Task> CollectFromSub { await foreach (var frameEvent in sub.Frames.WithCancellation(windowToken).ConfigureAwait(false)) { - if (TryParseFunctionalResponse(frameEvent, out var response)) - responses.Add(response!); + if (TryParseFunctionalResponse(frameEvent, out var response) && response!.HostArrivalTimestamp <= deadline) + responses.Add(response); } } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) @@ -299,8 +303,8 @@ private static async Task> CollectFromSub sub.Dispose(); while (sub.TryRead(out var frameEvent)) { - if (TryParseFunctionalResponse(frameEvent, out var response)) - responses.Add(response!); + if (TryParseFunctionalResponse(frameEvent, out var response) && response!.HostArrivalTimestamp <= deadline) + responses.Add(response); } } diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index 5fc8370..8f242de 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -741,18 +741,19 @@ public async Task A_Listener_Is_Restarted_When_The_Confirmation_Outlasted_The_Wi var options = new IsoTpFunctionalOptions { IsExtendedCanId = false, UseCanFd = false, UsePadding = true, NAs = TimeSpan.FromSeconds(2) }; using var functional = UdsFunctionalClient.Create( IsoTpFactory.OpenFunctional(service, FunctionalTxId, Ecu1, 0x7EF, options), - ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(400)); + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(1000)); var negative = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x22, 0x31 }); var positive = SingleFrameFrom(Ecu1, new byte[] { 0x62, 0xF1, 0x91, 0x02 }); using var cts = new CancellationTokenSource(ShortTimeout); - // The first request's confirmation is held for 500 ms -- longer than the 400 ms window - // the pre-send listener was given; the ECU answers negatively 150 ms after the frame - // is confirmed, inside the window as anchored at the transmission. + // The first request's confirmation is held for 1100 ms -- longer than the 1000 ms + // window the pre-send listener was given; the ECU answers negatively 150 ms after the + // frame is confirmed, inside the window as anchored at the transmission, with 850 ms + // to spare for the host to delay that timer. var first = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, TimeSpan.FromMilliseconds(30), cts.Token); await bus.DeferredEchoes.WaitForEnqueuedAsync(1, ShortTimeout); - await Task.Delay(500); + await Task.Delay(1100); bus.DeferredEchoes.ReleaseNext(); _ = Task.Run(async () => { await Task.Delay(150); bus.RaiseObserved(negative, isEcho: false); }); await first; @@ -778,18 +779,21 @@ public async Task A_Cancelled_Collection_After_A_Late_Confirmation_Still_Anchors var options = new IsoTpFunctionalOptions { IsExtendedCanId = false, UseCanFd = false, UsePadding = true, NAs = TimeSpan.FromSeconds(2) }; using var functional = UdsFunctionalClient.Create( IsoTpFactory.OpenFunctional(service, FunctionalTxId, Ecu1, 0x7EF, options), - ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(400)); + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(1000)); var negative = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x22, 0x31 }); var positive = SingleFrameFrom(Ecu1, new byte[] { 0x62, 0xF1, 0x91, 0x02 }); - // The confirmation is held for 500 ms, past the 400 ms window of the pre-send listener; - // the collection is then cancelled 50 ms in, and the ECU answers negatively after that, - // 150 ms after the confirmation -- inside its P2 from the transmission. + // The confirmation is held for 1100 ms, past the 1000 ms window of the pre-send + // listener; the collection is then cancelled 50 ms in, and the ECU answers negatively + // after that, 150 ms after the confirmation -- inside its P2 from the transmission. + // The quantity the host perturbs is the negative answer's timer, which must fire + // before the second request goes out at 1000 ms after the cancellation: a margin of + // 900 ms (Windows CI on #150 exceeded 300). using var early = new CancellationTokenSource(); var first = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, Window, early.Token); await bus.DeferredEchoes.WaitForEnqueuedAsync(1, ShortTimeout); - await Task.Delay(500); + await Task.Delay(1100); bus.DeferredEchoes.ReleaseNext(); await Task.Delay(50); early.Cancel(); @@ -820,7 +824,7 @@ public async Task A_Listener_Is_Kept_Through_A_Send_Whose_Confirmation_Outlasts_ var options = new IsoTpFunctionalOptions { IsExtendedCanId = false, UseCanFd = false, UsePadding = true, NAs = TimeSpan.FromSeconds(2) }; using var functional = UdsFunctionalClient.Create( IsoTpFactory.OpenFunctional(service, FunctionalTxId, Ecu1, 0x7EF, options), - ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(400), responsePendingWindow: TimeSpan.FromMilliseconds(1000)); + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(400), responsePendingWindow: TimeSpan.FromMilliseconds(2000)); var pending = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x10, 0x78 }); var negative = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x10, 0x12 }); @@ -831,7 +835,8 @@ public async Task A_Listener_Is_Kept_Through_A_Send_Whose_Confirmation_Outlasts_ // on the bus meanwhile, and the ECU's 0x78 arrives at 500 ms, 100 ms before the // confirmation is released -- heard by the listener while the send is still in // flight. The negative answer follows 600 ms after the confirmation: past the P2 - // window anchored there (1000 ms), inside P2* = 1000 ms from the 0x78 (1500 ms). + // window anchored there (1000 ms), inside P2* = 2000 ms from the 0x78 (2500 ms), with + // 1300 ms to spare for the host to delay its timer. var suppressed = functional.SendRawAsync(new byte[] { 0x10, 0x83 }, Window, cts.Token); await bus.DeferredEchoes.WaitForEnqueuedAsync(1, ShortTimeout); await Task.Delay(500); From 8172826a3e7abcdd58353f84e6c3859b4c76beba Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:37:40 +0200 Subject: [PATCH 35/55] docs(isotp): say what SettleAsync does when called on the channel's own actor Bugbot on #150: on the actor, the pumped frames sit behind the current work item and are not in the inbox when the call returns. They cannot be handled inline either -- frames the reader task posted earlier are ahead of them in the mailbox, and reassembly does not survive the reordering -- so the contract states that path as the exception it is. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.IsoTp/IIsoTpChannel.cs | 5 ++++- src/CanKit.Pro.IsoTp/IsoTpChannel.cs | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/CanKit.Pro.IsoTp/IIsoTpChannel.cs b/src/CanKit.Pro.IsoTp/IIsoTpChannel.cs index 9fa65ec..573b9ed 100644 --- a/src/CanKit.Pro.IsoTp/IIsoTpChannel.cs +++ b/src/CanKit.Pro.IsoTp/IIsoTpChannel.cs @@ -132,7 +132,10 @@ Task SendWithTransmitStampAsync(ReadOnlyMemory pdu, /// . For a decision taken at a deadline — is a /// response there, was there a 0x78 — what the inbox does not hold after this did not /// arrive before the call; without it, a frame stamped in time can still be on its way - /// through the channel's actor when the deadline fires (Codex on #150). + /// through the channel's actor when the deadline fires (Codex on #150). Called on the + /// 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. /// Task SettleAsync(); diff --git a/src/CanKit.Pro.IsoTp/IsoTpChannel.cs b/src/CanKit.Pro.IsoTp/IsoTpChannel.cs index 219fc19..75393a5 100644 --- a/src/CanKit.Pro.IsoTp/IsoTpChannel.cs +++ b/src/CanKit.Pro.IsoTp/IsoTpChannel.cs @@ -351,7 +351,10 @@ public Task SettleAsync() // What the demux has buffered goes to the actor now rather than after the reader's // next scheduling; then a no-op posted behind it completes once the actor has taken // everything queued so far. Called from the actor itself, the post would wait for the - // loop it is on: there is nothing ahead of the caller then. + // loop it is on, and the pumped frames cannot be handled inline either: frames the + // reader task posted earlier sit ahead of them in the mailbox, and reassembly does not + // survive the reordering (Bugbot on #150). They are handled after the current work + // item, which the contract says. PumpSubscription(); if (_actor is ProtocolActor { IsOnCurrentActor: true }) return Task.CompletedTask; try From 019ee821555c6a7b3ab0ea73af016bb0ae95bbff Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:37:41 +0200 Subject: [PATCH 36/55] test(uds): stamp the on-its-way 0x78 at the send, and set the two P2* readings 1500 ms apart macOS CI on #150, on 808e9c3, with the suite 60 % slower than usual. The starved-reader test stamped its 0x78 after a 20 ms timer that had to fire inside a 100 ms window; the stamp is now taken at the send, inside the window by construction, and the bound is measured from it. And the test that P2* runs from the 0x78's arrival, not from the collection's end, put an upper bound between two readings a 300 ms collection apart, which the host was 300 ms late against; the collection is now 1500 ms, the readings 1500 ms apart, and the bound 750 ms from either. Both still fail their mutations. Co-Authored-By: Claude Opus 5 --- .../TestCases/Uds/UdsClientTests.cs | 19 ++++++++++--------- .../TestCases/Uds/UdsFunctionalClientTests.cs | 18 ++++++++++-------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs index d256188..10cd5b4 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs @@ -661,22 +661,23 @@ 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 20 ms in, buffered by the demux -- the reader task that would - // take it to the actor is starved by construction. - await Task.Delay(20); + // 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(); byte[] sf = { 0x03, 0x7F, 0x3E, 0x78, 0x00, 0x00, 0x00, 0x00 }; - service.Deliver(new CanFrameView(CanFrameType.Can20, 0x7E8, sf, FrameFlags.None), Stopwatch.GetTimestamp()); + 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 how long it waited before it sent, a lower bound a loaded host only raises. - var sw = Stopwatch.StartNew(); + // 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(); - sw.Stop(); - sw.Elapsed.Should().BeGreaterThanOrEqualTo(TimeSpan.FromMilliseconds(400), - "the 0x78 on its way through the channel moved the window out to 620 ms"); + 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"); } // Bugbot on #150: a wait cancelled part-way keeps what remains of the window. diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index 8f242de..a615707 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -461,8 +461,10 @@ public async Task A_Window_Is_Anchored_At_The_Transmission_However_Late_It_Was_C } // Codex on #150: P2* runs from the 0x78's arrival, not from the end of the collection. - // With the 0x78 at the start of a 300 ms window and P2* = 400 ms, the next call may go - // out at 400 ms after the request, not at 700. + // With the 0x78 at the start of a 1500 ms window and P2* = 1600 ms, the next call may go + // out at 1600 ms after the request, not at 3100. The two readings are the collection's + // length apart, so the collection is long: an upper bound is what a loaded host breaks, + // and macOS CI on #150 was 300 ms late against a 300 ms difference. [Fact] public async Task A_Pending_Answer_Extends_The_Window_From_Its_Arrival_Not_From_The_Collections_End() { @@ -480,19 +482,19 @@ public async Task A_Pending_Answer_Extends_The_Window_From_Its_Arrival_Not_From_ using var functional = UdsFunctionalClient.Create( IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), - ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(100), responsePendingWindow: TimeSpan.FromMilliseconds(400)); + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(100), responsePendingWindow: TimeSpan.FromMilliseconds(1600)); using var cts = new CancellationTokenSource(ShortTimeout); var sw = Stopwatch.StartNew(); - await functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, TimeSpan.FromMilliseconds(300), cts.Token); + await functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, TimeSpan.FromMilliseconds(1500), cts.Token); var responses = await functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x91 }, TimeSpan.FromMilliseconds(50), cts.Token); sw.Stop(); responses.Should().ContainSingle(); - // From the 0x78 (at ~0 ms) plus 400 ms the second call goes out and collects for 50 ms: - // ~450 ms in all. From the collection's end (300 ms) plus 400 ms it could not finish - // before 750 ms. The bound sits between the two. - sw.Elapsed.Should().BeLessThan(TimeSpan.FromMilliseconds(620), + // From the 0x78 (at ~0 ms) plus 1600 ms the second call goes out and collects for + // 50 ms: ~1650 ms in all. From the collection's end (1500 ms) plus 1600 ms it could not + // finish before 3150 ms. The bound sits between the two, 750 ms from either. + sw.Elapsed.Should().BeLessThan(TimeSpan.FromMilliseconds(2400), "P2* is counted from the 0x78's arrival"); } From 976fb046361adb3ffba5afbfcbf9430b7e11bd60 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:43:17 +0200 Subject: [PATCH 37/55] fix(isotp): read the listener's buffer against now, not against a negative window's past Codex on #150: a negative window put the drain's deadline in the past, and a response buffered since then was carried to the next collection instead of returned, against the contract that zero or less returns what is buffered. Mutation-checked: with the deadline from the window, a collection at -1 s returns nothing for a frame buffered a moment before. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.IsoTp/IsoTpFunctionalListener.cs | 9 ++++++--- .../TestCases/IsoTp/IsoTpFunctionalClientTests.cs | 10 +++++++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/CanKit.Pro.IsoTp/IsoTpFunctionalListener.cs b/src/CanKit.Pro.IsoTp/IsoTpFunctionalListener.cs index f7d9b13..f2b3bad 100644 --- a/src/CanKit.Pro.IsoTp/IsoTpFunctionalListener.cs +++ b/src/CanKit.Pro.IsoTp/IsoTpFunctionalListener.cs @@ -57,16 +57,19 @@ public async Task> CollectAsync( if (Volatile.Read(ref _disposed) != 0 || _ended) throw new ObjectDisposedException(nameof(IsoTpFunctionalListener), _ended ? "The subscription ended: the service was disposed." : null); - long deadline = Stopwatch.GetTimestamp() + (long)(window.TotalSeconds * Stopwatch.Frequency); + long now = Stopwatch.GetTimestamp(); var responses = new List(_carried); _carried.Clear(); if (window <= TimeSpan.Zero) { // No timer for a read of the buffer: its callback would need a thread-pool thread, - // which a starved pool grants about once a second (macOS CI on #150). - TakeBuffered(responses, deadline); + // which a starved pool grants about once a second (macOS CI on #150). The + // deadline is now, not now less the window: what is buffered arrived before this + // call, and a negative window must not carry it to the next (Codex on #150). + TakeBuffered(responses, now); return responses.AsReadOnly(); } + long deadline = now + (long)(window.TotalSeconds * Stopwatch.Frequency); using var windowCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); windowCts.CancelAfter(window); try diff --git a/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs index b058217..c420426 100644 --- a/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs @@ -566,7 +566,7 @@ public async Task Functional_Listener_Keeps_What_Arrives_Between_Collections() using var client = IsoTpFactory.OpenFunctional(busA, FunctionalTxId, 0x7E8, 0x7EF, FastOptions()); // Delivered to the subscription, and confirmed as delivered, before any collection. - var delivered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource delivered = new(TaskCreationOptions.RunContinuationsAsynchronously); busA.FrameObserved += (_, e) => { if (e.CanFrame.ID == unchecked((int)EcuResponseId)) delivered.TrySetResult(true); }; using var listener = client.Listen(); busB.Transmit(frame); @@ -580,6 +580,14 @@ public async Task Functional_Listener_Keeps_What_Arrives_Between_Collections() // Not returned twice: the next collection starts from an empty buffer. (await listener.CollectAsync(TimeSpan.FromMilliseconds(50)).WaitAsync(ShortTimeout)).Should().BeEmpty(); (await listener.CollectAsync(TimeSpan.Zero).WaitAsync(ShortTimeout)).Should().BeEmpty(); + + // A negative window reads the buffer the same: what is buffered arrived before the + // call, whatever the window says about the past (Codex on #150). + delivered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + busB.Transmit(frame); + await delivered.Task.WaitAsync(ShortTimeout); + (await listener.CollectAsync(TimeSpan.FromSeconds(-1)).WaitAsync(ShortTimeout)) + .Should().ContainSingle("a window in the past does not carry a buffered response to the next collection"); } // Bugbot on #150: a subscription completed underneath -- the service disposed -- ends the From c0788718cfb9687123a870db8e8cf6335de6c700 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:56:39 +0200 Subject: [PATCH 38/55] test(uds): give the functional tests' late negative answers 1150 ms or more to arrive macOS CI on #150, on 976fb04: the late-worker test's negative answer, timed at 250 ms, had to land before the next request went out at P2* = 400 ms, and the runner's timer was later than that. That test and the three of the same shape use P2* = 1500 ms now. Co-Authored-By: Claude Opus 5 --- .../TestCases/Uds/UdsFunctionalClientTests.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index a615707..e8d86da 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -560,15 +560,18 @@ public async Task A_Listener_Starting_After_The_Window_Still_Hears_What_It_Buffe using var functional = UdsFunctionalClient.Create( IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), - ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(100), responsePendingWindow: TimeSpan.FromMilliseconds(400)); + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(100), responsePendingWindow: TimeSpan.FromMilliseconds(1500)); functional.ListenerStartDelay = TimeSpan.FromMilliseconds(150); // past the 100 ms window using var cts = new CancellationTokenSource(ShortTimeout); await functional.SendRawAsync(new byte[] { 0x10, 0x83 }, Window, cts.Token); // suppressed: the 0x78 is buffered before the worker runs var responses = await functional.DiagnosticSessionControlAsync(UdsSessionType.Extended, Window, cts.Token); + // P2* = 1500 ms: the negative answer's timer must fire before the second request goes + // out, with 1250 ms to spare (macOS CI on #150 exceeded 150). The same P2* in the + // three tests below, for the same reason. responses.Should().ContainSingle().Which.IsNegative.Should().BeFalse( - "the late worker read the buffered 0x78 and kept the window to 400 ms, past the negative at 250"); + "the late worker read the buffered 0x78 and kept the window to 1500 ms, past the negative at 250"); } // Codex on #150: a cancelled collection loses what it collected, but the listener that @@ -596,7 +599,7 @@ public async Task A_Cancelled_Collection_Does_Not_Lose_The_Pending_Answer_The_Li using var functional = UdsFunctionalClient.Create( IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), - ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(100), responsePendingWindow: TimeSpan.FromMilliseconds(400)); + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(100), responsePendingWindow: TimeSpan.FromMilliseconds(1500)); using var early = new CancellationTokenSource(TimeSpan.FromMilliseconds(30)); Func cancelled = () => functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, Window, early.Token); @@ -634,7 +637,7 @@ public async Task A_Cancelled_Wait_Leaves_The_Listener_To_Hear_The_Pending_Answe using var functional = UdsFunctionalClient.Create( IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), - ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(300), responsePendingWindow: TimeSpan.FromMilliseconds(600)); + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(300), responsePendingWindow: TimeSpan.FromMilliseconds(1500)); using var cts = new CancellationTokenSource(ShortTimeout); await functional.TesterPresentAsync(cancellationToken: cts.Token); // suppressed; 0x78 at 50 ms, negative at 250 ms, window to 650 ms @@ -675,7 +678,7 @@ public async Task A_Pending_Answer_In_The_Gap_After_A_Suppressed_Send_Is_Observe using var functional = UdsFunctionalClient.Create( IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), - ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(300), responsePendingWindow: TimeSpan.FromMilliseconds(600)); + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(300), responsePendingWindow: TimeSpan.FromMilliseconds(1500)); using var cts = new CancellationTokenSource(ShortTimeout); await functional.TesterPresentAsync(cancellationToken: cts.Token); // suppressed; 0x78 at 100 ms, negative at 500 ms From 72e0b82bedeea8b42fef0f22f86e78d9d36ff9f3 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:56:39 +0200 Subject: [PATCH 39/55] docs(uds): say which functional services are correlated on the positive SID alone Codex on #150: for a service whose positive response echoes nothing of the request -- 0x14, 0x23, 0x34, 0x35, 0x37, 0x84 -- the service identifier is the whole correlation, and another tester's answer to the same service inside the window is attributed to the call. Refusing them would refuse ClearDiagnosticInformation to everyone, the functional request there is, so the limit is stated instead of promised away. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/README.md | 5 ++++- src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 17 ++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/CanKit.Pro.Uds/README.md b/src/CanKit.Pro.Uds/README.md index 907428f..07b83ce 100644 --- a/src/CanKit.Pro.Uds/README.md +++ b/src/CanKit.Pro.Uds/README.md @@ -104,7 +104,10 @@ await uds.DownloadAsync( as UDS (`UdsFunctionalResponse` with source identifier, bytes, `IsNegative` and the NRC). Only answers correlated to the request are attributed: a positive response echoing the request's leading bytes (sub-function, DID, routine identifier, block counter, mode of operation, - memory address and size), or a negative response naming the service. Calls run one at a time. Every send starts a listener for its service + memory address and size), or a negative response naming the service; for a service whose + positive response echoes nothing of the request (0x14, 0x23, 0x34, 0x35, 0x37, 0x84) the + service identifier is the whole correlation, and another tester's answer to the same + service inside the window is not told apart. Calls run one at a time. Every send starts a listener for its service that stays up for the ECUs' P2 (`Create`'s `responseWindow`, default P2) and moves the window out by P2* (`responsePendingWindow`, default P2*) on each NRC 0x78 it hears, so nothing in the window goes unobserved — a suppressed send included — and a call for that diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index ffe5375..fadbc9c 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -87,8 +87,16 @@ public static UdsFunctionalClient Create(IsoTpFunctionalClient client, bool owns /// answer to it that arrives within : a positive response /// to the request's service, or a negative response naming it. Other traffic on the /// response identifiers -- another tester's answers, a late answer to an earlier request -- - /// is not attributed to this call. A request with suppressPosRspMsgIndication set is sent - /// and not collected for: an empty list comes back as soon as the frame is confirmed. + /// is not attributed to this call where the response lets it be told apart: a positive + /// response is matched on the request bytes it echoes (the sub-function, a DID, a routine + /// identifier, a block counter, a mode of operation, a memory address and size), a + /// periodic read on the identifier it carries data for. For a service whose positive + /// response echoes nothing of the request -- ClearDiagnosticInformation (0x14), + /// ReadMemoryByAddress (0x23), RequestDownload and RequestUpload (0x34, 0x35), + /// RequestTransferExit (0x37), SecuredDataTransmission (0x84) -- the service identifier + /// is the whole correlation, and another tester's answer to the same service inside the + /// window is attributed to this call. A request with suppressPosRspMsgIndication set is + /// sent and not collected for: an empty list comes back as soon as the frame is confirmed. /// public async Task> SendRawAsync(ReadOnlyMemory request, TimeSpan window, CancellationToken cancellationToken = default) @@ -435,7 +443,10 @@ public async Task> DiagnosticSessionControl // the block sequence counter (0x36), the modeOfOperation of RequestFileTransfer (0x38 -- // Codex on #150), and for WriteMemoryByAddress (0x3D) the addressAndLengthFormatIdentifier // with the address and size it sizes -- its low nibble the address's bytes, its high - // nibble the size's (Codex on #150). + // nibble the size's (Codex on #150). The rest echo nothing -- 0x14, 0x23, 0x34, 0x35, + // 0x37, 0x84 -- and are correlated on the positive SID alone, which SendRawAsync's + // documentation says; refusing them would refuse ClearDiagnosticInformation to everyone, + // the functional request there is (Codex on #150). private static int EchoedRequestBytes(ReadOnlySpan request) { byte sid = request[0]; From e1fdea29b2b3c55372d2a4dedbe8a6058efc9187 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:07:22 +0200 Subject: [PATCH 40/55] fix(uds): note a suppressed send's window again when the send leaves by exception Codex on #150, the physical client's twin of the functional fix: a suppressed send cancelled after a transmission that outlasted the provisional window left only that window, already over, and the next same-service request took the send's punctual answer. On an exceptional exit the window is noted from that instant, which is at most P2 from the transmission. Mutation-checked: without it the new stub test's next request goes out at once. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsClientImpl.cs | 14 ++++++- .../TestCases/Uds/UdsExpiredDeadlineTests.cs | 42 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/CanKit.Pro.Uds/UdsClientImpl.cs b/src/CanKit.Pro.Uds/UdsClientImpl.cs index 36b70ac..05f40c7 100644 --- a/src/CanKit.Pro.Uds/UdsClientImpl.cs +++ b/src/CanKit.Pro.Uds/UdsClientImpl.cs @@ -439,7 +439,19 @@ 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. _suppressedWindows.Note(request[0], Stopwatch.GetTimestamp(), _options.P2ClientMax); - var stamps = await _channel.SendWithTransmitStampAsync(request, linkedToken).ConfigureAwait(false); + IsoTpTransmitStamps stamps; + try + { + stamps = await _channel.SendWithTransmitStampAsync(request, linkedToken).ConfigureAwait(false); + } + catch + { + // 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); + throw; + } var sent = stamps.LastFrameTransmitTimestamp > 0 ? stamps.LastFrameTransmitTimestamp : Stopwatch.GetTimestamp(); _suppressedWindows.Note(request[0], sent, _options.P2ClientMax); } diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs index cce0912..4997e2e 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs @@ -422,6 +422,48 @@ public async Task O_A_Suppressed_Send_Cancelled_Before_Confirmation_Still_Opens_ "the second send waited out the window the cancelled send opened"); } + /// + /// Codex on #150 — the same, with the transmission outlasting the window noted before the + /// send: cancelled then, the frame is on the bus with that window already over, and the + /// window is noted again from the cancellation -- P2 from the transmission is at most that. + /// + [Fact] + public async Task R_A_Suppressed_Send_Cancelled_After_Its_Provisional_Window_Still_Opens_One() + { + using var channel = new StubChannel( + deliverAfter: TimeSpan.FromSeconds(5), + stampArrivalAtDelivery: true) + { + TransmissionTime = TimeSpan.FromMilliseconds(300), + CancellableSend = true, + HonorCancellation = true, + }; + using var client = NewClient(channel); + + // Cancelled at 150 ms: past the 80 ms window noted before the send, inside the 300 ms + // the send takes. + using var early = new CancellationTokenSource(TimeSpan.FromMilliseconds(150)); + Func cancelled = () => client.SendRawAsync(new byte[] { 0x3E, 0x80 }, early.Token); + await cancelled.Should().ThrowAsync(); + var cancelledAt = Stopwatch.GetTimestamp(); + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500)); + Func next = () => client.TesterPresentAsync(suppressPositiveResponse: false, cts.Token); + await next.Should().ThrowAsync(); // the stub never answers; what matters is when it sent + + long gapTicks; + lock (channel.Sent) + { + channel.Sent.Should().HaveCount(2); + gapTicks = channel.Sent[1].StartedAt - cancelledAt; + } + // The second send waited P2 from the cancellation, less a margin for the note preceding + // the throw; with only the pre-send window, already over, it would go out at once. + TimeSpan.FromSeconds((double)gapTicks / Stopwatch.Frequency).Should().BeGreaterThanOrEqualTo( + Budget - TimeSpan.FromMilliseconds(5), + "the cancelled send's window was noted again from the cancellation"); + } + /// /// Bugbot on #150 — the drain that ends a wait-out reads the inbox, and a queued reassembly /// fault throws from that read. It is stale, as it is for the discard that follows, and From a8b54a1c97528d3a14f809c5fbca338004b31ca6 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:25:10 +0200 Subject: [PATCH 41/55] feat(isotp)!: let a discard take the caller's stamp, so what it drops is what the caller has read Codex on #150. A caller that reads the inbox and then discards -- the UDS client, routing a 0x78 to its service's window before dropping the rest -- loses a frame that arrives between the read and the discard's own stamp. IIsoTpChannel.DiscardPendingPdus(long arrivedBefore) drops what arrived before the caller's stamp, taken before the read, and keeps what arrived since for the caller's next read. The discard stamp never moves back. Mutation-checked: with the parameter ignored the new test's frame from after the stamp is dropped. BREAKING CHANGE: IIsoTpChannel gains DiscardPendingPdus(long). Source- breaking for external implementers of the interface; consumers are unaffected. Per docs/decisions/0001-versioning-and-api-stability.md this is a minor bump before v1.3.0 and no shim is introduced. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.IsoTp/IIsoTpChannel.cs | 13 +++++++- src/CanKit.Pro.IsoTp/IsoTpChannel.cs | 13 +++++--- src/CanKit.Pro.IsoTp/README.md | 5 +++- .../CanKit.Pro.IsoTp.approved.txt | 1 + .../IsoTp/IsoTpChannelIntegrationTests.cs | 30 +++++++++++++++++++ .../TestCases/Uds/UdsExpiredDeadlineTests.cs | 2 ++ 6 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/CanKit.Pro.IsoTp/IIsoTpChannel.cs b/src/CanKit.Pro.IsoTp/IIsoTpChannel.cs index 573b9ed..660ebc6 100644 --- a/src/CanKit.Pro.IsoTp/IIsoTpChannel.cs +++ b/src/CanKit.Pro.IsoTp/IIsoTpChannel.cs @@ -150,6 +150,17 @@ Task SendWithTransmitStampAsync(ReadOnlyMemory pdu, /// int DiscardPendingPdus(); + /// + /// As , dropping what arrived before + /// (a + /// reading) rather than before now, and keeping what arrived since. For a caller that + /// inspects the inbox before discarding — routing an NRC 0x78 to its window — the stamp + /// taken before the inspection makes the two one step: everything the discard drops was + /// inspected, and a frame arriving between the inspection and the discard is kept for the + /// caller's next read instead of vanishing (Codex on #150). + /// + int DiscardPendingPdus(long arrivedBefore); + /// /// Enumerates every fully reassembled inbound PDU as it becomes available. The enumeration /// ends when the channel is disposed. A reassembly abort (N_Cr / CF sequence mismatch / @@ -163,7 +174,7 @@ Task SendWithTransmitStampAsync(ReadOnlyMemory pdu, /// Raised (on a thread-pool thread) every time a full PDU is reassembled. The same PDU is /// enqueued for / before the event /// fires, so a handler that synchronously waits on those APIs — or on - /// — cannot deadlock the protocol actor. Handlers must be + /// — cannot deadlock the protocol actor. Handlers must be /// non-throwing; a throwing handler is caught and surfaced via /// . /// diff --git a/src/CanKit.Pro.IsoTp/IsoTpChannel.cs b/src/CanKit.Pro.IsoTp/IsoTpChannel.cs index 75393a5..556126b 100644 --- a/src/CanKit.Pro.IsoTp/IsoTpChannel.cs +++ b/src/CanKit.Pro.IsoTp/IsoTpChannel.cs @@ -367,9 +367,14 @@ public Task SettleAsync() } } - public int DiscardPendingPdus() + public int DiscardPendingPdus() => DiscardPendingPdus(arrivedBefore: 0); + + /// + public int DiscardPendingPdus(long arrivedBefore) { - // Everything that arrived up to now is pending. The stamp goes first, so a frame the + // Everything that arrived up to now is pending -- or up to the caller's stamp, which + // may be earlier (Codex on #150); the discard stamp never moves back, so a frame + // already admitted stays admitted. The stamp goes first, so a frame the // pump posts -- or the reader task posts concurrently -- is dropped by the actor if it // arrived before it, and answered with no Flow Control that would invite the rest of // a transfer the caller has given up on (Bugbot on #143). Whatever the demux has @@ -381,8 +386,8 @@ public int DiscardPendingPdus() long stamp; lock (_pumpGate) { - stamp = Stopwatch.GetTimestamp(); - Volatile.Write(ref _discardStamp, stamp); + stamp = arrivedBefore > 0 ? arrivedBefore : Stopwatch.GetTimestamp(); + if (stamp > Volatile.Read(ref _discardStamp)) Volatile.Write(ref _discardStamp, stamp); PumpSubscription(unstampedArrival: stamp - 1); } diff --git a/src/CanKit.Pro.IsoTp/README.md b/src/CanKit.Pro.IsoTp/README.md index 7610e10..7c481d4 100644 --- a/src/CanKit.Pro.IsoTp/README.md +++ b/src/CanKit.Pro.IsoTp/README.md @@ -66,7 +66,10 @@ 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. + otherwise still be on its way when the deadline fires. `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. - Timings: `IsoTpChannelOptions.NAs` (TX-confirm), `NBs` (peer-FC wait), `NCr` (next CF wait) and `WftMax` (max consecutive `Wait` FCs) are configurable; defaults are conservative 1 s / 10. - Reception limits: a First Frame announcing more than `MaxReceivePduLength` (default 65 535 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 4175a4b..18029e1 100644 --- a/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.IsoTp.approved.txt +++ b/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.IsoTp.approved.txt @@ -13,6 +13,7 @@ namespace CanKit.Pro.IsoTp event System.EventHandler? BackgroundExceptionOccurred; event System.EventHandler? DatagramReceived; int DiscardPendingPdus(); + int DiscardPendingPdus(long arrivedBefore); System.Collections.Generic.IReadOnlyList GetReceptionsInProgress(); System.Collections.Generic.IAsyncEnumerable ReceiveAllAsync(System.Threading.CancellationToken cancellationToken = default); System.Threading.Tasks.Task ReceiveAsync(System.Threading.CancellationToken cancellationToken = default); diff --git a/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpChannelIntegrationTests.cs b/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpChannelIntegrationTests.cs index 5736f75..2a55102 100644 --- a/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpChannelIntegrationTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpChannelIntegrationTests.cs @@ -443,6 +443,36 @@ 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"); } + // 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 + // lost. + [Fact] + public async Task A_Discard_Given_A_Stamp_Keeps_What_Arrived_After_It() + { + 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); + + long stamp = Stopwatch.GetTimestamp(); + await Task.Delay(5); + byte[] sf = { 0x03, 0x7F, 0x3E, 0x78, 0x00, 0x00, 0x00, 0x00 }; + service.Deliver(new CanFrameView(CanFrameType.Can20, 0x7E8, sf, FrameFlags.None), Stopwatch.GetTimestamp()); + + channel.DiscardPendingPdus(stamp).Should().Be(0, "the frame arrived after the stamp"); + await channel.SettleAsync().WaitAsync(ShortTimeout); + channel.TryReceiveWithArrival(out var pdu).Should().BeTrue("a frame from after the stamp is kept"); + pdu.Pdu.Should().Equal(0x7F, 0x3E, 0x78); + + // The parameterless discard drops what arrived up to now -- by the actor refusing the + // pumped frame, which never reaches the inbox and so is not in the count. + service.Deliver(new CanFrameView(CanFrameType.Can20, 0x7E8, sf, FrameFlags.None), Stopwatch.GetTimestamp()); + channel.DiscardPendingPdus(); + await channel.SettleAsync().WaitAsync(ShortTimeout); + channel.TryReceiveWithArrival(out _).Should().BeFalse("a frame from before the discard is dropped"); + } + // Bugbot on #147: ignored means no effect at all -- a reassembly in flight survives a // stray First Frame that fits a Single Frame, and a short one (#27). [Theory] diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs index 4997e2e..9c64de4 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs @@ -766,6 +766,8 @@ public async Task ReceiveAsync(CancellationToken cancellationToken = def public int DiscardPendingPdus() => 0; + public int DiscardPendingPdus(long arrivedBefore) => 0; + public IAsyncEnumerable ReceiveAllAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); From 69b38f231911af158f23d81ca5c601dea78735ff Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:25:10 +0200 Subject: [PATCH 42/55] fix(uds): take the pre-send discard's stamp before routing what the inbox holds Codex on #150: a 0x78 for a service with a suppressed send open, arriving between the routing loop finding the inbox empty and the bulk discard, was dropped unrouted. The stamp is taken before the settle and the read, and the discard drops only what arrived before it; a frame from after it stays for the request's receive loop, which routes a stray itself. Closed by construction: the gap is a few microseconds wide and no test opens it reliably; the channel's own test covers the stamp's semantics. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsClientImpl.cs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/CanKit.Pro.Uds/UdsClientImpl.cs b/src/CanKit.Pro.Uds/UdsClientImpl.cs index 05f40c7..96a0f4f 100644 --- a/src/CanKit.Pro.Uds/UdsClientImpl.cs +++ b/src/CanKit.Pro.Uds/UdsClientImpl.cs @@ -25,7 +25,7 @@ namespace CanKit.Pro.Uds; /// /// /// Before each send (and on abort paths) the client calls -/// so a late ECU reply from a cancelled or +/// so a late ECU reply from a cancelled or /// timed-out wait cannot be consumed as the answer to a later request. SID correlation during /// the wait loop remains as a second line of defense for stray frames that arrive while a /// request is still outstanding. @@ -1122,7 +1122,7 @@ private async Task ExchangeOnceAsync(UdsServiceId serviceId, byte[] requ { // Best-effort: if a PDU is already sitting in the inbox when we abort (e.g. cancel // raced with arrival), drop it under the lock so it cannot poison the next caller. - DiscardStalePdus(); + DiscardStalePdus(Stopwatch.GetTimestamp()); throw; } } @@ -1140,8 +1140,9 @@ 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(); await SettleAsync().ConfigureAwait(false); - DiscardStalePdus(); + DiscardStalePdus(arrivedBefore); } private async Task SettleAsync() @@ -1156,7 +1157,11 @@ private async Task SettleAsync() } } - private void DiscardStalePdus() + // Drops what arrived before the stamp, after reading it: the stamp is taken before the + // read, so everything the discard drops was read, and a 0x78 arriving between the read + // and the discard is kept for the request's own receive loop, which routes a stray + // (Codex on #150). + private void DiscardStalePdus(long arrivedBefore) { try { @@ -1176,7 +1181,7 @@ private void DiscardStalePdus() } RouteStrayPending(queued); } - _channel.DiscardPendingPdus(); + _channel.DiscardPendingPdus(arrivedBefore); } catch (ObjectDisposedException) { From 6728759ae4ba856a5c0caac61124379bbb4efb6b Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:25:10 +0200 Subject: [PATCH 43/55] fix(uds): refuse a collection window beyond a timer's reach before anything is transmitted Codex on #150: a window past CancellationTokenSource.CancelAfter's bound passed the positivity check, the functional frame went out, and the collector's timer threw after it -- a session change on every ECU behind an argument error. The ISO-TP functional client refuses it before the send, and the UDS functional client before its listener starts. Mutation-checked: without both checks the two new tests see the frame transmitted. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.IsoTp/IsoTpFunctionalClient.cs | 8 ++++++++ src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 8 ++++++++ .../IsoTp/IsoTpFunctionalClientTests.cs | 18 ++++++++++++++++++ .../TestCases/Uds/UdsFunctionalClientTests.cs | 14 ++++++++++---- 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/CanKit.Pro.IsoTp/IsoTpFunctionalClient.cs b/src/CanKit.Pro.IsoTp/IsoTpFunctionalClient.cs index 299f05e..feb18b6 100644 --- a/src/CanKit.Pro.IsoTp/IsoTpFunctionalClient.cs +++ b/src/CanKit.Pro.IsoTp/IsoTpFunctionalClient.cs @@ -57,6 +57,9 @@ public sealed class IsoTpFunctionalClient : IDisposable // Shared dummy parsing endpoint for Normal addressing (no AE byte) — used in TryParsePci. private static readonly IsoTpEndpoint NormalParseEndpoint = IsoTpEndpoint.Normal(0, 0); + // CancellationTokenSource.CancelAfter's bound. + private static readonly TimeSpan MaxWindow = TimeSpan.FromMilliseconds(uint.MaxValue - 1); + private int _disposed; internal IsoTpFunctionalClient( @@ -125,6 +128,11 @@ public async Task> SendAndCollectAsync( ThrowIfDisposed(); if (pdu.Length == 0) throw new ArgumentException("ISO-TP PDU must be non-empty.", nameof(pdu)); + // A window the collector's timer would refuse is refused here, before the frame goes + // out: the send is not undone by the argument error that would follow it (Codex on #150). + if (window < TimeSpan.Zero || window > MaxWindow) + throw new ArgumentOutOfRangeException(nameof(window), window, + "The collection window must be between zero and what a timer can measure (about 49 days)."); // Drain-before-send: subscribe, synchronously discard whatever is already buffered // (background chatter, a previous request's late reply, an unrelated broadcast, …), diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index fadbc9c..3d740ff 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -156,6 +156,9 @@ private async Task> SendRawLockedAsync(Read if (window <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(window), window, "The collection window must be positive for a request that is answered."); + if (window > MaxCollectionWindow) + throw new ArgumentOutOfRangeException(nameof(window), window, + "The collection window exceeds what a timer can measure (about 49 days)."); // A read for more than one DID is answered with all of them in one PDU, which a Single // Frame cannot hold with their data; and only the first DID would be correlated here, @@ -292,6 +295,11 @@ private void Extend(byte sid, long arrival, long until) lock (_listeners) _openWindows.ExtendIfOpenAt(sid, arrival, until); } + // The longest window a timer measures (CancellationTokenSource.CancelAfter's bound); a + // longer one is refused before anything is transmitted, as a non-positive one is + // (Codex on #150). + private static readonly TimeSpan MaxCollectionWindow = TimeSpan.FromMilliseconds(uint.MaxValue - 1); + // How long a listener kept alive by a send in flight collects before it looks again. private static readonly TimeSpan InFlightSlice = TimeSpan.FromMilliseconds(20); diff --git a/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs index c420426..32178d1 100644 --- a/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs @@ -613,6 +613,24 @@ public async Task Functional_Listener_Reports_A_Service_Disposed_Underneath_On_T sw.Elapsed.Should().BeLessThan(ShortTimeout, "it throws instead of waiting the window"); } + // Codex on #150: a window the collector's timer would refuse is refused before the frame + // goes out, not by the timer after it. + [Fact] + public async Task Functional_Send_Refuses_A_Window_Beyond_A_Timers_Reach_Before_Transmitting() + { + var session = NewSession(); + using var busA = OpenClassic(session, 0); + using var busB = OpenClassic(session, 1); + int seen = 0; + busB.FrameObserved += (_, e) => { if (e.CanFrame.ID == 0x7DF) Interlocked.Increment(ref seen); }; + using var client = IsoTpFactory.OpenFunctional(busA, 0x7DF, 0x7E8, 0x7EF, FastOptions()); + + Func act = () => client.SendAndCollectAsync(new byte[] { 0x3E, 0x00 }, TimeSpan.MaxValue); + await act.Should().ThrowAsync(); + await Task.Delay(50); + seen.Should().Be(0, "nothing was transmitted"); + } + [Fact] public async Task Functional_Listener_Disposal_Ends_A_Collection_In_Progress() { diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index e8d86da..db1791a 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -689,9 +689,13 @@ public async Task A_Pending_Answer_In_The_Gap_After_A_Suppressed_Send_Is_Observe "the 0x78 in the gap moved the window past the negative at 500 ms"); } - // Codex on #150: a window the collector would reject is checked before anything goes out. - [Fact] - public async Task An_Invalid_Collection_Window_Transmits_Nothing() + // Codex on #150: a window the collector would reject -- non-positive, or longer than a + // timer measures -- is checked before anything goes out. + [Theory] + [InlineData(-2)] + [InlineData(0)] + [InlineData(long.MaxValue)] + public async Task An_Invalid_Collection_Window_Transmits_Nothing(long windowMilliseconds) { var session = NewSession(); using var busTester = OpenClassic(session, 0); @@ -702,8 +706,10 @@ public async Task An_Invalid_Collection_Window_Transmits_Nothing() using var functional = UdsFunctionalClient.Create( IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), ownsClient: true); - Func act = () => functional.DiagnosticSessionControlAsync(UdsSessionType.Extended, TimeSpan.FromMilliseconds(-2)); + var window = windowMilliseconds == long.MaxValue ? TimeSpan.MaxValue : TimeSpan.FromMilliseconds(windowMilliseconds); + Func act = () => functional.DiagnosticSessionControlAsync(UdsSessionType.Extended, window); await act.Should().ThrowAsync(); + await Task.Delay(50); seen.Should().Be(0, "nothing was transmitted"); } From fc86326b4c8513e6de072f0ca0743122174c5bd2 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:37:17 +0200 Subject: [PATCH 44/55] fix(uds): bound the functional client's windows at creation as a collection window is Codex on #150: a response or pending window beyond a timer's reach was accepted, the first request went out, and the listener faulted when it collected for it -- its window unwaited by the next call. Both are refused at creation. Mutation-checked: without the bound the new theory's two oversized cases construct a client. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 12 ++++++++---- .../TestCases/Uds/UdsFunctionalClientTests.cs | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index 3d740ff..6b334ab 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -56,10 +56,14 @@ private UdsFunctionalClient(IsoTpFunctionalClient client, bool ownsClient, TimeS TimeSpan responsePendingWindow) { _client = client ?? throw new ArgumentNullException(nameof(client)); - if (responseWindow <= TimeSpan.Zero) - throw new ArgumentOutOfRangeException(nameof(responseWindow), "The window must be positive."); - if (responsePendingWindow <= TimeSpan.Zero) - throw new ArgumentOutOfRangeException(nameof(responsePendingWindow), "The window must be positive."); + // Bounded above as a collection window is: a listener collecting for longer than a + // timer measures would fault, and the window it owned go unwaited (Codex on #150). + if (responseWindow <= TimeSpan.Zero || responseWindow > MaxCollectionWindow) + throw new ArgumentOutOfRangeException(nameof(responseWindow), responseWindow, + "The window must be positive and within what a timer can measure (about 49 days)."); + if (responsePendingWindow <= TimeSpan.Zero || responsePendingWindow > MaxCollectionWindow) + throw new ArgumentOutOfRangeException(nameof(responsePendingWindow), responsePendingWindow, + "The window must be positive and within what a timer can measure (about 49 days)."); _ownsClient = ownsClient; _responseWindow = responseWindow; _responsePendingWindow = responsePendingWindow; diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index db1791a..6c12c5e 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -689,6 +689,25 @@ public async Task A_Pending_Answer_In_The_Gap_After_A_Suppressed_Send_Is_Observe "the 0x78 in the gap moved the window past the negative at 500 ms"); } + // Codex on #150: the windows a client is created with are bounded as a collection window + // is; a listener collecting for longer than a timer measures would fault and leave its + // window unwaited. + [Theory] + [InlineData(0, 100)] + [InlineData(100, 0)] + [InlineData(50 * 24 * 3600 * 1000L, 100)] + [InlineData(100, 50 * 24 * 3600 * 1000L)] + public void A_Window_Outside_A_Timers_Reach_Is_Rejected_At_Creation(long responseMs, long pendingMs) + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var client = IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()); + + Action act = () => UdsFunctionalClient.Create(client, ownsClient: false, + responseWindow: TimeSpan.FromMilliseconds(responseMs), responsePendingWindow: TimeSpan.FromMilliseconds(pendingMs)); + act.Should().Throw(); + } + // Codex on #150: a window the collector would reject -- non-positive, or longer than a // timer measures -- is checked before anything goes out. [Theory] From 5cb50c0a91eb3ed00ebccfcdb8b1f7083320c847 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:50:25 +0200 Subject: [PATCH 45/55] fix(uds): bound every duration in the client options at a timer's reach Codex on #150, for BusyRepeatRequestDelay: a delay beyond what Task.Delay accepts was taken at construction and threw at the first NRC 0x21, after the request went out. P2ClientMax, P2StarClientMax and TesterPresentPeriod run timers too, and all four are bounded at construction now. Mutation-checked: without the bounds the new theory's four cases construct a client. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsClientImpl.cs | 21 +++++++++------- .../TestCases/Uds/UdsClientTests.cs | 24 +++++++++++++++++++ 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/CanKit.Pro.Uds/UdsClientImpl.cs b/src/CanKit.Pro.Uds/UdsClientImpl.cs index 96a0f4f..6d9ee57 100644 --- a/src/CanKit.Pro.Uds/UdsClientImpl.cs +++ b/src/CanKit.Pro.Uds/UdsClientImpl.cs @@ -63,22 +63,27 @@ public UdsClientImpl(IIsoTpChannel channel, UdsClientOptions options, bool ownsC _options = options; _ownsChannel = ownsChannel; - if (options.P2ClientMax <= TimeSpan.Zero) - throw new ArgumentException("P2ClientMax must be positive.", nameof(options)); - if (options.P2StarClientMax <= TimeSpan.Zero) - throw new ArgumentException("P2StarClientMax must be positive.", nameof(options)); + // 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). + if (options.P2ClientMax <= TimeSpan.Zero || options.P2ClientMax > MaxTimerSpan) + throw new ArgumentException("P2ClientMax must be positive and within a timer's reach (about 49 days).", nameof(options)); + if (options.P2StarClientMax <= TimeSpan.Zero || options.P2StarClientMax > MaxTimerSpan) + throw new ArgumentException("P2StarClientMax must be positive and within a timer's reach (about 49 days).", nameof(options)); if (options.MaxBusyRepeatRequests < 0) throw new ArgumentOutOfRangeException(nameof(options), "MaxBusyRepeatRequests must be >= 0 (0 disables the repeat)."); - if (options.BusyRepeatRequestDelay < TimeSpan.Zero) + if (options.BusyRepeatRequestDelay < TimeSpan.Zero || options.BusyRepeatRequestDelay > MaxTimerSpan) throw new ArgumentOutOfRangeException(nameof(options), - "BusyRepeatRequestDelay must not be negative."); + "BusyRepeatRequestDelay must not be negative, and must be within a timer's reach (about 49 days)."); if (options.MaxResponsePendingCount < 0) throw new ArgumentException("MaxResponsePendingCount must be non-negative.", nameof(options)); - if (options.TesterPresentPeriod <= TimeSpan.Zero) - throw new ArgumentException("TesterPresentPeriod must be positive.", nameof(options)); + if (options.TesterPresentPeriod <= TimeSpan.Zero || options.TesterPresentPeriod > MaxTimerSpan) + throw new ArgumentException("TesterPresentPeriod must be positive and within a timer's reach (about 49 days).", nameof(options)); } + // The longest span Task.Delay and CancellationTokenSource.CancelAfter accept. + private static readonly TimeSpan MaxTimerSpan = TimeSpan.FromMilliseconds(uint.MaxValue - 1); + public IIsoTpChannel Channel => _channel; public UdsClientOptions Options => _options; public byte CurrentSession => Volatile.Read(ref _currentSession); diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs index 10cd5b4..39aa15f 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs @@ -905,6 +905,30 @@ public void A_Negative_Busy_Repeat_Count_Is_Rejected_At_Construction() act.Should().Throw(); } + // Codex on #150: every duration in the options runs a timer, which measures about 49 days + // at most; one beyond that would throw when armed, after the request went out. + [Theory] + [InlineData("P2ClientMax")] + [InlineData("P2StarClientMax")] + [InlineData("BusyRepeatRequestDelay")] + [InlineData("TesterPresentPeriod")] + public void A_Duration_Beyond_A_Timers_Reach_Is_Rejected_At_Construction(string option) + { + var session = NewSession(); + using var bus = OpenClassic(session, 0); + using var channel = IsoTpFactory.Open(bus, IsoTpEndpoint.Normal(0x7E0, 0x7E8), FastIsoTp()); + var fiftyDays = TimeSpan.FromDays(50); + var options = option switch + { + "P2ClientMax" => new UdsClientOptions { P2ClientMax = fiftyDays }, + "P2StarClientMax" => new UdsClientOptions { P2StarClientMax = fiftyDays }, + "BusyRepeatRequestDelay" => new UdsClientOptions { BusyRepeatRequestDelay = fiftyDays }, + _ => new UdsClientOptions { TesterPresentPeriod = fiftyDays }, + }; + Action act = () => UdsClient.Create(channel, options); + act.Should().Throw(); + } + // Codex on #150: the With(...) clone carries the busy-repeat settings. [Fact] public void Options_With_Carries_The_Busy_Repeat_Settings() From cfec2b8cbee679226d42aac8e5be694f99322bef Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:08:49 +0200 Subject: [PATCH 46/55] fix(uds): leave no window behind a request the channel refused before transmitting Codex on #150: a suppressed request the channel rejects for its size -- or, on the functional client, any request too long for a Single Frame -- had its window noted before the send and noted again on the exception, and the next same-service request waited P2 for an answer to nothing. The channels refuse before transmitting with an argument error, an InvalidOperationException or ObjectDisposedException; on those the window goes back to what it was, and the functional listener started for the send is retired so the next call does not wait on it. A cancellation or transport fault, which comes after the frame may be out, still notes the window. Mutation-checked: with every exception treated as after the send, the three new cases wait 2 s. Co-Authored-By: Claude Opus 5 --- .../SuppressedResponseWindows.cs | 14 ++++++ src/CanKit.Pro.Uds/UdsClientImpl.cs | 13 ++++++ src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 46 +++++++++++++++++-- .../TestCases/Uds/UdsClientTests.cs | 28 +++++++++++ .../TestCases/Uds/UdsFunctionalClientTests.cs | 32 +++++++++++++ 5 files changed, 130 insertions(+), 3 deletions(-) diff --git a/src/CanKit.Pro.Uds/SuppressedResponseWindows.cs b/src/CanKit.Pro.Uds/SuppressedResponseWindows.cs index a2f37de..df54f50 100644 --- a/src/CanKit.Pro.Uds/SuppressedResponseWindows.cs +++ b/src/CanKit.Pro.Uds/SuppressedResponseWindows.cs @@ -49,6 +49,20 @@ public void ExtendIfOpenAt(byte sid, long arrival, long until) } } + /// + /// Puts the window for back to what + /// reported before a note that turned out to be for nothing -- a send the channel refused + /// before transmitting (Codex on #150). + /// + public void Restore(byte sid, bool had, long until) + { + lock (_gate) + { + if (had) _until[sid] = until; + else _until.Remove(sid); + } + } + public bool TryGetDeadline(byte sid, out long until) { lock (_gate) return _until.TryGetValue(sid, out until); diff --git a/src/CanKit.Pro.Uds/UdsClientImpl.cs b/src/CanKit.Pro.Uds/UdsClientImpl.cs index 6d9ee57..7eeca92 100644 --- a/src/CanKit.Pro.Uds/UdsClientImpl.cs +++ b/src/CanKit.Pro.Uds/UdsClientImpl.cs @@ -443,12 +443,19 @@ private async Task SendWithoutResponseAsync(byte[] request, CancellationToken ca // Noted before the send as well: cancelled between the driver's acceptance and the // 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); IsoTpTransmitStamps stamps; try { stamps = await _channel.SendWithTransmitStampAsync(request, linkedToken).ConfigureAwait(false); } + catch (Exception ex) when (RefusedBeforeTransmission(ex)) + { + // Nothing reached the bus: the window noted for it is put back (Codex on #150). + _suppressedWindows.Restore(request[0], hadWindow, previousUntil); + throw; + } catch { // A send that leaves by exception -- cancelled, or a transport fault -- may @@ -466,6 +473,12 @@ private async Task SendWithoutResponseAsync(byte[] request, CancellationToken ca } } + // The channel refuses a request before transmitting it with an argument error -- an + // empty or oversized PDU -- or because it is disposed; a cancellation or a transport fault + // comes after the frame may be out. + private static bool RefusedBeforeTransmission(Exception ex) + => ex is ArgumentException or InvalidOperationException or ObjectDisposedException; + // Under the request lock. A request for a service with a suppressed send still open waits // out that send's P2, reading what arrives: it is the suppressed send's and is dropped -- // except NRC 0x78, which says the peer's final answer is still coming and moves the window diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index 6b334ab..36ba05f 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -141,17 +141,29 @@ private async Task> SendRawLockedAsync(Read // a negative answer, a 0x78 -- goes unobserved, and a send cancelled between the // driver's acceptance and the confirmation is covered (Codex on #150). Its window // is moved out to the confirmation afterwards. + bool hadWindow; + long previousUntil; + lock (_listeners) hadWindow = _openWindows.TryGetDeadline(sid, out previousUntil); StartListening(sid, Stopwatch.GetTimestamp(), inFlight: true); try { await _client.SendAsync(request, cancellationToken).ConfigureAwait(false); } - finally + catch (Exception ex) when (RefusedBeforeTransmission(ex)) + { + // Nothing reached the bus: the window noted for it is put back, and the + // listener retires on finding it gone or as it was (Codex on #150). + RestoreWindow(sid, hadWindow, previousUntil); + throw; + } + catch { - // Through StartListening again: the window is moved out to the confirmation, - // and the listener, kept through the send, reads it (Codex on #150). StartListening(sid, Stopwatch.GetTimestamp(), inFlight: false); + throw; } + // Through StartListening again: the window is moved out to the confirmation, and + // the listener, kept through the send, reads it (Codex on #150). + StartListening(sid, Stopwatch.GetTimestamp(), inFlight: false); return Array.Empty(); } @@ -178,6 +190,9 @@ private async Task> SendRawLockedAsync(Read // is over, the window is moved out to the send's own instant plus P2: the collection // ran for `window` from the transmit confirmation, so that instant is at least now // less `window`, however long the confirmation took (Codex on #150). + bool hadWindowBefore; + long previousUntilBefore; + lock (_listeners) hadWindowBefore = _openWindows.TryGetDeadline(sid, out previousUntilBefore); StartListening(sid, Stopwatch.GetTimestamp(), inFlight: true); IReadOnlyList raw; try @@ -185,6 +200,12 @@ private async Task> SendRawLockedAsync(Read raw = await _client.SendAndCollectAsync(request, window, cancellationToken) .ConfigureAwait(false); } + catch (Exception ex) when (RefusedBeforeTransmission(ex)) + { + // Nothing reached the bus: the window noted for it is put back (Codex on #150). + RestoreWindow(sid, hadWindowBefore, previousUntilBefore); + throw; + } catch { // A collection that ends in a cancellation or a transport fault may still have put @@ -264,6 +285,25 @@ private void StartListening(byte sid, long from, bool inFlight) } } + // The functional client refuses a request before transmitting it -- oversized for a Single + // Frame, an argument error -- or because it is disposed; a cancellation or a transport fault + // comes after the frame may be out. + private static bool RefusedBeforeTransmission(Exception ex) + => ex is ArgumentException or InvalidOperationException or ObjectDisposedException; + + private void RestoreWindow(byte sid, bool had, long until) + { + lock (_listeners) + { + _inFlight.Remove(sid); + _openWindows.Restore(sid, had, until); + // The listener started for the send is collecting for the window it was given; + // retired here, its subscription ends and the collection with it, so the next call + // does not wait on it. A window put back still open gets a listener from that call. + if (_listeners.TryGetValue(sid, out var entry)) Retire(sid, entry.Ears); + } + } + // Under the listeners lock. Ends the send in flight for the service: notes its window // from the anchor the send gave, then applies the 0x78s heard meanwhile in arrival order, // each only if the window was open when it arrived -- one at the transmission moves it diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs index 39aa15f..ad67dff 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs @@ -680,6 +680,34 @@ public async Task A_Pending_Answer_Still_On_Its_Way_Through_The_Channel_Extends_ "the 0x78 on its way through the channel moved the window out to P2* from its arrival"); } + // Codex on #150: a suppressed request the channel refuses before transmitting -- here, + // longer than a classic-CAN ISO-TP PDU can be -- reaches no ECU, and leaves no window for + // the next request to wait out. + [Fact] + public async Task A_Suppressed_Send_The_Channel_Refuses_Leaves_No_Window() + { + var (client, _, dispose) = BuildPair( + 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"); + } + } + // 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() diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index 6c12c5e..6e5abaf 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -689,6 +689,38 @@ public async Task A_Pending_Answer_In_The_Gap_After_A_Suppressed_Send_Is_Observe "the 0x78 in the gap moved the window past the negative at 500 ms"); } + // Codex on #150: a request the functional client refuses before transmitting -- longer + // than a Single Frame carries -- reaches no ECU, and leaves no window for the next call + // to wait out; suppressed or not. + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task A_Request_The_Client_Refuses_Leaves_No_Window(bool suppressed) + { + var session = NewSession(); + using var busTester = OpenClassic(session, 0); + using var busEcus = OpenClassic(session, 1); + var positive = SingleFrameFrom(Ecu1, new byte[] { 0x7E, 0x00 }); + busEcus.FrameObserved += (_, e) => { if (e.CanFrame.ID == unchecked((int)FunctionalTxId)) busEcus.Transmit(positive); }; + + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(busTester, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), + ownsClient: true, responseWindow: TimeSpan.FromSeconds(2)); + + using var cts = new CancellationTokenSource(ShortTimeout); + var oversized = new byte[] { 0x3E, suppressed ? (byte)0x80 : (byte)0x00, 1, 2, 3, 4, 5, 6 }; // eight bytes: one too many + Func refused = () => functional.SendRawAsync(oversized, Window, cts.Token); + await refused.Should().ThrowAsync(); + + // Left with a window, this call would wait 2 s before sending; a loaded host only + // makes the call slower, so the bound is wide. + var sw = Stopwatch.StartNew(); + var responses = await functional.TesterPresentAsync(suppressPositiveResponse: false, TimeSpan.FromMilliseconds(100), cts.Token); + sw.Stop(); + responses.Should().ContainSingle(); + sw.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(1), "nothing was transmitted, so nothing is answered"); + } + // Codex on #150: the windows a client is created with are bounded as a collection window // is; a listener collecting for longer than a timer measures would fault and leave its // window unwaited. From a2b68a64d5fe1c61ed29102e8afd82ab42567538 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:22:26 +0200 Subject: [PATCH 47/55] fix(uds): retire the refused send's listener before the window is put back Bugbot on #150: retiring forgets the window, so the restore that preceded it was undone. On the functional client the earlier window is always waited out before a send, so nothing observable changed there; on the physical client, where two suppressed sends follow without a wait-out, the restore's effect is now covered: an earlier send's window survives a refused one. Mutation-checked: with the restore forgetting instead, the next request goes out at once. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 6 ++-- .../TestCases/Uds/UdsClientTests.cs | 29 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index 36ba05f..fc50494 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -296,11 +296,13 @@ private void RestoreWindow(byte sid, bool had, long until) lock (_listeners) { _inFlight.Remove(sid); - _openWindows.Restore(sid, had, until); // The listener started for the send is collecting for the window it was given; // retired here, its subscription ends and the collection with it, so the next call - // does not wait on it. A window put back still open gets a listener from that call. + // does not wait on it. Retired before the window is put back: retiring forgets the + // window (Bugbot on #150). A window put back still open gets a listener from the + // next call. if (_listeners.TryGetValue(sid, out var entry)) Retire(sid, entry.Ears); + _openWindows.Restore(sid, had, until); } } diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs index ad67dff..e453848 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs @@ -708,6 +708,35 @@ public async Task A_Suppressed_Send_The_Channel_Refuses_Leaves_No_Window() } } + // Bugbot on #150: a refused request puts the window back to what it was -- an earlier + // suppressed send's window still open stays open, and the next request still waits it out. + [Fact] + public async Task A_Suppressed_Send_The_Channel_Refuses_Leaves_An_Earlier_Window_As_It_Was() + { + var (client, _, dispose) = BuildPair( + 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"); + } + } + // 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() From 92b2ae4ab857c9133e97aa214a1c92c69c359157 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:22:26 +0200 Subject: [PATCH 48/55] fix(isotp): refuse a listener window beyond a timer's reach before taking what was carried over Codex on #150: the timer threw after the previous collection's carry-over had been taken, and it was lost. The check precedes the take now. By construction: the carry-over is filled only in the instant between a timer and its drain, which no test opens deterministically; the test covers the refusal and that the subscription's buffer survives it. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.IsoTp/IsoTpFunctionalListener.cs | 11 +++++++++++ .../TestCases/IsoTp/IsoTpFunctionalClientTests.cs | 12 ++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/CanKit.Pro.IsoTp/IsoTpFunctionalListener.cs b/src/CanKit.Pro.IsoTp/IsoTpFunctionalListener.cs index f2b3bad..fa883e4 100644 --- a/src/CanKit.Pro.IsoTp/IsoTpFunctionalListener.cs +++ b/src/CanKit.Pro.IsoTp/IsoTpFunctionalListener.cs @@ -23,6 +23,9 @@ namespace CanKit.Pro.IsoTp; /// public sealed class IsoTpFunctionalListener : IDisposable { + // CancellationTokenSource.CancelAfter's bound. + private static readonly TimeSpan MaxWindow = TimeSpan.FromMilliseconds(uint.MaxValue - 1); + private readonly ISubscription _subscription; // Arrived after a collection's deadline but before its drain read the buffer: kept for the // next collection, whose window they are in (Codex on #150). @@ -51,12 +54,20 @@ internal IsoTpFunctionalListener(ISubscription subscription) /// disposed -- which a collection in progress reports by returning what was buffered, and /// every collection after it by this exception. /// + /// + /// exceeds what a timer can measure; nothing buffered is lost. + /// public async Task> CollectAsync( TimeSpan window, CancellationToken cancellationToken = default) { if (Volatile.Read(ref _disposed) != 0 || _ended) throw new ObjectDisposedException(nameof(IsoTpFunctionalListener), _ended ? "The subscription ended: the service was disposed." : null); + // Before anything carried over is taken: a window the timer would refuse must not + // cost the previous collection's remainder (Codex on #150). + if (window > MaxWindow) + throw new ArgumentOutOfRangeException(nameof(window), window, + "The collection window exceeds what a timer can measure (about 49 days)."); long now = Stopwatch.GetTimestamp(); var responses = new List(_carried); _carried.Clear(); diff --git a/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs index 32178d1..745f257 100644 --- a/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpFunctionalClientTests.cs @@ -588,6 +588,18 @@ public async Task Functional_Listener_Keeps_What_Arrives_Between_Collections() await delivered.Task.WaitAsync(ShortTimeout); (await listener.CollectAsync(TimeSpan.FromSeconds(-1)).WaitAsync(ShortTimeout)) .Should().ContainSingle("a window in the past does not carry a buffered response to the next collection"); + + // A window beyond a timer's reach is refused, and what is buffered survives the + // refusal (Codex on #150). What an expired collection carried over survives it by + // construction -- the check precedes the take -- since no test fills the carry-over + // deterministically: it is the instant between the timer and the drain. + delivered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + busB.Transmit(frame); + await delivered.Task.WaitAsync(ShortTimeout); + Func oversized = () => listener.CollectAsync(TimeSpan.FromDays(50)); + await oversized.Should().ThrowAsync(); + (await listener.CollectAsync(TimeSpan.Zero).WaitAsync(ShortTimeout)) + .Should().ContainSingle("the refused collection took nothing"); } // Bugbot on #150: a subscription completed underneath -- the service disposed -- ends the From b6809a8e7c6214623e790834fad7a8081fd30798 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:34:37 +0200 Subject: [PATCH 49/55] test(uds): hold the stub's transmission until the cancellation in the two cancelled-send tests macOS CI on #150, on 92b2ae4: test R's cancellation at 150 ms raced the stub's 300 ms transmission, and the runner's timer lost by more than that. The transmission is held until cancelled in R and in O, so nothing races the cancellation. Co-Authored-By: Claude Opus 5 --- .../TestCases/Uds/UdsExpiredDeadlineTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs index 9c64de4..a2cf32b 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsExpiredDeadlineTests.cs @@ -393,7 +393,7 @@ public async Task O_A_Suppressed_Send_Cancelled_Before_Confirmation_Still_Opens_ deliverAfter: TimeSpan.FromSeconds(5), stampArrivalAtDelivery: true) { - TransmissionTime = TimeSpan.FromMilliseconds(200), + TransmissionTime = TimeSpan.FromSeconds(5), // held until the cancellation, so nothing races it CancellableSend = true, HonorCancellation = true, }; @@ -434,14 +434,14 @@ public async Task R_A_Suppressed_Send_Cancelled_After_Its_Provisional_Window_Sti deliverAfter: TimeSpan.FromSeconds(5), stampArrivalAtDelivery: true) { - TransmissionTime = TimeSpan.FromMilliseconds(300), + TransmissionTime = TimeSpan.FromSeconds(5), // held until the cancellation, so nothing races it CancellableSend = true, HonorCancellation = true, }; using var client = NewClient(channel); - // Cancelled at 150 ms: past the 80 ms window noted before the send, inside the 300 ms - // the send takes. + // Cancelled at 150 ms: past the 80 ms window noted before the send, with the send + // still held (macOS CI on #150 fired a 150 ms timer after a 300 ms send had completed). using var early = new CancellationTokenSource(TimeSpan.FromMilliseconds(150)); Func cancelled = () => client.SendRawAsync(new byte[] { 0x3E, 0x80 }, early.Token); await cancelled.Should().ThrowAsync(); From 367315c0d2d0b79ed35666b4374f91d9fc5e9d17 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:34:37 +0200 Subject: [PATCH 50/55] fix(uds): settle and route what the channel holds on an aborted request's discard Codex on #150: the discard on an aborted request pumped and dropped a 0x78 for a suppressed send that was still on its way, unrouted; it now takes its stamp, settles, reads and routes first, as the pre-send discard does. Mutation-checked: with the plain discard the new test's next request goes out at P2 instead of P2*. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsClientImpl.cs | 4 +- .../TestCases/Uds/UdsClientTests.cs | 40 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/CanKit.Pro.Uds/UdsClientImpl.cs b/src/CanKit.Pro.Uds/UdsClientImpl.cs index 7eeca92..52d791e 100644 --- a/src/CanKit.Pro.Uds/UdsClientImpl.cs +++ b/src/CanKit.Pro.Uds/UdsClientImpl.cs @@ -1140,7 +1140,9 @@ private async Task ExchangeOnceAsync(UdsServiceId serviceId, byte[] requ { // Best-effort: if a PDU is already sitting in the inbox when we abort (e.g. cancel // raced with arrival), drop it under the lock so it cannot poison the next caller. - DiscardStalePdus(Stopwatch.GetTimestamp()); + // As the pre-send discard: settled and read first, so a 0x78 for a suppressed send + // among it is routed to that send's window rather than dropped (Codex on #150). + await DiscardStalePdusAsync().ConfigureAwait(false); throw; } } diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs index e453848..6dce161 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs @@ -737,6 +737,46 @@ public async Task A_Suppressed_Send_The_Channel_Refuses_Leaves_An_Earlier_Window } } + // Codex on #150: the discard on an aborted request -- here, one the caller cancelled -- + // settles the channel and routes what it holds before dropping it, as the pre-send + // discard does; a 0x78 for a suppressed send still on its way is not dropped unrouted. + [Fact] + public async Task A_Pending_Answer_Still_On_Its_Way_Is_Routed_By_An_Aborted_Requests_Discard() + { + using var service = new StarvedReaderBusService(); + using var channel = IsoTpFactory.Open(service, IsoTpEndpoint.Normal(0x7E0, 0x7E8), FastIsoTp(useCanFd: false), leaveOpen: true); + var pendingBudget = TimeSpan.FromMilliseconds(600); + using var client = UdsClient.Create(channel, new UdsClientOptions + { + P2ClientMax = TimeSpan.FromMilliseconds(100), + P2StarClientMax = pendingBudget, + }); + + using var cts = new CancellationTokenSource(ShortTimeout); + await client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token); // suppressed: window P2 = 100 ms + + // Another service's request, cancelled by its caller at 50 ms; the 0x78 arrives while + // it waits -- after its pre-send discard, which would route it, and before its abort, + // whose discard is the one under test. (A host that delays either instant past the + // other lets the pre-send discard route it instead: a pass for the wrong reason, never + // a failure.) + using var early = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); + var other = client.ReadDataByIdentifierAsync(0xF190, early.Token); + await Task.Delay(20); + long arrival = Stopwatch.GetTimestamp(); + byte[] sf = { 0x03, 0x7F, 0x3E, 0x78, 0x00, 0x00, 0x00, 0x00 }; + service.Deliver(new CanFrameView(CanFrameType.Can20, 0x7E8, sf, FrameFlags.None), arrival); + 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, + "the aborted request's discard routed the 0x78 to the suppressed send's window"); + } + // 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() From 5672520701b82dc514c509458737b6e7fa446cd2 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Tue, 22 Sep 2026 06:39:53 +0200 Subject: [PATCH 51/55] fix(uds): discard stale replies before a suppressed send, as before an answered one Codex on #150: a late 0x78 to an earlier request for the service, queued since that request was cancelled, was read by the next call's wait-out as inside the suppressed send's window -- it predates the send, which the window's end alone does not say -- and moved the window out by P2* for nothing. The suppressed path runs the pre-send discard now, which routes a 0x78 for a service with a window still open and drops the rest. Mutation-checked: without it the new test's next request waits 1.9 s. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsClientImpl.cs | 7 +++ .../TestCases/Uds/UdsClientTests.cs | 47 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/CanKit.Pro.Uds/UdsClientImpl.cs b/src/CanKit.Pro.Uds/UdsClientImpl.cs index 52d791e..3865f9e 100644 --- a/src/CanKit.Pro.Uds/UdsClientImpl.cs +++ b/src/CanKit.Pro.Uds/UdsClientImpl.cs @@ -440,6 +440,13 @@ private async Task SendWithoutResponseAsync(byte[] request, CancellationToken ca await _requestLock.WaitAsync(linkedToken).ConfigureAwait(false); try { + // The stale-reply discard the answered path runs before its send, here too: a late + // 0x78 to an earlier request for this service, queued since that request's abort, + // would otherwise be read by the next call's wait-out as inside this send's window + // -- it predates the send, which the window's end alone does not say -- and move + // the window out by P2* for nothing (Codex on #150). A 0x78 for a service with a + // window still open is routed to it by the discard, as always. + await DiscardStalePdusAsync().ConfigureAwait(false); // Noted before the send as well: cancelled between the driver's acceptance and the // confirmation, the frame is on the bus and may still be answered (Codex on #150). // Moved out to the transmit stamp afterwards. diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs index 6dce161..ba6029a 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs @@ -777,6 +777,53 @@ public async Task A_Pending_Answer_Still_On_Its_Way_Is_Routed_By_An_Aborted_Requ "the aborted request's discard routed the 0x78 to the suppressed send's window"); } + // Codex on #150: a late 0x78 to an earlier request for the service, queued since that + // request was cancelled, is not the suppressed send's; the suppressed send discards it + // before it goes out, and the next call does not wait P2* on it. + [Fact] + public async Task A_Stale_Pending_Answer_Queued_Before_A_Suppressed_Send_Does_Not_Extend_Its_Window() + { + int calls = 0; + var (client, _, dispose) = BuildPair( + e => e.On(0x3E, req => + { + switch (Interlocked.Increment(ref calls)) + { + 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 }; + } + }), + 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"); + } + } + // 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() From c2dbc0b4a3163c044673aaf5eb1e9e00115cbce2 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:19:10 +0200 Subject: [PATCH 52/55] feat(isotp): report when a functional request went out, and leave out a response from before its handoff Codex on #150. IsoTpFunctionalClient.SendWithTransmitStampAsync and SendAndCollectWithTransmitStampAsync return IsoTpTransmitStamps -- the handoff to the driver and the transmission, as the physical channel reports them -- for a caller that keeps a deadline from the transmission; SendAsync and SendAndCollectAsync delegate to them. A response that arrived before the handoff answers something else: the subscription is made before the send, and another sender may hold the service's transmit lock in between. It is left out of the collection. Mutation-checked through the UDS test that opens that gap. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.IsoTp/IsoTpFunctionalClient.cs | 35 +++++++++++++++++-- .../IsoTpFunctionalCollection.cs | 22 ++++++++++++ src/CanKit.Pro.IsoTp/README.md | 6 ++++ .../CanKit.Pro.IsoTp.approved.txt | 7 ++++ 4 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 src/CanKit.Pro.IsoTp/IsoTpFunctionalCollection.cs diff --git a/src/CanKit.Pro.IsoTp/IsoTpFunctionalClient.cs b/src/CanKit.Pro.IsoTp/IsoTpFunctionalClient.cs index feb18b6..d23122a 100644 --- a/src/CanKit.Pro.IsoTp/IsoTpFunctionalClient.cs +++ b/src/CanKit.Pro.IsoTp/IsoTpFunctionalClient.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Threading; using System.Threading.Tasks; using CanKit.Abstractions.API.Can.Definitions; @@ -124,6 +125,21 @@ public async Task> SendAndCollectAsync( ReadOnlyMemory pdu, TimeSpan window, CancellationToken cancellationToken = default) + => (await SendAndCollectWithTransmitStampAsync(pdu, window, cancellationToken).ConfigureAwait(false)).Responses; + + /// + /// As , also returning when the request was handed to the + /// driver and when it was transmitted (, zero where the + /// driver reports neither). A response that arrived before the handoff answers something + /// else -- the subscription is made before the send, and a frame from between the two is + /// not this request's -- and is left out (Codex on #150); a caller keeping its own deadline + /// from a response, such as UDS P2* from an NRC 0x78, anchors it no earlier than the + /// handoff for the same reason. + /// + public async Task SendAndCollectWithTransmitStampAsync( + ReadOnlyMemory pdu, + TimeSpan window, + CancellationToken cancellationToken = default) { ThrowIfDisposed(); if (pdu.Length == 0) @@ -153,9 +169,13 @@ public async Task> SendAndCollectAsync( using var sub = _service.Subscribe(_responseFilter, includeEcho: true); DrainBuffered(sub); - await SendSingleFrameAsync(pdu, cancellationToken).ConfigureAwait(false); + var stamps = await SendSingleFrameAsync(pdu, cancellationToken).ConfigureAwait(false); - return await CollectFromSubscriptionAsync(sub, window, cancellationToken).ConfigureAwait(false); + var collected = await CollectFromSubscriptionAsync(sub, window, cancellationToken).ConfigureAwait(false); + long cutoff = stamps.LastFrameHandoffTimestamp; + if (cutoff > 0 && collected.Any(r => r.HostArrivalTimestamp < cutoff)) + collected = collected.Where(r => r.HostArrivalTimestamp >= cutoff).ToList().AsReadOnly(); + return new IsoTpFunctionalCollection(collected, stamps); } /// @@ -173,6 +193,14 @@ public async Task> SendAndCollectAsync( /// /// TX-confirm failed. public Task SendAsync(ReadOnlyMemory pdu, CancellationToken cancellationToken = default) + => SendWithTransmitStampAsync(pdu, cancellationToken); + + /// + /// As , returning when the frame was handed to the driver and when + /// it was transmitted (, zero where the driver reports + /// neither), for a caller that keeps a deadline from the transmission (Codex on #150). + /// + public Task SendWithTransmitStampAsync(ReadOnlyMemory pdu, CancellationToken cancellationToken = default) { ThrowIfDisposed(); if (pdu.Length == 0) @@ -231,7 +259,7 @@ public void Dispose() /// Sends as a functional Single Frame and awaits the CAN driver's /// TX confirmation. /// - private async Task SendSingleFrameAsync(ReadOnlyMemory pdu, CancellationToken ct) + private async Task SendSingleFrameAsync(ReadOnlyMemory pdu, CancellationToken ct) { int sfMax = IsoTpFrameCodec.SingleFrameMaxDataLength(_options.UseCanFd, _txEndpoint.UsesAddressExtension); @@ -271,6 +299,7 @@ private async Task SendSingleFrameAsync(ReadOnlyMemory pdu, CancellationTo new IsoTpException("Functional Single Frame TX confirmation failed with unknown reason."), }; } + return new IsoTpTransmitStamps(confirmation.HostHandoffTimestamp, confirmation.HostTransmitTimestamp); } private static async Task> CollectFromSubscriptionAsync( diff --git a/src/CanKit.Pro.IsoTp/IsoTpFunctionalCollection.cs b/src/CanKit.Pro.IsoTp/IsoTpFunctionalCollection.cs new file mode 100644 index 0000000..a2916e0 --- /dev/null +++ b/src/CanKit.Pro.IsoTp/IsoTpFunctionalCollection.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace CanKit.Pro.IsoTp; + +/// +/// What returns: the +/// responses collected within the window, and when the request went out. +/// +public readonly struct IsoTpFunctionalCollection +{ + internal IsoTpFunctionalCollection(IReadOnlyList responses, IsoTpTransmitStamps transmitStamps) + { + Responses = responses; + TransmitStamps = transmitStamps; + } + + /// The Single-Frame responses collected within the window, in arrival order. + public IReadOnlyList Responses { get; } + + /// When the request was handed to the driver and transmitted; zero where unknown. + public IsoTpTransmitStamps TransmitStamps { get; } +} diff --git a/src/CanKit.Pro.IsoTp/README.md b/src/CanKit.Pro.IsoTp/README.md index 7c481d4..bdd14c6 100644 --- a/src/CanKit.Pro.IsoTp/README.md +++ b/src/CanKit.Pro.IsoTp/README.md @@ -135,6 +135,12 @@ foreach (var r in responses) collected in arrival order. - `IsoTpFunctionalOptions` configures `IsExtendedCanId`, `UseCanFd`, `UsePadding`, `PaddingByte`, and `NAs` (TX-confirm timeout). +- **When the request went out**: `SendWithTransmitStampAsync` and + `SendAndCollectWithTransmitStampAsync` return `IsoTpTransmitStamps` — the instant the frame was + handed to the driver and the instant it was transmitted — for a caller that keeps a deadline + from the transmission. A response that arrived before the handoff answers something else (the + subscription is made before the send, and another sender may hold the service's transmit lock + in between) and is left out of the collection. - **Listening across collections**: `CollectResponsesAsync` subscribes per call, so a response that arrives between two calls — or between a `SendAsync` and the first call — is missed. `client.Listen()` subscribes once and returns an `IsoTpFunctionalListener` whose 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 18029e1..c00f507 100644 --- a/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.IsoTp.approved.txt +++ b/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.IsoTp.approved.txt @@ -121,7 +121,14 @@ namespace CanKit.Pro.IsoTp public void Dispose() { } public CanKit.Pro.IsoTp.IsoTpFunctionalListener Listen() { } public System.Threading.Tasks.Task> SendAndCollectAsync(System.ReadOnlyMemory pdu, System.TimeSpan window, System.Threading.CancellationToken cancellationToken = default) { } + public System.Threading.Tasks.Task SendAndCollectWithTransmitStampAsync(System.ReadOnlyMemory pdu, System.TimeSpan window, System.Threading.CancellationToken cancellationToken = default) { } public System.Threading.Tasks.Task SendAsync(System.ReadOnlyMemory pdu, System.Threading.CancellationToken cancellationToken = default) { } + public System.Threading.Tasks.Task SendWithTransmitStampAsync(System.ReadOnlyMemory pdu, System.Threading.CancellationToken cancellationToken = default) { } + } + public readonly struct IsoTpFunctionalCollection + { + public System.Collections.Generic.IReadOnlyList Responses { get; } + public CanKit.Pro.IsoTp.IsoTpTransmitStamps TransmitStamps { get; } } public sealed class IsoTpFunctionalListener : System.IDisposable { From 6006623333d45d825e29a7ac41afbe568e642681 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:19:10 +0200 Subject: [PATCH 53/55] fix(uds): anchor the functional window at the driver's acceptance, and take no 0x78 from before the handoff Codex on #150. A 0x78 for the service arriving after the request's subscription was made but before the frame was handed to the driver -- another sender holding the shared service's transmit lock -- was taken as this request's and moved its window out by P2*. The functional client anchors the window at the transmission the driver reports, as the physical client does, and applies a 0x78 heard while the send was in flight only if it arrived at or after the handoff. Two tests that encoded the earlier estimate -- the window anchored at the confirmation, the collection's length back -- now model a late acceptance, the case the estimate stood in for; the two readings are told apart in each. Mutation-checked: without the cutoff the new test's next request waits P2*; without the filter it collects the 0x78; with the old estimate, or the pre-send note alone, the anchoring tests fail. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 51 ++-- .../TestCases/Uds/UdsFunctionalClientTests.cs | 251 ++++++++++-------- 2 files changed, 166 insertions(+), 136 deletions(-) diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index fc50494..6fbd7c4 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -145,9 +145,10 @@ private async Task> SendRawLockedAsync(Read long previousUntil; lock (_listeners) hadWindow = _openWindows.TryGetDeadline(sid, out previousUntil); StartListening(sid, Stopwatch.GetTimestamp(), inFlight: true); + IsoTpTransmitStamps stamps; try { - await _client.SendAsync(request, cancellationToken).ConfigureAwait(false); + stamps = await _client.SendWithTransmitStampAsync(request, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (RefusedBeforeTransmission(ex)) { @@ -161,9 +162,11 @@ private async Task> SendRawLockedAsync(Read StartListening(sid, Stopwatch.GetTimestamp(), inFlight: false); throw; } - // Through StartListening again: the window is moved out to the confirmation, and - // the listener, kept through the send, reads it (Codex on #150). - StartListening(sid, Stopwatch.GetTimestamp(), inFlight: false); + // Through StartListening again: the window is moved out to the transmission -- the + // confirmation where the driver reports none -- and the listener, kept through the + // send, reads it; a 0x78 heard from before the handoff answered something else + // (Codex on #150). + StartListening(sid, TransmittedAt(stamps), inFlight: false, cutoff: stamps.LastFrameHandoffTimestamp); return Array.Empty(); } @@ -194,10 +197,10 @@ private async Task> SendRawLockedAsync(Read long previousUntilBefore; lock (_listeners) hadWindowBefore = _openWindows.TryGetDeadline(sid, out previousUntilBefore); StartListening(sid, Stopwatch.GetTimestamp(), inFlight: true); - IReadOnlyList raw; + IsoTpFunctionalCollection collected; try { - raw = await _client.SendAndCollectAsync(request, window, cancellationToken) + collected = await _client.SendAndCollectWithTransmitStampAsync(request, window, cancellationToken) .ConfigureAwait(false); } catch (Exception ex) when (RefusedBeforeTransmission(ex)) @@ -223,13 +226,17 @@ private async Task> SendRawLockedAsync(Read // parameter, arriving in this window, is told apart // (Codex on #150, twice). How many bytes, per service, is in EchoedRequestBytes. int echoed = Math.Min(EchoedRequestBytes(req), req.Length - 1); - // The window as anchored at the transmission -- the collection ran for `window` from - // the transmit confirmation, so the send was at least `window` ago -- noted before the - // 0x78s are read: a listener that retired while the confirmation outlasted the - // provisional window has forgotten it, and a 0x78 inside the ECU's P2 must still move - // it out (Codex on #150). - long transmitted = Stopwatch.GetTimestamp() - Ticks(window); - lock (_listeners) NoteAnchored(sid, transmitted); + // The window as anchored at the transmission, noted before the 0x78s are read: a + // listener that retired while the confirmation outlasted the provisional window has + // forgotten it, and a 0x78 inside the ECU's P2 must still move it out (Codex on #150). + // The transmission as the driver reported it; where it reports none, the collection + // ran for `window` from the confirmation, so the send was at least `window` ago. + var raw = collected.Responses; + long cutoff = collected.TransmitStamps.LastFrameHandoffTimestamp; + long transmitted = collected.TransmitStamps.LastFrameTransmitTimestamp > 0 + ? collected.TransmitStamps.LastFrameTransmitTimestamp + : Stopwatch.GetTimestamp() - Ticks(window); + lock (_listeners) NoteAnchored(sid, transmitted, cutoff); var responses = new List(raw.Count); foreach (var r in raw) { @@ -241,7 +248,7 @@ private async Task> SendRawLockedAsync(Read // the same frame; the earlier of the two to act moves the window, the later is // idle. Only a 0x78 that arrived while the window was open: a collection window // longer than P2 may hold one from after it, which revives nothing (Codex on #150). - if (IsResponsePending(data, sid)) + if (IsResponsePending(data, sid) && r.HostArrivalTimestamp >= cutoff) Extend(sid, r.HostArrivalTimestamp, r.HostArrivalTimestamp + Ticks(_responsePendingWindow)); if (positive || negative) responses.Add(new UdsFunctionalResponse(r.SourceCanId, data)); } @@ -268,7 +275,7 @@ private async Task> SendRawLockedAsync(Read // so a note that moves the window out either finds the listener still reading -- and it // re-reads the deadline before retiring -- or finds none and starts one; a listener cannot // retire, forgetting the window, between the note and the check (Codex on #150). - private void StartListening(byte sid, long from, bool inFlight) + private void StartListening(byte sid, long from, bool inFlight, long cutoff = 0) { lock (_listeners) { @@ -279,12 +286,15 @@ private void StartListening(byte sid, long from, bool inFlight) } else { - NoteAnchored(sid, from); + NoteAnchored(sid, from, cutoff); } EnsureListener(sid); } } + private static long TransmittedAt(IsoTpTransmitStamps stamps) + => stamps.LastFrameTransmitTimestamp > 0 ? stamps.LastFrameTransmitTimestamp : Stopwatch.GetTimestamp(); + // The functional client refuses a request before transmitting it -- oversized for a Single // Frame, an argument error -- or because it is disposed; a cancellation or a transport fault // comes after the frame may be out. @@ -310,13 +320,18 @@ private void RestoreWindow(byte sid, bool had, long until) // from the anchor the send gave, then applies the 0x78s heard meanwhile in arrival order, // each only if the window was open when it arrived -- one at the transmission moves it // out, one from after P2 does not (Codex on #150). - private void NoteAnchored(byte sid, long from) + private void NoteAnchored(byte sid, long from, long cutoff) { _openWindows.Note(sid, from, _responseWindow); if (!_inFlight.TryGetValue(sid, out var heard)) return; _inFlight.Remove(sid); + // Each only if it arrived at or after the cutoff -- the handoff to the driver; one + // from before it answered something else (Codex on #150). foreach (var arrival in heard) - _openWindows.ExtendIfOpenAt(sid, arrival, arrival + Ticks(_responsePendingWindow)); + { + if (arrival >= cutoff) + _openWindows.ExtendIfOpenAt(sid, arrival, arrival + Ticks(_responsePendingWindow)); + } } // Under the listeners lock. Starts a listener for the service's window if the window is diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index 6e5abaf..39b89fe 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -417,47 +417,92 @@ public async Task A_Periodic_Read_Is_Correlated_On_The_Requested_Identifier() responses.Should().ContainSingle().Which.Response.Should().Equal(0x6A, 0xF1, 0x11, 0x22); } - // Codex on #150: the window is the ECU's P2 from the *transmission*. With the transmit - // confirmation delayed, a window anchored before the send ends too early; after the - // collection it is moved out to the send's instant, which the collection's length gives. + // Codex on #150: the window is the ECU's P2 from the *transmission* -- the instant the + // driver accepted the frame, as the physical client counts it -- not from before the + // send. With the driver holding the frame 200 ms before accepting it, the next call for + // the service goes out P2 = 1000 ms after the acceptance, not 800. [Fact] - public async Task A_Window_Is_Anchored_At_The_Transmission_However_Late_It_Was_Confirmed() + public async Task A_Window_Is_Anchored_At_The_Drivers_Acceptance_Not_Before_The_Send() { using var bus = ControllableBus.DeferredEchoCapable(NewSession()); using var service = new CanBusService(bus); using var functional = UdsFunctionalClient.Create( IsoTpFactory.OpenFunctional(service, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), - ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(300)); + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(1000)); + + var positive = SingleFrameFrom(Ecu1, new byte[] { 0x62, 0xF1, 0x91, 0x02 }); + var acceptedAt = new List(); + using var hold = new ManualResetEventSlim(); + bus.OnTransmitting = frame => + { + if (frame.ID != unchecked((int)FunctionalTxId)) return; + lock (acceptedAt) + { + if (acceptedAt.Count == 0) hold.Wait(); // the first frame: the driver takes 200 ms to accept it + acceptedAt.Add(Stopwatch.GetTimestamp()); + } + }; + + using var cts = new CancellationTokenSource(ShortTimeout); + // On the pool: the driver's hold is inside the service's send lock, a synchronous wait. + var first = Task.Run(() => functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, TimeSpan.FromMilliseconds(50), cts.Token)); + await Task.Delay(200); + hold.Set(); + await bus.DeferredEchoes.WaitForEnqueuedAsync(1, ShortTimeout); + bus.DeferredEchoes.ReleaseNext(); + await first; + + var second = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x91 }, Window, cts.Token); + await bus.DeferredEchoes.WaitForEnqueuedAsync(2, ShortTimeout); // the count never decreases + bus.DeferredEchoes.ReleaseNext(); + _ = Task.Run(async () => { await Task.Delay(20); bus.RaiseObserved(positive, isEcho: false); }); + (await second).Should().ContainSingle(); + + long gap; + lock (acceptedAt) gap = acceptedAt[1] - acceptedAt[0]; + // A lower bound a loaded host only raises; noted before the send only, the window + // would have ended 200 ms sooner. + Between(0, gap).Should().BeGreaterThanOrEqualTo(TimeSpan.FromMilliseconds(950), + "the window is P2 from the driver's acceptance, not from before the send"); + } + + // Codex on #150: and not from the confirmation either. With the confirmation held 2 s past + // the acceptance, the window -- P2 = 1000 ms from the acceptance -- is over by the time + // the first call returns, and the next call goes out at once rather than 950 ms later. + [Fact] + public async Task A_Window_Is_Anchored_At_The_Drivers_Acceptance_Not_At_The_Confirmation() + { + using var bus = ControllableBus.DeferredEchoCapable(NewSession()); + using var service = new CanBusService(bus); + var options = new IsoTpFunctionalOptions { IsExtendedCanId = false, UseCanFd = false, UsePadding = true, NAs = TimeSpan.FromSeconds(5) }; + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(service, FunctionalTxId, Ecu1, 0x7EF, options), + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(1000)); var positive = SingleFrameFrom(Ecu1, new byte[] { 0x62, 0xF1, 0x91, 0x02 }); var sentAt = new List(); bus.OnTransmitting = frame => { if (frame.ID == unchecked((int)FunctionalTxId)) lock (sentAt) sentAt.Add(Stopwatch.GetTimestamp()); }; using var cts = new CancellationTokenSource(ShortTimeout); - // First request: its echo -- the transmit confirmation -- is held for 200 ms. The window - // is P2 = 300 ms from the transmission as the confirmation places it, so the next call - // for the service may not go out before 300 ms after that confirmation; anchored before - // the send instead, the window would end 200 ms sooner. Asserted as a lower bound on - // when the second request went out -- a loaded host only makes it later. var first = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, TimeSpan.FromMilliseconds(50), cts.Token); await bus.DeferredEchoes.WaitForEnqueuedAsync(1, ShortTimeout); - await Task.Delay(200); - var confirmedAt = Stopwatch.GetTimestamp(); + await Task.Delay(2000); // the confirmation held, the window long over bus.DeferredEchoes.ReleaseNext(); await first; - // The second collects for the whole P2: its answer is timed, and a host that delays the - // timer past a short window would leave the collection empty (macOS CI on #150). + var startedAt = Stopwatch.GetTimestamp(); var second = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x91 }, Window, cts.Token); - await bus.DeferredEchoes.WaitForEnqueuedAsync(2, ShortTimeout); // the count never decreases + await bus.DeferredEchoes.WaitForEnqueuedAsync(2, ShortTimeout); bus.DeferredEchoes.ReleaseNext(); _ = Task.Run(async () => { await Task.Delay(20); bus.RaiseObserved(positive, isEcho: false); }); (await second).Should().ContainSingle(); long secondSentAt; lock (sentAt) secondSentAt = sentAt[1]; - Between(confirmedAt, secondSentAt).Should().BeGreaterThanOrEqualTo(TimeSpan.FromMilliseconds(250), - "the window is anchored at the transmission, 300 ms of P2 from the confirmation"); + // Anchored at the confirmation, the second call would wait 950 ms before sending; the + // bound sits 450 ms from either reading. + Between(startedAt, secondSentAt).Should().BeLessThan(TimeSpan.FromMilliseconds(500), + "the window is P2 from the driver's acceptance, over by the time the confirmation came"); } // Codex on #150: P2* runs from the 0x78's arrival, not from the end of the collection. @@ -721,6 +766,57 @@ public async Task A_Request_The_Client_Refuses_Leaves_No_Window(bool suppressed) sw.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(1), "nothing was transmitted, so nothing is answered"); } + // Codex on #150: a 0x78 for the service that arrives after the request's subscription is + // made but before the frame is handed to the driver -- another sender holding the shared + // service's transmit lock -- answers something else: it is neither returned nor does it + // move this request's window out. + [Fact] + public async Task A_Pending_Answer_From_Before_The_Handoff_Is_Not_This_Requests() + { + using var bus = ControllableBus.DeferredEchoCapable(NewSession()); + using var service = new CanBusService(bus); + using var functional = UdsFunctionalClient.Create( + IsoTpFactory.OpenFunctional(service, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(100), responsePendingWindow: TimeSpan.FromMilliseconds(2000)); + + var pending = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x22, 0x78 }); + var positive = SingleFrameFrom(Ecu1, new byte[] { 0x62, 0xF1, 0x90, 0x02 }); + + // Another sender on the same service, held inside the driver's Transmit -- and so + // inside the service's transmit lock -- until released. + using var holding = new ManualResetEventSlim(); + var inside = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + bus.OnTransmitting = f => { if (f.ID == 0x123) { inside.TrySetResult(true); holding.Wait(); } }; + var other = Task.Run(() => service.SendConfirmed(CanFrame.Classic(0x123, new byte[8]), TimeSpan.FromSeconds(5))); + await inside.Task.WaitAsync(ShortTimeout); + + using var cts = new CancellationTokenSource(ShortTimeout); + // The request: subscribed and drained, then waiting for the lock -- on the pool, since + // the wait is synchronous and would hold this thread. (A host that delays it past the + // 0x78 lets the drain take the frame: a pass for the wrong reason, never a failure.) + var first = Task.Run(() => functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, Window, cts.Token)); + await Task.Delay(50); + bus.RaiseObserved(pending, isEcho: false); // before the handoff: not this request's + holding.Set(); + await bus.DeferredEchoes.WaitForEnqueuedAsync(1, ShortTimeout); + bus.DeferredEchoes.ReleaseNext(); // the other sender's confirmation + await other; + await bus.DeferredEchoes.WaitForEnqueuedAsync(2, ShortTimeout); + bus.DeferredEchoes.ReleaseNext(); // the request's + _ = Task.Run(async () => { await Task.Delay(20); bus.RaiseObserved(positive, isEcho: false); }); + var responses = await first; + responses.Should().ContainSingle().Which.IsNegative.Should().BeFalse("the 0x78 from before the handoff is not this request's"); + + // Nor did it move the window out: a next call goes out after P2, not after P2* = 2 s. + var sw = Stopwatch.StartNew(); + var second = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x91 }, TimeSpan.FromMilliseconds(50), cts.Token); + await bus.DeferredEchoes.WaitForEnqueuedAsync(3, ShortTimeout); + bus.DeferredEchoes.ReleaseNext(); + await second; + sw.Stop(); + sw.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(1), "the 0x78 from before the handoff did not move the window out"); + } + // Codex on #150: the windows a client is created with are bounded as a collection window // is; a listener collecting for longer than a timer measures would fault and leave its // window unwaited. @@ -791,31 +887,38 @@ public async Task An_IO_Control_Answer_For_Another_Did_Is_Not_Attributed() responses.Should().ContainSingle().Which.Response.Should().Equal(0x6F, 0xF1, 0x90, 0x03, 0x00); } - // Codex on #150: a transmit confirmation that outlasts the window lets the pre-send - // listener retire; anchoring the window afterwards must start one again, or the next call - // has nothing to wait for. + // Codex on #150: a driver that accepts the frame only after the provisional window has run + // out lets the pre-send listener retire; anchoring the window at the acceptance must start + // one again, or the next call has nothing to wait for. [Fact] - public async Task A_Listener_Is_Restarted_When_The_Confirmation_Outlasted_The_Window() + public async Task A_Listener_Is_Restarted_When_The_Acceptance_Outlasted_The_Window() { using var bus = ControllableBus.DeferredEchoCapable(NewSession()); using var service = new CanBusService(bus); - // N_As long enough to hold the confirmation past the window without timing the send out. - var options = new IsoTpFunctionalOptions { IsExtendedCanId = false, UseCanFd = false, UsePadding = true, NAs = TimeSpan.FromSeconds(2) }; + var options = new IsoTpFunctionalOptions { IsExtendedCanId = false, UseCanFd = false, UsePadding = true, NAs = TimeSpan.FromSeconds(3) }; using var functional = UdsFunctionalClient.Create( IsoTpFactory.OpenFunctional(service, FunctionalTxId, Ecu1, 0x7EF, options), ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(1000)); var negative = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x22, 0x31 }); var positive = SingleFrameFrom(Ecu1, new byte[] { 0x62, 0xF1, 0x91, 0x02 }); + int accepted = 0; + using var hold = new ManualResetEventSlim(); + bus.OnTransmitting = frame => + { + if (frame.ID != unchecked((int)FunctionalTxId)) return; + if (Interlocked.Increment(ref accepted) == 1) hold.Wait(); // the first frame: accepted 1100 ms after it was handed over + }; using var cts = new CancellationTokenSource(ShortTimeout); - // The first request's confirmation is held for 1100 ms -- longer than the 1000 ms - // window the pre-send listener was given; the ECU answers negatively 150 ms after the - // frame is confirmed, inside the window as anchored at the transmission, with 850 ms - // to spare for the host to delay that timer. - var first = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, TimeSpan.FromMilliseconds(30), cts.Token); - await bus.DeferredEchoes.WaitForEnqueuedAsync(1, ShortTimeout); + // The first request's acceptance is held for 1100 ms -- longer than the 1000 ms window + // the pre-send listener was given; the ECU answers negatively 150 ms after the frame + // is accepted, inside the window as anchored there, with 850 ms to spare for the host + // to delay that timer. + var first = Task.Run(() => functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, TimeSpan.FromMilliseconds(30), cts.Token)); await Task.Delay(1100); + hold.Set(); + await bus.DeferredEchoes.WaitForEnqueuedAsync(1, ShortTimeout); bus.DeferredEchoes.ReleaseNext(); _ = Task.Run(async () => { await Task.Delay(150); bus.RaiseObserved(negative, isEcho: false); }); await first; @@ -827,95 +930,7 @@ public async Task A_Listener_Is_Restarted_When_The_Confirmation_Outlasted_The_Wi var responses = await second; responses.Should().ContainSingle().Which.IsNegative.Should().BeFalse( - "the second request waited out the window anchored at the first's late confirmation"); - } - - // Codex on #150: with the confirmation held past the window, the pre-send listener has - // retired by the time the collection runs; a collection the caller then cancels leaves by - // exception, and the window must still be anchored for the request that went out. - [Fact] - public async Task A_Cancelled_Collection_After_A_Late_Confirmation_Still_Anchors_Its_Window() - { - using var bus = ControllableBus.DeferredEchoCapable(NewSession()); - using var service = new CanBusService(bus); - var options = new IsoTpFunctionalOptions { IsExtendedCanId = false, UseCanFd = false, UsePadding = true, NAs = TimeSpan.FromSeconds(2) }; - using var functional = UdsFunctionalClient.Create( - IsoTpFactory.OpenFunctional(service, FunctionalTxId, Ecu1, 0x7EF, options), - ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(1000)); - - var negative = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x22, 0x31 }); - var positive = SingleFrameFrom(Ecu1, new byte[] { 0x62, 0xF1, 0x91, 0x02 }); - - // The confirmation is held for 1100 ms, past the 1000 ms window of the pre-send - // listener; the collection is then cancelled 50 ms in, and the ECU answers negatively - // after that, 150 ms after the confirmation -- inside its P2 from the transmission. - // The quantity the host perturbs is the negative answer's timer, which must fire - // before the second request goes out at 1000 ms after the cancellation: a margin of - // 900 ms (Windows CI on #150 exceeded 300). - using var early = new CancellationTokenSource(); - var first = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x90 }, Window, early.Token); - await bus.DeferredEchoes.WaitForEnqueuedAsync(1, ShortTimeout); - await Task.Delay(1100); - bus.DeferredEchoes.ReleaseNext(); - await Task.Delay(50); - early.Cancel(); - Func cancelled = () => first; - await cancelled.Should().ThrowAsync(); - _ = Task.Run(async () => { await Task.Delay(100); bus.RaiseObserved(negative, isEcho: false); }); - - using var cts = new CancellationTokenSource(ShortTimeout); - var second = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x91 }, Window, cts.Token); - await bus.DeferredEchoes.WaitForEnqueuedAsync(2, ShortTimeout); - bus.DeferredEchoes.ReleaseNext(); - _ = Task.Run(async () => { await Task.Delay(20); bus.RaiseObserved(positive, isEcho: false); }); - var responses = await second; - - responses.Should().ContainSingle().Which.IsNegative.Should().BeFalse( - "the cancelled request's late negative answer fell in the window anchored when its collection was cancelled"); - } - - // Codex on #150: a suppressed send whose confirmation outlasts the window must not be - // left without a listener between the transmission and the send's return -- a 0x78 - // answered at the transmission would be lost, and with it the P2* that covers the - // request's final answer. - [Fact] - public async Task A_Listener_Is_Kept_Through_A_Send_Whose_Confirmation_Outlasts_The_Window() - { - using var bus = ControllableBus.DeferredEchoCapable(NewSession()); - using var service = new CanBusService(bus); - var options = new IsoTpFunctionalOptions { IsExtendedCanId = false, UseCanFd = false, UsePadding = true, NAs = TimeSpan.FromSeconds(2) }; - using var functional = UdsFunctionalClient.Create( - IsoTpFactory.OpenFunctional(service, FunctionalTxId, Ecu1, 0x7EF, options), - ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(400), responsePendingWindow: TimeSpan.FromMilliseconds(2000)); - - var pending = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x10, 0x78 }); - var negative = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x10, 0x12 }); - var positive = SingleFrameFrom(Ecu1, new byte[] { 0x50, 0x03, 0x00, 0x32, 0x01, 0xF4 }); - - using var cts = new CancellationTokenSource(ShortTimeout); - // The confirmation is held for 600 ms, past the 400 ms provisional window; the frame is - // on the bus meanwhile, and the ECU's 0x78 arrives at 500 ms, 100 ms before the - // confirmation is released -- heard by the listener while the send is still in - // flight. The negative answer follows 600 ms after the confirmation: past the P2 - // window anchored there (1000 ms), inside P2* = 2000 ms from the 0x78 (2500 ms), with - // 1300 ms to spare for the host to delay its timer. - var suppressed = functional.SendRawAsync(new byte[] { 0x10, 0x83 }, Window, cts.Token); - await bus.DeferredEchoes.WaitForEnqueuedAsync(1, ShortTimeout); - await Task.Delay(500); - bus.RaiseObserved(pending, isEcho: false); - await Task.Delay(100); - bus.DeferredEchoes.ReleaseNext(); - await suppressed; - _ = Task.Run(async () => { await Task.Delay(600); bus.RaiseObserved(negative, isEcho: false); }); - - var second = functional.DiagnosticSessionControlAsync(UdsSessionType.Extended, Window, cts.Token); - await bus.DeferredEchoes.WaitForEnqueuedAsync(2, ShortTimeout); - bus.DeferredEchoes.ReleaseNext(); - _ = Task.Run(async () => { await Task.Delay(20); bus.RaiseObserved(positive, isEcho: false); }); - var responses = await second; - - responses.Should().ContainSingle().Which.IsNegative.Should().BeFalse( - "the listener kept through the send heard the 0x78 and held the window past the negative answer"); + "the second request waited out the window anchored at the first's late acceptance"); } // Bugbot on #150: a collection that outlasts P2 anchors a window already over; that must From 0916c204742c3092f101bf33c29cb7b8a298582b Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:31:58 +0200 Subject: [PATCH 54/55] fix(uds): hold the handoff cutoff with the window, for the listener that reads a 0x78 after the anchoring Bugbot and Codex on #150, on 6006623: the cutoff was applied only to the 0x78s recorded while the send was in flight. A listener whose collection returns after the anchoring -- its window longer than the request's collection -- applied a 0x78 from before the handoff with no cutoff, and so did the anchoring after the response loop, which passed none and erased the one just set. The cutoff is kept per service with the window now, consulted by the listener, carried through both anchorings and forgotten with the window. The handoff test runs with a 1000 ms window as well, where the listener is the one that reads the 0x78; without the listener's check that case waits P2*. CodeQL's Where taken. Co-Authored-By: Claude Opus 5 --- src/CanKit.Pro.Uds/UdsFunctionalClient.cs | 25 +++++++++++++------ .../TestCases/Uds/UdsFunctionalClientTests.cs | 15 +++++++---- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs index 6fbd7c4..684b4e3 100644 --- a/src/CanKit.Pro.Uds/UdsFunctionalClient.cs +++ b/src/CanKit.Pro.Uds/UdsFunctionalClient.cs @@ -38,6 +38,10 @@ public sealed class UdsFunctionalClient : IDisposable // their listener does not retire, and whether such a 0x78 was punctual is decided against // the window as anchored once the send returns (Codex on #150). Under the same lock. private readonly Dictionary> _inFlight = new(); + // Per service, the handoff of its last send: a 0x78 from before it answered something + // else, whoever reads it -- the anchoring, or the listener whose collection returns after + // the anchoring (Bugbot on #150). Under the same lock; forgotten with the window. + private readonly Dictionary _cutoffs = new(); // One request on the wire at a time, its collection window included: overlapping calls // with the same SID would each collect the other's answers (Codex on #150), as the // physical client's request lock prevents there. @@ -255,7 +259,7 @@ private async Task> SendRawLockedAsync(Read // Through StartListening again, after the 0x78s above moved the window: a confirmation // that outlasted the window has let the listener retire, and the moved-out window // needs one (Codex on #150). - StartListening(sid, transmitted, inFlight: false); + StartListening(sid, transmitted, inFlight: false, cutoff); return responses; } @@ -323,17 +327,20 @@ private void RestoreWindow(byte sid, bool had, long until) private void NoteAnchored(byte sid, long from, long cutoff) { _openWindows.Note(sid, from, _responseWindow); + if (cutoff > 0) _cutoffs[sid] = cutoff; else _cutoffs.Remove(sid); if (!_inFlight.TryGetValue(sid, out var heard)) return; _inFlight.Remove(sid); // Each only if it arrived at or after the cutoff -- the handoff to the driver; one // from before it answered something else (Codex on #150). - foreach (var arrival in heard) - { - if (arrival >= cutoff) - _openWindows.ExtendIfOpenAt(sid, arrival, arrival + Ticks(_responsePendingWindow)); - } + foreach (var arrival in heard.Where(a => a >= cutoff)) + _openWindows.ExtendIfOpenAt(sid, arrival, arrival + Ticks(_responsePendingWindow)); } + // Under the listeners lock. Whether a 0x78 for the service that arrived then is from + // before the service's last handoff, and so answers something else. + private bool PredatesHandoff(byte sid, long arrival) + => _cutoffs.TryGetValue(sid, out var cutoff) && arrival < cutoff; + // Under the listeners lock. Starts a listener for the service's window if the window is // open and none is reading it; forgets a window already over -- a collection that outlasted // P2 -- because a listener for it would only retire on its first read (Bugbot on #150). @@ -416,8 +423,9 @@ private async Task ListenAsync(byte sid, IsoTpFunctionalListener ears) lock (_listeners) { if (_inFlight.TryGetValue(pendingSid, out var inFlight)) inFlight.Add(pending.HostArrivalTimestamp); - else _openWindows.ExtendIfOpenAt(pendingSid, pending.HostArrivalTimestamp, - pending.HostArrivalTimestamp + Ticks(_responsePendingWindow)); + else if (!PredatesHandoff(pendingSid, pending.HostArrivalTimestamp)) + _openWindows.ExtendIfOpenAt(pendingSid, pending.HostArrivalTimestamp, + pending.HostArrivalTimestamp + Ticks(_responsePendingWindow)); } } } @@ -442,6 +450,7 @@ private void Retire(byte sid, IsoTpFunctionalListener ears) { _listeners.Remove(sid); _openWindows.Forget(sid); + _cutoffs.Remove(sid); } ears.Dispose(); } diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs index 39b89fe..0888e88 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsFunctionalClientTests.cs @@ -770,14 +770,19 @@ public async Task A_Request_The_Client_Refuses_Leaves_No_Window(bool suppressed) // made but before the frame is handed to the driver -- another sender holding the shared // service's transmit lock -- answers something else: it is neither returned nor does it // move this request's window out. - [Fact] - public async Task A_Pending_Answer_From_Before_The_Handoff_Is_Not_This_Requests() + // With a 100 ms window the listener is in its short slices when the send returns and the + // 0x78 is applied by the anchoring; with a 1000 ms window the listener's collection returns + // after the anchoring and applies it itself (Bugbot on #150). Neither may move the window. + [Theory] + [InlineData(100, 1000)] + [InlineData(1000, 2000)] + public async Task A_Pending_Answer_From_Before_The_Handoff_Is_Not_This_Requests(int windowMs, int boundMs) { using var bus = ControllableBus.DeferredEchoCapable(NewSession()); using var service = new CanBusService(bus); using var functional = UdsFunctionalClient.Create( IsoTpFactory.OpenFunctional(service, FunctionalTxId, Ecu1, 0x7EF, FastOptions()), - ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(100), responsePendingWindow: TimeSpan.FromMilliseconds(2000)); + ownsClient: true, responseWindow: TimeSpan.FromMilliseconds(windowMs), responsePendingWindow: TimeSpan.FromMilliseconds(3000)); var pending = SingleFrameFrom(Ecu1, new byte[] { 0x7F, 0x22, 0x78 }); var positive = SingleFrameFrom(Ecu1, new byte[] { 0x62, 0xF1, 0x90, 0x02 }); @@ -807,14 +812,14 @@ public async Task A_Pending_Answer_From_Before_The_Handoff_Is_Not_This_Requests( var responses = await first; responses.Should().ContainSingle().Which.IsNegative.Should().BeFalse("the 0x78 from before the handoff is not this request's"); - // Nor did it move the window out: a next call goes out after P2, not after P2* = 2 s. + // Nor did it move the window out: a next call goes out after P2, not after P2* = 3 s. var sw = Stopwatch.StartNew(); var second = functional.SendRawAsync(new byte[] { 0x22, 0xF1, 0x91 }, TimeSpan.FromMilliseconds(50), cts.Token); await bus.DeferredEchoes.WaitForEnqueuedAsync(3, ShortTimeout); bus.DeferredEchoes.ReleaseNext(); await second; sw.Stop(); - sw.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(1), "the 0x78 from before the handoff did not move the window out"); + sw.Elapsed.Should().BeLessThan(TimeSpan.FromMilliseconds(boundMs), "the 0x78 from before the handoff did not move the window out"); } // Codex on #150: the windows a client is created with are bounded as a collection window From 9c458bda00548a24cd96e2d0b7ad1d469eb13f41 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:31:58 +0200 Subject: [PATCH 55/55] test(uds): stamp the aborted-discard test's 0x78 at the request's start, not after a timer macOS CI on #150, on 6006623: the 0x78 was stamped after a 20 ms timer, which the runner fired after the 100 ms window had run out, so the frame was -- correctly -- not the window's. The stamp is taken 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. Co-Authored-By: Claude Opus 5 --- .../TestCases/Uds/UdsClientTests.cs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs index ba6029a..146b160 100644 --- a/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/Uds/UdsClientTests.cs @@ -755,15 +755,17 @@ public async Task A_Pending_Answer_Still_On_Its_Way_Is_Routed_By_An_Aborted_Requ using var cts = new CancellationTokenSource(ShortTimeout); await client.SendRawAsync(new byte[] { 0x3E, 0x80 }, cts.Token); // suppressed: window P2 = 100 ms - // Another service's request, cancelled by its caller at 50 ms; the 0x78 arrives while - // it waits -- after its pre-send discard, which would route it, and before its abort, - // whose discard is the one under test. (A host that delays either instant past the - // other lets the pre-send discard route it instead: a pass for the wrong reason, never - // a failure.) + // Another service's request, cancelled by its caller at 50 ms; 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 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(TimeSpan.FromMilliseconds(50)); var other = client.ReadDataByIdentifierAsync(0xF190, early.Token); - await Task.Delay(20); long arrival = Stopwatch.GetTimestamp(); + await Task.Delay(20); byte[] sf = { 0x03, 0x7F, 0x3E, 0x78, 0x00, 0x00, 0x00, 0x00 }; service.Deliver(new CanFrameView(CanFrameType.Can20, 0x7E8, sf, FrameFlags.None), arrival); Func cancelled = () => other;