4e6eed7454
#570 - STRING: the built-in 88-byte Logix STRING holds 82 characters (LEN + DATA[82] + 2 pad), not 84; the write still sends the whole 88-byte structure and a read never decodes the pad as characters. - Mistype churn: a type refusal (CIP 0xFF/0x2107) on the existence read no longer throws from InitializeAsync, so the runtime is not disposed/re-created (and re-Forward-Opened) every poll; the driver also keeps a runtime that throws TypeMismatch from init and records the device as reachable. - Malformed tag name: a TagPath that does not parse, or a name the CIP symbolic encoder refuses (now also non-ASCII, empty member, >255-char member, unterminated subscript), is AbCipTagConfigurationException -> per-tag BadConfigurationError (named const in AbCipStatusMapper), warned once, never feeding the device backoff or health; siblings keep reading. A malformed probe tag no longer feeds the backoff either. - Micro800 BYTE/WORD/LWORD (0xD1/0xD2/0xD4) are type-checked as integers of their width: REAL/LREAL or a narrower integer over LWORD is TypeMismatch, not decoded garbage. #579 - ConnectionLease.ReleaseInBackground: one non-blocking release shape for both native AB runtimes. The AbCip runtime's Dispose no longer waits up to 2 s under the per-tag lock; the pool still registers the close synchronously so a re-create cannot overlap it. Offline soak tests (25 cycles, AbCip + AbLegacy) prove no leaked connection; FakeEipServer gains OpenConnections. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
325 lines
15 KiB
C#
325 lines
15 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);
|
|
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() { }
|
|
}
|
|
|
|
/// <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 Task.Delay(300, TestCt);
|
|
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);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|
|
}
|