using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; /// /// Regression coverage for Driver.AbLegacy-006 — a per-tag libplctag runtime (a single /// Tag handle) is cached and shared between the server read path and the poll loop. /// A Tag is not safe for concurrent operations, so the driver must serialise the /// Read → GetStatus → DecodeValue (and Encode → Write → GetStatus) sequence per runtime. /// [Trait("Category", "Unit")] public sealed class AbLegacyRuntimeConcurrencyTests { /// /// 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 : FakeAbLegacyTag { private int _inFlight; public int MaxConcurrent { get; private set; } public OverlapDetectingFake(AbLegacyTagCreateParams p) : base(p) { } public override async Task ReadAsync(CancellationToken ct) { EnterOp(); try { // Yield + small delay so an unserialised second caller is guaranteed to overlap. await Task.Delay(15, ct).ConfigureAwait(false); await base.ReadAsync(ct).ConfigureAwait(false); } finally { LeaveOp(); } } public override async Task WriteAsync(CancellationToken ct) { EnterOp(); try { await Task.Delay(15, ct).ConfigureAwait(false); await base.WriteAsync(ct).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); } [Fact] public async Task Concurrent_reads_of_same_tag_are_serialised_against_the_shared_runtime() { OverlapDetectingFake? shared = null; var factory = new FakeAbLegacyTagFactory { Customise = p => { shared = new OverlapDetectingFake(p) { Value = 7 }; return shared; }, }; var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)], Probe = new AbLegacyProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); // Eight callers race for the same tag — mimics the server read path + poll loop(s) // 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)); } [Fact] public async Task Concurrent_read_and_write_of_same_tag_do_not_overlap() { OverlapDetectingFake? shared = null; var factory = new FakeAbLegacyTagFactory { Customise = p => { shared = new OverlapDetectingFake(p) { Value = 1 }; return shared; }, }; var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)], Probe = new AbLegacyProbeOptions { Enabled = false }, }, "drv-1", factory); 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); } }