using System; using System.Collections.Generic; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Worker.MxAccess; using ZB.MOM.WW.MxGateway.Worker.Sta; using ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport; namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; /// /// Tests for . /// public sealed class MxAccessStaSessionTests { /// /// Verifies the launcher-env-var parse branches of /// : /// a valid non-negative value is honored (0 = disabled), while a /// missing, malformed, or negative value falls back to the executor /// default. Env mutation is restored in a finally so parallel tests /// never observe the temporary value. /// /// Raw env-var value, or null for unset. /// Expected resolved wait, or null for the executor default. [Theory] [InlineData(null, null)] [InlineData("", null)] [InlineData("junk", null)] [InlineData("-5", null)] [InlineData("0", 0)] [InlineData("250", 250)] public void ResolveWriteCompletionTimeout_ParsesEnvironmentValue(string? rawValue, int? expectedMilliseconds) { string? original = Environment.GetEnvironmentVariable( MxAccessStaSession.WriteCompletionWaitEnvironmentVariableName); try { Environment.SetEnvironmentVariable( MxAccessStaSession.WriteCompletionWaitEnvironmentVariableName, rawValue); TimeSpan resolved = MxAccessStaSession.ResolveWriteCompletionTimeout(); TimeSpan expected = expectedMilliseconds is null ? MxAccessCommandExecutor.DefaultWriteCompletionTimeout : TimeSpan.FromMilliseconds(expectedMilliseconds.Value); Assert.Equal(expected, resolved); } finally { Environment.SetEnvironmentVariable( MxAccessStaSession.WriteCompletionWaitEnvironmentVariableName, original); } } /// /// Verifies that StartAsync creates the MXAccess COM object and attaches the event sink on the STA thread. /// /// A task that represents the asynchronous operation. [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); } /// /// Verifies that StartAsync maps creation exceptions with HResult when the factory fails. /// /// A task that represents the asynchronous operation. [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( () => 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); } /// /// Verifies that Dispose detaches the event sink on the STA thread. /// /// A task that represents the asynchronous operation. [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)); } /// /// Fake MXAccess COM object factory for testing. /// private sealed class FakeMxAccessComObjectFactory : IMxAccessComObjectFactory { private readonly Exception? exception; /// /// Initializes a fake factory that optionally throws an exception. /// /// Exception to throw when Create is called; null to succeed. public FakeMxAccessComObjectFactory(Exception? exception = null) { this.exception = exception; } /// /// Gets the COM object created by this factory. /// public object CreatedObject { get; } = new(); /// /// Gets the managed thread ID when Create was called. /// public int? CreateThreadId { get; private set; } /// /// Gets the apartment state when Create was called. /// public ApartmentState? CreateApartmentState { get; private set; } /// public object Create() { CreateThreadId = Thread.CurrentThread.ManagedThreadId; CreateApartmentState = Thread.CurrentThread.GetApartmentState(); if (exception is not null) { throw exception; } return CreatedObject; } } /// /// Fake MXAccess event sink for testing. /// private sealed class FakeMxAccessEventSink : IMxAccessEventSink { /// /// Gets the attached MXAccess COM object. /// public object? AttachedObject { get; private set; } /// /// Gets the managed thread ID when Attach was called. /// public int? AttachThreadId { get; private set; } /// /// Gets the managed thread ID when Detach was called. /// public int? DetachThreadId { get; private set; } /// /// Gets the session identifier. /// public string? SessionId { get; private set; } /// public void Attach( object mxAccessComObject, string sessionId) { AttachedObject = mxAccessComObject; AttachThreadId = Thread.CurrentThread.ManagedThreadId; SessionId = sessionId; } /// public void Detach() { DetachThreadId = Thread.CurrentThread.ManagedThreadId; AttachedObject = null; } } /// /// 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. /// /// A task that represents the asynchronous operation. [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, _affinity, _comFactory) => 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); } /// /// Gap 1: Verifies that when MxAccessStaSession is created without an alarm /// command handler factory, SubscribeAlarms returns InvalidRequest with the /// exact "SubscribeAlarms requires an alarm command handler; the worker was /// constructed without one." diagnostic. The full phrase is asserted so the /// test fails if the diagnostic regresses to a misleading message that still /// happens to contain the word "alarm". /// /// A task that represents the asynchronous operation. [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.Equal( "SubscribeAlarms requires an alarm command handler; the worker was constructed without one.", reply.DiagnosticMessage); } /// /// 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. /// /// A task that represents the asynchronous operation. [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, _affinity, _comFactory) => 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); } /// /// Gap 2: Verifies that the STA poll loop stops when the session is disposed — /// no further PollOnce calls after disposal. /// joins the poll task before returning, so once Dispose returns no PollOnce /// call can still be in flight. The test asserts the poll count is frozen /// immediately after Dispose and stays frozen — deterministic, with no /// elapsed-time "no further polls" window that a slow agent could race. /// /// A task that represents the asynchronous operation. [Fact] public async Task Dispose_StopsAlarmPollLoop() { FakeAlarmCommandHandler handler = new(); FakeMxAccessComObjectFactory factory = new(); FakeMxAccessEventSink eventSink = new(); using StaRuntime runtime = CreateRuntime(); // using declaration: if an assertion below throws before the explicit // Dispose, the session (its STA poll loop and alarm handler) is still // torn down. Dispose is idempotent, so the explicit call mid-test and // the using-scope call do not conflict. using MxAccessStaSession session = new( runtime, factory, eventSink, new MxAccessEventQueue(), (_eq, _affinity, _comFactory) => 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."); // Dispose joins the poll task; when it returns the loop has stopped // and no PollOnce call is still running. session.Dispose(); int pollCountAtDispose = handler.PollCount; // The count is already frozen — re-reading after a yield must not // observe any further poll. This is a deterministic check, not a // timing window: a poll cannot start once the joined loop has exited. await Task.Yield(); Assert.Equal(pollCountAtDispose, handler.PollCount); } /// /// When the alarm poll loop's PollOnce throws a /// real failure (e.g. a COMException from GetXmlCurrentAlarms2), the /// failure must be recorded as a fault on the event queue so a broken /// alarm subscription becomes observable on the IPC fault path instead /// of silently faulting the never-awaited poll task. /// /// A task that represents the asynchronous operation. [Fact] public async Task RunAlarmPollLoop_WhenPollOnceThrows_RecordsFaultOnEventQueue() { FakeAlarmCommandHandler handler = new() { PollException = new System.Runtime.InteropServices.COMException( "GetXmlCurrentAlarms2 failed.", unchecked((int)0x80004005)), }; FakeMxAccessComObjectFactory factory = new(); FakeMxAccessEventSink eventSink = new(); using StaRuntime runtime = CreateRuntime(); MxAccessEventQueue eventQueue = new(); using MxAccessStaSession session = new( runtime, factory, eventSink, eventQueue, (_eq, _affinity, _comFactory) => handler); await session.StartAsync("session-1", workerProcessId: 1); // Wait up to 5s for the poll loop to fault the queue. using CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); while (!eventQueue.IsFaulted && !timeout.IsCancellationRequested) { await Task.Delay(50, CancellationToken.None); } Assert.True(eventQueue.IsFaulted, "Expected the alarm poll failure to fault the event queue."); WorkerFault? fault = session.DrainFault(); Assert.NotNull(fault); Assert.Equal(WorkerFaultCategory.MxaccessEventConversionFailed, fault!.Category); Assert.Contains("alarm poll failed", fault.DiagnosticMessage, StringComparison.OrdinalIgnoreCase); Assert.Equal(typeof(System.Runtime.InteropServices.COMException).FullName, fault.ExceptionType); } /// /// The alarm poll loop's catch for the graceful /// STA-runtime-shutdown signal must NOT also swallow a vanilla /// raised from inside the marshalled /// poll lambda — for example the STA-affinity assertion thrown by /// EnsureOnAlarmConsumerThread if a regression ever caused the poll /// to run off the alarm-consumer thread. The runtime-shutdown signal is now /// the dedicated ; a plain /// from PollOnce must reach /// the fault-recording arm and become observable on the event queue. /// /// A task that represents the asynchronous operation. [Fact] public async Task RunAlarmPollLoop_WhenPollOnceThrowsInvalidOperation_RecordsFaultOnEventQueue() { FakeAlarmCommandHandler handler = new() { PollException = new InvalidOperationException( "Alarm consumer accessed off its owning STA thread."), }; FakeMxAccessComObjectFactory factory = new(); FakeMxAccessEventSink eventSink = new(); using StaRuntime runtime = CreateRuntime(); MxAccessEventQueue eventQueue = new(); using MxAccessStaSession session = new( runtime, factory, eventSink, eventQueue, (_eq, _affinity, _comFactory) => handler); await session.StartAsync("session-1", workerProcessId: 1); using CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); while (!eventQueue.IsFaulted && !timeout.IsCancellationRequested) { await Task.Delay(50, CancellationToken.None); } Assert.True( eventQueue.IsFaulted, "Expected the alarm poll InvalidOperationException to fault the event queue, " + "not be silently swallowed as a shutdown signal."); WorkerFault? fault = session.DrainFault(); Assert.NotNull(fault); Assert.Equal(WorkerFaultCategory.MxaccessEventConversionFailed, fault!.Category); Assert.Equal(typeof(InvalidOperationException).FullName, fault.ExceptionType); Assert.Contains("alarm poll failed", fault.DiagnosticMessage, StringComparison.OrdinalIgnoreCase); } /// /// WRK-27. While the alarm poll's PollOnce is executing on the STA, a heartbeat captured mid-poll /// must report so the watchdog /// grants the poll the same grace-to-ceiling suppression as a dispatched command; once the poll /// returns the flag clears. PollOnce is blocked on a gate so the heartbeat can be captured while /// the STA call is genuinely in flight. /// /// A task that represents the asynchronous operation. [Fact] public async Task CaptureHeartbeat_DuringAlarmPoll_ReportsStaCallInProgress() { FakeAlarmCommandHandler handler = new() { BlockPoll = true }; FakeMxAccessComObjectFactory factory = new(); FakeMxAccessEventSink eventSink = new(); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new( runtime, factory, eventSink, new MxAccessEventQueue(), (_eq, _affinity, _comFactory) => handler); await session.StartAsync("session-1", workerProcessId: 1); // Wait until PollOnce is blocked mid-call on the STA thread. Assert.True( handler.WaitForPollEntered(TimeSpan.FromSeconds(5)), "Expected the alarm poll to start within 5 seconds."); // Captured mid-poll, the heartbeat advertises the in-progress STA call. Assert.True(session.CaptureHeartbeat().StaCallInProgress); // Release the poll and stop blocking; the flag clears once the poll returns. handler.BlockPoll = false; handler.ReleasePoll(); using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5)); while (session.CaptureHeartbeat().StaCallInProgress && !timeout.IsCancellationRequested) { await Task.Delay(25, CancellationToken.None); } Assert.False( session.CaptureHeartbeat().StaCallInProgress, "Expected StaCallInProgress to clear once the alarm poll returned."); } /// /// The STA-affinity guard throws when an /// IMxAccessAlarmConsumer call is attempted off the thread that created /// the consumer, mirroring the MxAccessSession.CreationThreadId invariant. /// [Fact] public void AssertOnAlarmConsumerThread_WhenOffOwningThread_Throws() { const int owningThread = 7; const int otherThread = 99; InvalidOperationException exception = Assert.Throws( () => MxAccessStaSession.AssertOnAlarmConsumerThread(owningThread, otherThread)); Assert.Contains("off its owning STA thread", exception.Message, StringComparison.Ordinal); } /// /// The STA-affinity guard is a no-op on the owning thread and /// when no alarm consumer is configured (expected thread id null). /// [Fact] public void AssertOnAlarmConsumerThread_OnOwningThreadOrUnset_DoesNotThrow() { MxAccessStaSession.AssertOnAlarmConsumerThread(expectedThreadId: 42, actualThreadId: 42); MxAccessStaSession.AssertOnAlarmConsumerThread(expectedThreadId: null, actualThreadId: 123); } /// /// Fake alarm command handler that records calls and tracks poll thread. /// private sealed class FakeAlarmCommandHandler : IAlarmCommandHandler { private readonly object gate = new object(); private readonly ManualResetEventSlim pollEntered = new(false); private readonly ManualResetEventSlim releasePoll = new(false); private int pollCount; private int? lastPollThreadId; /// Gets a value indicating whether the alarm client is currently subscribed. public bool IsSubscribed { get; private set; } /// Gets the last alarm subscription name. public string? LastSubscription { get; private set; } /// Exception thrown by PollOnce; null to succeed. public Exception? PollException { get; set; } /// When set, blocks until is called. public bool BlockPoll { get; set; } /// Waits until a blocking has entered and is blocked. /// Maximum time to wait. /// True if a poll entered within the timeout. public bool WaitForPollEntered(TimeSpan timeout) => pollEntered.Wait(timeout); /// Releases a blocked on the gate. public void ReleasePoll() => releasePoll.Set(); /// Gets the count of PollOnce calls. public int PollCount { get { lock (gate) return pollCount; } } /// Gets the managed thread ID of the last PollOnce call. public int? LastPollThreadId { get { lock (gate) return lastPollThreadId; } } /// public void Subscribe(SubscribeAlarmsCommand command, string sessionId) { IsSubscribed = true; LastSubscription = command.SubscriptionExpression; } /// 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 QueryActive(string? alarmFilterPrefix) => Array.Empty(); /// public void PollOnce() { lock (gate) { pollCount++; lastPollThreadId = Thread.CurrentThread.ManagedThreadId; } if (BlockPoll) { pollEntered.Set(); releasePoll.Wait(TimeSpan.FromSeconds(10)); } if (PollException is not null) { throw PollException; } } /// public void Dispose() { pollEntered.Dispose(); releasePoll.Dispose(); } } }