diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/ItemHandleReuseProbeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/ItemHandleReuseProbeTests.cs new file mode 100644 index 0000000..6557b3f --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/ItemHandleReuseProbeTests.cs @@ -0,0 +1,160 @@ +// 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. + +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(); + + /// 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); + } + } + + 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 = 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 = 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 = mx.AddItem(server, AckItem); + Log($"C: AddItem {AckItem} -> {c1}"); + mx.RemoveItem(server, c1); + Log($"C: RemoveItem {c1}"); + int c2 = mx.AddItem(server, OtherItem); + Log($"C: AddItem {OtherItem} -> {c2}"); + int c3 = 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 = mx.AddItem(server, AckItem); + int d2 = 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 void Log(string line) + { + log.Add(DateTime.Now.ToString("HH:mm:ss.fff") + " " + line); + } +}