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
@@ -242,6 +242,35 @@ public sealed class RequestSchedulerTests
ch.Sent[1][0].ShouldBe((byte)0x4C); // c went bare: the cancelled b was not packed with it
}
[Fact]
public async Task A_scheduler_fault_outside_the_exchange_fails_every_pending_waiter_and_keeps_pumping()
{
var ch = new FakeCipChannel();
await using var s = new RequestScheduler(ch, allowPacking: true);
var a = await HoldFirstAsync(s, ch, Req(8, 1));
var b = s.SendAsync(Req(8, 2), CancellationToken.None);
var c = s.SendAsync(Req(8, 3), CancellationToken.None);
var d = s.SendAsync(Req(8, 4), CancellationToken.None);
var broken = new InvalidOperationException("channel torn");
ch.MaxRequestBytesThrows = broken; // TakeBatch reads it when packing c behind b
ch.Gate!.SetResult();
(await a.WaitAsync(Timeout, Ct)).GeneralStatus.ShouldBe((byte)0);
var exB = await Should.ThrowAsync<AbTransportException>(() => b.WaitAsync(Timeout, Ct)); // already taken
var exC = await Should.ThrowAsync<AbTransportException>(() => c.WaitAsync(Timeout, Ct)); // still queued
var exD = await Should.ThrowAsync<AbTransportException>(() => d.WaitAsync(Timeout, Ct));
exB.Status.ShouldBe(AbStatus.BadConnection);
exB.InnerException.ShouldBeSameAs(broken);
exC.ShouldBeSameAs(exB);
exD.ShouldBeSameAs(exB);
s.QueuedCount.ShouldBe(0);
ch.SentCount.ShouldBe(1);
// The pump survived: a later request is answered, not left hanging.
ch.MaxRequestBytesThrows = null;
(await s.SendAsync(Req(8, 5), CancellationToken.None).WaitAsync(Timeout, Ct)).GeneralStatus.ShouldBe((byte)0);
}
[Fact]
public async Task A_token_already_cancelled_is_never_queued()
{
@@ -340,17 +369,24 @@ public sealed class RequestSchedulerTests
}
[Fact]
public async Task Dispose_cancels_queued_and_in_flight_waiters()
public async Task Dispose_faults_queued_and_in_flight_waiters_as_a_communication_failure_not_a_cancellation()
{
var ch = new FakeCipChannel();
var s = new RequestScheduler(ch, allowPacking: true);
var a = await HoldFirstAsync(s, ch, Req(8, 1));
var b = s.SendAsync(Req(8, 2), CancellationToken.None);
var a = await HoldFirstAsync(s, ch, Req(8, 1)); // in flight
using var own = new CancellationTokenSource();
var b = s.SendAsync(Req(8, 2), own.Token); // queued, with a live token
await s.DisposeAsync();
await Should.ThrowAsync<OperationCanceledException>(() => a.WaitAsync(Timeout, Ct));
await Should.ThrowAsync<OperationCanceledException>(() => b.WaitAsync(Timeout, Ct));
// Not OperationCanceledException: upstream would swallow that as "unsubscribed".
var inFlight = await Should.ThrowAsync<AbTransportException>(() => a.WaitAsync(Timeout, Ct));
inFlight.Status.ShouldBe(AbStatus.BadConnection);
var queued = await Should.ThrowAsync<AbTransportException>(() => b.WaitAsync(Timeout, Ct));
queued.Status.ShouldBe(AbStatus.BadConnection);
a.IsCanceled.ShouldBeFalse();
b.IsCanceled.ShouldBeFalse();
s.LiveRegistrations.ShouldBe(0);
ch.SentCount.ShouldBe(1);
Should.Throw<ObjectDisposedException>(() => s.SendAsync(Req(8), CancellationToken.None));
}
@@ -18,7 +18,16 @@ internal sealed class FakeCipChannel : ICipChannel
Respond = DefaultRespond;
}
public int MaxRequestBytes { get; set; }
private int _maxRequestBytes;
public int MaxRequestBytes
{
get => MaxRequestBytesThrows is { } ex ? throw ex : _maxRequestBytes;
set => _maxRequestBytes = value;
}
/// <summary>When set, reading <see cref="MaxRequestBytes"/> throws it (simulates a broken channel).</summary>
public Exception? MaxRequestBytesThrows { get; set; }
/// <summary>Every request, in send order.</summary>
public List<byte[]> Sent { get; } = [];
@@ -200,6 +200,62 @@ public sealed class LogixTagClientTests
}
}
[Fact]
public async Task A_struct_element_wider_than_one_frame_is_written_in_8_byte_aligned_contiguous_chunks()
{
const int room = 45; // aligns down to 40
var (ch, client, s) = Create(max: room + 12 + Path.Length);
await using var _ = s;
var data = Bytes(200, 9); // 2 elements of a 100-byte struct (> room)
await client.WriteAsync(Path, LogixTypeCode.Struct, 0x0ABC, 2, data, Ct);
var reassembled = new List<byte>();
foreach (var r in ch.Sent)
{
r[0].ShouldBe((byte)0x53);
r.Length.ShouldBeLessThanOrEqualTo(ch.MaxRequestBytes);
var body = r.AsSpan(2 + Path.Length);
BinaryPrimitives.ReadUInt16LittleEndian(body).ShouldBe(LogixTypeCode.Struct);
BinaryPrimitives.ReadUInt16LittleEndian(body[2..]).ShouldBe((ushort)0x0ABC); // handle
BinaryPrimitives.ReadUInt16LittleEndian(body[4..]).ShouldBe((ushort)2); // total elements
BinaryPrimitives.ReadUInt32LittleEndian(body[6..]).ShouldBe((uint)reassembled.Count); // contiguous
var chunk = body[10..];
(chunk.Length % 8).ShouldBe(0);
reassembled.AddRange(chunk.ToArray());
}
ch.Sent.Count.ShouldBe(5);
reassembled.ShouldBe(data);
}
[Fact]
public async Task A_read_that_never_stops_answering_partial_is_capped()
{
var (ch, client, s) = Create();
await using var _ = s;
ch.RespondPerService = r => FakeCipChannel.Reply(r[0], 0x06, TypedData(LogixTypeCode.DInt, new byte[4000]));
var ex = await Should.ThrowAsync<AbTransportException>(() => client.ReadAsync(Path, 1000, Ct));
ex.Status.ShouldBe(AbStatus.TooLarge);
ch.Sent.Count.ShouldBe(LogixTagClient.MaxReadBytes / 4000 + 1); // stops at the first chunk past the cap
}
[Theory]
[InlineData(LogixTypeCode.DInt, 4, 15)]
[InlineData(LogixTypeCode.DInt, 4, 17)]
[InlineData(LogixTypeCode.LReal, 2, 8)]
public async Task An_atomic_write_whose_data_does_not_match_its_element_count_is_rejected_before_the_wire(
ushort type, int elements, int length)
{
var (ch, client, s) = Create();
await using var _ = s;
await Should.ThrowAsync<ArgumentException>(
() => client.WriteAsync(Path, type, null, (ushort)elements, Bytes(length, 0), Ct));
ch.SentCount.ShouldBe(0);
}
[Fact]
public async Task A_failed_write_throws_with_the_reply_status()
{