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}" : ""); } }