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:
Joseph Doherty
2026-10-07 16:45:26 -04:00
parent b82fffd269
commit a251954bf7
8 changed files with 280 additions and 60 deletions
@@ -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());
}
@@ -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;