a67a5a4857
Gap 1 — WorkerPipeSession now passes `eq => new AlarmCommandHandler(eq)` as
the alarmCommandHandlerFactory in all three places it constructs
MxAccessStaSession (two convenience constructors and InitializeMxAccessAsync).
Previously the parameterless MxAccessStaSession() set the factory to null,
so every SubscribeAlarms / AcknowledgeAlarm / QueryActiveAlarms command
returned "alarm consumer not configured" in a deployed worker.
- Added internal `MxAccessStaSession(Func<MxAccessEventQueue, IAlarmCommandHandler>?)`
constructor that builds all defaults but accepts a factory.
- Added public `MxAccessStaSession(StaRuntime, factory, eventQueue, alarmFactory?)`
4-arg overload to complete the constructor chain.
Gap 2 — WnWrapAlarmConsumer now disables its internal threadpool Timer
(pollIntervalMilliseconds=0 in the default constructor). MxAccessStaSession
starts a `RunAlarmPollLoopAsync` background task that sleeps off-STA then
calls `staRuntime.InvokeAsync(() => handler.PollOnce())` at 500ms intervals.
This satisfies the ThreadingModel=Apartment requirement of wwAlarmConsumerClass:
every GetXmlCurrentAlarms2 call now runs on the worker's STA.
- Added `PollOnce()` to `IMxAccessAlarmConsumer`, `AlarmDispatcher`,
`IAlarmCommandHandler`, and `AlarmCommandHandler`.
- Poll loop cancelled and awaited before alarm handler disposal in both
ShutdownGracefullyAsync and Dispose.
Tests: 4 new tests in MxAccessStaSessionTests verify that
- SubscribeAlarms reaches the handler when the factory is wired (Gap 1)
- SubscribeAlarms returns InvalidRequest without a factory (regression guard)
- PollOnce is called on the STA thread within 3s (Gap 2)
- The poll loop stops after Dispose (Gap 2 lifecycle)
All fake IMxAccessAlarmConsumer / IAlarmCommandHandler test implementations
updated with no-op PollOnce() to satisfy the new interface member.
Worker tests: 199 passed / 1 pre-existing failure / 4 skipped (was 195/1/4).
Server tests: 308 passed / 0 failures (unchanged).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
249 lines
8.6 KiB
C#
249 lines
8.6 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using MxGateway.Contracts.Proto;
|
|
|
|
namespace 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>() => 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's polling timer
|
|
/// fires on a thread-pool thread; the only cross-thread surface
|
|
/// is the <see cref="AlarmDispatcher"/>'s event handler, which
|
|
/// hand-offs 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 object syncRoot = new object();
|
|
private AlarmDispatcher? dispatcher;
|
|
private bool disposed;
|
|
|
|
public AlarmCommandHandler(MxAccessEventQueue eventQueue)
|
|
: this(eventQueue, () => new WnWrapAlarmConsumer())
|
|
{
|
|
}
|
|
|
|
/// <summary>Test seam — inject a custom consumer factory.</summary>
|
|
public AlarmCommandHandler(
|
|
MxAccessEventQueue eventQueue,
|
|
Func<IMxAccessAlarmConsumer> consumerFactory)
|
|
{
|
|
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
|
|
this.consumerFactory = consumerFactory ?? throw new ArgumentNullException(nameof(consumerFactory));
|
|
}
|
|
|
|
public bool IsSubscribed
|
|
{
|
|
get { lock (syncRoot) return dispatcher is not null; }
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Subscribe(string subscription, string sessionId)
|
|
{
|
|
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
|
|
if (subscription is null) throw new ArgumentNullException(nameof(subscription));
|
|
|
|
lock (syncRoot)
|
|
{
|
|
if (dispatcher is not null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"AlarmCommandHandler already has an active subscription; " +
|
|
"call Unsubscribe before issuing another SubscribeAlarms command.");
|
|
}
|
|
IMxAccessAlarmConsumer consumer = consumerFactory()
|
|
?? throw new InvalidOperationException("Alarm consumer factory returned null.");
|
|
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(
|
|
eventQueue, new MxAccessEventMapper());
|
|
dispatcher = new AlarmDispatcher(consumer, sink, sessionId ?? string.Empty);
|
|
try
|
|
{
|
|
dispatcher.Subscribe(subscription);
|
|
}
|
|
catch
|
|
{
|
|
try { dispatcher.Dispose(); } catch { /* swallow */ }
|
|
dispatcher = null;
|
|
throw;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Unsubscribe()
|
|
{
|
|
AlarmDispatcher? toDispose;
|
|
lock (syncRoot)
|
|
{
|
|
toDispose = dispatcher;
|
|
dispatcher = null;
|
|
}
|
|
toDispose?.Dispose();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public int Acknowledge(
|
|
Guid alarmGuid,
|
|
string comment,
|
|
string operatorUser,
|
|
string operatorNode,
|
|
string operatorDomain,
|
|
string operatorFullName)
|
|
{
|
|
AlarmDispatcher? d = GetDispatcherOrThrow();
|
|
return d.Acknowledge(
|
|
alarmGuid,
|
|
comment ?? string.Empty,
|
|
operatorUser ?? string.Empty,
|
|
operatorNode ?? string.Empty,
|
|
operatorDomain ?? string.Empty,
|
|
operatorFullName ?? string.Empty);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public int AcknowledgeByName(
|
|
string alarmName,
|
|
string providerName,
|
|
string groupName,
|
|
string comment,
|
|
string operatorUser,
|
|
string operatorNode,
|
|
string operatorDomain,
|
|
string operatorFullName)
|
|
{
|
|
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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix)
|
|
{
|
|
AlarmDispatcher? d = GetDispatcherOrThrow();
|
|
IReadOnlyList<ActiveAlarmSnapshot> all = d.SnapshotActiveAlarms();
|
|
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()
|
|
{
|
|
AlarmDispatcher? d;
|
|
lock (syncRoot) d = dispatcher;
|
|
// No-op when not yet subscribed or already disposed.
|
|
d?.PollOnce();
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Per-session interface routing the worker's alarm IPC commands —
|
|
/// <c>SubscribeAlarmsCommand</c>, <c>AcknowledgeAlarmCommand</c>,
|
|
/// <c>QueryActiveAlarmsCommand</c>, <c>UnsubscribeAlarmsCommand</c> —
|
|
/// to the underlying <see cref="AlarmDispatcher"/>. Production binding
|
|
/// is <see cref="AlarmCommandHandler"/>; tests substitute a fake.
|
|
/// </summary>
|
|
public interface IAlarmCommandHandler : IDisposable
|
|
{
|
|
/// <summary>Begin a subscription against the supplied AVEVA alarm-provider expression.</summary>
|
|
void Subscribe(string subscription, string sessionId);
|
|
|
|
/// <summary>Tear down the active subscription. No-op if not subscribed.</summary>
|
|
void Unsubscribe();
|
|
|
|
/// <summary>Acknowledge a single alarm by GUID. Returns AVEVA's native status (0 = success).</summary>
|
|
int Acknowledge(
|
|
Guid alarmGuid,
|
|
string comment,
|
|
string operatorUser,
|
|
string operatorNode,
|
|
string operatorDomain,
|
|
string operatorFullName);
|
|
|
|
/// <summary>
|
|
/// Acknowledge a single alarm by (name, provider, group) — used when
|
|
/// the caller has the human-readable reference but not the GUID.
|
|
/// </summary>
|
|
int AcknowledgeByName(
|
|
string alarmName,
|
|
string providerName,
|
|
string groupName,
|
|
string comment,
|
|
string operatorUser,
|
|
string operatorNode,
|
|
string operatorDomain,
|
|
string operatorFullName);
|
|
|
|
/// <summary>
|
|
/// Snapshot the currently-active alarm set, optionally scoped to a
|
|
/// prefix matched against <c>AlarmFullReference</c>.
|
|
/// </summary>
|
|
IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix);
|
|
|
|
/// <summary>
|
|
/// Drives a single poll of the underlying alarm consumer on the
|
|
/// caller's thread. This is a no-op when there is no active
|
|
/// subscription. In production the caller is the worker's STA
|
|
/// (marshalled via <c>StaRuntime.InvokeAsync</c>), which satisfies
|
|
/// the <c>ThreadingModel=Apartment</c> requirement of
|
|
/// <c>wwAlarmConsumerClass</c>.
|
|
/// </summary>
|
|
void PollOnce();
|
|
}
|