Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipConnectionTests.cs
T

386 lines
17 KiB
C#

using System.Buffers.Binary;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Cip;
/// <summary>Serializes every test class that asserts on <see cref="CipConnection"/>'s process-wide
/// diagnostic counters, so no parallel test can move them mid-assertion.</summary>
[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class CipConnectionStatics
{
public const string Name = "CipConnection statics";
}
/// <summary><see cref="CipConnection"/> against <see cref="FakeCipTarget"/>. Wire I/O is real loopback;
/// the throttle, deadlines and keep-alive run on <see cref="ManualTimeProvider"/>.</summary>
[Collection(CipConnectionStatics.Name)]
public sealed class CipConnectionTests
{
private static readonly TimeSpan Guard = TimeSpan.FromSeconds(10);
private static readonly byte[] ReadTag = [0x4C, 0x02, 0x91, 0x03, 0x54, 0x61, 0x67, 0x00, 0x01, 0x00];
private static CancellationToken TestCt => TestContext.Current.CancellationToken;
private static CipConnection Create(FakeCipTarget target, ConnectionOptions o, ManualTimeProvider time, string path = "1,0") =>
new(new DeviceKey("127.0.0.1", target.Port, path, "ControlLogix"), o,
new ForwardOpenThrottle(o.MinForwardOpenInterval, time), time);
private static ConnectionOptions Options(CipRouting routing = CipRouting.Connected, int size = 4002, double timeoutSeconds = 2) =>
new() { Routing = routing, ConnectionSize = size, Timeout = TimeSpan.FromSeconds(timeoutSeconds) };
private static ushort RegularOTParams(byte[] fo) => BinaryPrimitives.ReadUInt16LittleEndian(fo.AsSpan(32));
// ---- options ---------------------------------------------------------------------------------
[Fact]
public void Defaults_match_the_press_safety_values()
{
var o = new ConnectionOptions();
o.MinForwardOpenInterval.ShouldBe(TimeSpan.FromSeconds(1));
o.RpiMicros.ShouldBe(2_000_000u);
o.TimeoutMultiplier.ShouldBe((byte)1);
o.ConnectionExpiry.ShouldBe(TimeSpan.FromSeconds(16));
o.KeepAliveInterval.ShouldBe(TimeSpan.FromSeconds(8));
o.VendorId.ShouldBe((ushort)0x1337);
new ConnectionOptions().OriginatorSerial.ShouldBe(o.OriginatorSerial); // per process, not per instance
}
public static TheoryData<TimeSpan> UnusableTimeouts => new() { Timeout.InfiniteTimeSpan, TimeSpan.Zero, TimeSpan.FromSeconds(-1) };
[Theory]
[MemberData(nameof(UnusableTimeouts))]
public void Options_reject_a_non_finite_or_non_positive_timeout(TimeSpan timeout) =>
Should.Throw<ArgumentOutOfRangeException>(() => new ConnectionOptions { Timeout = timeout });
[Fact]
public void Options_reject_a_forward_open_interval_below_one_second() =>
Should.Throw<ArgumentOutOfRangeException>(() => new ConnectionOptions { MinForwardOpenInterval = TimeSpan.FromMilliseconds(999) });
// ---- Large Forward Open fallback -------------------------------------------------------------
[Fact]
public async Task Large_open_service_not_supported_falls_back_to_regular_open_once()
{
await using var target = new FakeCipTarget();
target.ForwardOpenReply = fo => fo[0] == 0x5B ? FakeCipTarget.Refuse(0x08)(fo) : FakeCipTarget.Accept(fo);
var time = new ManualTimeProvider();
await using var conn = Create(target, Options(), time);
var before = Volatile.Read(ref CipConnection.ForwardOpenCount);
await conn.SendAsync(ReadTag, TestCt);
var fos = target.ForwardOpenRequests;
fos.Count.ShouldBe(2);
fos[0][0].ShouldBe((byte)0x5B);
fos[1][0].ShouldBe((byte)0x54);
RegularOTParams(fos[1]).ShouldBe((ushort)(0x4200 | 504));
conn.MaxRequestBytes.ShouldBe(502);
(Volatile.Read(ref CipConnection.ForwardOpenCount) - before).ShouldBe(2);
target.ConnectedRequests.ShouldBe(1);
}
[Fact]
public async Task Large_open_bad_size_falls_back_to_regular_open_once()
{
await using var target = new FakeCipTarget();
target.ForwardOpenReply = fo => fo[0] == 0x5B ? FakeCipTarget.Refuse(0x01, 0x0109)(fo) : FakeCipTarget.Accept(fo);
var time = new ManualTimeProvider();
await using var conn = Create(target, Options(), time);
await conn.SendAsync(ReadTag, TestCt);
target.ForwardOpenRequests.Select(f => f[0]).ShouldBe(new byte[] { 0x5B, 0x54 });
RegularOTParams(target.ForwardOpenRequests[1]).ShouldBe((ushort)(0x4200 | 504));
}
[Fact]
public async Task A_refused_fallback_is_not_retried_again()
{
await using var target = new FakeCipTarget { ForwardOpenReply = FakeCipTarget.Refuse(0x08) };
var time = new ManualTimeProvider();
await using var conn = Create(target, Options(), time);
await Should.ThrowAsync<AbTransportException>(() => conn.SendAsync(ReadTag, TestCt));
target.ForwardOpens.ShouldBe(2); // Large, then exactly one regular
}
[Fact]
public async Task Connection_in_use_does_not_fall_back()
{
await using var target = new FakeCipTarget { ForwardOpenReply = FakeCipTarget.Refuse(0x01, 0x0100) };
var time = new ManualTimeProvider();
await using var conn = Create(target, Options(), time);
var ex = await Should.ThrowAsync<AbTransportException>(() => conn.SendAsync(ReadTag, TestCt));
ex.Message.ShouldContain("Forward Open refused");
target.ForwardOpens.ShouldBe(1);
target.ConnectedRequests.ShouldBe(0);
}
[Fact]
public async Task A_small_connection_size_uses_a_regular_open()
{
await using var target = new FakeCipTarget();
var time = new ManualTimeProvider();
await using var conn = Create(target, Options(size: 504), time);
await conn.SendAsync(ReadTag, TestCt);
target.ForwardOpenRequests.Single()[0].ShouldBe((byte)0x54);
conn.MaxRequestBytes.ShouldBe(502);
}
// ---- routing (B3) ----------------------------------------------------------------------------
[Fact]
public async Task Direct_routing_sends_bare_cip_even_with_a_path()
{
await using var target = new FakeCipTarget();
var time = new ManualTimeProvider();
await using var conn = Create(target, Options(CipRouting.Direct), time, path: "1,0");
var reply = await conn.SendAsync(ReadTag, TestCt);
CipReply.Parse(reply).GeneralStatus.ShouldBe((byte)0);
target.DirectCips.Single().ShouldBe(ReadTag);
target.ForwardOpens.ShouldBe(0);
target.UnconnectedSends.ShouldBe(0);
target.ConnectedRequests.ShouldBe(0);
conn.MaxRequestBytes.ShouldBe(504);
}
[Fact]
public async Task UnconnectedSend_routing_wraps_in_0x52()
{
await using var target = new FakeCipTarget();
var time = new ManualTimeProvider();
await using var conn = Create(target, Options(CipRouting.UnconnectedSend), time, path: "1,0");
await conn.SendAsync(ReadTag, TestCt);
target.UnconnectedEmbedded.Single().ShouldBe(ReadTag);
var raw = FakeEipServer.RequestCip(target.Server.Received.Last(f => FakeEipServer.Command(f) == 0x006F));
raw.ShouldBe(ConnectionManagerServices.UnconnectedSend(ReadTag, new byte[] { 0x01, 0x00 }));
target.ForwardOpens.ShouldBe(0);
target.DirectRequests.ShouldBe(0);
conn.MaxRequestBytes.ShouldBe(504 - (12 + 2));
}
[Fact]
public async Task Connected_routing_never_sends_bare()
{
await using var target = new FakeCipTarget();
var time = new ManualTimeProvider();
await using var conn = Create(target, Options(), time);
for (var i = 0; i < 3; i++) await conn.SendAsync(ReadTag, TestCt);
target.ConnectedCips.Count.ShouldBe(3);
target.ConnectedCips.ShouldAllBe(c => c.SequenceEqual(ReadTag));
target.ForwardOpens.ShouldBe(1);
target.DirectRequests.ShouldBe(0);
target.UnconnectedSends.ShouldBe(0);
conn.MaxRequestBytes.ShouldBe(4000);
}
[Fact]
public async Task Connected_routing_with_an_empty_route_opens_straight_to_the_message_router()
{
// R9-1: Micro800 is Connected with an empty route; libplctag's Forward Open path is just 20 02 24 01.
await using var target = new FakeCipTarget();
var time = new ManualTimeProvider();
await using var conn = Create(target, Options(size: 504), time, path: "");
await conn.SendAsync(ReadTag, TestCt);
var fo = target.ForwardOpenRequests.Single();
fo[^6..].ShouldBe(new byte[] { 0xA3, 0x02, 0x20, 0x02, 0x24, 0x01 });
target.ConnectedRequests.ShouldBe(1);
}
// ---- reconnect -------------------------------------------------------------------------------
[Fact]
public async Task Caller_cancellation_does_not_reconnect()
{
await using var target = new FakeCipTarget();
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var held = 0;
target.ConnectedHandler = async cip =>
{
if (Interlocked.Increment(ref held) == 1) await release.Task;
return [(byte)(cip[0] | 0x80), 0, 0, 0];
};
var time = new ManualTimeProvider();
await using var conn = Create(target, Options(), time);
var before = Volatile.Read(ref CipConnection.ForwardOpenCount);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestCt);
var pending = conn.SendAsync(ReadTag, cts.Token);
await FakeCipTarget.Until(() => target.ConnectedRequests == 1, Guard, "the connected request");
await cts.CancelAsync();
await Should.ThrowAsync<OperationCanceledException>(() => pending);
release.SetResult();
await conn.SendAsync(ReadTag, TestCt);
(Volatile.Read(ref CipConnection.ForwardOpenCount) - before).ShouldBe(1);
target.ForwardOpens.ShouldBe(1);
target.Server.Connections.ShouldBe(1);
target.ConnectedRequests.ShouldBe(2);
}
[Fact]
public async Task Timeout_reconnects_through_the_throttle()
{
await using var target = new FakeCipTarget();
var answer = 1;
target.ConnectedHandler = cip => Task.FromResult<byte[]?>(
Volatile.Read(ref answer) == 1 ? [(byte)(cip[0] | 0x80), 0, 0, 0] : null);
var time = new ManualTimeProvider();
await using var conn = Create(target, Options(timeoutSeconds: 0.3), time);
await conn.SendAsync(ReadTag, TestCt); // first Forward Open, at fake t0
Volatile.Write(ref answer, 0);
var timedOut = await Should.ThrowAsync<AbTransportException>(() => conn.SendAsync(ReadTag, TestCt));
timedOut.Status.ShouldBe(AbStatus.Timeout); // the failing call keeps its own error
target.ForwardOpens.ShouldBe(1);
Volatile.Write(ref answer, 1);
var reconnect = conn.SendAsync(ReadTag, TestCt);
await time.WaitForTimerDueAtAsync(time.Start + TimeSpan.FromSeconds(1), Guard); // parked in the throttle
target.ForwardOpens.ShouldBe(1);
reconnect.IsCompleted.ShouldBeFalse();
time.Advance(TimeSpan.FromSeconds(1));
await reconnect.WaitAsync(Guard, TestCt);
target.ForwardOpens.ShouldBe(2);
target.Server.Connections.ShouldBe(2);
// A fresh connection serial per attempt, so the target never sees a duplicate of the stale connection.
var serials = target.ForwardOpenRequests.Select(f => BinaryPrimitives.ReadUInt16LittleEndian(f.AsSpan(16))).ToArray();
serials[1].ShouldNotBe(serials[0]);
}
[Fact]
public async Task A_stalled_open_is_bounded_by_the_deadline()
{
// R9-3: no caller token at all, and the target never answers the Forward Open.
await using var target = new FakeCipTarget();
var time = new ManualTimeProvider();
await using var conn = Create(target, Options(timeoutSeconds: 30), time);
target.Server.Stall = true;
var send = conn.SendAsync(ReadTag, CancellationToken.None);
await FakeCipTarget.Until(() => target.ForwardOpens == 0 && target.Server.Received.Count >= 2, Guard, "the Forward Open frame");
var openBudget = TimeSpan.FromSeconds(1) + 4 * TimeSpan.FromSeconds(30);
await time.WaitForTimerDueAtAsync(time.Start + openBudget, Guard);
time.Advance(openBudget);
var ex = await Should.ThrowAsync<AbTransportException>(() => send.WaitAsync(Guard, TestCt));
ex.Status.ShouldBe(AbStatus.Timeout);
}
// ---- keep-alive ------------------------------------------------------------------------------
[Fact]
public async Task Idle_connection_sends_one_keepalive_per_interval()
{
await using var target = new FakeCipTarget();
var time = new ManualTimeProvider();
await using var conn = Create(target, Options(), time);
var before = Volatile.Read(ref CipConnection.RequestsSent);
await conn.SendAsync(ReadTag, TestCt);
time.Advance(TimeSpan.FromSeconds(8));
conn.KeepAlivesSent.ShouldBe(1);
await FakeCipTarget.Until(() => target.ConnectedRequests == 2, Guard, "the keep-alive");
target.ConnectedCips[1].ShouldBe(CipConnection.KeepAliveRequest);
await time.WaitForTimerDueAtAsync(time.Start + TimeSpan.FromSeconds(16), Guard); // re-armed
time.Advance(TimeSpan.FromSeconds(7));
conn.KeepAlivesSent.ShouldBe(1); // exactly one per idle interval
time.Advance(TimeSpan.FromSeconds(1));
conn.KeepAlivesSent.ShouldBe(2);
await FakeCipTarget.Until(() => target.ConnectedRequests == 3, Guard, "the second keep-alive");
(Volatile.Read(ref CipConnection.RequestsSent) - before).ShouldBe(3); // keep-alives count
target.ForwardOpens.ShouldBe(1);
}
[Fact]
public async Task Traffic_suppresses_keepalive()
{
await using var target = new FakeCipTarget();
var time = new ManualTimeProvider();
await using var conn = Create(target, Options(), time);
await conn.SendAsync(ReadTag, TestCt);
for (var i = 0; i < 4; i++)
{
time.Advance(TimeSpan.FromSeconds(5));
await conn.SendAsync(ReadTag, TestCt);
}
conn.KeepAlivesSent.ShouldBe(0);
target.ConnectedCips.ShouldAllBe(c => c.SequenceEqual(ReadTag));
target.ConnectedRequests.ShouldBe(5);
}
[Fact]
public async Task No_keepalive_is_sent_when_the_connection_was_never_opened()
{
await using var target = new FakeCipTarget();
var time = new ManualTimeProvider();
await using var conn = Create(target, Options(), time);
time.Advance(TimeSpan.FromSeconds(30));
conn.KeepAlivesSent.ShouldBe(0);
target.ForwardOpens.ShouldBe(0);
target.Server.Connections.ShouldBe(0);
}
// ---- disposal --------------------------------------------------------------------------------
[Fact]
public async Task Send_after_dispose_throws_Abort()
{
await using var target = new FakeCipTarget();
var time = new ManualTimeProvider();
var conn = Create(target, Options(), time);
await conn.SendAsync(ReadTag, TestCt);
await conn.DisposeAsync();
var ex = await Should.ThrowAsync<AbTransportException>(() => conn.SendAsync(ReadTag, TestCt));
ex.Status.ShouldBe(AbStatus.Abort);
target.ForwardCloses.ShouldBe(1);
await conn.DisposeAsync(); // idempotent
}
[Fact]
public async Task Callers_queued_behind_an_open_get_Abort_when_disposed()
{
await using var target = new FakeCipTarget { ForwardOpenReply = FakeCipTarget.Refuse(0x01, 0x0113) };
var time = new ManualTimeProvider();
var conn = Create(target, Options(), time);
await Should.ThrowAsync<AbTransportException>(() => conn.SendAsync(ReadTag, TestCt)); // attempt 1, t0
var parked = conn.SendAsync(ReadTag, TestCt); // waits in the throttle
await time.WaitForTimerDueAtAsync(time.Start + TimeSpan.FromSeconds(1), Guard);
var queued = Enumerable.Range(0, 5).Select(_ => conn.SendAsync(ReadTag, TestCt)).ToArray();
var dispose = conn.DisposeAsync().AsTask();
time.Advance(TimeSpan.FromSeconds(1));
(await Should.ThrowAsync<AbTransportException>(() => parked.WaitAsync(Guard, TestCt))).Status.ShouldBe(AbStatus.Abort);
foreach (var q in queued)
(await Should.ThrowAsync<AbTransportException>(() => q.WaitAsync(Guard, TestCt))).Status.ShouldBe(AbStatus.Abort);
await dispose.WaitAsync(Guard, TestCt);
target.ForwardOpens.ShouldBe(1); // nothing is opened once disposal has begun
}
}