feat(ab-transport): per-device connection with routing modes, shared scheduler, keep-alive and Forward Open throttle
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
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;
|
||||
|
||||
/// <summary>Serializes every test class that asserts on <see cref="CipConnection"/>'s process-wide
|
||||
/// diagnostic counters, so no parallel test can move them mid-assertion.</summary>
|
||||
[CollectionDefinition(Name, DisableParallelization = true)]
|
||||
public sealed class CipConnectionStatics
|
||||
{
|
||||
public const string Name = "CipConnection statics";
|
||||
}
|
||||
|
||||
/// <summary><see cref="CipConnection"/> against <see cref="FakeCipTarget"/>. Wire I/O is real loopback;
|
||||
/// the throttle, deadlines and keep-alive run on <see cref="ManualTimeProvider"/>.</summary>
|
||||
[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<TimeSpan> 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<ArgumentOutOfRangeException>(() => new ConnectionOptions { Timeout = timeout });
|
||||
|
||||
[Fact]
|
||||
public void Options_reject_a_forward_open_interval_below_one_second() =>
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => 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<AbTransportException>(() => 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<AbTransportException>(() => 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<OperationCanceledException>(() => 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<byte[]?>(
|
||||
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<AbTransportException>(() => 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<AbTransportException>(() => 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<AbTransportException>(() => 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<AbTransportException>(() => 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<AbTransportException>(() => parked.WaitAsync(Guard, TestCt))).Status.ShouldBe(AbStatus.Abort);
|
||||
foreach (var q in queued)
|
||||
(await Should.ThrowAsync<AbTransportException>(() => q.WaitAsync(Guard, TestCt))).Status.ShouldBe(AbStatus.Abort);
|
||||
await dispose.WaitAsync(Guard, TestCt);
|
||||
target.ForwardOpens.ShouldBe(1); // nothing is opened once disposal has begun
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
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();
|
||||
await Task.Delay(50, TestCt);
|
||||
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 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 conn = new CipConnection(new DeviceKey("127.0.0.1", target.Port, "1,0", "ControlLogix"), options,
|
||||
new ForwardOpenThrottle(options.MinForwardOpenInterval, time), 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));
|
||||
}
|
||||
await Task.Delay(100, TestCt); // let any attempt released by the last step reach the wire
|
||||
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
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.Cip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests;
|
||||
|
||||
[Collection(CipConnectionStatics.Name)]
|
||||
public sealed class DeviceSessionPoolTests
|
||||
{
|
||||
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 ConnectionOptions Options(int size = 4002) => new() { ConnectionSize = size, Timeout = TimeSpan.FromSeconds(2) };
|
||||
|
||||
[Fact]
|
||||
public void Device_keys_compare_host_and_plc_attribute_case_insensitively()
|
||||
{
|
||||
var a = new DeviceKey("Press-07.plant.local", 44818, "1,0", "ControlLogix");
|
||||
var b = new DeviceKey("PRESS-07.PLANT.LOCAL", 44818, "1,0", "controllogix");
|
||||
a.ShouldBe(b);
|
||||
a.GetHashCode().ShouldBe(b.GetHashCode());
|
||||
a.ShouldNotBe(a with { CipPath = "1,1" });
|
||||
a.ShouldNotBe(a with { Port = 2222 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Leases_for_one_key_share_one_scheduler()
|
||||
{
|
||||
var pool = new DeviceSessionPool(new ManualTimeProvider());
|
||||
var key = new DeviceKey("plc1", 44818, "1,0", "ControlLogix");
|
||||
|
||||
await using var a = pool.Acquire(key, Options());
|
||||
await using var b = pool.Acquire(key with { Host = "PLC1" }, Options());
|
||||
await using var other = pool.Acquire(key with { CipPath = "1,2" }, Options());
|
||||
|
||||
b.Scheduler.ShouldBeSameAs(a.Scheduler);
|
||||
b.Channel.ShouldBeSameAs(a.Channel);
|
||||
other.Scheduler.ShouldNotBeSameAs(a.Scheduler);
|
||||
other.Channel.ShouldNotBeSameAs(a.Channel);
|
||||
pool.EntryCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_second_acquire_with_different_options_reuses_the_first_and_logs_once()
|
||||
{
|
||||
var logger = new CapturingLogger();
|
||||
var pool = new DeviceSessionPool(new ManualTimeProvider(), logger);
|
||||
var key = new DeviceKey("plc1", 44818, "1,0", "ControlLogix");
|
||||
var first = Options(4002);
|
||||
var second = Options(504) with { AllowPacking = true };
|
||||
|
||||
await using var a = pool.Acquire(key, first);
|
||||
await using var b = pool.Acquire(key, second);
|
||||
await using var c = pool.Acquire(key, second);
|
||||
await using var d = pool.Acquire(key, first);
|
||||
|
||||
((CipConnection)b.Channel).Options.ShouldBe(first);
|
||||
((CipConnection)c.Channel).Options.ShouldBe(first);
|
||||
logger.Warnings.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Last_lease_dispose_sends_forward_close_then_unregister()
|
||||
{
|
||||
await using var target = new FakeCipTarget();
|
||||
var pool = new DeviceSessionPool(new ManualTimeProvider());
|
||||
var key = new DeviceKey("127.0.0.1", target.Port, "1,0", "ControlLogix");
|
||||
var a = pool.Acquire(key, Options());
|
||||
var b = pool.Acquire(key, Options());
|
||||
(await a.Scheduler.SendAsync(ReadTag, TestCt)).GeneralStatus.ShouldBe((byte)0);
|
||||
|
||||
await a.DisposeAsync();
|
||||
await a.DisposeAsync(); // idempotent: must not release b's reference
|
||||
target.ForwardCloses.ShouldBe(0);
|
||||
(await b.Scheduler.SendAsync(ReadTag, TestCt)).GeneralStatus.ShouldBe((byte)0);
|
||||
|
||||
await b.DisposeAsync();
|
||||
await target.Server.WaitForFrameAsync(f => FakeEipServer.Command(f) == EncapCommand.UnRegisterSession, Guard);
|
||||
|
||||
target.ForwardCloses.ShouldBe(1);
|
||||
var frames = target.Server.Received.ToList();
|
||||
var close = frames.FindIndex(f => FakeEipServer.Command(f) == EncapCommand.SendRRData
|
||||
&& FakeCipTarget.IsForwardClose(FakeEipServer.RequestCip(f)));
|
||||
var unregister = frames.FindIndex(f => FakeEipServer.Command(f) == EncapCommand.UnRegisterSession);
|
||||
close.ShouldBeGreaterThanOrEqualTo(0);
|
||||
close.ShouldBeLessThan(unregister);
|
||||
// The Forward Close names the connection that was opened.
|
||||
target.ForwardCloseRequests.Single().AsSpan(8, 8).ToArray()
|
||||
.ShouldBe(target.ForwardOpenRequests.Single().AsSpan(16, 8).ToArray());
|
||||
pool.EntryCount.ShouldBe(0);
|
||||
await Should.ThrowAsync<ObjectDisposedException>(() => b.Scheduler.SendAsync(ReadTag, TestCt));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_later_acquire_builds_new_instances()
|
||||
{
|
||||
var pool = new DeviceSessionPool(new ManualTimeProvider());
|
||||
var key = new DeviceKey("plc1", 44818, "1,0", "ControlLogix");
|
||||
var first = pool.Acquire(key, Options());
|
||||
var scheduler = first.Scheduler;
|
||||
var channel = first.Channel;
|
||||
await first.DisposeAsync();
|
||||
|
||||
await using var second = pool.Acquire(key, Options());
|
||||
|
||||
second.Scheduler.ShouldNotBeSameAs(scheduler);
|
||||
second.Channel.ShouldNotBeSameAs(channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reconnect_after_the_last_release_is_still_throttled()
|
||||
{
|
||||
await using var target = new FakeCipTarget();
|
||||
var time = new ManualTimeProvider();
|
||||
var pool = new DeviceSessionPool(time);
|
||||
var key = new DeviceKey("127.0.0.1", target.Port, "1,0", "ControlLogix");
|
||||
|
||||
await using (var first = pool.Acquire(key, Options()))
|
||||
await first.Scheduler.SendAsync(ReadTag, TestCt); // Forward Open at fake t0
|
||||
|
||||
await using var second = pool.Acquire(key, Options());
|
||||
var send = second.Scheduler.SendAsync(ReadTag, TestCt);
|
||||
await time.WaitForTimerDueAtAsync(time.Start + TimeSpan.FromSeconds(1), Guard);
|
||||
target.ForwardOpens.ShouldBe(1);
|
||||
|
||||
time.Advance(TimeSpan.FromSeconds(1));
|
||||
await send.WaitAsync(Guard, TestCt);
|
||||
target.ForwardOpens.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_acquire_and_release_is_safe()
|
||||
{
|
||||
var pool = new DeviceSessionPool(new ManualTimeProvider());
|
||||
var keys = Enumerable.Range(0, 3).Select(i => new DeviceKey($"plc{i}", 44818, "1,0", "ControlLogix")).ToArray();
|
||||
var schedulers = new System.Collections.Concurrent.ConcurrentDictionary<(int, RequestScheduler), byte>();
|
||||
|
||||
var tasks = Enumerable.Range(0, 100).Select(i => Task.Run(async () =>
|
||||
{
|
||||
var lease = pool.Acquire(keys[i % 3], Options());
|
||||
schedulers.TryAdd((i % 3, lease.Scheduler), 0);
|
||||
await Task.Yield();
|
||||
await lease.DisposeAsync();
|
||||
}, TestCt)).ToArray();
|
||||
await Task.WhenAll(tasks).WaitAsync(Guard, TestCt);
|
||||
|
||||
pool.EntryCount.ShouldBe(0);
|
||||
// Every lease got a scheduler for its own key only, never another key's.
|
||||
schedulers.Keys.GroupBy(k => k.Item2).ShouldAllBe(g => g.Select(k => k.Item1).Distinct().Count() == 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shared_is_one_process_wide_instance() =>
|
||||
DeviceSessionPool.Shared.ShouldBeSameAs(DeviceSessionPool.Shared);
|
||||
|
||||
private sealed class CapturingLogger : ILogger
|
||||
{
|
||||
private int _warnings;
|
||||
public int Warnings => Volatile.Read(ref _warnings);
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (logLevel == LogLevel.Warning) Interlocked.Increment(ref _warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Buffers.Binary;
|
||||
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.Wire;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// A CIP target on top of <see cref="FakeEipServer"/>: answers Forward Open / Forward Close /
|
||||
/// Unconnected Send / Direct (bare SendRRData) / connected SendUnitData requests and counts each kind.
|
||||
/// </summary>
|
||||
internal sealed class FakeCipTarget : IAsyncDisposable
|
||||
{
|
||||
public const uint OTConnectionId = 0xC0FFEE01;
|
||||
|
||||
private readonly List<byte[]> _forwardOpens = [];
|
||||
private readonly List<byte[]> _forwardCloses = [];
|
||||
private readonly List<byte[]> _unconnectedEmbedded = [];
|
||||
private readonly List<byte[]> _direct = [];
|
||||
private readonly List<byte[]> _connected = [];
|
||||
|
||||
public FakeCipTarget()
|
||||
{
|
||||
Server.HandlerAsync = HandleAsync;
|
||||
}
|
||||
|
||||
public FakeEipServer Server { get; } = new();
|
||||
|
||||
public int Port => Server.Port;
|
||||
|
||||
/// <summary>Forward Open request CIP → reply CIP. Defaults to <see cref="Accept"/>.</summary>
|
||||
public Func<byte[], byte[]> ForwardOpenReply { get; set; } = Accept;
|
||||
|
||||
/// <summary>Reply to an ordinary service request (Direct, embedded in Unconnected Send, or connected).
|
||||
/// Defaults to success with two data bytes (a Vendor ID, for the keep-alive).</summary>
|
||||
public Func<byte[], byte[]> ServiceReply { get; set; } = req => [(byte)(req[0] | 0x80), 0, 0, 0, 0x01, 0x00];
|
||||
|
||||
/// <summary>Connected requests only: may delay, or return null to never answer. Defaults to <see cref="ServiceReply"/>.</summary>
|
||||
public Func<byte[], Task<byte[]?>>? ConnectedHandler { get; set; }
|
||||
|
||||
public int ForwardOpens { get { lock (_forwardOpens) return _forwardOpens.Count; } }
|
||||
public int ForwardCloses { get { lock (_forwardCloses) return _forwardCloses.Count; } }
|
||||
public int UnconnectedSends { get { lock (_unconnectedEmbedded) return _unconnectedEmbedded.Count; } }
|
||||
public int DirectRequests { get { lock (_direct) return _direct.Count; } }
|
||||
public int ConnectedRequests { get { lock (_connected) return _connected.Count; } }
|
||||
|
||||
public IReadOnlyList<byte[]> ForwardOpenRequests { get { lock (_forwardOpens) return [.. _forwardOpens]; } }
|
||||
public IReadOnlyList<byte[]> ForwardCloseRequests { get { lock (_forwardCloses) return [.. _forwardCloses]; } }
|
||||
public IReadOnlyList<byte[]> UnconnectedEmbedded { get { lock (_unconnectedEmbedded) return [.. _unconnectedEmbedded]; } }
|
||||
public IReadOnlyList<byte[]> DirectCips { get { lock (_direct) return [.. _direct]; } }
|
||||
public IReadOnlyList<byte[]> ConnectedCips { get { lock (_connected) return [.. _connected]; } }
|
||||
|
||||
/// <summary>A successful (Large) Forward Open reply echoing the request's ids and serials.</summary>
|
||||
public static byte[] Accept(byte[] fo) => new CipWriter()
|
||||
.U8((byte)(fo[0] | 0x80)).U8(0).U8(0).U8(0)
|
||||
.U32(OTConnectionId)
|
||||
.U32(BinaryPrimitives.ReadUInt32LittleEndian(fo.AsSpan(12)))
|
||||
.U16(BinaryPrimitives.ReadUInt16LittleEndian(fo.AsSpan(16)))
|
||||
.U16(BinaryPrimitives.ReadUInt16LittleEndian(fo.AsSpan(18)))
|
||||
.U32(BinaryPrimitives.ReadUInt32LittleEndian(fo.AsSpan(20)))
|
||||
.U32(2_000_000).U32(2_000_000).U8(0).U8(0)
|
||||
.ToArray();
|
||||
|
||||
/// <summary>A refusal with the given general and extended status.</summary>
|
||||
public static Func<byte[], byte[]> Refuse(byte general, params ushort[] extended) => fo =>
|
||||
{
|
||||
var w = new CipWriter().U8((byte)(fo[0] | 0x80)).U8(0).U8(general).U8((byte)extended.Length);
|
||||
foreach (var e in extended) w.U16(e);
|
||||
return w.ToArray();
|
||||
};
|
||||
|
||||
public static bool IsForwardOpen(byte[] cip) => cip[0] is 0x54 or 0x5B && AddressesConnectionManager(cip);
|
||||
|
||||
public static bool IsForwardClose(byte[] cip) => cip[0] == 0x4E && AddressesConnectionManager(cip);
|
||||
|
||||
private static bool AddressesConnectionManager(byte[] cip) =>
|
||||
cip.Length >= 6 && cip.AsSpan(2, 4).SequenceEqual(CipPath.ConnectionManager);
|
||||
|
||||
private async Task<byte[]?> HandleAsync(byte[] frame)
|
||||
{
|
||||
var cip = FakeEipServer.RequestCip(frame);
|
||||
if (FakeEipServer.Command(frame) == EncapCommand.SendUnitData)
|
||||
{
|
||||
lock (_connected) _connected.Add(cip);
|
||||
var reply = ConnectedHandler is null ? ServiceReply(cip) : await ConnectedHandler(cip);
|
||||
return reply is null ? null : FakeEipServer.UnitReply(frame, reply);
|
||||
}
|
||||
|
||||
if (IsForwardOpen(cip))
|
||||
{
|
||||
lock (_forwardOpens) _forwardOpens.Add(cip);
|
||||
return FakeEipServer.RRReply(frame, ForwardOpenReply(cip));
|
||||
}
|
||||
if (IsForwardClose(cip))
|
||||
{
|
||||
lock (_forwardCloses) _forwardCloses.Add(cip);
|
||||
var w = new CipWriter().U8(0xCE).U8(0).U8(0).U8(0).Bytes(cip.AsSpan(8, 8)).U8(0).U8(0);
|
||||
return FakeEipServer.RRReply(frame, w.ToArray());
|
||||
}
|
||||
if (cip[0] == 0x52 && AddressesConnectionManager(cip))
|
||||
{
|
||||
var len = BinaryPrimitives.ReadUInt16LittleEndian(cip.AsSpan(8));
|
||||
var embedded = cip.AsSpan(10, len).ToArray();
|
||||
lock (_unconnectedEmbedded) _unconnectedEmbedded.Add(embedded);
|
||||
return FakeEipServer.RRReply(frame, ServiceReply(embedded));
|
||||
}
|
||||
|
||||
lock (_direct) _direct.Add(cip);
|
||||
return FakeEipServer.RRReply(frame, ServiceReply(cip));
|
||||
}
|
||||
|
||||
/// <summary>Polls (real time) until <paramref name="condition"/> holds.</summary>
|
||||
public static async Task Until(Func<bool> condition, TimeSpan timeout, string what)
|
||||
{
|
||||
var giveUp = DateTime.UtcNow + timeout;
|
||||
while (!condition())
|
||||
{
|
||||
if (DateTime.UtcNow > giveUp) throw new TimeoutException($"Timed out waiting for {what}.");
|
||||
await Task.Delay(5);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => Server.DisposeAsync();
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic <see cref="TimeProvider"/>: time moves only on <see cref="Advance"/>, which fires every
|
||||
/// timer falling due on the way, in due order, synchronously on the calling thread.
|
||||
/// (Microsoft.Extensions.TimeProvider.Testing is not pinned in this repo and no package may be added.)
|
||||
/// </summary>
|
||||
internal sealed class ManualTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly List<ManualTimer> _timers = [];
|
||||
private DateTimeOffset _now;
|
||||
|
||||
public ManualTimeProvider() => Start = _now = new DateTimeOffset(2026, 10, 7, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public DateTimeOffset Start { get; }
|
||||
|
||||
public override DateTimeOffset GetUtcNow() { lock (_lock) return _now; }
|
||||
|
||||
public override long GetTimestamp() => GetUtcNow().UtcTicks;
|
||||
|
||||
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
|
||||
|
||||
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
|
||||
{
|
||||
var t = new ManualTimer(this, callback, state);
|
||||
lock (_lock) _timers.Add(t);
|
||||
t.Change(dueTime, period);
|
||||
return t;
|
||||
}
|
||||
|
||||
/// <summary>Armed timers (not disposed, with a due time).</summary>
|
||||
public int ArmedTimers { get { lock (_lock) return _timers.Count(t => t.Due is not null); } }
|
||||
|
||||
public bool HasTimerDueAt(DateTimeOffset at) { lock (_lock) return _timers.Any(t => t.Due == at); }
|
||||
|
||||
/// <summary>Waits (in real time) until some code under test has armed a timer due at <paramref name="at"/>,
|
||||
/// i.e. is genuinely parked on the fake clock rather than merely not yet scheduled.</summary>
|
||||
public async Task WaitForTimerDueAtAsync(DateTimeOffset at, TimeSpan timeout)
|
||||
{
|
||||
var giveUp = DateTime.UtcNow + timeout;
|
||||
while (!HasTimerDueAt(at))
|
||||
{
|
||||
if (DateTime.UtcNow > giveUp) throw new TimeoutException($"No timer armed for {at:O} (now {GetUtcNow():O}).");
|
||||
await Task.Delay(5);
|
||||
}
|
||||
}
|
||||
|
||||
public void Advance(TimeSpan by)
|
||||
{
|
||||
DateTimeOffset target;
|
||||
lock (_lock) target = _now + by;
|
||||
while (true)
|
||||
{
|
||||
ManualTimer? next;
|
||||
lock (_lock)
|
||||
{
|
||||
next = _timers.Where(t => t.Due <= target).MinBy(t => t.Due);
|
||||
if (next is null) { _now = target; return; }
|
||||
_now = next.Due!.Value;
|
||||
next.Due = next.Period is { } p ? _now + p : null;
|
||||
}
|
||||
next.Fire();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ManualTimer(ManualTimeProvider owner, TimerCallback callback, object? state) : ITimer
|
||||
{
|
||||
public DateTimeOffset? Due; // guarded by owner._lock
|
||||
public TimeSpan? Period; // guarded by owner._lock
|
||||
private bool _disposed;
|
||||
|
||||
public void Fire() => callback(state);
|
||||
|
||||
public bool Change(TimeSpan dueTime, TimeSpan period)
|
||||
{
|
||||
lock (owner._lock)
|
||||
{
|
||||
if (_disposed) return false;
|
||||
Due = dueTime == Timeout.InfiniteTimeSpan ? null : owner._now + dueTime;
|
||||
Period = period == Timeout.InfiniteTimeSpan || period == TimeSpan.Zero ? null : period;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (owner._lock)
|
||||
{
|
||||
_disposed = true;
|
||||
Due = null;
|
||||
owner._timers.Remove(this);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user