fe94a3bdb8
Ab.Transport.Tests: predecessor-wait counter instead of 300 ms (DeviceSessionPoolTests x2), throttle queue/interval-park hooks instead of 50/100 ms (ForwardOpenThrottleTests x2), UnobservedTaskProbe with a must-report control fault instead of N GC cycles (EncapsulationClientTests), re-armed keep-alive timer as the control for the never-opened keep-alive test. AbCip.Tests: still-polling sibling / control driver read counts instead of 300-500 ms (subscriptions x4, alarm projection x5), structural ProbeTask==null + enabled-probe control (host probe x2), predecessor wait (NativeAbCipLeaseReleaseTests), processed waits in the shutdown/logging tests. AbLegacy.Tests: sibling-subscription control (Unsubscribe_halts_polling), ProbeCts==null + control. AbCip.IntegrationTests differential safety: a post-phase control open through the same throttle instead of 1.5-2.5 s sleeps (via a native tag init, keeping the harness read-only guard green). Fake tag ReadCount made interlocked; CapturingLogger entries snapshotted under a lock. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
142 lines
6.5 KiB
C#
142 lines
6.5 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Gitea #579: the native runtime's <c>Dispose</c> — 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
|
|
/// (<see cref="ConnectionLease.ReleaseInBackground"/>, 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.
|
|
/// </summary>
|
|
[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);
|
|
|
|
/// <summary>A target whose Read Tag Fragmented (0x52) answers DINT 42.</summary>
|
|
private static FakeCipTarget NewTarget()
|
|
{
|
|
var target = new FakeCipTarget();
|
|
target.ConnectedHandler = req => Task.FromResult<byte[]?>(
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|