feat(worker): route alarm acks through the operator-authenticated AckMsg writer
ci / nightly-windev (push) Has been skipped
ci / java (push) Failing after 2m5s
ci / windows-x86 (push) Successful in 2m10s
ci / portable (push) Failing after 2m17s

AlarmCommandHandler -> AlarmDispatcher -> IAlarmAckWriter: the dispatcher resolves the ack item through the consumer and the writer authenticates the operator and writes the comment. The executor builds AlarmAckOperator from operator_user/domain/password, maps AlarmAckOutcome to the reply (Applied/Unconfirmed Ok, AlarmNotFound/NotAckable InvalidRequest, AuthenticationFailed/WriteRejected MxaccessFailure) and reports escaping exceptions by type and HRESULT only, so operator_password cannot leak. Removes the inert managed/WNAL alarm-client ack path.

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
This commit is contained in:
Joseph Doherty
2026-10-19 19:35:55 -04:00
parent 0d4b2aea2d
commit d6db0ff40d
14 changed files with 695 additions and 1282 deletions
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.Sta;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
@@ -39,10 +40,12 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
private readonly Action? threadAffinityCheck;
private readonly IMxAccessComObjectFactory? comFactory;
private readonly Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory;
private readonly Func<IManagedAlarmAckClient?>? managedAckFactory;
private readonly Func<IAlarmAckWriter>? ackWriterFactory;
private readonly MxAccessEventMapper mapper = new MxAccessEventMapper();
private readonly object syncRoot = new object();
private readonly IAlarmAckWriter deferredAckWriter;
private AlarmDispatcher? dispatcher;
private IAlarmAckWriter? ackWriter;
private FailoverAlarmConsumer? failoverConsumer;
private EventHandler<AlarmProviderModeChange>? providerModeChangedHandler;
private string subscribeSessionId = string.Empty;
@@ -118,14 +121,15 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
/// <paramref name="comFactory"/>. Tests inject a fake so they need no
/// live COM factory.
/// </param>
/// <param name="managedAckFactory">
/// Optional per-subscription factory for a managed ack client
/// (<see cref="IManagedAlarmAckClient"/>). When it yields a non-null
/// client, GUID-keyed acknowledges route through AVEVA's modern
/// <c>AlarmClient.AlarmAckByGUID</c> instead of the wnwrap COM path.
/// Defaults to <see langword="null"/> (wnwrap ack). Production supplies
/// <see cref="AaManagedAlarmAckConsumer.EnvGatedFactory"/>, which is a
/// no-op unless <c>MXGATEWAY_USE_MANAGED_ALARM_ACK=1</c>.
/// <param name="ackWriterFactory">
/// Optional seam that builds the <see cref="IAlarmAckWriter"/> used for
/// every acknowledgement. Defaults to an <see cref="AlarmAckWriter"/> over
/// <paramref name="comFactory"/> that registers its own MXAccess server
/// (client name from <see cref="AlarmAckWriter.ResolveClientName"/>).
/// The writer is created once per handler, on the first acknowledgement
/// (so a subscription never fails because ack is unavailable), survives
/// unsubscribe/resubscribe, and is disposed with the handler. Tests
/// inject a fake.
/// </param>
public AlarmCommandHandler(
MxAccessEventQueue eventQueue,
@@ -133,14 +137,15 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
Action? threadAffinityCheck,
IMxAccessComObjectFactory? comFactory,
Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory,
Func<IManagedAlarmAckClient?>? managedAckFactory = null)
Func<IAlarmAckWriter>? ackWriterFactory = 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;
this.ackWriterFactory = ackWriterFactory;
this.deferredAckWriter = new DeferredAckWriter(this);
}
/// <summary>Gets a value indicating whether the handler is subscribed.</summary>
@@ -181,14 +186,8 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
failover.ProviderModeChanged += providerModeChangedHandler;
}
// Optional managed ack client (opt-in via env); null → wnwrap ack.
// 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);
dispatcher = new AlarmDispatcher(consumer, sink, subscribeSessionId, deferredAckWriter);
try
{
dispatcher.Subscribe(subscriptionExpression);
@@ -323,47 +322,31 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
}
/// <inheritdoc />
public int Acknowledge(
Guid alarmGuid,
string comment,
string operatorUser,
string operatorNode,
string operatorDomain,
string operatorFullName)
public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator)
{
if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator));
threadAffinityCheck?.Invoke();
AlarmDispatcher? d = GetDispatcherOrThrow();
return d.Acknowledge(
alarmGuid,
comment ?? string.Empty,
operatorUser ?? string.Empty,
operatorNode ?? string.Empty,
operatorDomain ?? string.Empty,
operatorFullName ?? string.Empty);
AlarmDispatcher d = GetDispatcherOrThrow();
return d.Acknowledge(alarmGuid, comment ?? string.Empty, ackOperator);
}
/// <inheritdoc />
public int AcknowledgeByName(
public AlarmAckResult AcknowledgeByName(
string alarmName,
string providerName,
string groupName,
string comment,
string operatorUser,
string operatorNode,
string operatorDomain,
string operatorFullName)
AlarmAckOperator ackOperator)
{
if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator));
threadAffinityCheck?.Invoke();
AlarmDispatcher? d = GetDispatcherOrThrow();
AlarmDispatcher d = GetDispatcherOrThrow();
return d.AcknowledgeByName(
alarmName ?? string.Empty,
providerName ?? string.Empty,
groupName ?? string.Empty,
comment ?? string.Empty,
operatorUser ?? string.Empty,
operatorNode ?? string.Empty,
operatorDomain ?? string.Empty,
operatorFullName ?? string.Empty);
ackOperator);
}
/// <inheritdoc />
@@ -398,6 +381,60 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
d?.PollOnce();
}
private IAlarmAckWriter GetOrCreateAckWriter()
{
lock (syncRoot)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
if (ackWriter is not null) return ackWriter;
if (ackWriterFactory is not null)
{
ackWriter = ackWriterFactory() ?? throw new InvalidOperationException("Alarm ack writer factory returned null.");
return ackWriter;
}
if (comFactory is null)
{
throw new InvalidOperationException(
"Alarm acknowledgement requires an IMxAccessComObjectFactory; the alarm command handler was constructed without one.");
}
// StaMessagePump.PumpPendingMessages is PeekMessage/DispatchMessage on
// the calling thread — the STA, since acks run there — which is what
// delivers the writer's OnWriteComplete callback.
ackWriter = new AlarmAckWriter(
comFactory,
() => new StaMessagePump().PumpPendingMessages(),
MxAccessStaSession.ResolveWriteCompletionTimeout(),
AlarmAckWriter.ResolveClientName());
return ackWriter;
}
}
/// <summary>
/// The writer handed to each <see cref="AlarmDispatcher"/>. Resolves the
/// handler's real writer on first acknowledgement so the writer (and its
/// MXAccess server registration) is created only when an ack happens and
/// outlives dispatcher churn. Disposing it is a no-op; the handler owns
/// the real writer.
/// </summary>
private sealed class DeferredAckWriter : IAlarmAckWriter
{
private readonly AlarmCommandHandler owner;
public DeferredAckWriter(AlarmCommandHandler owner)
{
this.owner = owner;
}
public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator) =>
owner.GetOrCreateAckWriter().Acknowledge(itemAddress, comment, ackOperator);
public void Dispose()
{
}
}
private AlarmDispatcher GetDispatcherOrThrow()
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
@@ -418,5 +455,13 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
if (disposed) return;
disposed = true;
Unsubscribe();
IAlarmAckWriter? writerToDispose;
lock (syncRoot)
{
writerToDispose = ackWriter;
ackWriter = null;
}
try { writerToDispose?.Dispose(); } catch { /* swallow */ }
}
}