using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbCip; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; /// /// Regression coverage for 05/STAB-4 — a per-tag libplctag runtime (a single Tag /// handle) is cached in DeviceState.Runtimes and shared between the server read path, /// every poll-group loop, and the alarm-projection loop. A Tag handle is not safe for /// concurrent Read/GetStatus/Decode (or Encode/Write/GetStatus), so the driver must serialise /// the whole sequence per runtime — mirrors AbLegacyRuntimeConcurrencyTests. /// [Trait("Category", "Unit")] public sealed class AbCipRuntimeConcurrencyTests { /// /// A fake runtime that records the maximum number of operations in flight against the /// same handle. If the driver fails to serialise, two callers overlap inside the /// Read → GetStatus → Decode window and exceeds 1. /// private sealed class OverlapDetectingFake : FakeAbCipTag { private int _inFlight; /// Gets the maximum number of concurrent operations detected on this handle. public int MaxConcurrent { get; private set; } /// Initializes a new instance of the class. /// The tag creation parameters. public OverlapDetectingFake(AbCipTagCreateParams p) : base(p) { } /// public override async Task ReadAsync(CancellationToken cancellationToken) { EnterOp(); try { // Yield + small delay so an unserialised second caller is guaranteed to overlap. await Task.Delay(15, cancellationToken).ConfigureAwait(false); await base.ReadAsync(cancellationToken).ConfigureAwait(false); } finally { LeaveOp(); } } /// public override async Task WriteAsync(CancellationToken cancellationToken) { EnterOp(); try { await Task.Delay(15, cancellationToken).ConfigureAwait(false); await base.WriteAsync(cancellationToken).ConfigureAwait(false); } finally { LeaveOp(); } } private void EnterOp() { var n = Interlocked.Increment(ref _inFlight); lock (this) { if (n > MaxConcurrent) MaxConcurrent = n; } } private void LeaveOp() => Interlocked.Decrement(ref _inFlight); } private static AbCipDriver NewDriver(FakeAbCipTagFactory factory, params AbCipTagDefinition[] tags) { var opts = new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], Tags = tags, }; return new AbCipDriver(opts, "drv-1", factory); } /// Verifies that concurrent reads of the same tag are serialised against the shared runtime. [Fact] public async Task Concurrent_reads_of_same_tag_are_serialised_against_the_shared_runtime() { OverlapDetectingFake? shared = null; var factory = new FakeAbCipTagFactory { Customise = p => shared = new OverlapDetectingFake(p) { Value = 7 }, }; var drv = NewDriver(factory, new AbCipTagDefinition("X", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); // Eight callers race for the same tag — mimics the server read path + poll loop(s) + the // alarm-projection loop hitting one cached runtime at once. var reads = Enumerable.Range(0, 8) .Select(_ => drv.ReadAsync(["X"], CancellationToken.None)) .ToArray(); await Task.WhenAll(reads); shared.ShouldNotBeNull(); shared!.MaxConcurrent.ShouldBe(1, "operations on a shared libplctag Tag must not overlap"); reads.ShouldAllBe(r => r.Result.Single().Value!.Equals(7)); } /// Verifies that a concurrent read and write of the same tag do not overlap on the shared runtime. [Fact] public async Task Concurrent_read_and_write_of_same_tag_do_not_overlap() { OverlapDetectingFake? shared = null; var factory = new FakeAbCipTagFactory { Customise = p => shared = new OverlapDetectingFake(p) { Value = 1 }, }; var drv = NewDriver(factory, new AbCipTagDefinition("X", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); var readTask = drv.ReadAsync(["X"], CancellationToken.None); var writeTask = drv.WriteAsync([new WriteRequest("X", 99)], CancellationToken.None); await Task.WhenAll(readTask, writeTask); shared.ShouldNotBeNull(); shared!.MaxConcurrent.ShouldBe(1); } }