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();