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; /// Press safety: on fake time, and the storm it exists to stop. [Collection(CipConnectionStatics.Name)] // the storm test moves CipConnection's static counters public sealed class ForwardOpenThrottleTests { private static readonly TimeSpan Second = TimeSpan.FromSeconds(1); private static readonly TimeSpan Guard = TimeSpan.FromSeconds(10); private static CancellationToken TestCt => TestContext.Current.CancellationToken; [Fact] public async Task Second_attempt_inside_interval_waits() { var time = new ManualTimeProvider(); var throttle = new ForwardOpenThrottle(Second, time); await throttle.RunAsync(_ => Task.FromResult(1), TestCt); time.Advance(TimeSpan.FromMilliseconds(400)); var started = 0; var second = throttle.RunAsync(_ => { Interlocked.Increment(ref started); return Task.FromResult(2); }, TestCt); await time.WaitForTimerDueAtAsync(time.Start + Second, Guard); // waits for the remainder, measured from attempt 1's start time.Advance(TimeSpan.FromMilliseconds(599)); Volatile.Read(ref started).ShouldBe(0); second.IsCompleted.ShouldBeFalse(); time.Advance(TimeSpan.FromMilliseconds(1)); (await second.WaitAsync(Guard, TestCt)).ShouldBe(2); Volatile.Read(ref started).ShouldBe(1); } [Fact] public async Task Attempt_after_interval_proceeds_immediately() { var time = new ManualTimeProvider(); var throttle = new ForwardOpenThrottle(Second, time); await throttle.RunAsync(_ => Task.FromResult(1), TestCt); time.Advance(Second); var started = false; var second = throttle.RunAsync(_ => { started = true; return Task.FromResult(2); }, TestCt); started.ShouldBeTrue(); // ran synchronously: no wait was needed second.IsCompletedSuccessfully.ShouldBeTrue(); time.ArmedTimers.ShouldBe(0); } [Fact] public async Task Only_one_attempt_in_flight() { var time = new ManualTimeProvider(); var throttle = new ForwardOpenThrottle(Second, time); var inFlight = 0; var maxInFlight = 0; var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); async Task Attempt(CancellationToken _) { var now = Interlocked.Increment(ref inFlight); InterlockedMax(ref maxInFlight, now); await release.Task; Interlocked.Decrement(ref inFlight); return now; } var first = throttle.RunAsync(Attempt, TestCt); time.Advance(TimeSpan.FromSeconds(5)); // the interval is no obstacle; only the in-flight one is var others = Enumerable.Range(0, 4).Select(_ => throttle.RunAsync(Attempt, TestCt)).ToArray(); // Positive control (#583): all four have reached the throttle and are parked behind the in-flight attempt. await FakeCipTarget.Until(() => throttle.Queued == 4, Guard, "four callers queued behind the first"); Volatile.Read(ref inFlight).ShouldBe(1); others.ShouldAllBe(t => !t.IsCompleted); release.SetResult(); await first.WaitAsync(Guard, TestCt); // Attempt 2 starts at once (attempt 1 started 5 s ago); each later one then waits out the // interval from its predecessor's start. await others[0].WaitAsync(Guard, TestCt); for (var i = 1; i < others.Length; i++) { await time.WaitForTimerDueAtAsync(time.GetUtcNow() + Second, Guard); time.Advance(Second); await FakeCipTarget.Until(() => others.Count(t => t.IsCompleted) == i + 1, Guard, $"attempt {i + 2}"); } Volatile.Read(ref maxInFlight).ShouldBe(1); } [Fact] public async Task Caller_deadline_while_waiting_throws_throttled() { var time = new ManualTimeProvider(); var throttle = new ForwardOpenThrottle(Second, time); await throttle.RunAsync(_ => Task.FromResult(1), TestCt); using var cts = new CancellationTokenSource(); var invoked = false; var second = throttle.RunAsync(_ => { invoked = true; return Task.FromResult(2); }, cts.Token); await time.WaitForTimerDueAtAsync(time.Start + Second, Guard); await cts.CancelAsync(); var ex = await Should.ThrowAsync(() => second.WaitAsync(Guard, TestCt)); ex.Status.ShouldBe(AbStatus.BadConnection); ex.Message.ShouldContain("Forward Open throttled"); invoked.ShouldBeFalse(); } [Fact] public async Task A_wall_clock_step_does_not_shorten_the_interval() { var time = new ManualTimeProvider(); var throttle = new ForwardOpenThrottle(Second, time); await throttle.RunAsync(_ => Task.FromResult(1), TestCt); time.WallClockOffset = TimeSpan.FromHours(1); // NTP / DST / operator steps the clock forward var started = 0; var second = throttle.RunAsync(_ => { Interlocked.Increment(ref started); return Task.FromResult(2); }, TestCt); await time.WaitForTimerDueAtAsync(time.Start + Second, Guard); // still parked for the full interval Volatile.Read(ref started).ShouldBe(0); time.Advance(Second); (await second.WaitAsync(Guard, TestCt)).ShouldBe(2); } [Fact] public async Task Refusing_target_sees_at_most_one_forward_open_per_interval() { // The storm: 50 callers hammering a device that refuses every Forward Open, for 3 s of fake time. // Attempt starts are >= 1 s apart, so t = 0, 1, 2, 3 is the most there can be. await using var target = new FakeCipTarget { ForwardOpenReply = FakeCipTarget.Refuse(0x01, 0x0113) }; var time = new ManualTimeProvider(); var options = new ConnectionOptions { ConnectionSize = 504, Timeout = TimeSpan.FromSeconds(2) }; var throttle = new ForwardOpenThrottle(options.MinForwardOpenInterval, time); var conn = new CipConnection(new DeviceKey("127.0.0.1", target.Port, "1,0", "ControlLogix"), options, throttle, time); using var stop = CancellationTokenSource.CreateLinkedTokenSource(TestCt); var failures = 0; var callers = Enumerable.Range(0, 50).Select(_ => Task.Run(async () => { while (!stop.IsCancellationRequested) { try { await conn.SendAsync(new byte[] { 0x4C, 0x00 }, stop.Token); } catch (AbTransportException) { Interlocked.Increment(ref failures); } catch (OperationCanceledException) { /* stopping */ } } }, TestCt)).ToArray(); for (var step = 0; step < 30; step++) { await Task.Delay(10, TestCt); time.Advance(TimeSpan.FromMilliseconds(100)); } // Positive control (#583), instead of a fixed settle: wait until the next attempt is parked on the interval. // Then no attempt is in flight and none can start until the clock moves, so every attempt the last step // released has already reached the wire and been counted. await FakeCipTarget.Until(() => throttle.IsWaitingOutInterval, Guard, "the next attempt to park on the interval"); await stop.CancelAsync(); await Task.WhenAll(callers).WaitAsync(Guard, TestCt); await conn.DisposeAsync(); time.GetUtcNow().ShouldBe(time.Start + TimeSpan.FromSeconds(3)); target.ForwardOpens.ShouldBeLessThanOrEqualTo(4); target.ForwardOpens.ShouldBeGreaterThanOrEqualTo(1); Volatile.Read(ref failures).ShouldBeGreaterThanOrEqualTo(target.ForwardOpens); target.ConnectedRequests.ShouldBe(0); } private static void InterlockedMax(ref int location, int value) { int current; while (value > (current = Volatile.Read(ref location)) && Interlocked.CompareExchange(ref location, value, current) != current) { } } }