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; /// Serializes every test class that asserts on 's process-wide /// diagnostic counters, so no parallel test can move them mid-assertion. [CollectionDefinition(Name, DisableParallelization = true)] public sealed class CipConnectionStatics { public const string Name = "CipConnection statics"; } /// against . Wire I/O is real loopback; /// the throttle, deadlines and keep-alive run on . [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 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(() => new ConnectionOptions { Timeout = timeout }); [Fact] public void Options_reject_a_forward_open_interval_below_one_second() => Should.Throw(() => 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(() => 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(() => 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(() => 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( 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(() => 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(() => 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(() => 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(() => 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(() => parked.WaitAsync(Guard, TestCt))).Status.ShouldBe(AbStatus.Abort); foreach (var q in queued) (await Should.ThrowAsync(() => q.WaitAsync(Guard, TestCt))).Status.ShouldBe(AbStatus.Abort); await dispose.WaitAsync(Guard, TestCt); target.ForwardOpens.ShouldBe(1); // nothing is opened once disposal has begun } }