Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipLeaseReleaseTests.cs
T
Joseph Doherty 4e6eed7454 fix(abcip): native runtime STRING capacity, mistype churn, malformed-name config error, LWORD type check; non-blocking lease release (#570, #579)
#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
2026-10-07 23:05:49 -04:00

140 lines
6.2 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);
await Task.Delay(300, Ct);
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;
}
}