diff --git a/docs/GatewayTesting.md b/docs/GatewayTesting.md
index 3c49583..a543d02 100644
--- a/docs/GatewayTesting.md
+++ b/docs/GatewayTesting.md
@@ -554,6 +554,23 @@ windev has 36 logical CPUs and `xunit.runner.json` sets `maxParallelThreads: -1`
suite runs far wider there than on the macOS dev box — that width is what turns these
real-clock deadlines into failures.
+### Two more findings from the 2026-08-15 windev gate
+
+- `SecretsStorePathGuardTests.CreateBuilder_AcceptsSecretsStoreOutsideContentRoot_AndCreatesIt`
+ fails **deterministically on Windows, on `main` as well as on any branch**, so it is not a
+ signal about the change under test. Creating the builder opens `secrets.db`, and
+ `Microsoft.Data.Sqlite`'s connection pool keeps the file handle alive past the test body,
+ so the recursive directory delete in the cleanup hits a still-open file — a sharing
+ violation Windows enforces and Unix does not. Pre-existing and tracked separately; do not
+ chase it as a regression. Subtract it from the expected pass count on Windows.
+- The `StaWaitHelper` timing tests (`WaitForSignalOrMessages_*`) flake on a loaded box with a
+ signature that reads like a broken wait but is not: the helper wakes on *input being
+ present*, so a message posted to the test thread ends the wait early. That is the helper
+ doing exactly what the STA pump needs. The tests drain the queue with
+ `PumpPendingMessages()` first for that reason; a failure here means the box was busy enough
+ to queue a message mid-test, not that the wait stopped honouring its handle or its timeout.
+ Re-run the class on its own before treating it as real, per the load caveat above.
+
### The full-suite testhost hang was a zero-buffer named pipe (fixed)
For months a full-suite run on windev reported `855 passed, 0 failed` and then never
diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessHandleRegistryTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessHandleRegistryTests.cs
index 14814d7..9fcbc2b 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessHandleRegistryTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessHandleRegistryTests.cs
@@ -256,6 +256,93 @@ public sealed class MxAccessHandleRegistryTests
Assert.True(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Plain));
}
+ ///
+ /// Pins the registry half of MxAccessSession.TryGetCachedReadFor's fall-through
+ /// contract. That scan walks
+ /// in order and skips any candidate carrying neither a plain nor a supervisory advice,
+ /// because an added-but-unadvised item will never receive a fresh OnDataChange and so
+ /// can only serve a stale cache entry. The registry has to make that skip possible: the
+ /// duplicate registrations of one tag must come back in a stable ascending order, and the
+ /// advice index must answer per item handle rather than per tag. Asserted here rather than
+ /// on the session because the session's read path needs a live MXAccess COM instance.
+ ///
+ [Fact]
+ public void GetItemHandlesForDefinition_MultipleCandidates_AdviceIndexDiscriminatesTheAdvisedOne()
+ {
+ MxAccessHandleRegistry registry = new();
+ registry.RegisterServerHandle(serverHandle: 1, clientName: "client");
+
+ // Same tag under two item handles: 10 is added but never advised, 20 is advised. Registered
+ // out of order so the ascending-order guarantee is doing real work.
+ registry.RegisterItemHandle(1, itemHandle: 20, "Tank1.PV", string.Empty, hasItemContext: false);
+ registry.RegisterItemHandle(1, itemHandle: 10, "Tank1.PV", string.Empty, hasItemContext: false);
+ registry.RegisterAdviceHandle(1, itemHandle: 20, MxAccessAdviceKind.Plain);
+
+ IReadOnlyList candidates = registry.GetItemHandlesForDefinition(1, "Tank1.PV");
+
+ Assert.Equal(new[] { 10, 20 }, candidates);
+
+ // The unadvised candidate is visited first and skipped; the advised one is the survivor.
+ Assert.False(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Plain));
+ Assert.False(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Supervisory));
+ Assert.True(registry.ContainsAdviceHandle(1, 20, MxAccessAdviceKind.Plain));
+
+ // Supervisory alone qualifies too, so a later advise on 10 makes it the first survivor.
+ registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Supervisory);
+ Assert.True(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Supervisory));
+ }
+
+ ///
+ /// One adversarial lifecycle run over every index at once: register, advise, re-register the
+ /// same item handle under a new tag, unadvise, then tear the server down. Each individual
+ /// transition is covered above; this pins that they compose — the reverse definition index,
+ /// the per-item advice index and the per-server removal index must agree after every step,
+ /// since a stale entry in any one of them resurrects a handle MXAccess has already retired.
+ ///
+ [Fact]
+ public void RegisterAdviseReregisterUnadviseUnregister_LeavesEveryIndexConsistent()
+ {
+ MxAccessHandleRegistry registry = new();
+ registry.RegisterServerHandle(serverHandle: 1, clientName: "client");
+
+ // Register.
+ registry.RegisterItemHandle(1, itemHandle: 10, "Tank1.PV", string.Empty, hasItemContext: false);
+ Assert.True(registry.ContainsItemHandle(1, 10));
+ Assert.Equal(new[] { 10 }, registry.GetItemHandlesForDefinition(1, "Tank1.PV"));
+ Assert.Empty(registry.AdviceHandles);
+
+ // Advise.
+ registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Plain);
+ Assert.True(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Plain));
+ Assert.Single(registry.AdviceHandles);
+
+ // Re-register the SAME item handle under a new tag. The advice is keyed on the item handle,
+ // not the tag, so it survives — but the old definition entry must not.
+ registry.RegisterItemHandle(1, itemHandle: 10, "Tank2.PV", string.Empty, hasItemContext: false);
+ Assert.Empty(registry.GetItemHandlesForDefinition(1, "Tank1.PV"));
+ Assert.Equal(new[] { 10 }, registry.GetItemHandlesForDefinition(1, "Tank2.PV"));
+ Assert.Single(registry.ItemHandles);
+ Assert.True(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Plain));
+
+ // Unadvise: the item stays registered and still resolves by its current tag.
+ registry.RemoveAdviceHandles(1, itemHandle: 10);
+ Assert.False(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Plain));
+ Assert.Empty(registry.AdviceHandles);
+ Assert.True(registry.ContainsItemHandle(1, 10));
+ Assert.Equal(new[] { 10 }, registry.GetItemHandlesForDefinition(1, "Tank2.PV"));
+
+ // Unregister the server: every index drains, including the definition index the re-register
+ // rewrote.
+ registry.UnregisterServerHandle(1);
+ Assert.False(registry.ContainsServerHandle(1));
+ Assert.False(registry.ContainsItemHandle(1, 10));
+ Assert.Empty(registry.GetItemHandlesForDefinition(1, "Tank1.PV"));
+ Assert.Empty(registry.GetItemHandlesForDefinition(1, "Tank2.PV"));
+ Assert.Empty(registry.ServerHandles);
+ Assert.Empty(registry.ItemHandles);
+ Assert.Empty(registry.AdviceHandles);
+ }
+
private static List Map(
IReadOnlyList source,
Func selector)
diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Sta/StaWaitHelperTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Sta/StaWaitHelperTests.cs
index 925f86c..f3572a9 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Sta/StaWaitHelperTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Sta/StaWaitHelperTests.cs
@@ -90,6 +90,11 @@ public sealed class StaWaitHelperTests
{
using AutoResetEvent signal = new(initialState: true);
+ // Drained for the same reason as the other two waits: a stale message could end this wait
+ // instead of the handle, which would leave the signal un-consumed and fail the
+ // post-condition below for a reason that has nothing to do with pre-signalling.
+ new StaMessagePump().PumpPendingMessages();
+
Stopwatch elapsed = Stopwatch.StartNew();
StaWaitHelper.WaitForSignalOrMessages(signal, 30_000);
elapsed.Stop();