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);
+ }
+}