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:
+104
-4
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user