docs(worker): record live item-handle-reuse probe for ack writer
ci / windows-x86 (push) Successful in 1m29s
ci / nightly-windev (push) Has been skipped
ci / java (push) Successful in 2m47s
ci / nightly-windev (pull_request) Has been skipped
ci / portable (push) Failing after 3m1s
ci / portable (pull_request) Failing after 1m46s
ci / windows-x86 (pull_request) Successful in 1m24s
ci / java (pull_request) Successful in 2m36s

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
This commit is contained in:
Joseph Doherty
2026-10-20 07:10:38 -04:00
parent f13950e453
commit bd82036d5f
4 changed files with 206 additions and 1 deletions
+29
View File
@@ -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.
+4 -1
View File
@@ -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
@@ -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;
/// <summary>
/// Live probe that logs the item handles MXAccess returns when the same
/// <c>AckMsg</c> item is removed and re-added, to decide whether a late
/// <c>OnWriteComplete</c> for a dropped handle could match a re-added one.
/// </summary>
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<string> log = new List<string>();
/// <summary>Initializes a new instance of the <see cref="ItemHandleReuseProbeTests"/> class.</summary>
/// <param name="output">Test output helper for logging.</param>
public ItemHandleReuseProbeTests(ITestOutputHelper output)
{
this.output = output;
}
/// <summary>Adds, removes and re-adds the AckMsg item on a fresh registration and logs every handle.</summary>
[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);
}
}
@@ -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;
}