using System.Diagnostics; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; /// /// Gitea #579: the native runtime's Dispose — which the driver calls under the tag's lock — used to /// block for up to 2 s waiting for the last lease's Forward Close. It now releases in the background /// (, the same shape as the native AbLegacy runtime), and /// these tests pin both halves of why that is safe: the caller is not held, and a re-create still cannot /// overlap the close or leak a connection. /// [Trait("Category", "Unit")] public sealed class NativeAbCipLeaseReleaseTests { private static readonly TimeSpan Guard = TimeSpan.FromSeconds(15); private static CancellationToken Ct => TestContext.Current.CancellationToken; private static AbCipTagCreateParams Params(int port) => new( Gateway: "127.0.0.1", Port: port, CipPath: "1,0", LibplctagPlcAttribute: "controllogix", TagName: "T", Timeout: TimeSpan.FromSeconds(5), AllowPacking: false, ElementCount: 1, IsArray: false, AuthoredType: AbCipDataType.DInt, UseConnectedMessaging: true); /// A target whose Read Tag Fragmented (0x52) answers DINT 42. private static FakeCipTarget NewTarget() { var target = new FakeCipTarget(); target.ConnectedHandler = req => Task.FromResult( req[0] == 0x52 ? [0xD2, 0x00, 0x00, 0x00, 0xC4, 0x00, 0x2A, 0x00, 0x00, 0x00] : target.ServiceReply(req)); return target; } [Fact] public async Task Release_under_the_tag_lock_returns_promptly_while_the_Forward_Close_is_still_pending() { await using var target = NewTarget(); var closeHeld = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); target.ForwardCloseGate = () => closeHeld.Task; var pool = new DeviceSessionPool(); var factory = new NativeAbCipTagFactory(pool); var first = (NativeAbCipTagRuntime)factory.Create(Params(target.Port)); await first.InitializeAsync(Ct); target.ForwardOpens.ShouldBe(1); // The driver disposes under the per-tag lock; hold one and time the release inside it. using var tagLock = new SemaphoreSlim(1, 1); await tagLock.WaitAsync(Ct); var sw = Stopwatch.StartNew(); first.Dispose(); sw.Stop(); tagLock.Release(); // The old Dispose waited up to 2 s for exactly this close; it must not wait at all now. sw.Elapsed.ShouldBeLessThan(TimeSpan.FromMilliseconds(500)); await FakeCipTarget.Until(() => target.ForwardCloses == 1, Guard, "the Forward Close"); (first.TeardownTask is null).ShouldBeFalse(); first.TeardownTask!.IsCompleted.ShouldBeFalse(); // the close really is still pending // An immediate re-create (what the driver does after an evict) must NOT open a second session while // the first one's Forward Close is in flight (review finding 22) — the pool holds it behind the close. var second = (NativeAbCipTagRuntime)factory.Create(Params(target.Port)); var init = second.InitializeAsync(Ct); // Positive control (#583): the second open has reached the pool's predecessor wait and parked there, so // the missing Forward Open below is that wait holding it — not the open simply not having got that far. await FakeCipTarget.Until(() => pool.Diagnostics.PredecessorWaits == 1, Guard, "the re-create to park behind the close"); target.ForwardOpens.ShouldBe(1); init.IsCompleted.ShouldBeFalse(); closeHeld.SetResult(); await first.TeardownTask.WaitAsync(Guard, Ct); await init.WaitAsync(Guard, Ct); target.ForwardOpens.ShouldBe(2); second.GetStatus().ShouldBe((int)AbStatus.Ok); second.DecodeValue(AbCipDataType.DInt, null).ShouldBe(42); second.Dispose(); await second.TeardownTask!.WaitAsync(Guard, Ct); target.ForwardCloses.ShouldBe(2); } /// /// The offline soak: N create → read → release cycles, each release fire-and-forget and each create /// immediately after it — the evict/re-create rhythm at its tightest. Every connection opened is closed /// (one Forward Open and one Forward Close per cycle, one TCP connection per cycle), no TCP connection is /// left open on the target, and the pool ends empty. A clock that runs fast lets the 1 s Forward Open /// throttle (press safety) pass every cycle without the test taking N seconds; the throttle is still /// exercised. /// [Fact] public async Task Repeated_create_and_release_cycles_leak_no_connection() { const int cycles = 25; await using var target = NewTarget(); var pool = new DeviceSessionPool(new FastForwardClock()); var factory = new NativeAbCipTagFactory(pool); NativeAbCipTagRuntime? last = null; for (var i = 0; i < cycles; i++) { var rt = (NativeAbCipTagRuntime)factory.Create(Params(target.Port)); await rt.InitializeAsync(Ct).WaitAsync(Guard, Ct); rt.DecodeValue(AbCipDataType.DInt, null).ShouldBe(42); rt.Dispose(); // not awaited — the next cycle starts at once last = rt; } await last!.TeardownTask!.WaitAsync(Guard, Ct); await FakeCipTarget.Until(() => target.Server.OpenConnections == 0, Guard, "every TCP connection to close"); target.ForwardOpens.ShouldBe(cycles); target.ForwardCloses.ShouldBe(cycles); target.Server.Connections.ShouldBe(cycles); pool.EntryCount.ShouldBe(0); } /// /// Real timers, but a monotonic clock that jumps 10 s on every read — so an interval measured between /// two reads has always "elapsed". The Forward Open throttle reads elapsed time this way; the only other /// reader, the keep-alive idle check, runs on a real-time timer and at worst sends a harmless keep-alive. /// private sealed class FastForwardClock : TimeProvider { private long _skew; public override long GetTimestamp() => System.GetTimestamp() + Interlocked.Add(ref _skew, 10 * System.TimestampFrequency); public override long TimestampFrequency => System.TimestampFrequency; } }