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 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
|
the full reference, so clients reach the by-name arm; worker unit tests cover the GUID
|
||||||
arm.
|
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
|
`MxAccessWriteCompletionCache` and reuses one advised `AckMsg` item per alarm
|
||||||
(least-recently-used, capped at 256). After an ack ends unconfirmed it marks the
|
(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
|
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`)
|
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
|
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
|
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
|
// (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
|
// 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.
|
// 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);
|
DropItem(mx, node);
|
||||||
cached = false;
|
cached = false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user