386 lines
17 KiB
C#
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
|
|
}
|
|
}
|