From bd82036d5f825c6afebb778398508c8064a23695 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 20 Oct 2026 07:10:38 -0400 Subject: [PATCH] docs(worker): record live item-handle-reuse probe for ack writer Add the Skip-gated ItemHandleReuseProbeTests probe and its windev result (2026-10-20): within one MXAccess registration, item handles increase monotonically and are never reused across remove/re-add, so the ack writer's Unconfirmed drop-and-re-add mitigation holds and no per-handle generation counter is needed. Numbering restarts only on a new registration, which teardown already isolates. Document the guarantee at the Dirty re-add and in AlarmProbeFindings.md. Fixes #166 Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT --- docs/AlarmProbeFindings.md | 29 +++ docs/MxAccessWorkerInstanceDesign.md | 5 +- .../Probes/ItemHandleReuseProbeTests.cs | 166 ++++++++++++++++++ .../MxAccess/AlarmAckWriter.cs | 7 + 4 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/ItemHandleReuseProbeTests.cs diff --git a/docs/AlarmProbeFindings.md b/docs/AlarmProbeFindings.md index 15ab240..17a54f7 100644 --- a/docs/AlarmProbeFindings.md +++ b/docs/AlarmProbeFindings.md @@ -515,3 +515,32 @@ returns gRPC `InvalidArgument`. The GUID arm of the ack command was not driven live. The public alarm RPCs expose only the full reference, so clients reach the by-name arm; worker unit tests cover the GUID arm. + +## Item handle reuse + +`AlarmAckWriter` correlates an ack with its `OnWriteComplete` by the +(server handle, item handle) pair, because the callback carries no per-write id. +After an unconfirmed ack it drops the `AckMsg` item and re-adds it so the next +ack waits on a new handle that a late completion for the old one cannot match. +That only works if MXAccess does not hand the same number out again. + +`ItemHandleReuseProbeTests` (Skip-gated, worker tests) checked this on windev +(`10.100.0.48`, Galaxy `DEV`) on 2026-10-20 against +`TestMachine_001.TestAlarm001.AckMsg`, without writing: + +| Sequence on one registration (server handle 1) | Item handles returned | +|---|---| +| `AddItem` + `Advise`, then `UnAdvise` + `RemoveItem`, five times | 1, 2, 3, 4, 5 | +| `AddItem`, then `RemoveItem`, five times | 6, 7, 8, 9, 10 | +| Add and remove `AckMsg`, add `Acked`, add `AckMsg` again | 11, then 12 (`Acked`), 13 | +| Add `AckMsg` twice while both are held | 14, 15 | + +Within a registration the numbers increase and a freed number is never reused, +so the drop-and-re-add mitigation holds. Numbering is per registration: +`Unregister` + `Register` on the same COM object returned server handle 2 and +item handle 1, and a new `LMXProxyServerClass` returned server handle 1 and +item handle 1 again. The writer is not exposed to that restart because its +teardown detaches its `OnWriteComplete` handler from the old COM object and +clears that server handle's completion entries before it creates a new one. +No per-handle generation counter is needed. + diff --git a/docs/MxAccessWorkerInstanceDesign.md b/docs/MxAccessWorkerInstanceDesign.md index b648b1a..8da84e7 100644 --- a/docs/MxAccessWorkerInstanceDesign.md +++ b/docs/MxAccessWorkerInstanceDesign.md @@ -356,7 +356,10 @@ stay acceptance-only regardless of which completion (if any) was attributed. `MxAccessWriteCompletionCache` and reuses one advised `AckMsg` item per alarm (least-recently-used, capped at 256). After an ack ends unconfirmed it marks the item dirty, and the next ack to that alarm removes and re-adds the item so the -write waits on a fresh item handle the stale completion cannot match. Every +write waits on a fresh item handle the stale completion cannot match. This +relies on MXAccess never reusing an item handle number within one +registration, which a live probe confirmed (see "Item handle reuse" in +[Alarm Probe Findings](./AlarmProbeFindings.md)). Every drop of an item (dirty re-add, eviction, or a failed `AddItem`/`Advise`/`Write`) also removes that handle's completion entry, and tearing down the registration removes every entry for its server handle, so the cache does not grow by one 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..9c294a7 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/ItemHandleReuseProbeTests.cs @@ -0,0 +1,166 @@ +// 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(); + + /// 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); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs index 1538607..d6d774c 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs @@ -166,6 +166,13 @@ public sealed class AlarmAckWriter : IAlarmAckWriter // (server, item) handle with no per-write id, so a late one would // be credited to this write. Drop the item and re-add it so this // write waits on a fresh handle the stale completion cannot match. + // That holds because MXAccess never reuses an item handle number + // within one registration: the live probe (ItemHandleReuseProbeTests, + // windev 2026-10-20) saw strictly increasing handles across repeated + // remove/re-add of the same item. Numbering restarts only on a new + // registration, and TearDownServer detaches this writer's + // OnWriteComplete handler and clears the server's completion entries + // first, so the old registration's completions cannot reach it. DropItem(mx, node); cached = false; }