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 b1f16985..6790b898 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
@@ -50,7 +50,7 @@
{
"id": "B2.4",
"subject": "B2: failing FocasFixedTreeConcurrencyTests (writer-vs-reader stress on the four DeviceState caches) \u2014 STAB-5 repro",
- "status": "pending",
+ "status": "completed",
"blockedBy": []
},
{
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFixedTreeConcurrencyTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFixedTreeConcurrencyTests.cs
new file mode 100644
index 00000000..d608ba59
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFixedTreeConcurrencyTests.cs
@@ -0,0 +1,99 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
+
+///
+/// Regression coverage for 05/STAB-5 — the four per-device fixed-tree caches
+/// (LastFixedSnapshots, LastTimers, LastServoLoads, LastSpindleLoads)
+/// are written by the background fixed-tree loop while ReadAsync callers read them
+/// concurrently. A plain
+/// throws / corrupts when a resize races a read — undefined behaviour. They must be
+/// .
+///
+[Trait("Category", "Unit")]
+public sealed class FocasFixedTreeConcurrencyTests
+{
+ private static FocasDriver.DeviceState NewDeviceState() =>
+ new(new FocasHostAddress("10.0.0.5", FocasHostAddress.DefaultPort),
+ new FocasDeviceOptions("focas://10.0.0.5"));
+
+ ///
+ /// Hammers concurrent writers (which force dictionary resizes) against concurrent readers on
+ /// all four caches. A plain Dictionary is not concurrency-safe: its structural-mutation
+ /// version check throws "operations that change non-concurrent collections must have exclusive
+ /// access to the collection", and its lookup collision guard throws "concurrent operations
+ /// are not supported" — deterministically under this load. A
+ /// ConcurrentDictionary never throws.
+ ///
+ ///
+ /// Uses several writers, not one, so the fault is deterministic rather than timing-dependent
+ /// UB: the fixed-tree caches are shared mutable state with zero synchronization, so ANY
+ /// concurrent access is the defect — a driver that survives this survives the real
+ /// writer-loop-vs-reader-callers race the finding cites.
+ ///
+ [Fact]
+ public async Task Concurrent_access_to_fixed_tree_caches_never_throws()
+ {
+ var state = NewDeviceState();
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ var faults = new System.Collections.Concurrent.ConcurrentQueue();
+ const int rounds = 40;
+ const int growth = 4_000;
+
+ void Writer(int seed)
+ {
+ try
+ {
+ for (var round = 0; round < rounds && !cts.IsCancellationRequested; round++)
+ {
+ state.LastFixedSnapshots.Clear();
+ state.LastServoLoads.Clear();
+ state.LastSpindleLoads.Clear();
+ for (var i = 0; i < growth; i++)
+ {
+ // Distinct keys force repeated dictionary growth (resize = the race window);
+ // the Clear() each round keeps the caches churning through resizes.
+ state.LastFixedSnapshots[$"Axes/A{seed}_{i}/AbsolutePosition"] = i;
+ state.LastServoLoads[$"Servo{seed}_{i}"] = i;
+ state.LastSpindleLoads[seed * growth + i] = i;
+ state.LastTimers[(FocasTimerKind)(i % 4)] = new FocasTimer((FocasTimerKind)(i % 4), i, i);
+ }
+ }
+ }
+ catch (Exception ex) { faults.Enqueue(ex); }
+ }
+
+ void Reader()
+ {
+ try
+ {
+ while (!cts.IsCancellationRequested && faults.IsEmpty)
+ {
+ for (var i = 0; i < growth; i++)
+ {
+ state.LastFixedSnapshots.TryGetValue($"Axes/A0_{i}/AbsolutePosition", out _);
+ state.LastServoLoads.TryGetValue($"Servo0_{i}", out _);
+ state.LastSpindleLoads.TryGetValue(i, out _);
+ state.LastTimers.TryGetValue((FocasTimerKind)(i % 4), out _);
+ }
+ }
+ }
+ catch (Exception ex) { faults.Enqueue(ex); }
+ }
+
+ var tasks = new List
+ {
+ Task.Run(() => Writer(0)),
+ Task.Run(() => Writer(1)),
+ Task.Run(() => Writer(2)),
+ Task.Run(Reader),
+ Task.Run(Reader),
+ };
+ await Task.WhenAll(tasks);
+
+ faults.ShouldBeEmpty(
+ faults.TryPeek(out var first) ? $"concurrent cache access threw: {first}" : "");
+ }
+}