fix(ab-transport): harden Task 9 pool and connection (close chaining, uncancellable shared open, monotonic throttle, canonical CIP path, internal ctors, shared-pool logger)
This commit is contained in:
+17
@@ -106,6 +106,23 @@ public sealed class ForwardOpenThrottleTests
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -153,6 +153,109 @@ public sealed class DeviceSessionPoolTests
|
||||
schedulers.Keys.GroupBy(k => k.Item2).ShouldAllBe(g => g.Select(k => k.Item1).Distinct().Count() == 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Device_keys_ignore_whitespace_in_the_cip_path()
|
||||
{
|
||||
var a = new DeviceKey("plc1", 44818, "1,0", "ControlLogix");
|
||||
var b = new DeviceKey("plc1", 44818, " 1, 0 ", "ControlLogix");
|
||||
b.CipPath.ShouldBe("1,0");
|
||||
b.ShouldBe(a);
|
||||
b.GetHashCode().ShouldBe(a.GetHashCode());
|
||||
(a with { CipPath = "1, 2" }).CipPath.ShouldBe("1,2");
|
||||
|
||||
var pool = new DeviceSessionPool(new ManualTimeProvider());
|
||||
await using var la = pool.Acquire(a, Options());
|
||||
await using var lb = pool.Acquire(b, Options());
|
||||
lb.Scheduler.ShouldBeSameAs(la.Scheduler); // one connection, one throttle
|
||||
pool.EntryCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_new_connection_waits_for_every_earlier_close_not_just_the_latest()
|
||||
{
|
||||
// A is closing (its Forward Close is held); B is acquired and released without ever opening, so
|
||||
// its own close is instant; C must still not open until A's close has finished.
|
||||
await using var target = new FakeCipTarget();
|
||||
var closeHeld = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
target.ForwardCloseGate = () => closeHeld.Task;
|
||||
var time = new ManualTimeProvider();
|
||||
var pool = new DeviceSessionPool(time);
|
||||
var key = new DeviceKey("127.0.0.1", target.Port, "1,0", "ControlLogix");
|
||||
|
||||
var a = pool.Acquire(key, Options());
|
||||
await a.Scheduler.SendAsync(ReadTag, TestCt);
|
||||
var aClosing = a.DisposeAsync().AsTask();
|
||||
await FakeCipTarget.Until(() => target.ForwardCloses == 1, Guard, "A's Forward Close");
|
||||
|
||||
await pool.Acquire(key, Options()).DisposeAsync(); // B: never opened
|
||||
time.Advance(TimeSpan.FromSeconds(5)); // the throttle is no obstacle for C
|
||||
|
||||
await using var c = pool.Acquire(key, Options());
|
||||
var cSend = c.Scheduler.SendAsync(ReadTag, TestCt);
|
||||
await Task.Delay(300, TestCt);
|
||||
target.ForwardOpens.ShouldBe(1); // C is held back behind A
|
||||
cSend.IsCompleted.ShouldBeFalse();
|
||||
|
||||
closeHeld.SetResult();
|
||||
await aClosing.WaitAsync(Guard, TestCt);
|
||||
(await cSend.WaitAsync(Guard, TestCt)).GeneralStatus.ShouldBe((byte)0);
|
||||
target.ForwardOpens.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disposing_during_an_open_still_forward_closes_what_the_target_accepted()
|
||||
{
|
||||
// The scheduler's stop token (and any caller's) ends only the WAIT; the Forward Open on the wire
|
||||
// completes, and the connection it created is closed.
|
||||
await using var target = new FakeCipTarget();
|
||||
var openHeld = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
target.ForwardOpenGate = () => openHeld.Task;
|
||||
var pool = new DeviceSessionPool(new ManualTimeProvider());
|
||||
var key = new DeviceKey("127.0.0.1", target.Port, "1,0", "ControlLogix");
|
||||
|
||||
var lease = pool.Acquire(key, Options());
|
||||
var send = lease.Scheduler.SendAsync(ReadTag, TestCt);
|
||||
await FakeCipTarget.Until(() => target.ForwardOpens == 1, Guard, "the Forward Open");
|
||||
var disposing = lease.DisposeAsync().AsTask();
|
||||
|
||||
var failed = await Should.ThrowAsync<AbTransportException>(() => send.WaitAsync(Guard, TestCt));
|
||||
failed.Status.ShouldBe(AbStatus.BadConnection); // the scheduler's waiter is released at once
|
||||
target.ForwardCloses.ShouldBe(0);
|
||||
|
||||
openHeld.SetResult(); // the target accepts, late
|
||||
await disposing.WaitAsync(Guard, TestCt);
|
||||
|
||||
target.ForwardCloses.ShouldBe(1);
|
||||
target.ForwardCloseRequests.Single().AsSpan(8, 8).ToArray()
|
||||
.ShouldBe(target.ForwardOpenRequests.Single().AsSpan(16, 8).ToArray());
|
||||
target.ConnectedRequests.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_shared_pool_logger_can_be_configured()
|
||||
{
|
||||
var logger = new CapturingLogger();
|
||||
DeviceSessionPool.ConfigureSharedLogger(new SingleLoggerFactory(logger));
|
||||
try
|
||||
{
|
||||
var key = new DeviceKey($"shared-logger-{Guid.NewGuid():N}", 44818, "1,0", "ControlLogix");
|
||||
await using var a = DeviceSessionPool.Shared.Acquire(key, Options(4002));
|
||||
await using var b = DeviceSessionPool.Shared.Acquire(key, Options(504));
|
||||
logger.Warnings.ShouldBe(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeviceSessionPool.ConfigureSharedLogger(Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SingleLoggerFactory(ILogger logger) : ILoggerFactory
|
||||
{
|
||||
public ILogger CreateLogger(string categoryName) => logger;
|
||||
public void AddProvider(ILoggerProvider provider) { }
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shared_is_one_process_wide_instance() =>
|
||||
DeviceSessionPool.Shared.ShouldBeSameAs(DeviceSessionPool.Shared);
|
||||
|
||||
@@ -37,6 +37,12 @@ internal sealed class FakeCipTarget : IAsyncDisposable
|
||||
/// <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; }
|
||||
|
||||
/// <summary>Awaited before a Forward Open is answered (the request is already counted).</summary>
|
||||
public Func<Task>? ForwardOpenGate { get; set; }
|
||||
|
||||
/// <summary>Awaited before a Forward Close is answered (the request is already counted).</summary>
|
||||
public Func<Task>? ForwardCloseGate { 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; } }
|
||||
@@ -88,11 +94,13 @@ internal sealed class FakeCipTarget : IAsyncDisposable
|
||||
if (IsForwardOpen(cip))
|
||||
{
|
||||
lock (_forwardOpens) _forwardOpens.Add(cip);
|
||||
if (ForwardOpenGate is { } openGate) await openGate();
|
||||
return FakeEipServer.RRReply(frame, ForwardOpenReply(cip));
|
||||
}
|
||||
if (IsForwardClose(cip))
|
||||
{
|
||||
lock (_forwardCloses) _forwardCloses.Add(cip);
|
||||
if (ForwardCloseGate is { } closeGate) await closeGate();
|
||||
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());
|
||||
}
|
||||
|
||||
+6
-2
@@ -15,9 +15,13 @@ internal sealed class ManualTimeProvider : TimeProvider
|
||||
|
||||
public DateTimeOffset Start { get; }
|
||||
|
||||
public override DateTimeOffset GetUtcNow() { lock (_lock) return _now; }
|
||||
/// <summary>Steps the wall clock (<see cref="GetUtcNow"/>) without moving monotonic time
|
||||
/// (<see cref="GetTimestamp"/>) or any timer, as an NTP or operator clock change does.</summary>
|
||||
public TimeSpan WallClockOffset { get; set; }
|
||||
|
||||
public override long GetTimestamp() => GetUtcNow().UtcTicks;
|
||||
public override DateTimeOffset GetUtcNow() { lock (_lock) return _now + WallClockOffset; }
|
||||
|
||||
public override long GetTimestamp() { lock (_lock) return _now.UtcTicks; }
|
||||
|
||||
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user