fix(abcip): harden Ab.Transport CIP codec and EncapsulationClient (#564, #565)

CIP codec (#564): PatchU16 bounds guard; CipReply content equality; CipPath
port/length/ASCII/odd/oversize validation with ArgumentException for malformed
tag names and routes; CipPath.MessageRouter/ConnectionManager are now
ReadOnlySpan<byte>; refused Forward Open surfaces ForwardOpenRefusedException
with structured general/extended status; test CaptureFile uses BinaryPrimitives.

EncapsulationClient (#565): frame write bounded by the exchange timeout, which
faults (never cancels) the connection; finite positive timeouts enforced at
every entry point; gate wait bounded by the timeout; deterministic reorder of
the 300 ms test; new dispose-during-drain, gate-wait and RegisterSession
zero-context tests.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-07 22:54:32 -04:00
parent 04b6563286
commit 7892eb13a8
15 changed files with 379 additions and 30 deletions
@@ -151,13 +151,15 @@ public sealed class EncapsulationClientTests
using var callerCts = new CancellationTokenSource();
var first = client.SendRRDataAsync(new byte[] { 0x01 }, TimeSpan.FromMilliseconds(300), callerCts.Token);
await server.WaitForFrameAsync(IsRR, Guard);
// Both calls are issued back-to-back, before any await: `first` acquired the gate synchronously
// and `next` is therefore already queued behind it before the cancel — the ordering does not
// depend on how long the test thread takes to get around to it.
var next = client.SendRRDataAsync(new byte[] { 0x02 }, Generous, TestCt);
await callerCts.CancelAsync();
await Should.ThrowAsync<OperationCanceledException>(first.WaitAsync(Guard, TestCt));
client.IsConnected.ShouldBeTrue(); // cancel alone never faults
await server.WaitForFrameAsync(IsRR, Guard); // the abandoned request was on the wire
// Queued behind the draining exchange: must fail fast once that exchange times out, not hang.
var next = client.SendRRDataAsync(new byte[] { 0x02 }, Generous, TestCt);
var ex = await Should.ThrowAsync<AbTransportException>(next.WaitAsync(Guard, TestCt));
ex.Status.ShouldBe(AbStatus.BadConnection);
@@ -168,6 +170,104 @@ public sealed class EncapsulationClientTests
.Status.ShouldBe(AbStatus.BadConnection);
}
[Fact]
public async Task RegisterSession_reply_matches_only_through_the_zero_context_tolerance()
{
// Documents a dependency: RegisterSession goes out with sender context 0 (ab_server refuses
// non-zero) so its reply echoes 0, while the exchange's own context is a non-zero counter value.
// The only thing that lets the reply match is TryMatch's "reply context == 0" rule; tighten
// that rule and every Connect breaks, not just the zero-context-server case.
await using var server = new FakeEipServer();
await using var client = await Connect(server);
var request = EncapsulationCodec.ReadHeader(server.Received[0]);
request.Command.ShouldBe(EncapCommand.RegisterSession);
request.SenderContext.ShouldBe(0UL);
client.SessionHandle.ShouldBe(FakeEipServer.Session);
client.IsConnected.ShouldBeTrue();
}
[Theory]
[InlineData(0)]
[InlineData(-5)]
public async Task Non_positive_timeouts_are_rejected_everywhere(int milliseconds)
{
await using var server = new FakeEipServer();
var bad = TimeSpan.FromMilliseconds(milliseconds);
await Should.ThrowAsync<ArgumentOutOfRangeException>(
EncapsulationClient.ConnectAsync("127.0.0.1", server.Port, bad, TestCt));
await using var client = await Connect(server);
await Should.ThrowAsync<ArgumentOutOfRangeException>(client.SendRRDataAsync(new byte[] { 0x4C }, bad, TestCt));
await Should.ThrowAsync<ArgumentOutOfRangeException>(client.SendUnitDataAsync(1, new byte[] { 0x4C }, bad, TestCt));
client.IsConnected.ShouldBeTrue(); // a bad argument is not a comms fault
server.Received.Count(IsRR).ShouldBe(0);
}
[Fact]
public async Task Infinite_timeouts_are_rejected_everywhere()
{
await using var server = new FakeEipServer();
var inf = Timeout.InfiniteTimeSpan;
await Should.ThrowAsync<ArgumentOutOfRangeException>(
EncapsulationClient.ConnectAsync("127.0.0.1", server.Port, inf, TestCt));
await using var client = await Connect(server);
await Should.ThrowAsync<ArgumentOutOfRangeException>(client.SendRRDataAsync(new byte[] { 0x4C }, inf, TestCt));
await Should.ThrowAsync<ArgumentOutOfRangeException>(client.SendUnitDataAsync(1, new byte[] { 0x4C }, inf, TestCt));
client.IsConnected.ShouldBeTrue();
}
[Fact]
public async Task Waiting_for_the_gate_is_bounded_by_the_timeout_and_leaves_the_connection_intact()
{
await using var server = new FakeEipServer { Stall = true };
var client = await Connect(server);
var holder = client.SendRRDataAsync(new byte[] { 0x01 }, Generous, TestCt); // holds the gate: no reply comes
await server.WaitForFrameAsync(IsRR, Guard);
var ex = await Should.ThrowAsync<AbTransportException>(
client.SendRRDataAsync(new byte[] { 0x02 }, TimeSpan.FromMilliseconds(150), TestCt).WaitAsync(Guard, TestCt));
ex.Status.ShouldBe(AbStatus.Timeout);
client.IsConnected.ShouldBeTrue(); // the waiter wrote nothing, so nothing desynced
holder.IsCompleted.ShouldBeFalse(); // positive control: the holder is still waiting on its reply
server.Received.Count(IsRR).ShouldBe(1);
await client.DisposeAsync(); // closes the socket under the holder
(await Should.ThrowAsync<AbTransportException>(holder.WaitAsync(Guard, TestCt)))
.Status.ShouldBe(AbStatus.BadConnection);
}
[Theory]
[InlineData(false)] // drain outlives the unregister budget: the socket is closed under it, nothing is written
[InlineData(true)] // drain times out while Dispose waits for the gate: the session is faulted, nothing is written
public async Task Dispose_during_a_cancelled_reply_drain_closes_without_interleaving_an_UnRegisterSession(bool drainTimesOutFirst)
{
await using var server = new FakeEipServer { Stall = true };
var client = await Connect(server);
using var callerCts = new CancellationTokenSource();
var first = client.SendRRDataAsync(new byte[] { 0x01 },
drainTimesOutFirst ? TimeSpan.FromMilliseconds(100) : Generous, callerCts.Token);
await server.WaitForFrameAsync(IsRR, Guard);
await callerCts.CancelAsync();
await Should.ThrowAsync<OperationCanceledException>(first.WaitAsync(Guard, TestCt));
await client.DisposeAsync().AsTask().WaitAsync(Guard, TestCt); // must not hang on the drain
client.IsConnected.ShouldBeFalse();
(await Should.ThrowAsync<AbTransportException>(client.SendRRDataAsync(new byte[] { 0x03 }, Generous, TestCt)))
.Status.ShouldBe(AbStatus.BadConnection);
// Absence needs a positive control: a second client's clean dispose MUST put an UnRegisterSession
// on the wire, so by the time it arrives the first client's (had there been one) would have too.
var second = await Connect(server);
await second.DisposeAsync();
await server.WaitForFrameAsync(f => FakeEipServer.Command(f) == EncapCommand.UnRegisterSession, Guard);
server.Received.Count(f => FakeEipServer.Command(f) == EncapCommand.UnRegisterSession).ShouldBe(1);
server.Received.Count(IsRR).ShouldBe(1); // and the queued/abandoned work never doubled
}
[Fact]
public async Task An_abandoned_exchange_that_times_out_leaves_no_unobserved_task_exception()
{
@@ -228,7 +328,7 @@ public sealed class EncapsulationClientTests
var units = server.Received.Where(f => FakeEipServer.Command(f) == EncapCommand.SendUnitData).ToList();
var sequences = units.Select(FakeEipServer.RequestSequence).ToList();
sequences.ShouldBe(Enumerable.Range(1, 20).Select(i => (ushort)i).ToList()); // strictly increasing, no dupes
units.Select(f => BitConverter.ToUInt32(f, 36)).ShouldAllBe(id => id == 0xAABBCCDD); // A1 item connection id
units.Select(f => BinaryPrimitives.ReadUInt32LittleEndian(f.AsSpan(36))).ShouldAllBe(id => id == 0xAABBCCDD); // A1 item connection id
for (var i = 0; i < 20; i++) results[i].ToArray().ShouldBe(new[] { (byte)(i + 1) }); // each got its own reply
server.MaxOutstanding.ShouldBe(1);
}