From 32f248c93d69df2a65e425dbb42bdd2b0b818fa6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 11:40:17 -0400 Subject: [PATCH] test(abcip): failing repro for STAB-4 unserialized shared-handle ops --- ...2-09-driver-fleet-batch-plan.md.tasks.json | 2 +- .../AbCipRuntimeConcurrencyTests.cs | 123 ++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRuntimeConcurrencyTests.cs diff --git a/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json b/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json index 7c2aa958..fd71d35d 100644 --- a/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json +++ b/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json @@ -28,7 +28,7 @@ { "id": "B2.1", "subject": "B2: failing AbCipRuntimeConcurrencyTests (reentrancy-guarded FakeAbCipTag hammer) \u2014 STAB-4 repro", - "status": "pending", + "status": "completed", "blockedBy": [] }, { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRuntimeConcurrencyTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRuntimeConcurrencyTests.cs new file mode 100644 index 00000000..118ad5a9 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRuntimeConcurrencyTests.cs @@ -0,0 +1,123 @@ +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); + } +}