// ItemHandleReuseProbeTests.cs // // Probe for issue #166: does MXAccess hand out a new item handle number when the // same item is removed and re-added on one LMXProxyServer registration? The // AlarmAckWriter's Unconfirmed mitigation (drop the item, re-add it, wait on the // fresh handle) relies on the number changing, because OnWriteComplete carries // only the (server, item) handle pair. // // The probe only adds, advises and removes items. It never writes, so it does // not acknowledge the alarm or create Historian rows. // // HOW TO RUN (dev rig with MXAccess installed and the DEV Galaxy running): // 1. Remove (or set to null) the Skip parameter on the [Fact] below. // 2. dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj // -p:Platform=x86 --filter FullyQualifiedName~ItemHandleReuseProbeTests // --logger "console;verbosity=detailed" // 3. Read the logged handles. Results are recorded in docs/AlarmProbeFindings.md. // // RESULT (windev, 2026-10-20): within one registration item handles increase // monotonically and are never reused (1..15 across add/remove/re-add of the same // item, with and without Advise, interleaved with another item, and while held // twice). Item numbering restarts at 1 after Unregister + Register, and a fresh // COM object is handed server handle 1 again. using System; using System.Collections.Generic; using System.Runtime.InteropServices; using System.Threading; using ArchestrA.MxAccess; using ZB.MOM.WW.MxGateway.Worker.MxAccess; using Xunit.Abstractions; namespace ZB.MOM.WW.MxGateway.Worker.Tests.Probes; /// /// Live probe that logs the item handles MXAccess returns when the same /// AckMsg item is removed and re-added, to decide whether a late /// OnWriteComplete for a dropped handle could match a re-added one. /// public sealed class ItemHandleReuseProbeTests { private const string AckItem = "TestMachine_001.TestAlarm001.AckMsg"; private const string OtherItem = "TestMachine_001.TestAlarm001.Acked"; private const int Rounds = 5; private readonly ITestOutputHelper output; private readonly List log = new List(); // Item handles returned on the first registration, in the order they were handed out. private readonly List firstRegistrationHandles = new List(); /// Initializes a new instance of the class. /// Test output helper for logging. public ItemHandleReuseProbeTests(ITestOutputHelper output) { this.output = output; } /// Adds, removes and re-adds the AckMsg item on a fresh registration and logs every handle. [Fact(Skip = "Live dev-rig probe (issue #166) — flip Skip=null on a host with MXAccess and the DEV Galaxy running.")] public void RemoveThenReAdd_LogsItemHandles() { Exception? threadException = null; Thread thread = new Thread(() => { try { RunProbe(); } catch (Exception ex) { threadException = ex; } }); thread.SetApartmentState(ApartmentState.STA); thread.Start(); thread.Join(); foreach (string line in log) { output.WriteLine(line); } if (threadException is not null) { throw new InvalidOperationException("Probe failed: " + threadException.Message, threadException); } // The ack writer's drop-and-re-add mitigation depends on this: within one // registration a handle number is never handed out twice. Assert.Equal((2 * Rounds) + 5, firstRegistrationHandles.Count); for (int i = 1; i < firstRegistrationHandles.Count; i++) { Assert.True( firstRegistrationHandles[i] > firstRegistrationHandles[i - 1], $"Item handle {firstRegistrationHandles[i]} did not increase past {firstRegistrationHandles[i - 1]}; MXAccess reused or reordered item handles within one registration."); } } private void RunProbe() { LMXProxyServerClass com = new LMXProxyServerClass(); try { IMxAccessServer mx = new MxAccessComServer(com); int server = mx.Register("MxGatewayProbe166"); Log($"Register -> server handle {server}"); // A: the AlarmAckWriter drop path (Advise, then UnAdvise + RemoveItem). for (int i = 0; i < Rounds; i++) { int h = Held(mx.AddItem(server, AckItem)); mx.Advise(server, h); Log($"A{i}: AddItem+Advise {AckItem} -> {h}"); mx.UnAdvise(server, h); mx.RemoveItem(server, h); Log($"A{i}: UnAdvise+RemoveItem {h}"); } // B: RemoveItem without Advise. for (int i = 0; i < Rounds; i++) { int h = Held(mx.AddItem(server, AckItem)); Log($"B{i}: AddItem {AckItem} -> {h}"); mx.RemoveItem(server, h); Log($"B{i}: RemoveItem {h}"); } // C: interleave another item to see whether a freed number is handed to any item. int c1 = Held(mx.AddItem(server, AckItem)); Log($"C: AddItem {AckItem} -> {c1}"); mx.RemoveItem(server, c1); Log($"C: RemoveItem {c1}"); int c2 = Held(mx.AddItem(server, OtherItem)); Log($"C: AddItem {OtherItem} -> {c2}"); int c3 = Held(mx.AddItem(server, AckItem)); Log($"C: AddItem {AckItem} -> {c3}"); mx.RemoveItem(server, c2); mx.RemoveItem(server, c3); Log($"C: RemoveItem {c2}, {c3}"); // D: the same item added twice while the first is still held. int d1 = Held(mx.AddItem(server, AckItem)); int d2 = Held(mx.AddItem(server, AckItem)); Log($"D: AddItem {AckItem} twice while held -> {d1}, {d2}"); mx.RemoveItem(server, d1); mx.RemoveItem(server, d2); mx.Unregister(server); Log($"Unregister {server}"); // E: the TearDownServer path: re-register on the same COM object and add again. int server2 = mx.Register("MxGatewayProbe166"); int e1 = mx.AddItem(server2, AckItem); Log($"E: re-Register -> server handle {server2}; AddItem {AckItem} -> {e1}"); mx.RemoveItem(server2, e1); mx.Unregister(server2); } finally { Marshal.FinalReleaseComObject(com); } // F: a fresh COM object, as AlarmAckWriter creates after a teardown. LMXProxyServerClass com2 = new LMXProxyServerClass(); try { IMxAccessServer mx2 = new MxAccessComServer(com2); int server3 = mx2.Register("MxGatewayProbe166"); int f1 = mx2.AddItem(server3, AckItem); Log($"F: new COM object Register -> server handle {server3}; AddItem {AckItem} -> {f1}"); mx2.RemoveItem(server3, f1); mx2.Unregister(server3); } finally { Marshal.FinalReleaseComObject(com2); } } private int Held(int itemHandle) { firstRegistrationHandles.Add(itemHandle); return itemHandle; } private void Log(string line) { log.Add(DateTime.Now.ToString("HH:mm:ss.fff") + " " + line); } }