Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ForwardOpenThrottleTests.cs
T
Joseph Doherty 3edff3304b fix(ab-transport): per-pool diagnostics + per-device open-attempt limit, CIP-level close recycle, CipConnection/pool test gaps (#566, #581)
- Process-wide CipConnection statics (ForwardOpenCount, Forward Close counts, RequestsSent, exchange
  stats, OpenAttempts, OpenAttemptBudget) move to an instance ConnectionDiagnostics owned by each
  DeviceSessionPool (and by a stand-alone CipConnection). The open-attempt budget becomes a disposable
  per-DeviceKey limit (LimitOpenAttempts); the differential harness scopes one to the press under test.
- LibplctagOrphanRelease.ForceRootingForTest is gone: ReleaseIfOrphaned(tag, canReadHandles) overload.
- A connected reply with general 0x01 / extended 0x0107 or 0x0203 (target no longer holds the class-3
  connection, TCP still up) retires the session at once; the next send reopens through the throttle.
- New tests: dispose during RegisterSession, abandoned-open observer (falsified), scheduler-dispose-throws
  guard (DeviceSessionPool.CloseAsync), release-in-background with a shared open in flight, per-device
  limit scoping, CIP-close recycle (request + keep-alive + negative control).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:21:02 -04:00

178 lines
8.0 KiB
C#

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>Press safety: <see cref="ForwardOpenThrottle"/> on fake time, and the storm it exists to stop.</summary>
[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<int> 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<AbTransportException>(() => 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) { }
}
}