From bb8386d126dff9c5b20eb95255bfc3c675a5e8f6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 11:46:12 -0400 Subject: [PATCH] test(focas): failing repro for STAB-5 cross-thread Dictionary mutation --- ...2-09-driver-fleet-batch-plan.md.tasks.json | 2 +- .../FocasFixedTreeConcurrencyTests.cs | 99 +++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFixedTreeConcurrencyTests.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 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}" : ""); + } +}