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
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user