A.3 (worker IPC slice): proto SubscribeAlarms/Acknowledge/QueryActive commands + executor routing

Adds the worker-side IPC surface for the alarm subsystem so the gateway
can drive the AlarmDispatcher across the named-pipe boundary. Adds four
proto MxCommandKind values + matching command messages and two
MxCommandReply payload variants:

- SubscribeAlarmsCommand(subscription_expression)
- UnsubscribeAlarmsCommand
- AcknowledgeAlarmCommand(alarm_guid, comment, operator_user/node/domain/full_name)
- QueryActiveAlarmsCommand(alarm_filter_prefix)
- AcknowledgeAlarmReplyPayload(native_status)
- QueryActiveAlarmsReplyPayload(repeated ActiveAlarmSnapshot snapshots)

Worker plumbing:

- New IAlarmCommandHandler interface + AlarmCommandHandler production
  impl. Lazy-creates an AlarmDispatcher (with a wnwrap-backed consumer
  by default) on the first SubscribeAlarms; routes Acknowledge / QueryActive /
  Unsubscribe through it. Idempotent under repeated Unsubscribe; rejects
  a second Subscribe without an intervening Unsubscribe; cleans up the
  consumer if the underlying Subscribe call throws.
- MxAccessCommandExecutor: 4 new switch arms map MxCommandKind values to
  IAlarmCommandHandler calls. Acknowledge surfaces the AVEVA native
  status into both MxCommandReply.Hresult and the dedicated
  AcknowledgeAlarmReplyPayload.NativeStatus so gateway-side consumers
  can echo it without unpacking the outer envelope. Invalid GUIDs and
  missing payloads return InvalidRequest; handler exceptions return
  MxaccessFailure with the exception message in DiagnosticMessage.
- MxAccessStaSession: new constructor overload accepts an
  alarmCommandHandlerFactory; it's invoked on the STA thread during
  StartAsync and the resulting handler is passed into the executor.
  ShutdownGracefullyAsync + Dispose tear it down on the STA before the
  data-side cleanup runs.

Tests: 20 new unit tests covering AlarmCommandHandler lazy lifecycle
(Subscribe/Unsubscribe/Acknowledge/Query/Dispose, error paths) and the
executor's 4 alarm switch arms (OK/InvalidRequest/MxaccessFailure paths,
hresult propagation, prefix filtering). Worker test suite total: 192
passed / 3 skipped (live probes) / 1 pre-existing structure-test fail
(untouched).

Deferred to next slice: gateway-side WorkerAlarmRpcDispatcher that
replaces NotWiredAlarmRpcDispatcher, builds + sends these commands across
the IPC, and unwraps the resulting MxCommandReply into AcknowledgeAlarmReply
/ ActiveAlarmSnapshot stream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-01 10:52:04 -04:00
parent 82eb0ad569
commit 01f5e6ad91
7 changed files with 3170 additions and 352 deletions
@@ -4,6 +4,7 @@ using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using MxGateway.Contracts.Proto;
using MxGateway.Worker.Conversion;
using MxGateway.Worker.Sta;
namespace MxGateway.Worker.MxAccess;
@@ -14,8 +15,10 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
private readonly IMxAccessEventSink eventSink;
private readonly MxAccessEventQueue eventQueue;
private readonly StaRuntime staRuntime;
private readonly Func<MxAccessEventQueue, IAlarmCommandHandler>? alarmCommandHandlerFactory;
private StaCommandDispatcher? commandDispatcher;
private MxAccessSession? session;
private IAlarmCommandHandler? alarmCommandHandler;
private bool disposed;
/// <summary>
@@ -69,11 +72,29 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
IMxAccessComObjectFactory factory,
IMxAccessEventSink eventSink,
MxAccessEventQueue eventQueue)
: this(staRuntime, factory, eventSink, eventQueue, alarmCommandHandlerFactory: null)
{
}
/// <summary>
/// Initializes a new instance of <see cref="MxAccessStaSession"/> with all
/// dependencies including an alarm-command handler factory. The factory is
/// invoked on the STA thread during <see cref="StartAsync(string, int, CancellationToken)"/>;
/// pass <c>null</c> to opt out of alarm-side commands (the worker rejects
/// them with an "alarm consumer not configured" diagnostic).
/// </summary>
public MxAccessStaSession(
StaRuntime staRuntime,
IMxAccessComObjectFactory factory,
IMxAccessEventSink eventSink,
MxAccessEventQueue eventQueue,
Func<MxAccessEventQueue, IAlarmCommandHandler>? alarmCommandHandlerFactory)
{
this.staRuntime = staRuntime ?? throw new ArgumentNullException(nameof(staRuntime));
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
this.eventSink = eventSink ?? throw new ArgumentNullException(nameof(eventSink));
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
this.alarmCommandHandlerFactory = alarmCommandHandlerFactory;
}
/// <summary>
@@ -117,9 +138,16 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
}
session = MxAccessSession.Create(factory, eventSink, sessionId);
if (alarmCommandHandlerFactory is not null)
{
alarmCommandHandler = alarmCommandHandlerFactory(eventQueue);
}
commandDispatcher = new StaCommandDispatcher(
staRuntime,
new MxAccessCommandExecutor(session));
new MxAccessCommandExecutor(
session,
new VariantConverter(),
alarmCommandHandler));
return session.CreateWorkerReady(workerProcessId);
},
@@ -279,6 +307,27 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
commandDispatcher?.RequestShutdown();
// Stop the alarm consumer's polling timer and tear down the
// dispatcher BEFORE the data-side cleanup begins. The alarm
// consumer holds a wnwrap COM RCW that needs the STA pump to
// unwind cleanly; doing it here gives it the opportunity while
// the STA is still alive.
IAlarmCommandHandler? alarmHandlerToDispose = alarmCommandHandler;
alarmCommandHandler = null;
if (alarmHandlerToDispose is not null)
{
try
{
await staRuntime.InvokeAsync(
() => alarmHandlerToDispose.Dispose(),
cancellationToken).ConfigureAwait(false);
}
catch
{
// Swallow — alarm cleanup must not block data shutdown.
}
}
Stopwatch stopwatch = Stopwatch.StartNew();
MxAccessShutdownResult result;
if (session is null)
@@ -333,6 +382,19 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
RequestShutdown();
IAlarmCommandHandler? alarmHandlerToDispose = alarmCommandHandler;
alarmCommandHandler = null;
if (alarmHandlerToDispose is not null)
{
try
{
staRuntime.InvokeAsync(() => alarmHandlerToDispose.Dispose())
.Wait(TimeSpan.FromSeconds(2));
}
catch (AggregateException) { }
catch (ObjectDisposedException) { }
}
if (session is not null)
{
try