Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
e460d7f
feat(uds): the collected UDS findings -- busy repeat, functional addr…
dborgards Sep 21, 2026
1cc3af2
test(uds): dispose the token source and the client CodeQL flagged on …
dborgards Sep 21, 2026
7cbebdc
fix(uds): correlate functional answers to the request, require a wind…
dborgards Sep 21, 2026
77368d2
fix(uds): serialise functional requests, and reject a negative busy-r…
dborgards Sep 21, 2026
2769a4b
fix(uds): a functional call queued behind another does not send after…
dborgards Sep 21, 2026
e514791
fix(uds): correlate functional answers on the echoed parameters, and …
dborgards Sep 21, 2026
15882ec
fix(uds): keep a suppressed send's window per service, on both client…
dborgards Sep 21, 2026
29b5bab
fix(uds): extend a suppressed send's window on NRC 0x78, and keep eve…
dborgards Sep 21, 2026
a09fceb
refactor(uds): fold the pending-response check CodeQL flagged into on…
dborgards Sep 21, 2026
7269ce3
fix(uds): note a functional window before the send, keep a cancelled …
dborgards Sep 21, 2026
2da4e3a
fix(uds): anchor a functional request's window at its transmission, h…
dborgards Sep 21, 2026
1dc74ac
feat(isotp): stamp a functional response with its arrival; fix(uds): …
dborgards Sep 21, 2026
0319c25
fix(uds): route a heard 0x78 to its own service's window, survive a q…
dborgards Sep 21, 2026
01ece81
fix(uds): give the functional client one listener per service for the…
dborgards Sep 21, 2026
534b1a5
fix(uds): correlate the DID-echoing services 0x24, 0x2C and 0x2F on t…
dborgards Sep 21, 2026
db58008
fix(uds): restart the functional listener when anchoring a window aft…
dborgards Sep 21, 2026
79c3ea1
fix(uds): route a stray 0x78 to its service's window, start no listen…
dborgards Sep 21, 2026
3728387
feat(isotp): let a functional client listen on one subscription acros…
dborgards Sep 21, 2026
9c4a976
fix(uds): subscribe the functional listener before the send, retire i…
dborgards Sep 21, 2026
da77871
test(isotp): dispose the listener under test by using, for a throw be…
dborgards Sep 21, 2026
2bb7a00
fix(isotp): keep a frame from after the deadline for the next collect…
dborgards Sep 21, 2026
d7cefc1
fix(uds): anchor the functional window when the collection leaves by …
dborgards Sep 21, 2026
c59b9aa
test(isotp): dispose the service under test by using, for a throw bef…
dborgards Sep 21, 2026
ef55548
fix(uds): revive no window for a 0x78 from after its end, and correla…
dborgards Sep 21, 2026
675e29b
fix(uds): revive no window for a late 0x78 heard while another servic…
dborgards Sep 21, 2026
0d7266b
fix(uds): revive no window for a 0x78 of the waited service itself fr…
dborgards Sep 21, 2026
1d0b046
fix(uds): revive no functional window for a 0x78 collected after the …
dborgards Sep 21, 2026
36d8017
fix(uds): read what the functional listener's subscription buffered b…
dborgards Sep 21, 2026
e9ce1fc
fix(isotp): read the listener's buffer without a timer for a zero window
dborgards Sep 21, 2026
0d6fa8b
fix(uds): correlate a functional RequestFileTransfer on its modeOfOpe…
dborgards Sep 21, 2026
edba9e4
fix(uds): keep the functional listener through a send whose confirmat…
dborgards Sep 21, 2026
c3c025b
feat(isotp)!: let a channel be settled, so a look at the inbox at a d…
dborgards Sep 21, 2026
2a697de
fix(uds): settle the channel before reading the inbox empty at a dead…
dborgards Sep 21, 2026
808e9c3
fix(isotp): bound a functional collection by its frames' arrival stam…
dborgards Sep 21, 2026
8172826
docs(isotp): say what SettleAsync does when called on the channel's o…
dborgards Sep 21, 2026
019ee82
test(uds): stamp the on-its-way 0x78 at the send, and set the two P2*…
dborgards Sep 21, 2026
976fb04
fix(isotp): read the listener's buffer against now, not against a neg…
dborgards Sep 21, 2026
c078871
test(uds): give the functional tests' late negative answers 1150 ms o…
dborgards Sep 21, 2026
72e0b82
docs(uds): say which functional services are correlated on the positi…
dborgards Sep 21, 2026
e1fdea2
fix(uds): note a suppressed send's window again when the send leaves …
dborgards Sep 21, 2026
a8b54a1
feat(isotp)!: let a discard take the caller's stamp, so what it drops…
dborgards Sep 21, 2026
69b38f2
fix(uds): take the pre-send discard's stamp before routing what the i…
dborgards Sep 21, 2026
6728759
fix(uds): refuse a collection window beyond a timer's reach before an…
dborgards Sep 21, 2026
fc86326
fix(uds): bound the functional client's windows at creation as a coll…
dborgards Sep 21, 2026
5cb50c0
fix(uds): bound every duration in the client options at a timer's reach
dborgards Sep 21, 2026
cfec2b8
fix(uds): leave no window behind a request the channel refused before…
dborgards Sep 21, 2026
a2b68a6
fix(uds): retire the refused send's listener before the window is put…
dborgards Sep 21, 2026
92b2ae4
fix(isotp): refuse a listener window beyond a timer's reach before ta…
dborgards Sep 21, 2026
b6809a8
test(uds): hold the stub's transmission until the cancellation in the…
dborgards Sep 21, 2026
367315c
fix(uds): settle and route what the channel holds on an aborted reque…
dborgards Sep 21, 2026
5672520
fix(uds): discard stale replies before a suppressed send, as before a…
dborgards Sep 22, 2026
c2dbc0b
feat(isotp): report when a functional request went out, and leave out…
dborgards Sep 22, 2026
6006623
fix(uds): anchor the functional window at the driver's acceptance, an…
dborgards Sep 22, 2026
0916c20
fix(uds): hold the handoff cutoff with the window, for the listener t…
dborgards Sep 22, 2026
9c458bd
test(uds): stamp the aborted-discard test's 0x78 at the request's sta…
dborgards Sep 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion src/CanKit.Pro.IsoTp/IIsoTpChannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,19 @@ Task<IsoTpTransmitStamps> SendWithTransmitStampAsync(ReadOnlyMemory<byte> pdu,
/// </summary>
IReadOnlyList<IsoTpReceptionInProgress> GetReceptionsInProgress();

/// <summary>
/// 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
/// <see cref="GetReceptionsInProgress"/>. 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). Called on the
/// channel's own actor — from a <c>BackgroundExceptionOccurred</c> 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.
/// </summary>
Task SettleAsync();

/// <summary>
/// Drains every buffered inbox item — both completed PDUs and pending reassembly-abort
/// faults enqueued by <c>AbortRx</c> — and returns how many were dropped. Also silently
Expand All @@ -137,6 +150,17 @@ Task<IsoTpTransmitStamps> SendWithTransmitStampAsync(ReadOnlyMemory<byte> pdu,
/// </summary>
int DiscardPendingPdus();

/// <summary>
/// As <see cref="DiscardPendingPdus()"/>, dropping what arrived before
/// <paramref name="arrivedBefore"/> (a <see cref="System.Diagnostics.Stopwatch.GetTimestamp"/>
/// 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).
/// </summary>
int DiscardPendingPdus(long arrivedBefore);

/// <summary>
/// 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 /
Expand All @@ -150,7 +174,7 @@ Task<IsoTpTransmitStamps> SendWithTransmitStampAsync(ReadOnlyMemory<byte> pdu,
/// Raised (on a thread-pool thread) every time a full PDU is reassembled. The same PDU is
/// enqueued for <see cref="ReceiveAsync"/>/<see cref="ReceiveAllAsync"/> before the event
/// fires, so a handler that synchronously waits on those APIs — or on
/// <see cref="DiscardPendingPdus"/> — cannot deadlock the protocol actor. Handlers must be
/// <see cref="DiscardPendingPdus()"/> — cannot deadlock the protocol actor. Handlers must be
/// non-throwing; a throwing handler is caught and surfaced via
/// <see cref="BackgroundExceptionOccurred"/>.
/// </summary>
Expand Down
36 changes: 32 additions & 4 deletions src/CanKit.Pro.IsoTp/IsoTpChannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -344,9 +344,37 @@ public bool TryReceiveWithArrival(out IsoTpReceivedPdu pdu)
}

/// <inheritdoc />
public int DiscardPendingPdus()
/// <inheritdoc />
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, 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
{
return _actor.PostAsync(() => { });
}
catch (ObjectDisposedException)
{
return Task.CompletedTask; // channel tearing down: nothing left on its way
}
}
Comment thread
dborgards marked this conversation as resolved.

public int DiscardPendingPdus() => DiscardPendingPdus(arrivedBefore: 0);

/// <inheritdoc />
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
Expand All @@ -358,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);
}

