diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SessionAclDecisionCacheTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SessionAclDecisionCacheTests.cs
index d78cffe5..2ab67dce 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SessionAclDecisionCacheTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SessionAclDecisionCacheTests.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
@@ -14,7 +15,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// different from the one asked. Each test below pins one dimension the key must discriminate on,
/// because a key that collides across any of them hands one session another's authority.
///
-public sealed class SessionAclDecisionCacheTests
+public sealed class SessionAclDecisionCacheTests(ITestOutputHelper output)
{
private const string Session = "key-1";
private const string Node = "Plant/Modbus/dev1/Speed";
@@ -540,8 +541,18 @@ public sealed class SessionAclDecisionCacheTests
/// threads per round land red about once in twenty thousand, a two-party
/// no better (its post-phase action releases from inside one racer, handing it a head
/// start), while the long-lived racers plus a third-thread starter used below turn roughly a
- /// quarter to a half of all rounds red. So a round or two is enough in practice, and the
- /// twenty thousand are for the machine that is slower or busier than this one.
+ /// sixth to a half of all rounds red.
+ ///
+ ///
+ /// ⚠️ Do not cut the round count on that arithmetic. A per-round rate near 25% suggests a
+ /// couple of hundred rounds would be overwhelming — but the misses are not independent, they
+ /// arrive in run-length bursts: the racers get descheduled together for a stretch, and the
+ /// stretch can be the whole run. Measured, against the mutation, with the rest of this assembly
+ /// running in parallel as it does in CI: 2,000 rounds passed vacuously in 1 run of 3
+ /// (stale=0 — the defect present and undetected), while 10,000 was red in 5 of 5, first hit
+ /// within rounds 1-13, stale 1,595-4,591. The same 2,000 rounds run alone never missed
+ /// (70-986 stale), so the load from the sibling classes is the variable that matters, and a
+ /// standalone measurement will overstate the margin. The cost of the margin is ~20ms.
///
///
/// Every round gets a fresh cache: both racers must start from the -1 seed, or the
@@ -564,21 +575,31 @@ public sealed class SessionAclDecisionCacheTests
[Fact]
public void Concurrent_lookups_at_mixed_generations_always_publish_the_highest()
{
- const int rounds = 20_000;
+ const int rounds = 10_000;
const int spinsBeforeYield = 4_000;
- // The cache under race, handed over through a slot the starter rewrites between rounds.
+ // Array-as-ref-cell: a one-element array is the cheapest storage a lambda can close over AND that
+ // Volatile.Read/Write can take a `ref` to — a captured local cannot be passed by ref, and a plain
+ // field would need the test moved onto the class.
var slot = new SessionAclDecisionCache[1];
var gate = 0; // starter's release signal: the round number both racers are cleared to run
var finished = 0; // racers' completion count for the current round
var firstStaleRound = -1;
var staleRounds = 0;
+ // ⚠️ Deliberately NOT the file's Barrier-per-round idiom (see
+ // Concurrent_eviction_and_insertion_leave_the_counter_exact, which is the right shape for its job:
+ // many workers that only need to start together). This gate/finished spin protocol exists because
+ // the window here is nanoseconds wide and a Barrier releases from inside one of the racers — see the
+ // remarks above. Both idioms are in the file on purpose; neither is a stray.
+ //
// Raw threads rather than the thread pool: the pool may serialise two work items onto one thread,
// which would make the race unreachable. Held for the whole run so the release is the only skew.
var low = new Thread(() => Race(1L)) { IsBackground = true };
var high = new Thread(() => Race(2L)) { IsBackground = true };
+ var started = Stopwatch.GetTimestamp();
+
low.Start();
high.Start();
@@ -602,6 +623,21 @@ public sealed class SessionAclDecisionCacheTests
low.Join();
high.Join();
+ var elapsed = Stopwatch.GetElapsedTime(started);
+
+ // Emitted unconditionally, because the way this test fails on a constrained runner is by passing.
+ // ⚠️ It cannot report a hit rate: under the fixed code no round is stale BY DEFINITION, and how many
+ // rounds actually entered the read-then-exchange window is unobservable from out here — the only way
+ // to see it would be to instrument production, which is not worth a diagnostic. So it reports what it
+ // can honestly measure, and the reading is: fewer than 3 processors means the three threads cannot
+ // run concurrently and the race is unreachable, while a per-round cost well above the 1.5-3us/round
+ // this machine turns in means the racers are being descheduled rather than spinning (24us/round was
+ // observed under load, and that is the shape of a run that proves less than it looks like it does).
+ // The measured red rate under the single-shot mutation is 16-46% of rounds.
+ output.WriteLine(
+ $"rounds={rounds} stale={staleRounds} processors={Environment.ProcessorCount} " +
+ $"elapsed={elapsed.TotalMilliseconds:F1}ms perRound={elapsed.TotalMicroseconds / rounds:F2}us");
+
firstStaleRound.ShouldBe(
-1,
$"round {firstStaleRound} (of {staleRounds} stale in {rounds}): the newer generation must " +