3edff3304b
- 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
178 lines
8.0 KiB
C#
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) { }
|
|
}
|
|
}
|