diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs
new file mode 100644
index 0000000..f21a93f
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs
@@ -0,0 +1,229 @@
+using System;
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+using ZB.MOM.WW.MxGateway.Contracts.Proto;
+using ZB.MOM.WW.MxGateway.Worker.MxAccess;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace ZB.MOM.WW.MxGateway.Worker.Tests.Probes;
+
+///
+/// Live dev-rig smoke test for the MANAGED GUID-ack path — the
+/// wired into
+/// . Where AlarmsLiveSmokeTests acks by
+/// name through the wnwrap COM consumer, this acks by GUID through AVEVA's
+/// modern aaAlarmManagedClient.AlarmClient.AlarmAckByGUID — the path
+/// that carries operator domain + full name and is not E_NOTIMPL.
+///
+/// Flow: subscribe a wnwrap reader + a managed ack client to
+/// \\<machine>\Galaxy!DEV; wait for a Raise; pull a real
+/// (non-degraded) alarm GUID from the reader's snapshot; ack it by GUID via
+/// the dispatcher (which routes to the managed client); then assert the ack
+/// registers as a subsequent
+/// transition (and that the active snapshot shows the alarm acked).
+///
+/// This is the runtime confirmation the static work could not close: that an
+/// authenticated GUID ack actually flips state. rc=0 alone is "accepted",
+/// not "applied" — the Acknowledge transition is the real assertion. For a
+/// SECURED alarm the ack must run under an operator with Operate permission;
+/// run the dev-rig flip script and, if the test UDA is secured, an
+/// authenticated operator session (see docs/AlarmProbeFindings.md).
+///
+/// Skip-gated; flip Skip=null on the dev rig with AVEVA +
+/// aaAlarmManagedClient.dll present and the flip script running.
+///
+public sealed class AlarmManagedGuidAckLiveSmokeTests
+{
+ private static readonly string SubscriptionExpression =
+ $@"\\{Environment.MachineName}\Galaxy!DEV";
+ private static readonly TimeSpan TransitionWaitTimeout = TimeSpan.FromSeconds(20);
+ private static readonly TimeSpan RaiseWait = TimeSpan.FromSeconds(30);
+ private static readonly TimeSpan AckConfirmWait = TimeSpan.FromSeconds(30);
+
+ private const string SessionId = "alarms-managed-guid-ack-smoke";
+
+ private readonly ITestOutputHelper output;
+ private readonly Stopwatch elapsed = Stopwatch.StartNew();
+ private readonly ConcurrentQueue log = new ConcurrentQueue();
+
+ /// Initializes a new instance of the AlarmManagedGuidAckLiveSmokeTests class.
+ /// Test output helper for logging.
+ public AlarmManagedGuidAckLiveSmokeTests(ITestOutputHelper output)
+ {
+ this.output = output;
+ }
+
+ /// Acks a live alarm by GUID through the managed client and verifies the acked transition.
+ [Fact(Skip = "Live dev-rig smoke test — flip Skip=null with AVEVA + aaAlarmManagedClient.dll + the alarm flip script, and an operator authorized to ack.")]
+ public void ManagedGuidAck_AcksActiveAlarm_RegistersAcknowledge()
+ {
+ Exception? threadException = null;
+ var done = new ManualResetEventSlim(false);
+ var thread = new Thread(() =>
+ {
+ try { RunSmoke(); }
+ catch (Exception ex) { threadException = ex; }
+ finally { done.Set(); }
+ });
+ thread.IsBackground = false;
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ done.Wait();
+ thread.Join();
+
+ output.WriteLine($"Captured {log.Count} log line(s):");
+ while (log.TryDequeue(out string? line)) output.WriteLine(line);
+
+ if (threadException != null) throw threadException;
+ }
+
+ private void RunSmoke()
+ {
+ Log($"Subscription expression: {SubscriptionExpression}");
+
+ MxAccessEventQueue queue = new MxAccessEventQueue();
+ WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(
+ new WNWRAPCONSUMERLib.wwAlarmConsumerClass(),
+ maxAlarmsPerFetch: 1024);
+ MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
+
+ // Construct the managed ack client directly (production selects it via
+ // AaManagedAlarmAckConsumer.EnvGatedFactory when MXGATEWAY_USE_MANAGED_ALARM_ACK=1).
+ using IManagedAlarmAckClient managedAck =
+ new AaManagedAlarmAckConsumer(SessionId + ".ack");
+ using AlarmDispatcher dispatcher =
+ new AlarmDispatcher(consumer, sink, SessionId, managedAck);
+
+ Log("Constructed reader + managed ack client + dispatcher.");
+ dispatcher.Subscribe(SubscriptionExpression);
+ Log("Subscribe (reader + managed ack) -> ok. Driving PollOnce from this STA...");
+
+ // 1. Wait for a Raise so there is an active, unacked alarm to ack.
+ OnAlarmTransitionEvent? raiseBody = null;
+ DateTime raiseDeadline = DateTime.UtcNow + RaiseWait;
+ while (DateTime.UtcNow < raiseDeadline && raiseBody is null)
+ {
+ WorkerEvent? evt = WaitForTransition(queue, TransitionWaitTimeout, "raise", consumer);
+ if (evt is null) break;
+ OnAlarmTransitionEvent body = evt.Event.OnAlarmTransition;
+ Log("Transition: " + DescribeTransition(body));
+ Assert.Equal(SessionId, evt.Event.SessionId);
+ if (body.TransitionKind == AlarmTransitionKind.Raise) raiseBody = body;
+ }
+ Assert.NotNull(raiseBody);
+
+ // 2. Pull a REAL alarm GUID from the reader's snapshot (alarmmgr mode).
+ // Skip degraded/subtag records — their GUID is synthetic and not
+ // valid for AlarmAckByGUID.
+ var records = consumer.SnapshotActiveAlarms(out _);
+ Log($"Snapshot records: {records.Count}");
+ foreach (var r in records)
+ Log($" rec guid={r.AlarmGuid} state={r.State} degraded={r.Degraded} ref={r.ProviderName}!{r.Group}.{r.TagName}");
+
+ MxAlarmSnapshotRecord? target = records.FirstOrDefault(r =>
+ !r.Degraded &&
+ r.AlarmGuid != Guid.Empty &&
+ r.State == MxAlarmStateKind.UnackAlm);
+ Assert.NotNull(target);
+ Log($"Ack target GUID={target!.AlarmGuid} ref={target.ProviderName}!{target.Group}.{target.TagName}");
+
+ // 3. Ack by GUID through the dispatcher -> managed AlarmAckByGUID, with
+ // the current Windows identity (domain + full name are carried
+ // natively on this path, unlike the wnwrap 6-arg AckByName).
+ string user = Environment.UserName;
+ string node = Environment.MachineName;
+ string domain = Environment.UserDomainName ?? string.Empty;
+ Log($"Ack identity user='{user}' node='{node}' domain='{domain}'");
+
+ int rc = dispatcher.Acknowledge(
+ alarmGuid: target.AlarmGuid,
+ ackComment: "managed-guid-ack smoke",
+ ackOperatorName: user,
+ ackOperatorNode: node,
+ ackOperatorDomain: domain,
+ ackOperatorFullName: user);
+ Log($"dispatcher.Acknowledge(managed GUID) -> rc={rc}");
+ Assert.Equal(0, rc);
+
+ // 4. The ack must APPLY, not merely be accepted: wait for an
+ // Acknowledge transition on the same alarm, or the snapshot showing
+ // it acked. rc=0 with neither is the inert-ack failure the probe saw.
+ bool acked = false;
+ DateTime confirmDeadline = DateTime.UtcNow + AckConfirmWait;
+ while (DateTime.UtcNow < confirmDeadline && !acked)
+ {
+ WorkerEvent? evt = WaitForTransition(queue, TransitionWaitTimeout, "post-ack", consumer);
+ if (evt is not null)
+ {
+ OnAlarmTransitionEvent body = evt.Event.OnAlarmTransition;
+ Log("Post-ack transition: " + DescribeTransition(body));
+ if (body.TransitionKind == AlarmTransitionKind.Acknowledge)
+ {
+ acked = true;
+ break;
+ }
+ }
+
+ // Also accept the active snapshot flipping to an acked state for the
+ // target GUID, in case the ack delta was folded without a discrete
+ // Acknowledge transition.
+ var now = consumer.SnapshotActiveAlarms(out _);
+ if (now.Any(r => r.AlarmGuid == target.AlarmGuid &&
+ (r.State == MxAlarmStateKind.AckAlm || !string.IsNullOrEmpty(r.OperatorName))))
+ {
+ Log("Snapshot shows target GUID acked (AckAlm / operator populated).");
+ acked = true;
+ }
+ }
+
+ Assert.True(
+ acked,
+ "Managed GUID ack returned rc=0 but no Acknowledge transition or acked snapshot was observed " +
+ "within the confirm window — the ack was accepted but not applied (see docs/AlarmProbeFindings.md; " +
+ "most likely the alarm's MxSecurityOperate classification requires an authenticated operator).");
+ }
+
+ private WorkerEvent? WaitForTransition(
+ MxAccessEventQueue queue,
+ TimeSpan timeout,
+ string label,
+ WnWrapAlarmConsumer consumer)
+ {
+ DateTime deadline = DateTime.UtcNow + timeout;
+ int pollCount = 0;
+ while (DateTime.UtcNow < deadline)
+ {
+ try
+ {
+ consumer.PollOnce();
+ pollCount++;
+ }
+ catch (Exception ex)
+ {
+ Log($"PollOnce threw on poll #{pollCount + 1}: {ex.GetType().Name}: {ex.Message}");
+ throw;
+ }
+ if (queue.TryDequeue(out WorkerEvent? evt) && evt is not null)
+ {
+ if (evt.Event.Family == MxEventFamily.OnAlarmTransition) return evt;
+ Log($"Skipped non-alarm event (family={evt.Event.Family}) while waiting for {label}.");
+ }
+ Thread.Sleep(500);
+ }
+ Log($"Timed out waiting for {label} transition after {timeout.TotalSeconds:F0}s (polls={pollCount}).");
+ return null;
+ }
+
+ private static string DescribeTransition(OnAlarmTransitionEvent body)
+ {
+ return string.Format(
+ "kind={0} ref='{1}' operator='{2}' comment='{3}' ts={4:o}",
+ body.TransitionKind, body.AlarmFullReference, body.OperatorUser, body.OperatorComment,
+ body.TransitionTimestamp?.ToDateTime() ?? DateTime.MinValue);
+ }
+
+ private void Log(string line) => log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}");
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs
index c9afa8d..44da620 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs
@@ -76,7 +76,7 @@ public sealed class WorkerPipeSession
options,
() => Process.GetCurrentProcess().Id,
new WorkerPipeSessionOptions(),
- () => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)),
+ () => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory)),
logger,
stream)
{
@@ -98,7 +98,7 @@ public sealed class WorkerPipeSession
options,
processIdProvider,
new WorkerPipeSessionOptions(),
- () => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)),
+ () => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory)),
logger: null)
{
}
@@ -1585,7 +1585,7 @@ public sealed class WorkerPipeSession
// parameterless CompleteStartupHandshakeAsync is used without a
// prior factory call.
_runtimeSession ??= new MxAccessStaSession(
- (eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null));
+ (eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory));
IWorkerRuntimeSession session = _runtimeSession;
try
{
diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AaManagedAlarmAckConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AaManagedAlarmAckConsumer.cs
new file mode 100644
index 0000000..8c68f9f
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AaManagedAlarmAckConsumer.cs
@@ -0,0 +1,231 @@
+using System;
+using System.IO;
+using System.Reflection;
+using WNWRAPCONSUMERLib;
+
+namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
+
+///
+/// Acknowledge path over AVEVA's modern managed alarm client,
+/// aaAlarmManagedClient.AlarmClient (the C++/CLI assembly the AVEVA
+/// Alarm Control itself uses: AlarmClient → CwwAlarmConsumer →
+/// alarmlst.dll → alarmmgr.exe). Preferred over a hand-rolled native
+/// WNAL consumer because AlarmAckByGUID performs the
+/// register → match-by-GUID → submit sequence internally — the record
+/// layout and selection semantics that are provider-defined (and therefore
+/// NOT safely recoverable by static RE of alarmlst.dll) stay inside AVEVA's
+/// own code. See for why the native
+/// acquisition path is deliberately unfinished.
+///
+/// The AlarmClient members are famorasm (protected-internal)
+/// and mxaccessgw is not an InternalsVisibleTo friend, so everything
+/// here is invoked by reflection. In full-trust .NET Framework 4.8 that is
+/// permitted. Method shapes are confirmed from the assembly metadata:
+/// .ctor() (internal)
+/// int InitializeConsumer(string appName)
+/// int RegisterConsumer(int hWnd, string product, string app, string version, bool)
+/// int Subscribe(string expr, short fromPri, short toPri,
+/// localQueryType, localSortFlags,
+/// localAlarmFilterState mask, localAlarmFilterState spec)
+/// int AlarmAckByGUID(System.Guid guid, string comment, string oper,
+/// string node, string domain, string fullName)
+/// int DeregisterConsumer() / int UninitializeConsumer() / Dispose()
+///
+/// THREADING: the underlying consumer is apartment-affine (same as the
+/// wnwrap COM path). Construct and call this only on the worker STA
+/// (marshal via StaRuntime.InvokeAsync).
+///
+/// x86 ONLY — aaAlarmManagedClient.dll is a 32-bit mixed-mode PE.
+///
+/// OPEN (confirm at runtime, flagged inline): the trailing bool of
+/// RegisterConsumer (passed false here); whether
+/// Subscribe is actually required before an ack by GUID; and the
+/// native status returned on a real secured ack.
+///
+public sealed class AaManagedAlarmAckConsumer : IManagedAlarmAckClient
+{
+ /// Default install location of the managed alarm client (eac.dll folder).
+ public const string DefaultAssemblyPath =
+ @"C:\Program Files (x86)\ArchestrA\Framework\Bin\Invensys_Systems__Inc__eac.dll\aaAlarmManagedClient.dll";
+
+ ///
+ /// Env var that opts the worker into the managed ack path. Unset/anything
+ /// other than "1" keeps the existing wnwrap ack behavior.
+ ///
+ public const string EnableEnvVar = "MXGATEWAY_USE_MANAGED_ALARM_ACK";
+
+ private const string AckApplicationName = "ZB.MOM.WW.MxGateway.ack";
+
+ ///
+ /// Production factory for the seam:
+ /// returns a managed ack client only when == "1",
+ /// otherwise (ack stays on the wnwrap path). Never
+ /// throws — a construction failure (missing DLL, etc.) degrades to the
+ /// wnwrap path rather than breaking the alarm subscription.
+ ///
+ public static IManagedAlarmAckClient? EnvGatedFactory()
+ {
+ if (!string.Equals(Environment.GetEnvironmentVariable(EnableEnvVar), "1", StringComparison.Ordinal))
+ return null;
+ try
+ {
+ return new AaManagedAlarmAckConsumer(AckApplicationName);
+ }
+ catch
+ {
+ // Opt-in enhancement only: if the managed client can't load, fall
+ // back to the wnwrap ack path rather than failing alarm reads.
+ return null;
+ }
+ }
+
+ private const string TypeName = "aaAlarmManagedClient.AlarmClient";
+ private const BindingFlags Inst = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
+
+ private readonly object syncRoot = new();
+ private readonly object client;
+ private readonly MethodInfo initializeConsumer;
+ private readonly MethodInfo registerConsumer;
+ private readonly MethodInfo subscribe;
+ private readonly MethodInfo alarmAckByGuid;
+ private readonly MethodInfo? deregisterConsumer;
+ private readonly MethodInfo? uninitializeConsumer;
+
+ private bool subscribed;
+ private bool disposed;
+
+ /// Consumer application name (e.g. "MxGateway.ack").
+ /// Override for .
+ public AaManagedAlarmAckConsumer(string applicationName, string? assemblyPath = null)
+ {
+ if (string.IsNullOrWhiteSpace(applicationName))
+ throw new ArgumentException("Application name is required.", nameof(applicationName));
+
+ string path = assemblyPath ?? DefaultAssemblyPath;
+ if (!File.Exists(path))
+ throw new FileNotFoundException($"aaAlarmManagedClient.dll not found at '{path}'.", path);
+
+ Assembly asm = Assembly.LoadFrom(path);
+ Type type = asm.GetType(TypeName, throwOnError: true)
+ ?? throw new InvalidOperationException($"Type '{TypeName}' not found in {path}.");
+
+ initializeConsumer = Resolve(type, "InitializeConsumer");
+ registerConsumer = Resolve(type, "RegisterConsumer");
+ subscribe = Resolve(type, "Subscribe");
+ alarmAckByGuid = Resolve(type, "AlarmAckByGUID");
+ deregisterConsumer = type.GetMethod("DeregisterConsumer", Inst, null, Type.EmptyTypes, null);
+ uninitializeConsumer = type.GetMethod("UninitializeConsumer", Inst, null, Type.EmptyTypes, null);
+
+ client = Activator.CreateInstance(type, nonPublic: true)
+ ?? throw new InvalidOperationException($"Could not construct '{TypeName}'.");
+
+ ApplicationName = applicationName;
+ }
+
+ /// The consumer application name this instance registered under.
+ public string ApplicationName { get; }
+
+ private static MethodInfo Resolve(Type type, string name) =>
+ type.GetMethod(name, Inst)
+ ?? throw new MissingMethodException(TypeName, name);
+
+ ///
+ /// Run the Initialize → Register → Subscribe lifecycle once, mirroring the
+ /// proven wnwrap argument values. is the
+ /// alarm query root, e.g. \\NODE\Galaxy!Area. Throws if any native
+ /// status is non-zero.
+ ///
+ public void Subscribe(string subscription)
+ {
+ if (subscription is null) throw new ArgumentNullException(nameof(subscription));
+ lock (syncRoot)
+ {
+ if (disposed) throw new ObjectDisposedException(nameof(AaManagedAlarmAckConsumer));
+ if (subscribed) throw new InvalidOperationException("Subscribe was already called.");
+
+ int init = (int)initializeConsumer.Invoke(client, new object[] { ApplicationName })!;
+ if (init != 0) throw new InvalidOperationException($"InitializeConsumer returned {init}.");
+
+ // RegisterConsumer(hWnd, product, app, version, bool). hWnd=0 => pull model.
+ // The trailing bool is unconfirmed; false = client/non-modal is the safe default.
+ int reg = (int)registerConsumer.Invoke(client, new object[]
+ {
+ 0, "ZB.MOM.WW.MxGateway", ApplicationName, "1.0", false,
+ })!;
+ if (reg != 0) throw new InvalidOperationException($"RegisterConsumer returned {reg}.");
+
+ int sub = (int)subscribe.Invoke(client, BuildSubscribeArgs(subscription))!;
+ if (sub != 0) throw new InvalidOperationException($"Subscribe('{subscription}') returned {sub}.");
+
+ subscribed = true;
+ }
+ }
+
+ ///
+ /// Acknowledge a single alarm by GUID with full operator identity. Returns
+ /// AVEVA's native status (0 = success). A zero return means accepted, not
+ /// necessarily applied — verify the alarm's acked state / Historian row
+ /// (see docs/AlarmProbeFindings.md).
+ ///
+ public int AcknowledgeByGuid(
+ Guid alarmGuid,
+ string comment,
+ string operatorName,
+ string operatorNode,
+ string operatorDomain,
+ string operatorFullName)
+ {
+ lock (syncRoot)
+ {
+ if (disposed) throw new ObjectDisposedException(nameof(AaManagedAlarmAckConsumer));
+ if (!subscribed) throw new InvalidOperationException("Subscribe must be called before acknowledging.");
+
+ return (int)alarmAckByGuid.Invoke(client, new object[]
+ {
+ alarmGuid,
+ comment ?? string.Empty,
+ operatorName ?? string.Empty,
+ operatorNode ?? string.Empty,
+ operatorDomain ?? string.Empty,
+ operatorFullName ?? string.Empty,
+ })!;
+ }
+ }
+
+ ///
+ /// Build the 7 Subscribe args, boxing the proven wnwrap enum integers into
+ /// aaAlarmManagedClient's own enum parameter types (localQueryType /
+ /// localSortFlags / localAlarmFilterState) via .
+ ///
+ private object[] BuildSubscribeArgs(string subscription)
+ {
+ ParameterInfo[] p = subscribe.GetParameters();
+ if (p.Length != 7)
+ throw new InvalidOperationException($"Unexpected AlarmClient.Subscribe arity {p.Length}; expected 7.");
+
+ return new object[]
+ {
+ subscription,
+ (short)1, // wFromPri
+ (short)999, // wToPri
+ Enum.ToObject(p[3].ParameterType, (int)eQueryType.qtSummary),
+ Enum.ToObject(p[4].ParameterType, (int)eSortFlags.sfReturnNewestFirst),
+ Enum.ToObject(p[5].ParameterType, (int)eAlarmFilterState.asAlarmActiveNow),
+ Enum.ToObject(p[6].ParameterType, (int)eAlarmFilterState.asAlarmActiveNow),
+ };
+ }
+
+ ///
+ public void Dispose()
+ {
+ lock (syncRoot)
+ {
+ if (disposed) return;
+ disposed = true;
+
+ try { if (subscribed) deregisterConsumer?.Invoke(client, Array.Empty