test(focas): failing repro for STAB-5 cross-thread Dictionary mutation
This commit is contained in:
@@ -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": []
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression coverage for 05/STAB-5 — the four per-device fixed-tree caches
|
||||
/// (<c>LastFixedSnapshots</c>, <c>LastTimers</c>, <c>LastServoLoads</c>, <c>LastSpindleLoads</c>)
|
||||
/// are written by the background fixed-tree loop while <c>ReadAsync</c> callers read them
|
||||
/// concurrently. A plain <see cref="System.Collections.Generic.Dictionary{TKey,TValue}"/>
|
||||
/// throws / corrupts when a resize races a read — undefined behaviour. They must be
|
||||
/// <see cref="System.Collections.Concurrent.ConcurrentDictionary{TKey,TValue}"/>.
|
||||
/// </summary>
|
||||
[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"));
|
||||
|
||||
/// <summary>
|
||||
/// Hammers concurrent writers (which force dictionary resizes) against concurrent readers on
|
||||
/// all four caches. A plain <c>Dictionary</c> 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
|
||||
/// <c>ConcurrentDictionary</c> never throws.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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<Exception>();
|
||||
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>
|
||||
{
|
||||
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}" : "");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user