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>
408 lines
14 KiB
C#
408 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Runtime.InteropServices;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using MxGateway.Contracts.Proto;
|
|
using MxGateway.Worker.MxAccess;
|
|
using MxGateway.Worker.Sta;
|
|
|
|
namespace MxGateway.Worker.Tests.MxAccess;
|
|
|
|
/// <summary>
|
|
/// Tests for <see cref="MxAccessStaSession"/>.
|
|
/// </summary>
|
|
public sealed class MxAccessStaSessionTests
|
|
{
|
|
/// <summary>
|
|
/// Verifies that StartAsync creates the MXAccess COM object and attaches the event sink on the STA thread.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task StartAsync_CreatesComObjectAndAttachesEventSinkOnStaThread()
|
|
{
|
|
FakeMxAccessComObjectFactory factory = new();
|
|
FakeMxAccessEventSink eventSink = new();
|
|
using StaRuntime runtime = CreateRuntime();
|
|
using MxAccessStaSession session = new(runtime, factory, eventSink);
|
|
|
|
WorkerReady ready = await session.StartAsync("session-1", workerProcessId: 1234);
|
|
|
|
Assert.Equal(1234, ready.WorkerProcessId);
|
|
Assert.Equal(MxAccessInteropInfo.ProgId, ready.MxaccessProgid);
|
|
Assert.Equal(MxAccessInteropInfo.Clsid, ready.MxaccessClsid);
|
|
Assert.NotNull(ready.ReadyTimestamp);
|
|
Assert.Equal(runtime.StaThreadId, factory.CreateThreadId);
|
|
Assert.Equal(runtime.StaThreadId, eventSink.AttachThreadId);
|
|
Assert.Equal(ApartmentState.STA, factory.CreateApartmentState);
|
|
Assert.Same(factory.CreatedObject, eventSink.AttachedObject);
|
|
Assert.Equal("session-1", eventSink.SessionId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that StartAsync maps creation exceptions with HResult when the factory fails.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task StartAsync_WhenFactoryFails_MapsCreationExceptionWithHResult()
|
|
{
|
|
const int hresult = unchecked((int)0x80040154);
|
|
FakeMxAccessComObjectFactory factory = new(new COMException("Class not registered.", hresult));
|
|
FakeMxAccessEventSink eventSink = new();
|
|
using StaRuntime runtime = CreateRuntime();
|
|
using MxAccessStaSession session = new(runtime, factory, eventSink);
|
|
|
|
MxAccessCreationException exception = await Assert.ThrowsAsync<MxAccessCreationException>(
|
|
() => session.StartAsync(workerProcessId: 1234));
|
|
|
|
Assert.Equal(hresult, exception.CapturedHResult);
|
|
Assert.Equal(MxAccessInteropInfo.ProgId, exception.AttemptedProgId);
|
|
Assert.Equal(MxAccessInteropInfo.Clsid, exception.AttemptedClsid);
|
|
Assert.Null(eventSink.AttachedObject);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that Dispose detaches the event sink on the STA thread.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Dispose_DetachesEventSinkOnStaThread()
|
|
{
|
|
FakeMxAccessComObjectFactory factory = new();
|
|
FakeMxAccessEventSink eventSink = new();
|
|
using StaRuntime runtime = CreateRuntime();
|
|
MxAccessStaSession session = new(runtime, factory, eventSink);
|
|
await session.StartAsync(workerProcessId: 1234);
|
|
|
|
session.Dispose();
|
|
|
|
Assert.Equal(runtime.StaThreadId, eventSink.DetachThreadId);
|
|
}
|
|
|
|
private static StaRuntime CreateRuntime()
|
|
{
|
|
return new StaRuntime(
|
|
new NoopComApartmentInitializer(),
|
|
new StaMessagePump(),
|
|
TimeSpan.FromMilliseconds(25));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fake MXAccess COM object factory for testing.
|
|
/// </summary>
|
|
private sealed class FakeMxAccessComObjectFactory : IMxAccessComObjectFactory
|
|
{
|
|
private readonly Exception? exception;
|
|
|
|
/// <summary>
|
|
/// Initializes a fake factory that optionally throws an exception.
|
|
/// </summary>
|
|
/// <param name="exception">Exception to throw when Create is called; null to succeed.</param>
|
|
public FakeMxAccessComObjectFactory(Exception? exception = null)
|
|
{
|
|
this.exception = exception;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the COM object created by this factory.
|
|
/// </summary>
|
|
public object CreatedObject { get; } = new();
|
|
|
|
/// <summary>
|
|
/// Gets the managed thread ID when Create was called.
|
|
/// </summary>
|
|
public int? CreateThreadId { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Gets the apartment state when Create was called.
|
|
/// </summary>
|
|
public ApartmentState? CreateApartmentState { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Creates the COM object or throws the configured exception.
|
|
/// </summary>
|
|
public object Create()
|
|
{
|
|
CreateThreadId = Thread.CurrentThread.ManagedThreadId;
|
|
CreateApartmentState = Thread.CurrentThread.GetApartmentState();
|
|
|
|
if (exception is not null)
|
|
{
|
|
throw exception;
|
|
}
|
|
|
|
return CreatedObject;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fake MXAccess event sink for testing.
|
|
/// </summary>
|
|
private sealed class FakeMxAccessEventSink : IMxAccessEventSink
|
|
{
|
|
/// <summary>
|
|
/// Gets the attached MXAccess COM object.
|
|
/// </summary>
|
|
public object? AttachedObject { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Gets the managed thread ID when Attach was called.
|
|
/// </summary>
|
|
public int? AttachThreadId { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Gets the managed thread ID when Detach was called.
|
|
/// </summary>
|
|
public int? DetachThreadId { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Gets the session identifier.
|
|
/// </summary>
|
|
public string? SessionId { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Attaches the MXAccess COM object and records thread context.
|
|
/// </summary>
|
|
/// <param name="mxAccessComObject">MXAccess COM object to attach.</param>
|
|
/// <param name="sessionId">Identifier of the session.</param>
|
|
public void Attach(
|
|
object mxAccessComObject,
|
|
string sessionId)
|
|
{
|
|
AttachedObject = mxAccessComObject;
|
|
AttachThreadId = Thread.CurrentThread.ManagedThreadId;
|
|
SessionId = sessionId;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Detaches the MXAccess COM object and records thread context.
|
|
/// </summary>
|
|
public void Detach()
|
|
{
|
|
DetachThreadId = Thread.CurrentThread.ManagedThreadId;
|
|
AttachedObject = null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gap 1: Verifies that when MxAccessStaSession is created with an alarm handler factory,
|
|
/// a SubscribeAlarms command dispatched through the session reaches the handler.
|
|
/// This proves the fix in WorkerPipeSession (and the new internal constructor) correctly
|
|
/// wires the factory rather than leaving alarmCommandHandler null.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task StartAsync_WithAlarmCommandHandlerFactory_SubscribeAlarmsCommandReachesHandler()
|
|
{
|
|
FakeAlarmCommandHandler handler = new();
|
|
FakeMxAccessComObjectFactory factory = new();
|
|
FakeMxAccessEventSink eventSink = new();
|
|
using StaRuntime runtime = CreateRuntime();
|
|
using MxAccessStaSession session = new(
|
|
runtime,
|
|
factory,
|
|
eventSink,
|
|
new MxAccessEventQueue(),
|
|
_eq => handler);
|
|
|
|
await session.StartAsync("session-1", workerProcessId: 1);
|
|
|
|
StaCommand subscribeCommand = new StaCommand(
|
|
"session-1",
|
|
"corr-1",
|
|
new MxCommand
|
|
{
|
|
Kind = MxCommandKind.SubscribeAlarms,
|
|
SubscribeAlarms = new SubscribeAlarmsCommand
|
|
{
|
|
SubscriptionExpression = @"\\HOST\Galaxy!Area",
|
|
},
|
|
});
|
|
|
|
MxCommandReply reply = await session.DispatchAsync(subscribeCommand);
|
|
|
|
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
|
Assert.True(handler.IsSubscribed);
|
|
Assert.Equal(@"\\HOST\Galaxy!Area", handler.LastSubscription);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gap 1: Verifies that when MxAccessStaSession is created with the default
|
|
/// parameterless constructor (no alarm factory), SubscribeAlarms returns
|
|
/// InvalidRequest with "alarm consumer not configured" diagnostic.
|
|
/// This validates the baseline before the fix.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task StartAsync_WithoutAlarmCommandHandlerFactory_SubscribeAlarmsReturnsInvalidRequest()
|
|
{
|
|
FakeMxAccessComObjectFactory factory = new();
|
|
FakeMxAccessEventSink eventSink = new();
|
|
using StaRuntime runtime = CreateRuntime();
|
|
// Use the 4-arg (no factory) constructor — equivalent to the old MxAccessStaSession()
|
|
using MxAccessStaSession session = new(runtime, factory, eventSink);
|
|
|
|
await session.StartAsync("session-1", workerProcessId: 1);
|
|
|
|
StaCommand subscribeCommand = new StaCommand(
|
|
"session-1",
|
|
"corr-1",
|
|
new MxCommand
|
|
{
|
|
Kind = MxCommandKind.SubscribeAlarms,
|
|
SubscribeAlarms = new SubscribeAlarmsCommand
|
|
{
|
|
SubscriptionExpression = @"\\HOST\Galaxy!Area",
|
|
},
|
|
});
|
|
|
|
MxCommandReply reply = await session.DispatchAsync(subscribeCommand);
|
|
|
|
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
|
|
Assert.Contains("alarm", reply.DiagnosticMessage, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gap 2: Verifies that after StartAsync with an alarm handler factory, the STA poll
|
|
/// loop calls PollOnce on the handler via the STA within a reasonable timeout.
|
|
/// This proves polling is driven by the STA rather than the consumer's internal timer.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task StartAsync_WithAlarmCommandHandlerFactory_PollOnceCalledViaSta()
|
|
{
|
|
FakeAlarmCommandHandler handler = new();
|
|
FakeMxAccessComObjectFactory factory = new();
|
|
FakeMxAccessEventSink eventSink = new();
|
|
using StaRuntime runtime = CreateRuntime();
|
|
using MxAccessStaSession session = new(
|
|
runtime,
|
|
factory,
|
|
eventSink,
|
|
new MxAccessEventQueue(),
|
|
_eq => handler);
|
|
|
|
await session.StartAsync("session-1", workerProcessId: 1);
|
|
|
|
// Wait up to 3s for at least one PollOnce call from the STA poll loop.
|
|
using CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
|
while (handler.PollCount == 0 && !timeout.IsCancellationRequested)
|
|
{
|
|
await Task.Delay(50, CancellationToken.None);
|
|
}
|
|
|
|
Assert.True(handler.PollCount > 0,
|
|
"Expected PollOnce to be called at least once by the STA poll loop within 3 seconds.");
|
|
Assert.NotNull(handler.LastPollThreadId);
|
|
Assert.Equal(runtime.StaThreadId, handler.LastPollThreadId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gap 2: Verifies that the STA poll loop stops when the session is disposed —
|
|
/// no further PollOnce calls after disposal.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Dispose_StopsAlarmPollLoop()
|
|
{
|
|
FakeAlarmCommandHandler handler = new();
|
|
FakeMxAccessComObjectFactory factory = new();
|
|
FakeMxAccessEventSink eventSink = new();
|
|
using StaRuntime runtime = CreateRuntime();
|
|
MxAccessStaSession session = new(
|
|
runtime,
|
|
factory,
|
|
eventSink,
|
|
new MxAccessEventQueue(),
|
|
_eq => handler);
|
|
|
|
await session.StartAsync("session-1", workerProcessId: 1);
|
|
|
|
// Wait for at least one poll to occur, then dispose.
|
|
using CancellationTokenSource initTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
|
while (handler.PollCount == 0 && !initTimeout.IsCancellationRequested)
|
|
{
|
|
await Task.Delay(50, CancellationToken.None);
|
|
}
|
|
|
|
Assert.True(handler.PollCount > 0, "Prerequisite: poll loop must have fired before dispose.");
|
|
|
|
session.Dispose();
|
|
int pollCountAtDispose = handler.PollCount;
|
|
|
|
// Wait 1 second and verify no further polls occur.
|
|
await Task.Delay(1000);
|
|
Assert.Equal(pollCountAtDispose, handler.PollCount);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Noop STA COM apartment initializer for testing.
|
|
/// </summary>
|
|
private sealed class NoopComApartmentInitializer : IStaComApartmentInitializer
|
|
{
|
|
/// <summary>
|
|
/// Initializes the COM apartment (no-op).
|
|
/// </summary>
|
|
public void Initialize()
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Uninitializes the COM apartment (no-op).
|
|
/// </summary>
|
|
public void Uninitialize()
|
|
{
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fake alarm command handler that records calls and tracks poll thread.
|
|
/// </summary>
|
|
private sealed class FakeAlarmCommandHandler : IAlarmCommandHandler
|
|
{
|
|
private readonly object gate = new object();
|
|
private int pollCount;
|
|
private int? lastPollThreadId;
|
|
|
|
public bool IsSubscribed { get; private set; }
|
|
public string? LastSubscription { get; private set; }
|
|
|
|
public int PollCount
|
|
{
|
|
get { lock (gate) return pollCount; }
|
|
}
|
|
|
|
public int? LastPollThreadId
|
|
{
|
|
get { lock (gate) return lastPollThreadId; }
|
|
}
|
|
|
|
public void Subscribe(string subscription, string sessionId)
|
|
{
|
|
IsSubscribed = true;
|
|
LastSubscription = subscription;
|
|
}
|
|
|
|
public void Unsubscribe()
|
|
{
|
|
IsSubscribed = false;
|
|
}
|
|
|
|
public int Acknowledge(Guid alarmGuid, string comment, string operatorUser,
|
|
string operatorNode, string operatorDomain, string operatorFullName)
|
|
=> 0;
|
|
|
|
public int AcknowledgeByName(string alarmName, string providerName, string groupName,
|
|
string comment, string operatorUser, string operatorNode,
|
|
string operatorDomain, string operatorFullName)
|
|
=> 0;
|
|
|
|
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix)
|
|
=> Array.Empty<ActiveAlarmSnapshot>();
|
|
|
|
public void PollOnce()
|
|
{
|
|
lock (gate)
|
|
{
|
|
pollCount++;
|
|
lastPollThreadId = Thread.CurrentThread.ManagedThreadId;
|
|
}
|
|
}
|
|
|
|
public void Dispose() { }
|
|
}
|
|
}
|