Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs
T

658 lines
26 KiB
C#

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;
/// <summary>
/// Tests for <see cref="MxAccessStaSession"/>.
/// </summary>
public sealed class MxAccessStaSessionTests
{
/// <summary>
/// Verifies the launcher-env-var parse branches of
/// <see cref="MxAccessStaSession.ResolveWriteCompletionTimeout"/>:
/// 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.
/// </summary>
/// <param name="rawValue">Raw env-var value, or null for unset.</param>
/// <param name="expectedMilliseconds">Expected resolved wait, or null for the executor default.</param>
[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);
}
}
/// <summary>
/// Verifies that StartAsync creates the MXAccess COM object and attaches the event sink on the STA thread.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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>
/// <returns>A task that represents the asynchronous operation.</returns>
[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>
/// <returns>A task that represents the asynchronous operation.</returns>
[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; }
/// <inheritdoc />
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; }
/// <inheritdoc />
public void Attach(
object mxAccessComObject,
string sessionId)
{
AttachedObject = mxAccessComObject;
AttachThreadId = Thread.CurrentThread.ManagedThreadId;
SessionId = sessionId;
}
/// <inheritdoc />
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>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// 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".
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <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>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// Gap 2: Verifies that the STA poll loop stops when the session is disposed —
/// no further PollOnce calls after disposal. <see cref="MxAccessStaSession.Dispose"/>
/// 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// The alarm poll loop's catch for the graceful
/// STA-runtime-shutdown signal must NOT also swallow a vanilla
/// <see cref="InvalidOperationException"/> raised from inside the marshalled
/// poll lambda — for example the STA-affinity assertion thrown by
/// <c>EnsureOnAlarmConsumerThread</c> if a regression ever caused the poll
/// to run off the alarm-consumer thread. The runtime-shutdown signal is now
/// the dedicated <see cref="StaRuntimeShutdownException"/>; a plain
/// <see cref="InvalidOperationException"/> from <c>PollOnce</c> must reach
/// the fault-recording arm and become observable on the event queue.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// WRK-27. While the alarm poll's PollOnce is executing on the STA, a heartbeat captured mid-poll
/// must report <see cref="WorkerRuntimeHeartbeatSnapshot.StaCallInProgress"/> 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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.");
}
/// <summary>
/// The STA-affinity guard throws when an
/// IMxAccessAlarmConsumer call is attempted off the thread that created
/// the consumer, mirroring the MxAccessSession.CreationThreadId invariant.
/// </summary>
[Fact]
public void AssertOnAlarmConsumerThread_WhenOffOwningThread_Throws()
{
const int owningThread = 7;
const int otherThread = 99;
InvalidOperationException exception = Assert.Throws<InvalidOperationException>(
() => MxAccessStaSession.AssertOnAlarmConsumerThread(owningThread, otherThread));
Assert.Contains("off its owning STA thread", exception.Message, StringComparison.Ordinal);
}
/// <summary>
/// The STA-affinity guard is a no-op on the owning thread and
/// when no alarm consumer is configured (expected thread id null).
/// </summary>
[Fact]
public void AssertOnAlarmConsumerThread_OnOwningThreadOrUnset_DoesNotThrow()
{
MxAccessStaSession.AssertOnAlarmConsumerThread(expectedThreadId: 42, actualThreadId: 42);
MxAccessStaSession.AssertOnAlarmConsumerThread(expectedThreadId: null, actualThreadId: 123);
}
/// <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 readonly ManualResetEventSlim pollEntered = new(false);
private readonly ManualResetEventSlim releasePoll = new(false);
private int pollCount;
private int? lastPollThreadId;
/// <summary>Gets a value indicating whether the alarm client is currently subscribed.</summary>
public bool IsSubscribed { get; private set; }
/// <summary>Gets the last alarm subscription name.</summary>
public string? LastSubscription { get; private set; }
/// <summary>Exception thrown by PollOnce; null to succeed.</summary>
public Exception? PollException { get; set; }
/// <summary>When set, <see cref="PollOnce"/> blocks until <see cref="ReleasePoll"/> is called.</summary>
public bool BlockPoll { get; set; }
/// <summary>Waits until a blocking <see cref="PollOnce"/> has entered and is blocked.</summary>
/// <param name="timeout">Maximum time to wait.</param>
/// <returns>True if a poll entered within the timeout.</returns>
public bool WaitForPollEntered(TimeSpan timeout) => pollEntered.Wait(timeout);
/// <summary>Releases a <see cref="PollOnce"/> blocked on the gate.</summary>
public void ReleasePoll() => releasePoll.Set();
/// <summary>Gets the count of PollOnce calls.</summary>
public int PollCount
{
get { lock (gate) return pollCount; }
}
/// <summary>Gets the managed thread ID of the last PollOnce call.</summary>
public int? LastPollThreadId
{
get { lock (gate) return lastPollThreadId; }
}
/// <inheritdoc />
public void Subscribe(SubscribeAlarmsCommand command, string sessionId)
{
IsSubscribed = true;
LastSubscription = command.SubscriptionExpression;
}
/// <inheritdoc />
public void Unsubscribe()
{
IsSubscribed = false;
}
/// <inheritdoc />
public int Acknowledge(Guid alarmGuid, string comment, string operatorUser,
string operatorNode, string operatorDomain, string operatorFullName)
=> 0;
/// <inheritdoc />
public int AcknowledgeByName(string alarmName, string providerName, string groupName,
string comment, string operatorUser, string operatorNode,
string operatorDomain, string operatorFullName)
=> 0;
/// <inheritdoc />
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix)
=> Array.Empty<ActiveAlarmSnapshot>();
/// <inheritdoc />
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;
}
}
/// <inheritdoc />
public void Dispose()
{
pollEntered.Dispose();
releasePoll.Dispose();
}
}
}