Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/DeviceSessionPoolTests.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

393 lines
18 KiB
C#

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 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");
new DeviceKey("plc1", 44818, " ", "ControlLogix").CipPath.ShouldBe("");
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 void Whitespace_inside_a_cip_path_part_is_kept_so_a_typo_fails_loudly()
{
var typo = new DeviceKey("plc1", 44818, "1,2 3", "ControlLogix");
typo.CipPath.ShouldNotBe("1,23"); // never silently slot 23 on another controller
typo.CipPath.ShouldBe("1,2 3");
Should.Throw<ArgumentException>(() => CipPath.EncodeRoute(typo.CipPath));
CipPath.EncodeRoute(new DeviceKey("plc1", 44818, " 1, 0 ", "ControlLogix").CipPath).ShouldBe(new byte[] { 0x01, 0x00 });
}
[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);
// Positive control (#583): C's open has reached the predecessor wait and parked there, so the absence of
// its Forward Open below is the wait holding it, not C merely not having got that far yet.
await FakeCipTarget.Until(() => pool.Diagnostics.PredecessorWaits == 1, Guard, "C to park behind A's close");
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() { }
}
/// <summary>
/// Gitea #579: <see cref="ConnectionLease.ReleaseInBackground"/> returns before the last release's Forward
/// Close completes, yet a successor acquired that same instant still cannot open until the close is over —
/// the closing chain is registered synchronously, inside the call.
/// </summary>
[Fact]
public async Task Release_in_background_returns_before_the_close_and_a_successor_still_waits_for_it()
{
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 teardown = a.ReleaseInBackground();
pool.EntryCount.ShouldBe(0); // bookkeeping done before it returned
await FakeCipTarget.Until(() => target.ForwardCloses == 1, Guard, "the Forward Close");
teardown.IsCompleted.ShouldBeFalse(); // ...but the close is still pending
time.Advance(TimeSpan.FromSeconds(5)); // the throttle is no obstacle
await using var b = pool.Acquire(key, Options());
var bSend = b.Scheduler.SendAsync(ReadTag, TestCt);
await FakeCipTarget.Until(() => pool.Diagnostics.PredecessorWaits == 1, Guard, "B to park behind A's close");
target.ForwardOpens.ShouldBe(1); // held behind A's close
bSend.IsCompleted.ShouldBeFalse();
closeHeld.SetResult();
await teardown.WaitAsync(Guard, TestCt);
(await bSend.WaitAsync(Guard, TestCt)).GeneralStatus.ShouldBe((byte)0);
target.ForwardOpens.ShouldBe(2);
await a.ReleaseInBackground().WaitAsync(Guard, TestCt); // idempotent: b's reference is untouched
pool.EntryCount.ShouldBe(1);
}
/// <summary>
/// Gitea #566: releasing a lease while the device's shared open is still on the wire does not wait for that
/// open (up to its whole open budget) — <see cref="ConnectionLease.ReleaseInBackground"/> (#579) returns at
/// once, and the teardown it hands back still Forward Closes what the open produced once it lands.
/// </summary>
[Fact]
public async Task Release_in_background_does_not_wait_behind_an_in_flight_shared_open()
{
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");
((CipConnection)lease.Channel).OpenInProgress.ShouldBeTrue();
var teardown = lease.ReleaseInBackground(); // returns synchronously: the open is still held
pool.EntryCount.ShouldBe(0);
teardown.IsCompleted.ShouldBeFalse("the teardown waits for the open on the wire; the release call did not");
(await Should.ThrowAsync<AbTransportException>(() => send.WaitAsync(Guard, TestCt))).Status.ShouldBe(AbStatus.BadConnection);
openHeld.SetResult();
await teardown.WaitAsync(Guard, TestCt);
target.ForwardCloses.ShouldBe(1); // what the late-accepted open created is still closed
}
/// <summary>Gitea #566: the close path disposes the connection even when the scheduler's dispose throws —
/// otherwise the connection's keep-alive would hold the press connection open forever.</summary>
[Fact]
public async Task Close_disposes_the_connection_even_when_the_scheduler_dispose_throws()
{
var logger = new CapturingLogger();
var scheduler = new Disposable(() => throw new InvalidOperationException("scheduler dispose failed"));
var connection = new Disposable(() => { });
var key = new DeviceKey("plc1", 44818, "1,0", "ControlLogix");
await DeviceSessionPool.CloseAsync(key, scheduler, connection, logger);
scheduler.Disposed.ShouldBe(1);
connection.Disposed.ShouldBe(1);
logger.Warnings.ShouldBe(1);
// And a throwing connection dispose is logged, never propagated to the release.
var failing = new Disposable(() => throw new InvalidOperationException("close failed"));
await DeviceSessionPool.CloseAsync(key, new Disposable(() => { }), failing, logger);
failing.Disposed.ShouldBe(1);
logger.Warnings.ShouldBe(2);
}
private sealed class Disposable(Action onDispose) : IAsyncDisposable
{
private int _disposed;
public int Disposed => Volatile.Read(ref _disposed);
public ValueTask DisposeAsync()
{
Interlocked.Increment(ref _disposed);
onDispose();
return ValueTask.CompletedTask;
}
}
[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);
}
}
}