From 6affbd57eac0f50b33d1f51d7bcb036a101cafa8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 18 Oct 2026 12:42:07 -0400 Subject: [PATCH 1/2] feat(worker): add opt-in managed GUID alarm-ack path Adds a managed acknowledge path over AVEVA's modern alarm client so GUID acks carry full operator identity (domain + full name) and work on builds where the wnwrap COM AlarmAckByGUID is E_NOTIMPL. Default off; opt in with MXGATEWAY_USE_MANAGED_ALARM_ACK=1 (wnwrap behavior unchanged otherwise). - AaManagedAlarmAckConsumer: reflection wrapper over aaAlarmManagedClient.AlarmClient (InitializeConsumer/RegisterConsumer/ Subscribe/AlarmAckByGUID); EnvGatedFactory degrades to null (wnwrap) if the DLL can't load, so alarm reads never break. - IManagedAlarmAckClient seam. AlarmDispatcher takes an optional client and routes GUID acks to it (subscribing + disposing it alongside the reader). - AlarmCommandHandler gains an optional managedAckFactory; WorkerPipeSession wires AaManagedAlarmAckConsumer.EnvGatedFactory at the production sites. - WnalNativeAckConsumer: confirmed native alarmlst.dll WNAL submit layer (WNALalarmReqSetText / WNALUserRequest / WNALUserRequestEx + lpAlmAck layout). List acquisition is intentionally not hand-rolled: the alarm-record layout and selection semantics are provider-defined and not recoverable from alarmlst.dll, so inferring them risks acking the wrong alarm. - AlarmManagedGuidAckLiveSmokeTests: skip-gated live smoke that pulls a real (non-degraded) alarm GUID and asserts the ack APPLIES (Acknowledge transition / acked snapshot), not merely rc=0. By-name acks stay on wnwrap (AlarmClient has no AlarmAckByName). Grounded in static RE of alarmlst.dll, aaAlarmManagedClient.dll and alarmmgr.exe. Co-Authored-By: Claude Opus 4.8 --- .../AlarmManagedGuidAckLiveSmokeTests.cs | 229 +++++++++++++++ .../Ipc/WorkerPipeSession.cs | 6 +- .../MxAccess/AaManagedAlarmAckConsumer.cs | 231 +++++++++++++++ .../MxAccess/AlarmCommandHandler.cs | 18 +- .../MxAccess/AlarmDispatcher.cs | 29 +- .../MxAccess/IManagedAlarmAckClient.cs | 44 +++ .../MxAccess/WnalNativeAckConsumer.cs | 273 ++++++++++++++++++ 7 files changed, 824 insertions(+), 6 deletions(-) create mode 100644 src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AaManagedAlarmAckConsumer.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IManagedAlarmAckClient.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnalNativeAckConsumer.cs 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()); } catch { /* best effort */ } + try { uninitializeConsumer?.Invoke(client, Array.Empty()); } catch { /* best effort */ } + (client as IDisposable)?.Dispose(); + } + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs index f1d5b1b..8888e55 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs @@ -39,6 +39,7 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler private readonly Action? threadAffinityCheck; private readonly IMxAccessComObjectFactory? comFactory; private readonly Func, IMxAccessAlarmConsumer>? standbyFactory; + private readonly Func? managedAckFactory; private readonly MxAccessEventMapper mapper = new MxAccessEventMapper(); private readonly object syncRoot = new object(); private AlarmDispatcher? dispatcher; @@ -117,18 +118,29 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler /// . Tests inject a fake so they need no /// live COM factory. /// + /// + /// Optional per-subscription factory for a managed ack client + /// (). When it yields a non-null + /// client, GUID-keyed acknowledges route through AVEVA's modern + /// AlarmClient.AlarmAckByGUID instead of the wnwrap COM path. + /// Defaults to (wnwrap ack). Production supplies + /// , which is a + /// no-op unless MXGATEWAY_USE_MANAGED_ALARM_ACK=1. + /// public AlarmCommandHandler( MxAccessEventQueue eventQueue, Func consumerFactory, Action? threadAffinityCheck, IMxAccessComObjectFactory? comFactory, - Func, IMxAccessAlarmConsumer>? standbyFactory) + Func, IMxAccessAlarmConsumer>? standbyFactory, + Func? managedAckFactory = null) { this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue)); this.consumerFactory = consumerFactory ?? throw new ArgumentNullException(nameof(consumerFactory)); this.threadAffinityCheck = threadAffinityCheck; this.comFactory = comFactory; this.standbyFactory = standbyFactory; + this.managedAckFactory = managedAckFactory; } /// Gets a value indicating whether the handler is subscribed. @@ -169,8 +181,10 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler failover.ProviderModeChanged += providerModeChangedHandler; } + // Optional managed ack client (opt-in via env); null → wnwrap ack. + IManagedAlarmAckClient? managedAck = managedAckFactory?.Invoke(); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(eventQueue, mapper); - dispatcher = new AlarmDispatcher(consumer, sink, subscribeSessionId); + dispatcher = new AlarmDispatcher(consumer, sink, subscribeSessionId, managedAck); try { dispatcher.Subscribe(subscriptionExpression); diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs index e9f4012..a76cdc4 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs @@ -36,20 +36,28 @@ public sealed class AlarmDispatcher : IDisposable private readonly MxAccessAlarmEventSink sink; private readonly string sessionId; private readonly EventHandler handler; + private readonly IManagedAlarmAckClient? managedAck; private bool disposed; /// Initializes a new alarm dispatcher for the given consumer, sink, and session ID. /// The alarm consumer. /// The alarm event sink. /// The session identifier. + /// + /// Optional managed ack client. When non-null, GUID-keyed acknowledges + /// route to it (AVEVA's modern AlarmClient.AlarmAckByGUID) instead + /// of the wnwrap consumer; null keeps the existing wnwrap ack path. + /// public AlarmDispatcher( IMxAccessAlarmConsumer consumer, MxAccessAlarmEventSink sink, - string sessionId) + string sessionId, + IManagedAlarmAckClient? managedAck = null) { this.consumer = consumer ?? throw new ArgumentNullException(nameof(consumer)); this.sink = sink ?? throw new ArgumentNullException(nameof(sink)); this.sessionId = sessionId ?? string.Empty; + this.managedAck = managedAck; // Sink.Attach is the seam that propagates the session id onto the // proto SessionId field of every emitted MxEvent. Pass the consumer // as the "associated COM object" — sink ignores the object reference @@ -70,6 +78,10 @@ public sealed class AlarmDispatcher : IDisposable { if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher)); consumer.Subscribe(subscription); + // The managed ack client maintains its own registered/subscribed + // consumer; run its lifecycle against the same expression so a later + // AlarmAckByGUID has a live consumer to act through. + managedAck?.Subscribe(subscription); } /// @@ -93,6 +105,20 @@ public sealed class AlarmDispatcher : IDisposable string ackOperatorFullName) { if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher)); + if (managedAck is not null) + { + // Preferred path: AVEVA's modern AlarmClient.AlarmAckByGUID carries + // operator domain + full name and is not E_NOTIMPL like the wnwrap + // COM AlarmAckByGUID. + return managedAck.AcknowledgeByGuid( + alarmGuid, + ackComment, + ackOperatorName, + ackOperatorNode, + ackOperatorDomain, + ackOperatorFullName); + } + return consumer.AcknowledgeByGuid( alarmGuid, ackComment, @@ -268,5 +294,6 @@ public sealed class AlarmDispatcher : IDisposable try { consumer.AlarmTransitionEmitted -= handler; } catch { /* swallow */ } try { sink.Detach(); } catch { /* swallow */ } try { consumer.Dispose(); } catch { /* swallow */ } + try { managedAck?.Dispose(); } catch { /* swallow */ } } } diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IManagedAlarmAckClient.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IManagedAlarmAckClient.cs new file mode 100644 index 0000000..ffe0258 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IManagedAlarmAckClient.cs @@ -0,0 +1,44 @@ +using System; + +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// +/// Optional ack-only seam over AVEVA's modern managed alarm client +/// (aaAlarmManagedClient.AlarmClient). When supplied to an +/// , GUID-keyed acknowledges route here +/// instead of the wnwrap COM consumer's AlarmAckByGUID (which is +/// E_NOTIMPL on current builds) — carrying full operator identity +/// (domain + full name) that the wnwrap 6-arg AlarmAckByName drops. +/// The production implementation is . +/// +public interface IManagedAlarmAckClient : IDisposable +{ + /// + /// Run the client's Initialize → Register → Subscribe lifecycle against + /// the same subscription expression the read consumer uses + /// (\\<node>\Galaxy!<area>). Must be called once, + /// on the worker STA, before . + /// + /// The subscription expression. + void Subscribe(string subscription); + + /// + /// Acknowledge a single alarm by GUID with full operator identity. + /// Returns AVEVA's native status (0 = success; 0 means accepted, not + /// necessarily applied — verify the alarm's acked state / Historian row). + /// + /// The alarm GUID. + /// The acknowledgment comment. + /// The operator name. + /// The operator node. + /// The operator domain. + /// The operator full name. + /// The AVEVA-native status code. + int AcknowledgeByGuid( + Guid alarmGuid, + string comment, + string operatorName, + string operatorNode, + string operatorDomain, + string operatorFullName); +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnalNativeAckConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnalNativeAckConsumer.cs new file mode 100644 index 0000000..aaca704 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnalNativeAckConsumer.cs @@ -0,0 +1,273 @@ +using System; +using System.Runtime.InteropServices; +using System.Text; + +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// +/// Native alarm-acknowledge path over alarmlst.dll's public WNAL* +/// C exports (the same stack the AVEVA Alarm Control uses: +/// aaAlarmManagedClient → CwwAlarmConsumer → alarmlst.dll → alarmmgr.exe). +/// +/// Why this exists alongside : the legacy +/// WNWRAPCONSUMERLib COM surface cannot carry a full/secured ack — +/// AlarmAckByGUID is E_NOTIMPL, the 8-arg AlarmAckByName +/// is a vendor stub (-55), and neither exposes the signed-ack password. The +/// native request record built by has six text +/// fields — node, operator, comment, password, domain, full name — so +/// this path can do a fully-attributed (and signed) ack. +/// +/// THREADING: these calls are not inherently STA-bound the way the COM +/// consumer is, but keep all of them on the worker STA for ordering/parity +/// with the rest of the alarm surface (marshal via StaRuntime.InvokeAsync). +/// +/// x86 ONLY: alarmlst.dll is 32-bit; this compiles into the x86 worker. +/// +/// STATUS: the ack-SUBMIT contract (, +/// , ) and the +/// request-record layout are confirmed by disassembly (see +/// docs/alarm-ack-findings.md in the reverse-engineering workspace). The +/// list-ACQUISITION exports in are declared from +/// their export names/ordinals but their argument signatures are NOT yet +/// reverse-engineered — DO NOT call them until the signatures are verified, +/// or the stdcall stack will be corrupted. +/// +public sealed class WnalNativeAckConsumer +{ + private const string AlarmLst = "alarmlst.dll"; + + /// Request-record version stamped at WORD offset 2; WNALUserRequest rejects anything else. + private const ushort AlmAckVersion = 0x104; + + /// Byte offset where WNALalarmReqSetText begins the string-offset table + heap. + private const int StringHeapBase = 0x42; + + /// Sentinel the exports reject as an invalid list handle. + public const int InvalidAlarmList = -1; + + // --------------------------------------------------------------------- + // CONFIRMED exports (disassembly-verified signatures). __stdcall, ANSI. + // --------------------------------------------------------------------- + + /// + /// Pack operator identity + comment into an existing request record. + /// Arg order is confirmed from the debug format string. Per-field rule: + /// pass null to KEEP the record's existing value, "" to CLEAR it, + /// a string to SET it. Strings are single-byte ASCII (strcpy_s). + /// Return: positive = bytes used; NEGATIVE = record too small, magnitude + /// is the required recSize; 0 = was null. + /// + [DllImport(AlarmLst, EntryPoint = "WNALalarmReqSetText", + CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Ansi, ExactSpelling = true)] + private static extern int WnalReqSetText( + IntPtr lpAlReq, + [MarshalAs(UnmanagedType.LPStr)] string? nodeName, + [MarshalAs(UnmanagedType.LPStr)] string? oper, + [MarshalAs(UnmanagedType.LPStr)] string? comment, + [MarshalAs(UnmanagedType.LPStr)] string? passWd, + [MarshalAs(UnmanagedType.LPStr)] string? domainName, + [MarshalAs(UnmanagedType.LPStr)] string? fullName); + + /// + /// Submit a user request (an ack) over the alarms SELECTED in + /// . a2 is reserved (0); a3 is a capacity + /// hint (the native client passes the grid row count); a4 is scope + /// (0 = all, 1 = selected). Returns 0 on success (provider status), + /// non-zero on error. + /// + [DllImport(AlarmLst, EntryPoint = "WNALUserRequest", + CallingConvention = CallingConvention.StdCall, ExactSpelling = true)] + private static extern int WnalUserRequest(int hAlarmList, uint a2, uint a3, int a4, IntPtr lpAlmAck); + + /// + /// Submit a user request (an ack) against an EXPLICIT array of + /// 4-byte alarm handles — no selection state + /// required. mirrors the scope/flag scalar. + /// Returns 0 on success. + /// + [DllImport(AlarmLst, EntryPoint = "WNALUserRequestEx", + CallingConvention = CallingConvention.StdCall, ExactSpelling = true)] + private static extern int WnalUserRequestEx( + int hAlarmList, IntPtr handleArray, int count, uint a4, IntPtr lpAlmAck); + + /// Acknowledge-scope values for the a4 argument. + private enum AckScope + { + All = 0, + Selected = 1, + } + + /// + /// Build a request record and submit an ack for the given alarm + /// handles via . Caller supplies a live + /// and the handle(s) obtained from the + /// list's records (see — acquisition path). + /// Returns the native status (0 == success). + /// + /// A registered, subscribed alarm-list handle. + /// The 4-byte alarm handles to acknowledge. + /// Ack comment. + /// Operator / user name. + /// Operator node name. + /// Operator domain (carried natively, unlike the COM path). + /// Operator full name (carried natively, unlike the COM path). + /// Operator password for a signed/secured ack, or null. + public int AcknowledgeByHandles( + int hAlarmList, + int[] alarmHandles, + string comment, + string operatorName, + string operatorNode, + string operatorDomain, + string operatorFullName, + string? password) + { + if (hAlarmList == InvalidAlarmList) + throw new ArgumentException("Invalid alarm-list handle.", nameof(hAlarmList)); + if (alarmHandles is null || alarmHandles.Length == 0) + throw new ArgumentException("At least one alarm handle is required.", nameof(alarmHandles)); + + IntPtr record = IntPtr.Zero; + IntPtr handles = IntPtr.Zero; + try + { + record = BuildAckRecord( + nodeName: operatorNode, + oper: operatorName, + comment: comment, + passWd: password, + domainName: operatorDomain, + fullName: operatorFullName); + + handles = Marshal.AllocHGlobal(alarmHandles.Length * sizeof(int)); + for (int i = 0; i < alarmHandles.Length; i++) + Marshal.WriteInt32(handles, i * sizeof(int), alarmHandles[i]); + + return WnalUserRequestEx( + hAlarmList, + handles, + alarmHandles.Length, + (uint)AckScope.Selected, + record); + } + finally + { + if (handles != IntPtr.Zero) Marshal.FreeHGlobal(handles); + if (record != IntPtr.Zero) Marshal.FreeHGlobal(record); + } + } + + /// + /// Acknowledge whatever is currently SELECTED in the list (a4 = Selected) + /// via . Use when selection has already been + /// established on the list rather than passing explicit handles. + /// + public int AcknowledgeSelected( + int hAlarmList, + uint rowCountHint, + string comment, + string operatorName, + string operatorNode, + string operatorDomain, + string operatorFullName, + string? password) + { + if (hAlarmList == InvalidAlarmList) + throw new ArgumentException("Invalid alarm-list handle.", nameof(hAlarmList)); + + IntPtr record = IntPtr.Zero; + try + { + record = BuildAckRecord(operatorNode, operatorName, comment, password, operatorDomain, operatorFullName); + return WnalUserRequest(hAlarmList, a2: 0, a3: rowCountHint, a4: (int)AckScope.Selected, record); + } + finally + { + if (record != IntPtr.Zero) Marshal.FreeHGlobal(record); + } + } + + /// + /// Allocate a zeroed request record (header + room for the string heap), + /// stamp recSize + version, then let pack the + /// operator/comment text. Returns an unmanaged buffer the caller must free. + /// + private static IntPtr BuildAckRecord( + string nodeName, string oper, string comment, string? passWd, string domainName, string fullName) + { + // recSize must be >= 0x42 + sum(strlen+1 of each field). Size generously; + // SetText returns a negative "needed size" if this is too small, which we + // honor by growing once. + int capacity = EstimateCapacity(nodeName, oper, comment, passWd, domainName, fullName); + + for (int attempt = 0; attempt < 2; attempt++) + { + IntPtr buf = Marshal.AllocHGlobal(capacity); + try + { + // Zero the whole record (fixed header 0x04..0x31 stays 0 — WNALUserRequest + // does not validate it; it is informational/provider-filled). + for (int off = 0; off < capacity; off += sizeof(long)) + { + int remaining = capacity - off; + if (remaining >= sizeof(long)) Marshal.WriteInt64(buf, off, 0); + else for (int b = off; b < capacity; b++) Marshal.WriteByte(buf, b, 0); + } + + Marshal.WriteInt16(buf, 0, (short)(ushort)capacity); // WORD recSize + Marshal.WriteInt16(buf, 2, unchecked((short)AlmAckVersion)); // WORD version = 0x104 + + int rc = WnalReqSetText(buf, nodeName, oper, comment, passWd, domainName, fullName); + if (rc >= 0) + return buf; + + // rc < 0 => record too small; |rc| is the required size. Grow and retry. + capacity = -rc; + Marshal.FreeHGlobal(buf); + } + catch + { + Marshal.FreeHGlobal(buf); + throw; + } + } + + throw new InvalidOperationException("WNALalarmReqSetText could not size the ack record."); + } + + private static int EstimateCapacity(params string?[] fields) + { + int bytes = StringHeapBase; + foreach (string? f in fields) + bytes += (f is null ? 0 : Encoding.ASCII.GetByteCount(f)) + 1; + // Round up with headroom; records are tiny, over-allocation is cheap. + return Math.Max(1024, bytes + 64); + } + + // --------------------------------------------------------------------- + // LIST ACQUISITION — intentionally NOT implemented here, by RE conclusion. + // + // The ABIs of WNALSelectEntry (func 0x1d) and WNALGetAlarmRecordEx (func + // 0x1c) are recoverable, but their SEMANTICS are not: WNALGetAlarmRecordEx + // does not define the alarm-record layout — it hands alarmmgr/the provider a + // set of opaque output buffers (four 0x41-byte string buffers, a 0x8b-byte + // buffer, scalars) that the PROVIDER fills. So the record's GUID offset and + // the 4-byte alarm handle that WNALUserRequestEx consumes are provider-defined + // and cannot be read out of alarmlst.dll; likewise WNALSelectEntry's arg + // meanings live on the provider side of MakeAlarmMgrFuncCall. Hand-rolling the + // acquisition from static RE alone would be guessing which bytes are the + // handle/GUID — a wrong guess acks the wrong alarm. + // + // Therefore DO NOT complete the native acquisition by inference. Obtain the + // hAlarmList + handles from a component that already encapsulates + // register/query/match correctly: + // * Preferred: aaAlarmManagedClient.dll AlarmClient.AlarmAckByGUID + // (int AlarmAckByGUID(Guid, comment, oper, node, domain, fullName)) — the + // Alarm Control's own code; managed signatures are exact (no ABI risk). + // It is internal, so invoke by reflection from the x86 worker. + // * Or drive ack through the existing WnWrapAlarmConsumer, which already + // owns a registered/subscribed consumer, and fix the auth gap (Stage 0). + // Keep the confirmed SUBMIT methods above for the case where a real + // hAlarmList + handles are supplied by such a component. + // --------------------------------------------------------------------- +} From 6f4c7c3bf15f20537aab71d7c7faadcb80abd798 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 18 Oct 2026 13:11:01 -0400 Subject: [PATCH 2/2] Keep subtag acks and alarm reads safe on the managed ack path Review fixes for the opt-in managed GUID ack: - Subtag-mode GUID acks (forced subtag, or failover on the standby) stay on the consumer; their GUIDs are synthetic and only the subtag consumer can resolve them. Forced-subtag sessions no longer build a managed client. - A failed managed Initialize/Register/Subscribe drops back to the wnwrap ack path instead of failing the alarm subscription. - Drop stale crefs to a nonexistent Unverified member in WnalNativeAckConsumer. - Unit tests for routing, fallback, and disposal; document MXGATEWAY_USE_MANAGED_ALARM_ACK. Co-Authored-By: Claude Opus 5.5 --- docs/AlarmClientDiscovery.md | 22 +++ docs/GatewayConfiguration.md | 7 + .../MxAccess/AlarmDispatcherTests.cs | 144 ++++++++++++++++++ .../MxAccess/AlarmCommandHandler.cs | 6 +- .../MxAccess/AlarmDispatcher.cs | 37 ++++- .../MxAccess/WnalNativeAckConsumer.cs | 10 +- 6 files changed, 214 insertions(+), 12 deletions(-) diff --git a/docs/AlarmClientDiscovery.md b/docs/AlarmClientDiscovery.md index 6caa43f..7878f81 100644 --- a/docs/AlarmClientDiscovery.md +++ b/docs/AlarmClientDiscovery.md @@ -793,6 +793,28 @@ in the codebase for the forward-compat shape, but the gateway-side `AcknowledgeAlarmByName` when the public RPC supplies a recognizable `Provider!Group.Tag` reference. +**Opt-in managed GUID ack.** Setting `MXGATEWAY_USE_MANAGED_ALARM_ACK=1` +in the gateway's environment (workers inherit it) routes GUID-keyed acks +through `aaAlarmManagedClient.AlarmClient.AlarmAckByGUID` instead of the +wnwrap method. That managed method is implemented on this build and +carries operator domain and full name natively. The worker reaches it by +reflection (`AaManagedAlarmAckConsumer`) because the members are +non-public, and runs its own Initialize → Register → Subscribe lifecycle +against the alarm subscription expression. The path is deliberately +conservative: + +- If the DLL cannot load or the lifecycle returns a non-zero status, the + worker drops back to the wnwrap ack path; alarm reads never fail + because of it. +- Subtag-mode acks (forced subtag, or failover currently on the standby) + stay on the subtag consumer: their GUIDs are synthetic and only it can + resolve them. +- By-name acks stay on wnwrap — `AlarmClient` has no by-name ack. + +A zero return still means *accepted*, not *applied*; a secured +(`MxSecurityOperate`) alarm needs an authenticated operator. The skip-gated +`AlarmManagedGuidAckLiveSmokeTests` asserts the ack actually lands. + ### 5. STA / threading — resolved The wnwrap COM is `ThreadingModel=Apartment`. The consumer's diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md index b80266e..4b0c0af 100644 --- a/docs/GatewayConfiguration.md +++ b/docs/GatewayConfiguration.md @@ -446,6 +446,13 @@ behavior. The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and `StreamAlarms` are session-less RPCs served by the monitor. +GUID-keyed acknowledges can opt into AVEVA's managed alarm client by setting +the environment variable `MXGATEWAY_USE_MANAGED_ALARM_ACK=1` on the gateway +process (workers inherit it; under NSSM, add it to the service environment). +It is off by default and has no `MxGateway:*` option because it is still +awaiting live confirmation on the rig. See +[AlarmClientDiscovery](./AlarmClientDiscovery.md) ("Opt-in managed GUID ack"). + ### Alarm fallback options The `Fallback` sub-section controls how the alarm feed selects between the diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs index d41b42f..7c85796 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs @@ -203,6 +203,100 @@ public sealed class AlarmDispatcherTests Assert.Equal("Alice Smith", consumer.LastAckOperatorFullName); } + /// Verifies that a managed ack client takes GUID acks with full operator identity. + [Fact] + public void Acknowledge_WithManagedAckClient_RoutesGuidAckToManagedClient() + { + FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = -1 }; + FakeManagedAckClient managed = new FakeManagedAckClient { AcknowledgeReturn = 0 }; + using AlarmDispatcher dispatcher = new AlarmDispatcher( + consumer, + new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), + SessionId, + managed); + dispatcher.Subscribe(@"\\HOST\Galaxy!Area1"); + + Guid guid = Guid.NewGuid(); + int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith"); + + Assert.Equal(0, rc); + Assert.Equal(@"\\HOST\Galaxy!Area1", managed.LastSubscription); + Assert.Equal(guid, managed.LastAckGuid); + Assert.Equal("CORP", managed.LastAckOperatorDomain); + Assert.Equal("Alice Smith", managed.LastAckOperatorFullName); + Assert.Equal(Guid.Empty, consumer.LastAckGuid); + } + + /// + /// Verifies that a failed managed-client lifecycle neither fails the alarm + /// subscription nor leaves acks routed to the broken client. + /// + [Fact] + public void Subscribe_WhenManagedAckClientFails_FallsBackToConsumerAck() + { + FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = 0 }; + FakeManagedAckClient managed = new FakeManagedAckClient { ThrowOnSubscribe = true }; + using AlarmDispatcher dispatcher = new AlarmDispatcher( + consumer, + new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), + SessionId, + managed); + + dispatcher.Subscribe(@"\\HOST\Galaxy!Area1"); + Guid guid = Guid.NewGuid(); + int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith"); + + Assert.Equal(@"\\HOST\Galaxy!Area1", consumer.LastSubscription); + Assert.True(managed.Disposed); + Assert.Equal(0, rc); + Assert.Equal(guid, consumer.LastAckGuid); + Assert.Equal(Guid.Empty, managed.LastAckGuid); + } + + /// + /// Verifies that while the failover consumer serves the subtag standby, + /// GUID acks (synthetic GUIDs) stay on the consumer, not the managed client. + /// + [Fact] + public void Acknowledge_WhenFailoverInSubtagMode_KeepsGuidAckOnConsumer() + { + FakeAlarmConsumer primary = new FakeAlarmConsumer { ThrowOnSubscribe = true }; + FakeAlarmConsumer standby = new FakeAlarmConsumer { AcknowledgeReturn = 0 }; + FailoverAlarmConsumer failover = new FailoverAlarmConsumer( + primary, standby, new FailoverSettings(threshold: 1, probeIntervalSeconds: 0, stableProbes: 3)); + FakeManagedAckClient managed = new FakeManagedAckClient { AcknowledgeReturn = -1 }; + using AlarmDispatcher dispatcher = new AlarmDispatcher( + failover, + new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), + SessionId, + managed); + dispatcher.Subscribe(@"\\HOST\Galaxy!Area1"); + Assert.Equal(AlarmProviderMode.Subtag, failover.Mode); + + Guid guid = Guid.NewGuid(); + int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith"); + + Assert.Equal(0, rc); + Assert.Equal(guid, standby.LastAckGuid); + Assert.Equal(Guid.Empty, managed.LastAckGuid); + } + + /// Verifies that disposing the dispatcher disposes the managed ack client. + [Fact] + public void Dispose_WithManagedAckClient_DisposesManagedClient() + { + FakeManagedAckClient managed = new FakeManagedAckClient(); + AlarmDispatcher dispatcher = new AlarmDispatcher( + new FakeAlarmConsumer(), + new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), + SessionId, + managed); + + dispatcher.Dispose(); + + Assert.True(managed.Disposed); + } + /// Verifies that acknowledge-by-name calls are forwarded to the consumer. [Fact] public void AcknowledgeByName_WhenInvoked_ForwardsToConsumerWithFullTuple() @@ -470,9 +564,13 @@ public sealed class AlarmDispatcherTests AlarmTransitionEmitted?.Invoke(this, transition); } + /// Gets or sets a value indicating whether throws. + public bool ThrowOnSubscribe { get; set; } + /// public void Subscribe(string subscription) { + if (ThrowOnSubscribe) throw new InvalidOperationException("subscribe failed"); LastSubscription = subscription; } @@ -533,4 +631,50 @@ public sealed class AlarmDispatcherTests Disposed = true; } } + + private sealed class FakeManagedAckClient : IManagedAlarmAckClient + { + /// Gets the last subscription expression. + public string? LastSubscription { get; private set; } + /// Gets the GUID from the last acknowledge call. + public Guid LastAckGuid { get; private set; } + /// Gets the operator domain from the last acknowledge call. + public string? LastAckOperatorDomain { get; private set; } + /// Gets the operator full name from the last acknowledge call. + public string? LastAckOperatorFullName { get; private set; } + /// Gets or sets the return code for acknowledge operations. + public int AcknowledgeReturn { get; set; } + /// Gets or sets a value indicating whether throws. + public bool ThrowOnSubscribe { get; set; } + /// Gets a value indicating whether this instance has been disposed. + public bool Disposed { get; private set; } + + /// + public void Subscribe(string subscription) + { + if (ThrowOnSubscribe) throw new InvalidOperationException("RegisterConsumer returned 5."); + LastSubscription = subscription; + } + + /// + public int AcknowledgeByGuid( + Guid alarmGuid, + string comment, + string operatorName, + string operatorNode, + string operatorDomain, + string operatorFullName) + { + LastAckGuid = alarmGuid; + LastAckOperatorDomain = operatorDomain; + LastAckOperatorFullName = operatorFullName; + return AcknowledgeReturn; + } + + /// + public void Dispose() + { + Disposed = true; + } + } } diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs index 8888e55..3b10631 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs @@ -182,7 +182,11 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler } // Optional managed ack client (opt-in via env); null → wnwrap ack. - IManagedAlarmAckClient? managedAck = managedAckFactory?.Invoke(); + // A forced-subtag session never has alarmmgr GUIDs to ack, so it + // skips the extra managed consumer entirely. + IManagedAlarmAckClient? managedAck = command.ForcedMode == AlarmProviderMode.Subtag + ? null + : managedAckFactory?.Invoke(); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(eventQueue, mapper); dispatcher = new AlarmDispatcher(consumer, sink, subscribeSessionId, managedAck); try diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs index a76cdc4..388266e 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs @@ -36,7 +36,7 @@ public sealed class AlarmDispatcher : IDisposable private readonly MxAccessAlarmEventSink sink; private readonly string sessionId; private readonly EventHandler handler; - private readonly IManagedAlarmAckClient? managedAck; + private IManagedAlarmAckClient? managedAck; private bool disposed; /// Initializes a new alarm dispatcher for the given consumer, sink, and session ID. @@ -46,7 +46,9 @@ public sealed class AlarmDispatcher : IDisposable /// /// Optional managed ack client. When non-null, GUID-keyed acknowledges /// route to it (AVEVA's modern AlarmClient.AlarmAckByGUID) instead - /// of the wnwrap consumer; null keeps the existing wnwrap ack path. + /// of the wnwrap consumer while the alarmmgr provider is active; null + /// keeps the existing wnwrap ack path. Subtag-mode acks always stay on + /// the consumer because their GUIDs are synthetic. /// public AlarmDispatcher( IMxAccessAlarmConsumer consumer, @@ -80,10 +82,31 @@ public sealed class AlarmDispatcher : IDisposable consumer.Subscribe(subscription); // The managed ack client maintains its own registered/subscribed // consumer; run its lifecycle against the same expression so a later - // AlarmAckByGUID has a live consumer to act through. - managedAck?.Subscribe(subscription); + // AlarmAckByGUID has a live consumer to act through. It is an opt-in + // enhancement, so a failed lifecycle drops back to the wnwrap ack path + // rather than failing the alarm subscription. + if (managedAck is not null) + { + try + { + managedAck.Subscribe(subscription); + } + catch + { + try { managedAck.Dispose(); } catch { /* swallow */ } + managedAck = null; + } + } } + /// + /// True when the consumer is currently serving alarms from the subtag + /// fallback, whose alarm GUIDs are synthetic and unknown to alarmmgr. + /// + private bool IsSubtagActive() => + consumer is SubtagAlarmConsumer || + (consumer is FailoverAlarmConsumer failover && failover.Mode == AlarmProviderMode.Subtag); + /// /// Forward an AcknowledgeAlarm request to the underlying /// consumer's AlarmAckByGUID. Returns the AVEVA-native @@ -105,11 +128,13 @@ public sealed class AlarmDispatcher : IDisposable string ackOperatorFullName) { if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher)); - if (managedAck is not null) + if (managedAck is not null && !IsSubtagActive()) { // Preferred path: AVEVA's modern AlarmClient.AlarmAckByGUID carries // operator domain + full name and is not E_NOTIMPL like the wnwrap - // COM AlarmAckByGUID. + // COM AlarmAckByGUID. Skipped in subtag mode: those GUIDs are + // synthetic and only the subtag consumer can resolve them (it acks + // via an AckMsg write). return managedAck.AcknowledgeByGuid( alarmGuid, ackComment, diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnalNativeAckConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnalNativeAckConsumer.cs index aaca704..b86daa9 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnalNativeAckConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnalNativeAckConsumer.cs @@ -27,10 +27,10 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// , ) and the /// request-record layout are confirmed by disassembly (see /// docs/alarm-ack-findings.md in the reverse-engineering workspace). The -/// list-ACQUISITION exports in are declared from -/// their export names/ordinals but their argument signatures are NOT yet -/// reverse-engineered — DO NOT call them until the signatures are verified, -/// or the stdcall stack will be corrupted. +/// list-ACQUISITION side (obtaining hAlarmList + alarm handles) is +/// deliberately NOT implemented — see the note at the end of this class. +/// Nothing in the worker calls this type yet; it is kept for when a real +/// list handle can be supplied by a component that owns acquisition. /// public sealed class WnalNativeAckConsumer { @@ -101,7 +101,7 @@ public sealed class WnalNativeAckConsumer /// Build a request record and submit an ack for the given alarm /// handles via . Caller supplies a live /// and the handle(s) obtained from the - /// list's records (see — acquisition path). + /// list's records (acquisition is not implemented — see the class note). /// Returns the native status (0 == success). /// /// A registered, subscribed alarm-list handle.