fix(ab-transport): harden Task 10 scheduler and tag client (dispose faults, pump guard, read cap, write validation, 8-byte wide-struct alignment)

This commit is contained in:
Joseph Doherty
2026-10-07 16:22:40 -04:00
parent 1c7fd5d5de
commit 5211975067
5 changed files with 184 additions and 19 deletions
@@ -117,7 +117,7 @@ public sealed class RequestScheduler : IAsyncDisposable
/// <summary>
/// Queue one CIP request. The returned reply carries the service's own general status; a non-zero
/// status is not an exception here. Faults with <see cref="AbTransportException"/> when the channel
/// or the MSP envelope fails; cancels when <paramref name="ct"/> fires.
/// or the MSP envelope fails, or the scheduler is disposed; cancels only when <paramref name="ct"/> fires.
/// </summary>
public Task<CipReply> SendAsync(byte[] request, CancellationToken ct)
{
@@ -154,9 +154,10 @@ public sealed class RequestScheduler : IAsyncDisposable
return p.Tcs.Task;
}
private List<Pending> TakeBatch()
/// <summary>Moves the next batch from the queue into <paramref name="batch"/>. Filling a caller-owned
/// list means a throw part-way through still leaves every taken waiter reachable for faulting.</summary>
private void TakeBatch(List<Pending> batch)
{
var batch = new List<Pending>();
lock (_lock)
{
var sizes = new List<int>();
@@ -178,7 +179,6 @@ public sealed class RequestScheduler : IAsyncDisposable
_queue.RemoveFirst();
}
}
return batch;
}
private async Task PumpAsync()
@@ -192,13 +192,40 @@ public sealed class RequestScheduler : IAsyncDisposable
// so a surplus signal (e.g. from a cancelled waiter) is just one empty pass.
while (!_stop.IsCancellationRequested)
{
var batch = TakeBatch();
if (batch.Count == 0) break;
await ExecuteAsync(batch).ConfigureAwait(false);
var batch = new List<Pending>();
try
{
TakeBatch(batch);
if (batch.Count == 0) break;
await ExecuteAsync(batch).ConfigureAwait(false);
}
catch (Exception ex)
{
// Last resort: ExecuteAsync already handles every exchange failure, so reaching here
// means the scheduler itself broke (e.g. MaxRequestBytes threw). Fault everything
// pending rather than let the pump die and leave callers hanging, then keep pumping
// so later requests still get an answer.
FaultAll(batch, ex);
break;
}
}
}
}
private void FaultAll(List<Pending> batch, Exception ex)
{
var error = ex as AbTransportException
?? new AbTransportException(AbStatus.BadConnection, $"Request scheduler failed: {ex.Message}", ex);
List<Pending> queued;
lock (_lock)
{
queued = [.. _queue];
_queue.Clear();
}
foreach (var b in batch) b.Fail(error, this);
foreach (var q in queued) q.Fail(error, this);
}
private async Task ExecuteAsync(List<Pending> batch)
{
try
@@ -224,7 +251,9 @@ public sealed class RequestScheduler : IAsyncDisposable
}
catch (OperationCanceledException) when (_stop.IsCancellationRequested)
{
foreach (var b in batch) b.Cancel(_stop.Token, this);
// The scheduler is being disposed: not the caller's cancellation, so not an OCE.
var error = DisposedError();
foreach (var b in batch) b.Fail(error, this);
}
catch (AbTransportException ex)
{
@@ -237,6 +266,17 @@ public sealed class RequestScheduler : IAsyncDisposable
}
}
/// <summary>
/// The error a waiter sees when the scheduler (i.e. its connection) goes away under it. Deliberately
/// NOT an <see cref="OperationCanceledException"/>: upstream treats OCE as "the caller unsubscribed"
/// and swallows it, whereas this must surface as a communication failure. A waiter cancelled by its
/// own token still gets an OCE.
/// </summary>
private static AbTransportException DisposedError() =>
new(AbStatus.BadConnection, "Scheduler disposed.");
/// <summary>Stops the pump. In-flight and queued waiters fault with
/// <see cref="AbTransportException"/> (<see cref="AbStatus.BadConnection"/>).</summary>
public async ValueTask DisposeAsync()
{
lock (_lock)
@@ -253,7 +293,8 @@ public sealed class RequestScheduler : IAsyncDisposable
remaining = [.. _queue];
_queue.Clear();
}
foreach (var p in remaining) p.Cancel(_stop.Token, this);
var error = DisposedError();
foreach (var p in remaining) p.Fail(error, this);
_signal.Dispose();
_stop.Dispose();
}
@@ -19,6 +19,14 @@ public sealed class LogixTagClient(RequestScheduler scheduler, ICipChannel chann
/// size, type, struct handle, element count, offset.</summary>
private const int FragmentedWriteOverhead = 12;
/// <summary>Upper bound on one read's accumulated data, so a controller that keeps answering
/// Partial (0x06) cannot grow memory without limit. Far above any real Logix tag.</summary>
internal const int MaxReadBytes = 2 * 1024 * 1024;
/// <summary>Chunk alignment when one element is wider than a frame (Logix lays structures out on
/// at most 8-byte boundaries).</summary>
private const int WideElementAlignment = 8;
private readonly RequestScheduler _scheduler = scheduler ?? throw new ArgumentNullException(nameof(scheduler));
private readonly ICipChannel _channel = channel ?? throw new ArgumentNullException(nameof(channel));
@@ -52,6 +60,9 @@ public sealed class LogixTagClient(RequestScheduler scheduler, ICipChannel chann
$"Read fragment type 0x{fragmentType:X4} differs from the first reply's 0x{type:X4}.");
data.AddRange(chunk.Span);
lastChunk = chunk.Length;
if (data.Count > MaxReadBytes)
throw new AbTransportException(AbStatus.TooLarge,
$"Fragmented read exceeded {MaxReadBytes} bytes; the controller kept answering Partial.");
}
return (type, handle, data.ToArray());
@@ -60,12 +71,23 @@ public sealed class LogixTagClient(RequestScheduler scheduler, ICipChannel chann
/// <summary>
/// Write <paramref name="data"/> to the tag at <paramref name="path"/>. One Write Tag when the
/// request fits <see cref="ICipChannel.MaxRequestBytes"/>; otherwise Write Tag Fragmented chunks of
/// <c>MaxRequestBytes - 12 - path.Length</c> bytes aligned down to the element width.
/// <c>MaxRequestBytes - 12 - path.Length</c> bytes aligned down to the element width (or to 8 bytes
/// when a single element is wider than a frame).
/// </summary>
/// <remarks>A fragmented write is NOT atomic across fragments — the controller applies each
/// fragment as it arrives, so a reader can observe a partly-written value. libplctag behaves the
/// same way.</remarks>
/// <exception cref="ArgumentException">For an atomic type, <paramref name="data"/> is not exactly
/// <paramref name="elements"/> × the type's width.</exception>
public async Task WriteAsync(byte[] path, ushort type, ushort? handle, ushort elements,
ReadOnlyMemory<byte> data, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(path);
var atomicWidth = LogixTypeCode.ByteWidth(type);
if (atomicWidth > 0 && data.Length != elements * atomicWidth)
throw new ArgumentException(
$"Write of {elements} element(s) of type 0x{type:X4} needs {elements * atomicWidth} bytes, got {data.Length}.",
nameof(data));
var max = _channel.MaxRequestBytes;
var isStruct = type == LogixTypeCode.Struct;
var singleSize = 2 + path.Length + 2 + (isStruct ? 2 : 0) + 2 + data.Length;
@@ -95,9 +117,10 @@ public sealed class LogixTagClient(RequestScheduler scheduler, ICipChannel chann
private static int AlignedChunkSize(int room, int width)
{
// A structure element can be wider than one frame; then align to the 4-byte boundary Logix
// lays structures out on rather than refusing the write.
var alignment = width <= room ? width : 4;
// A structure element can be wider than one frame. Write Tag Fragmented's offset is a byte
// offset and the controller reassembles, so this is allowed: align to 8 bytes instead of
// refusing the write.
var alignment = width <= room ? width : WideElementAlignment;
var chunk = room / alignment * alignment;
if (chunk <= 0)
throw new AbTransportException(AbStatus.TooLarge,