Expand Down
76 changes: 67 additions & 9 deletions src/CanKit.Pro.IsoTp/IsoTpFunctionalClient.cs
Original file line number Diff line number Diff line change
@@ -1,5 +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;
Expand Down Expand Up @@ -56,6 +58,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(
Expand Down Expand Up @@ -120,10 +125,30 @@ public async Task<IReadOnlyList<IsoTpFunctionalResponse>> SendAndCollectAsync(
ReadOnlyMemory<byte> pdu,
TimeSpan window,
CancellationToken cancellationToken = default)
=> (await SendAndCollectWithTransmitStampAsync(pdu, window, cancellationToken).ConfigureAwait(false)).Responses;

/// <summary>
/// As <see cref="SendAndCollectAsync"/>, also returning when the request was handed to the
/// driver and when it was transmitted (<see cref="IsoTpTransmitStamps"/>, 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.
/// </summary>
public async Task<IsoTpFunctionalCollection> SendAndCollectWithTransmitStampAsync(
ReadOnlyMemory<byte> pdu,
TimeSpan window,
CancellationToken cancellationToken = default)
{
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, …),
Expand All @@ -144,9 +169,13 @@ public async Task<IReadOnlyList<IsoTpFunctionalResponse>> 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);
}

/// <summary>
Expand All @@ -164,6 +193,14 @@ public async Task<IReadOnlyList<IsoTpFunctionalResponse>> SendAndCollectAsync(
/// </exception>
/// <exception cref="IsoTpException">TX-confirm failed.</exception>
public Task SendAsync(ReadOnlyMemory<byte> pdu, CancellationToken cancellationToken = default)
=> SendWithTransmitStampAsync(pdu, cancellationToken);

/// <summary>
/// As <see cref="SendAsync"/>, returning when the frame was handed to the driver and when
/// it was transmitted (<see cref="IsoTpTransmitStamps"/>, zero where the driver reports
/// neither), for a caller that keeps a deadline from the transmission (Codex on #150).
/// </summary>
public Task<IsoTpTransmitStamps> SendWithTransmitStampAsync(ReadOnlyMemory<byte> pdu, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (pdu.Length == 0)
Expand Down Expand Up @@ -193,6 +230,19 @@ public async Task<IReadOnlyList<IsoTpFunctionalResponse>> CollectResponsesAsync(
return await CollectFromSubscriptionAsync(sub, window, cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// Subscribes to the response range now and returns a listener that keeps the subscription
/// across collections. Obtained before a <see cref="SendAsync"/>, it closes the gap between
/// send and subscribe that <see cref="CollectResponsesAsync"/> leaves, and a response that
/// arrives between two of its collections is buffered for the next one.
/// </summary>
/// <exception cref="ObjectDisposedException">The client was disposed.</exception>
public IsoTpFunctionalListener Listen()
{
ThrowIfDisposed();
return new IsoTpFunctionalListener(_service.Subscribe(_responseFilter, includeEcho: true));
}

/// <inheritdoc/>
public void Dispose()
{
Expand All @@ -209,7 +259,7 @@ public void Dispose()
/// Sends <paramref name="pdu"/> as a functional Single Frame and awaits the CAN driver's
/// TX confirmation.
/// </summary>
private async Task SendSingleFrameAsync(ReadOnlyMemory<byte> pdu, CancellationToken ct)
private async Task<IsoTpTransmitStamps> SendSingleFrameAsync(ReadOnlyMemory<byte> pdu, CancellationToken ct)
{
int sfMax = IsoTpFrameCodec.SingleFrameMaxDataLength(_options.UseCanFd,
_txEndpoint.UsesAddressExtension);
Expand Down Expand Up @@ -249,6 +299,7 @@ private async Task SendSingleFrameAsync(ReadOnlyMemory<byte> pdu, CancellationTo
new IsoTpException("Functional Single Frame TX confirmation failed with unknown reason."),
};
}
return new IsoTpTransmitStamps(confirmation.HostHandoffTimestamp, confirmation.HostTransmitTimestamp);
}

private static async Task<IReadOnlyList<IsoTpFunctionalResponse>> CollectFromSubscriptionAsync(
Expand All @@ -257,6 +308,10 @@ private static async Task<IReadOnlyList<IsoTpFunctionalResponse>> CollectFromSub
var responses = new List<IsoTpFunctionalResponse>();

// 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;
Expand All @@ -265,8 +320,8 @@ private static async Task<IReadOnlyList<IsoTpFunctionalResponse>> CollectFromSub
{
await foreach (var frameEvent in sub.Frames.WithCancellation(windowToken).ConfigureAwait(false))
{
if (TryParseFunctionalResponse(frameEvent.Frame, out var response))
responses.Add(response!);
if (TryParseFunctionalResponse(frameEvent, out var response) && response!.HostArrivalTimestamp <= deadline)
responses.Add(response);
}
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
Expand All @@ -285,8 +340,8 @@ private static async Task<IReadOnlyList<IsoTpFunctionalResponse>> CollectFromSub
sub.Dispose();
while (sub.TryRead(out var frameEvent))
{
if (TryParseFunctionalResponse(frameEvent.Frame, out var response))
responses.Add(response!);
if (TryParseFunctionalResponse(frameEvent, out var response) && response!.HostArrivalTimestamp <= deadline)
responses.Add(response);
}
}

Expand All @@ -303,9 +358,10 @@ private static void DrainBuffered(ISubscription sub)
while (sub.TryRead(out _)) { }
}

private static bool TryParseFunctionalResponse(CanFrameView frame,
internal static bool TryParseFunctionalResponse(in CanFrameEvent frameEvent,
out IsoTpFunctionalResponse? response)
{
var frame = frameEvent.Frame;
var payload = frame.Data.ToArray();
bool isCanFd = frame.FrameKind == CanFrameType.CanFd;

Expand Down Expand Up @@ -333,7 +389,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;
}

Expand Down
22 changes: 22 additions & 0 deletions src/CanKit.Pro.IsoTp/IsoTpFunctionalCollection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.Collections.Generic;

namespace CanKit.Pro.IsoTp;

/// <summary>
/// What <see cref="IsoTpFunctionalClient.SendAndCollectWithTransmitStampAsync"/> returns: the
/// responses collected within the window, and when the request went out.
/// </summary>
public readonly struct IsoTpFunctionalCollection
{
internal IsoTpFunctionalCollection(IReadOnlyList<IsoTpFunctionalResponse> responses, IsoTpTransmitStamps transmitStamps)
{
Responses = responses;
TransmitStamps = transmitStamps;
}

/// <summary>The Single-Frame responses collected within the window, in arrival order.</summary>
public IReadOnlyList<IsoTpFunctionalResponse> Responses { get; }

/// <summary>When the request was handed to the driver and transmitted; zero where unknown.</summary>
public IsoTpTransmitStamps TransmitStamps { get; }
}
Loading
Loading