Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs
T
Joseph Doherty d6db0ff40d
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
feat(worker): route alarm acks through the operator-authenticated AckMsg writer
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
2026-10-19 19:35:55 -04:00

468 lines
19 KiB
C#

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;
/// <summary>
/// Per-session owner of the worker's alarm-side state. Lazy-creates an
/// <see cref="AlarmDispatcher"/> (with a wnwrap-backed
/// <see cref="WnWrapAlarmConsumer"/> by default) on the first
/// <see cref="Subscribe"/> call, then routes
/// <see cref="Acknowledge"/> / <see cref="QueryActive"/> /
/// <see cref="Unsubscribe"/> through the same instance for the
/// session's lifetime.
/// </summary>
/// <remarks>
/// <para>
/// Construction is dependency-injectable: the consumer factory
/// (default <c>() =&gt; new WnWrapAlarmConsumer()</c>) lets tests
/// substitute a fake without touching AVEVA COM. The event queue
/// is supplied by the owning <see cref="MxAccessStaSession"/> so
/// the alarm-side proto events land on the same queue the worker
/// already drains for IPC dispatch.
/// </para>
/// <para>
/// Threading: invoked from <see cref="MxAccessCommandExecutor"/>
/// which runs on the STA. The wnwrap consumer owns no internal
/// timer — the worker's STA drives <see cref="PollOnce"/> via
/// <c>StaRuntime.InvokeAsync</c>, so the consumer's transition
/// events fire on the same STA. The
/// <see cref="AlarmDispatcher"/>'s event handler hands transitions
/// into the thread-safe <see cref="MxAccessEventQueue"/>.
/// </para>
/// </remarks>
public sealed class AlarmCommandHandler : IAlarmCommandHandler
{
private readonly MxAccessEventQueue eventQueue;
private readonly Func<IMxAccessAlarmConsumer> consumerFactory;
private readonly Action? threadAffinityCheck;
private readonly IMxAccessComObjectFactory? comFactory;
private readonly Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory;
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;
private string subscriptionExpression = string.Empty;
private bool disposed;
/// <summary>Initializes a new alarm command handler with the given event queue.</summary>
/// <param name="eventQueue">The event queue.</param>
public AlarmCommandHandler(MxAccessEventQueue eventQueue)
: this(eventQueue, () => new WnWrapAlarmConsumer(), threadAffinityCheck: null)
{
}
/// <summary>Test seam — inject a custom consumer factory.</summary>
/// <param name="eventQueue">The event queue.</param>
/// <param name="consumerFactory">The alarm consumer factory.</param>
public AlarmCommandHandler(
MxAccessEventQueue eventQueue,
Func<IMxAccessAlarmConsumer> consumerFactory)
: this(eventQueue, consumerFactory, threadAffinityCheck: null)
{
}
/// <summary>
/// Production constructor that also injects an
/// STA-affinity guard. <paramref name="threadAffinityCheck"/> is
/// invoked at the entry of every method that touches the underlying
/// <see cref="IMxAccessAlarmConsumer"/> (or the wnwrap COM object
/// through it) — <see cref="Subscribe"/>, <see cref="Unsubscribe"/>,
/// <see cref="Acknowledge"/>, <see cref="AcknowledgeByName"/>,
/// <see cref="QueryActive"/>, <see cref="PollOnce"/> — so an
/// off-STA call raises a programming-error diagnostic instead of
/// deadlocking on cross-apartment marshaling to the
/// <c>ThreadingModel=Apartment</c> wnwrap CLSID. The guard is
/// optional: tests that already drive the handler on a single
/// thread can pass <c>null</c>.
/// </summary>
/// <param name="eventQueue">The event queue.</param>
/// <param name="consumerFactory">The alarm consumer factory.</param>
/// <param name="threadAffinityCheck">Optional thread affinity check action.</param>
public AlarmCommandHandler(
MxAccessEventQueue eventQueue,
Func<IMxAccessAlarmConsumer> consumerFactory,
Action? threadAffinityCheck)
: this(eventQueue, consumerFactory, threadAffinityCheck, comFactory: null, standbyFactory: null)
{
}
/// <summary>
/// Full constructor that also threads the MXAccess COM-object factory and
/// an optional standby-consumer seam so the subscribe path can build the
/// subtag / failover consumers required by
/// <see cref="SubscribeAlarmsCommand.ForcedMode"/> and
/// <see cref="SubscribeAlarmsCommand.WatchList"/>.
/// </summary>
/// <param name="eventQueue">The event queue.</param>
/// <param name="consumerFactory">
/// Factory for the PRIMARY (alarmmgr) consumer — the existing
/// wnwrap-backed source. Used alone in alarmmgr mode and as the primary
/// of the failover composite in auto mode.
/// </param>
/// <param name="threadAffinityCheck">Optional STA thread-affinity guard.</param>
/// <param name="comFactory">
/// The MXAccess COM-object factory used to build the
/// <see cref="LmxSubtagAlarmSource"/> backing the subtag consumer. May be
/// <see langword="null"/> when a <paramref name="standbyFactory"/> is
/// supplied (tests) or when only the alarmmgr path is ever exercised.
/// </param>
/// <param name="standbyFactory">
/// Optional seam that builds the STANDBY (subtag) consumer from a watch
/// list. Defaults to a <see cref="SubtagAlarmConsumer"/> over an
/// <see cref="LmxSubtagAlarmSource"/> built from
/// <paramref name="comFactory"/>. Tests inject a fake so they need no
/// live COM factory.
/// </param>
/// <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,
Func<IMxAccessAlarmConsumer> consumerFactory,
Action? threadAffinityCheck,
IMxAccessComObjectFactory? comFactory,
Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory,
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.ackWriterFactory = ackWriterFactory;
this.deferredAckWriter = new DeferredAckWriter(this);
}
/// <summary>Gets a value indicating whether the handler is subscribed.</summary>
public bool IsSubscribed
{
get { lock (syncRoot) return dispatcher is not null; }
}
/// <inheritdoc />
public void Subscribe(SubscribeAlarmsCommand command, string sessionId)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
if (command is null) throw new ArgumentNullException(nameof(command));
threadAffinityCheck?.Invoke();
lock (syncRoot)
{
if (dispatcher is not null)
{
throw new InvalidOperationException(
"AlarmCommandHandler already has an active subscription; " +
"call Unsubscribe before issuing another SubscribeAlarms command.");
}
subscribeSessionId = sessionId ?? string.Empty;
subscriptionExpression = command.SubscriptionExpression ?? string.Empty;
IMxAccessAlarmConsumer consumer = BuildConsumer(command)
?? throw new InvalidOperationException("Alarm consumer factory returned null.");
// When the selected consumer is a failover composite, surface its
// provider switches onto the worker's event queue so connected
// gateway clients can observe degraded/recovered state. The handler
// is unsubscribed/disposed on Unsubscribe/Dispose below.
if (consumer is FailoverAlarmConsumer failover)
{
failoverConsumer = failover;
providerModeChangedHandler = OnProviderModeChanged;
failover.ProviderModeChanged += providerModeChangedHandler;
}
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(eventQueue, mapper);
dispatcher = new AlarmDispatcher(consumer, sink, subscribeSessionId, deferredAckWriter);
try
{
dispatcher.Subscribe(subscriptionExpression);
}
catch
{
DetachProviderModeChanged();
try { dispatcher.Dispose(); } catch { /* swallow */ }
dispatcher = null;
throw;
}
}
}
/// <summary>
/// Selects and builds the alarm consumer from the command's
/// <see cref="SubscribeAlarmsCommand.ForcedMode"/> and
/// <see cref="SubscribeAlarmsCommand.WatchList"/>:
/// <list type="bullet">
/// <item><description>
/// <c>Alarmmgr</c>, or <c>Unspecified</c> with an empty watch
/// list: the existing primary (alarmmgr) consumer only —
/// today's behavior.
/// </description></item>
/// <item><description>
/// <c>Subtag</c>: a <see cref="SubtagAlarmConsumer"/> only.
/// </description></item>
/// <item><description>
/// <c>Unspecified</c> with a non-empty watch list (auto): a
/// <see cref="FailoverAlarmConsumer"/> over the primary and a
/// subtag standby.
/// </description></item>
/// </list>
/// </summary>
private IMxAccessAlarmConsumer BuildConsumer(SubscribeAlarmsCommand command)
{
List<AlarmSubtagTarget> watchList = new List<AlarmSubtagTarget>(command.WatchList);
if (command.ForcedMode == AlarmProviderMode.Subtag)
{
return BuildStandby(watchList);
}
if (command.ForcedMode == AlarmProviderMode.Unspecified
&& (command.FallbackEnabled || watchList.Count > 0))
{
IMxAccessAlarmConsumer primary = consumerFactory()
?? throw new InvalidOperationException("Alarm consumer factory returned null.");
IMxAccessAlarmConsumer standby = BuildStandby(watchList);
AlarmFailoverConfig? failoverConfig = command.Failover;
FailoverSettings settings = new FailoverSettings(
failoverConfig?.ConsecutiveFailureThreshold ?? 3,
failoverConfig?.FailbackProbeIntervalSeconds ?? 30,
failoverConfig?.FailbackStableProbes ?? 3);
return new FailoverAlarmConsumer(primary, standby, settings);
}
// Alarmmgr, or Unspecified with an empty watch list — primary only.
return consumerFactory()
?? throw new InvalidOperationException("Alarm consumer factory returned null.");
}
/// <inheritdoc />
public void UpdateWatchList(IReadOnlyList<AlarmSubtagTarget> watchList)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
if (watchList is null) throw new ArgumentNullException(nameof(watchList));
threadAffinityCheck?.Invoke();
lock (syncRoot)
{
if (dispatcher is null || failoverConsumer is null)
{
throw new InvalidOperationException(
"UpdateAlarmWatchList requires an active fallback-capable alarm subscription.");
}
IMxAccessAlarmConsumer replacement = BuildStandby(watchList);
failoverConsumer.ReplaceStandby(replacement, subscriptionExpression);
}
}
private IMxAccessAlarmConsumer BuildStandby(IReadOnlyList<AlarmSubtagTarget> watchList)
{
if (standbyFactory is not null)
{
return standbyFactory(watchList)
?? throw new InvalidOperationException("Standby alarm consumer factory returned null.");
}
if (comFactory is null)
{
throw new InvalidOperationException(
"Subtag alarm consumer requires an IMxAccessComObjectFactory; the alarm command "
+ "handler was constructed without one and no standby factory was supplied.");
}
return new SubtagAlarmConsumer(new LmxSubtagAlarmSource(comFactory), watchList);
}
private void OnProviderModeChanged(object? sender, AlarmProviderModeChange change)
{
if (change is null) return;
eventQueue.Enqueue(mapper.CreateOnAlarmProviderModeChanged(
subscribeSessionId, change.Mode, change.Reason, change.HResult, change.AtUtc));
}
private void DetachProviderModeChanged()
{
if (failoverConsumer is not null && providerModeChangedHandler is not null)
{
try { failoverConsumer.ProviderModeChanged -= providerModeChangedHandler; }
catch { /* swallow */ }
}
failoverConsumer = null;
providerModeChangedHandler = null;
}
/// <inheritdoc />
public void Unsubscribe()
{
threadAffinityCheck?.Invoke();
AlarmDispatcher? toDispose;
lock (syncRoot)
{
DetachProviderModeChanged();
toDispose = dispatcher;
dispatcher = null;
subscriptionExpression = string.Empty;
}
toDispose?.Dispose();
}
/// <inheritdoc />
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, ackOperator);
}
/// <inheritdoc />
public AlarmAckResult AcknowledgeByName(
string alarmName,
string providerName,
string groupName,
string comment,
AlarmAckOperator ackOperator)
{
if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator));
threadAffinityCheck?.Invoke();
AlarmDispatcher d = GetDispatcherOrThrow();
return d.AcknowledgeByName(
alarmName ?? string.Empty,
providerName ?? string.Empty,
groupName ?? string.Empty,
comment ?? string.Empty,
ackOperator);
}
/// <inheritdoc />
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(
string? alarmFilterPrefix,
out bool snapshotTruncated)
{
threadAffinityCheck?.Invoke();
AlarmDispatcher? d = GetDispatcherOrThrow();
// The verdict rides out of the same call that produced the records, so
// filtering below cannot separate it from the set it describes.
IReadOnlyList<ActiveAlarmSnapshot> all = d.SnapshotActiveAlarms(out snapshotTruncated);
if (string.IsNullOrEmpty(alarmFilterPrefix)) return all;
List<ActiveAlarmSnapshot> filtered = new List<ActiveAlarmSnapshot>(all.Count);
foreach (ActiveAlarmSnapshot snap in all)
{
if (snap.AlarmFullReference.StartsWith(alarmFilterPrefix!, StringComparison.Ordinal))
{
filtered.Add(snap);
}
}
return filtered;
}
/// <inheritdoc />
public void PollOnce()
{
threadAffinityCheck?.Invoke();
AlarmDispatcher? d;
lock (syncRoot) d = dispatcher;
// No-op when not yet subscribed or already disposed.
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));
AlarmDispatcher? d;
lock (syncRoot) d = dispatcher;
if (d is null)
{
throw new InvalidOperationException(
"AlarmCommandHandler has no active subscription; " +
"call SubscribeAlarms before issuing alarm-related commands.");
}
return d;
}
/// <inheritdoc />
public void Dispose()
{
if (disposed) return;
disposed = true;
Unsubscribe();
IAlarmAckWriter? writerToDispose;
lock (syncRoot)
{
writerToDispose = ackWriter;
ackWriter = null;
}
try { writerToDispose?.Dispose(); } catch { /* swallow */ }
}
}