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.Eip; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Cip; /// Serializes the loopback-socket test classes. Their diagnostic counters are per connection / per /// pool since Gitea #581 (no longer process-wide statics), but still /// reconfigures 's logger, and the timing-sensitive storm test is kept /// off a thread pool its neighbours are saturating. [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 = conn.Diagnostics.ForwardOpens; 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); (conn.Diagnostics.ForwardOpens - 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 A_spent_open_attempt_budget_refuses_the_reconnect_before_any_io() { // Task 18 review r1 (C1): the differential harness caps the press at one open attempt, so a press // that refuses the Forward Open sees one TCP connect and one Forward Open, never one per remaining tag. await using var target = new FakeCipTarget { ForwardOpenReply = FakeCipTarget.Refuse(0x01, 0x0100) }; var time = new ManualTimeProvider(); await using var conn = Create(target, Options(size: 504), time); using (conn.Diagnostics.LimitOpenAttempts(Key(target), 1)) { await Should.ThrowAsync(() => conn.SendAsync(ReadTag, TestCt)); var retry = conn.SendAsync(ReadTag, TestCt); await time.WaitForTimerDueAtAsync(time.Start + TimeSpan.FromSeconds(1), Guard); // parked in the throttle time.Advance(TimeSpan.FromSeconds(1)); var refused = await Should.ThrowAsync(() => retry.WaitAsync(Guard, TestCt)); refused.Status.ShouldBe(AbStatus.Abort); refused.Message.ShouldContain("budget"); target.Server.Connections.ShouldBe(1); target.ForwardOpens.ShouldBe(1); conn.Diagnostics.OpenAttempts.ShouldBe(2); // the refused one is counted too } } [Fact] public async Task An_open_attempt_limit_is_scoped_to_one_device_and_lifted_by_its_scope() { // Gitea #566 / #581: the limit is per DeviceKey on one pool's diagnostics, not a process-wide static, so // limiting the press under test can never refuse an open to any other device. await using var limited = new FakeCipTarget(); await using var other = new FakeCipTarget(); var time = new ManualTimeProvider(); var pool = new DeviceSessionPool(time); var limitedKey = Key(limited); using (pool.Diagnostics.LimitOpenAttempts(limitedKey, 0)) { await using var otherLease = pool.Acquire(Key(other), Options()); (await otherLease.Scheduler.SendAsync(ReadTag, TestCt)).GeneralStatus.ShouldBe((byte)0); await using var limitedLease = pool.Acquire(limitedKey, Options()); var refused = await Should.ThrowAsync(() => limitedLease.Scheduler.SendAsync(ReadTag, TestCt)); refused.Status.ShouldBe(AbStatus.Abort); refused.Message.ShouldContain("budget"); Should.Throw(() => pool.Diagnostics.LimitOpenAttempts(limitedKey, 5)); // one at a time } pool.Diagnostics.HasOpenAttemptLimit(limitedKey).ShouldBeFalse(); time.Advance(TimeSpan.FromSeconds(5)); // the refused attempt still started the device's throttle interval await using var lifted = pool.Acquire(limitedKey, Options()); (await lifted.Scheduler.SendAsync(ReadTag, TestCt).WaitAsync(Guard, TestCt)).GeneralStatus.ShouldBe((byte)0); limited.ForwardOpens.ShouldBe(1); // the refused attempt never reached the wire; the lifted one did other.ForwardOpens.ShouldBe(1); } private static DeviceKey Key(FakeCipTarget target) => new("127.0.0.1", target.Port, "1,0", "ControlLogix"); [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 = conn.Diagnostics.ForwardOpens; 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); (conn.Diagnostics.ForwardOpens - 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 = conn.Diagnostics.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"); (conn.Diagnostics.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)); // Positive control (#583): the keep-alive timer really ticked (at 8, 16 and 24 s) and re-armed itself, so // the zero below is the timer declining to send, not a timer that never ran. time.HasTimerDueAt(time.Start + TimeSpan.FromSeconds(32)).ShouldBeTrue(); 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 } [Fact] public async Task Disposing_during_the_session_registration_never_puts_a_Forward_Open_on_the_wire() { // Gitea #566: disposal that begins while the TCP connect / RegisterSession is on the wire lets that step // finish (it is bounded), then releases the session instead of sending a Forward Open nobody would close. await using var target = new FakeCipTarget(); var registerHeld = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); target.Server.RegisterSessionGate = () => registerHeld.Task; var time = new ManualTimeProvider(); var conn = Create(target, Options(), time); var send = conn.SendAsync(ReadTag, TestCt); await target.Server.WaitForFrameAsync(f => FakeEipServer.Command(f) == EncapCommand.RegisterSession, Guard); var dispose = conn.DisposeAsync().AsTask(); conn.OpenInProgress.ShouldBeTrue(); // disposal is waiting out the attempt on the wire... dispose.IsCompleted.ShouldBeFalse(); // ...rather than abandoning it registerHeld.SetResult(); (await Should.ThrowAsync(() => send.WaitAsync(Guard, TestCt))).Status.ShouldBe(AbStatus.Abort); await dispose.WaitAsync(Guard, TestCt); // The session is released (positive control); the server records frames in arrival order, so a Forward // Open sent before the UnRegisterSession would already be counted. await target.Server.WaitForFrameAsync(f => FakeEipServer.Command(f) == EncapCommand.UnRegisterSession, Guard); target.ForwardOpens.ShouldBe(0); target.ForwardCloses.ShouldBe(0); conn.Diagnostics.OpenAttempts.ShouldBe(1); } private const ushort AbandonedOpenRefusal = 0x7A11; // an extended status no other test uses [Fact] public async Task An_open_every_waiter_abandoned_leaves_no_unobserved_task_exception() { // Gitea #566: the shared open fails AFTER its only waiter gave up (its own token). Nothing awaits the // open's task then; without the observer it would surface as UnobservedTaskException at finalization. var unobserved = await UnobservedTaskProbe.CollectAsync(AbandonAFailingOpenAsync, Guard); unobserved.ShouldNotContain(e => e.Message.Contains($"0x{AbandonedOpenRefusal:X4}")); } // Kept out of the test body so no local in the test's own state machine roots the abandoned open. [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] private static async Task AbandonAFailingOpenAsync() { await using var target = new FakeCipTarget { ForwardOpenReply = FakeCipTarget.Refuse(0x01, AbandonedOpenRefusal) }; var openHeld = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); target.ForwardOpenGate = () => openHeld.Task; var time = new ManualTimeProvider(); await using var conn = Create(target, Options(size: 504), time); using (var cts = new CancellationTokenSource()) { var waiter = conn.SendAsync(ReadTag, cts.Token); await FakeCipTarget.Until(() => target.ForwardOpens == 1, Guard, "the Forward Open"); await cts.CancelAsync(); await Should.ThrowAsync(() => waiter); } openHeld.SetResult(); // the target refuses, after every waiter has gone await FakeCipTarget.Until(() => !conn.OpenInProgress, Guard, "the abandoned open to fail"); } // ---- CIP-level close (Gitea #566) ------------------------------------------------------------ private static byte[] Ok(byte[] cip) => [(byte)(cip[0] | 0x80), 0, 0, 0]; private static byte[] Status(byte[] cip, byte general, ushort extended) => [(byte)(cip[0] | 0x80), 0, general, 1, (byte)extended, (byte)(extended >> 8)]; [Theory] [InlineData(ConnectionManagerServices.TargetConnectionNotFound)] [InlineData(ConnectionManagerServices.ConnectionTimedOut)] public async Task A_reply_saying_the_CIP_connection_is_gone_retires_it_and_the_next_send_reopens(ushort extended) { // The target dropped the class-3 connection but the TCP session is still up: every later connected request // would get the same answer with the session looking healthy, so the connection is recycled at once. await using var target = new FakeCipTarget(); var gone = 0; target.ConnectedHandler = cip => Task.FromResult( Interlocked.Exchange(ref gone, 0) == 1 ? Status(cip, 0x01, extended) : Ok(cip)); var time = new ManualTimeProvider(); await using var conn = Create(target, Options(), time); await conn.SendAsync(ReadTag, TestCt); Volatile.Write(ref gone, 1); var ex = await Should.ThrowAsync(() => conn.SendAsync(ReadTag, TestCt)); ex.Status.ShouldBe(AbStatus.BadConnection); ex.Message.ShouldContain($"0x{extended:X4}"); conn.Diagnostics.ConnectionsClosedByTarget.ShouldBe(1); await target.Server.WaitForFrameAsync(f => FakeEipServer.Command(f) == EncapCommand.UnRegisterSession, Guard); await FakeCipTarget.Until(() => target.Server.OpenConnections == 0, Guard, "the session to close"); target.ForwardCloses.ShouldBe(0); // the target just said there is nothing to close var reopen = conn.SendAsync(ReadTag, TestCt); await time.WaitForTimerDueAtAsync(time.Start + TimeSpan.FromSeconds(1), Guard); // through the throttle time.Advance(TimeSpan.FromSeconds(1)); CipReply.Parse(await reopen.WaitAsync(Guard, TestCt)).GeneralStatus.ShouldBe((byte)0); target.ForwardOpens.ShouldBe(2); target.Server.Connections.ShouldBe(2); } [Fact] public async Task A_keepalive_told_the_connection_is_gone_recycles_it_before_any_request_fails() { await using var target = new FakeCipTarget(); target.ConnectedHandler = cip => Task.FromResult( cip.SequenceEqual(CipConnection.KeepAliveRequest) ? Status(cip, 0x01, 0x0107) : Ok(cip)); var time = new ManualTimeProvider(); await using var conn = Create(target, Options(), time); await conn.SendAsync(ReadTag, TestCt); time.Advance(TimeSpan.FromSeconds(8)); // idle: the keep-alive goes out and is told the connection is gone await FakeCipTarget.Until(() => conn.Diagnostics.ConnectionsClosedByTarget == 1, Guard, "the retirement"); await FakeCipTarget.Until(() => target.Server.OpenConnections == 0, Guard, "the session to close"); // The next request reopens (the throttle interval is long past) instead of failing on a dead connection. CipReply.Parse(await conn.SendAsync(ReadTag, TestCt).WaitAsync(Guard, TestCt)).GeneralStatus.ShouldBe((byte)0); target.ForwardOpens.ShouldBe(2); } [Fact] public async Task Other_error_statuses_describe_the_request_and_leave_the_connection_alone() { // Negative control for the recycle: a tag-level error and a connection-failure status that is not about // this connection come back as ordinary replies, and the next request rides the same connection. await using var target = new FakeCipTarget(); var replies = new Queue>( [ cip => Status(cip, 0x04, 0x0000), // path segment error: the tag cip => Status(cip, 0x01, 0x0204), // unconnected request timed out: not this connection Ok, ]); target.ConnectedHandler = cip => Task.FromResult(replies.Dequeue()(cip)); var time = new ManualTimeProvider(); await using var conn = Create(target, Options(), time); CipReply.Parse(await conn.SendAsync(ReadTag, TestCt)).GeneralStatus.ShouldBe((byte)0x04); CipReply.Parse(await conn.SendAsync(ReadTag, TestCt)).GeneralStatus.ShouldBe((byte)0x01); CipReply.Parse(await conn.SendAsync(ReadTag, TestCt)).GeneralStatus.ShouldBe((byte)0); // same connection conn.Diagnostics.ConnectionsClosedByTarget.ShouldBe(0); target.ForwardOpens.ShouldBe(1); target.Server.Connections.ShouldBe(1); } [Fact] public void Only_connection_failure_0107_and_0203_mean_the_connection_is_gone() { ConnectionManagerServices.IsConnectionGone([0xCC, 0, 0x01, 1, 0x07, 0x01]).ShouldBe((ushort)0x0107); ConnectionManagerServices.IsConnectionGone([0xCC, 0, 0x01, 1, 0x03, 0x02]).ShouldBe((ushort)0x0203); ConnectionManagerServices.IsConnectionGone([0xCC, 0, 0x01, 1, 0x04, 0x02]).ShouldBeNull(); // 0x0204 ConnectionManagerServices.IsConnectionGone([0xCC, 0, 0x01, 0]).ShouldBeNull(); // no extended word ConnectionManagerServices.IsConnectionGone([0xCC, 0, 0x05, 1, 0x07, 0x01]).ShouldBeNull(); // not 0x01 ConnectionManagerServices.IsConnectionGone([0xCC, 0, 0, 0]).ShouldBeNull(); } }