using System; using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; namespace ZB.MOM.WW.MxGateway.Worker.Sta; public sealed class StaRuntime : IDisposable { private readonly IStaComApartmentInitializer comApartmentInitializer; private readonly StaMessagePump messagePump; private readonly ConcurrentQueue commandQueue = new(); private readonly AutoResetEvent commandWakeEvent = new(false); private readonly ManualResetEventSlim startedEvent = new(false); private readonly ManualResetEventSlim stoppedEvent = new(false); private readonly object gate = new(); private readonly Thread staThread; private readonly TimeSpan idlePumpInterval; private bool disposed; private bool startRequested; private bool shutdownRequested; private Exception? startupException; private long lastActivityUtcTicks; private bool comInitialized; /// /// Initializes a new instance of with default dependencies. /// public StaRuntime() : this(new StaComApartmentInitializer(), new StaMessagePump(), TimeSpan.FromMilliseconds(50)) { } /// /// Initializes a new instance of with custom dependencies. /// /// COM apartment initializer. /// Message pump for the STA thread. /// Interval for idle message pump waits. public StaRuntime( IStaComApartmentInitializer comApartmentInitializer, StaMessagePump messagePump, TimeSpan idlePumpInterval) { this.comApartmentInitializer = comApartmentInitializer ?? throw new ArgumentNullException(nameof(comApartmentInitializer)); this.messagePump = messagePump ?? throw new ArgumentNullException(nameof(messagePump)); if (idlePumpInterval <= TimeSpan.Zero) { throw new ArgumentOutOfRangeException( nameof(idlePumpInterval), "The idle pump interval must be greater than zero."); } this.idlePumpInterval = idlePumpInterval; lastActivityUtcTicks = DateTimeOffset.UtcNow.UtcTicks; staThread = new Thread(ThreadMain) { IsBackground = true, Name = "MxGateway.Worker.STA" }; staThread.SetApartmentState(ApartmentState.STA); } /// /// Gets the managed thread ID of the STA thread. /// public int? StaThreadId { get; private set; } /// /// Gets the timestamp of the last STA thread activity. /// public DateTimeOffset LastActivityUtc => new(new DateTime(Volatile.Read(ref lastActivityUtcTicks), DateTimeKind.Utc)); /// /// Gets a value indicating whether the STA runtime is currently running. /// public bool IsRunning => startedEvent.IsSet && !stoppedEvent.IsSet; /// /// Pumps any pending Windows messages on the calling thread and refreshes /// the STA activity timestamp. Intended for commands that synchronously /// hold the STA (e.g. ReadBulk) and must allow inbound MXAccess COM events /// to dispatch while they wait. Because a long-running command invokes this /// on every wait iteration, refreshing activity here keeps a busy STA from /// being mistaken for a hung one: a healthy command that keeps pumping stays /// fresh past HeartbeatStuckCeiling, while a genuinely stuck STA (no /// pumping) still accrues staleness and faults correctly. Callers must /// already be on the STA; the method is otherwise safe (PeekMessage simply /// finds no messages). /// /// The number of messages pumped. public int PumpPendingMessages() { int pumpedMessages = messagePump.PumpPendingMessages(); MarkActivity(); return pumpedMessages; } /// /// Starts the STA thread. /// public void Start() { ThrowIfDisposed(); lock (gate) { if (shutdownRequested) { throw new StaRuntimeShutdownException(); } if (!startRequested) { startRequested = true; staThread.Start(); } } startedEvent.Wait(); if (startupException is not null) { throw new InvalidOperationException( "The worker STA runtime failed to initialize.", startupException); } } /// /// Invokes an action on the STA thread asynchronously. /// /// Action to invoke on the STA thread. /// Cancellation token. /// Task that completes when the action executes. public Task InvokeAsync(Action command, CancellationToken cancellationToken = default) { if (command is null) { throw new ArgumentNullException(nameof(command)); } return InvokeAsync( () => { command(); return true; }, cancellationToken); } /// /// Invokes a function on the STA thread asynchronously. /// /// Return type of the function. /// Function to invoke on the STA thread. /// Cancellation token. /// Task that returns the function result. public Task InvokeAsync(Func command, CancellationToken cancellationToken = default) { if (command is null) { throw new ArgumentNullException(nameof(command)); } ThrowIfDisposed(); if (cancellationToken.IsCancellationRequested) { return Task.FromCanceled(cancellationToken); } StaWorkItem workItem = new(command, cancellationToken); lock (gate) { if (shutdownRequested) { return Task.FromException(new StaRuntimeShutdownException()); } commandQueue.Enqueue(workItem); } commandWakeEvent.Set(); return workItem.Task; } /// /// Requests graceful shutdown of the STA runtime within a timeout. /// /// Maximum time to wait for shutdown. /// True if shutdown completed; otherwise false. public bool Shutdown(TimeSpan timeout) { if (timeout < TimeSpan.Zero && timeout != Timeout.InfiniteTimeSpan) { throw new ArgumentOutOfRangeException(nameof(timeout)); } lock (gate) { shutdownRequested = true; } commandWakeEvent.Set(); if (!startedEvent.IsSet && !staThread.IsAlive) { CancelQueuedCommands(); stoppedEvent.Set(); return true; } bool stopped = stoppedEvent.Wait(timeout); if (stopped) { CancelQueuedCommands(); } return stopped; } /// public void Dispose() { if (disposed) { return; } bool stopped = Shutdown(TimeSpan.FromSeconds(5)); if (stopped) { commandWakeEvent.Dispose(); startedEvent.Dispose(); stoppedEvent.Dispose(); } disposed = true; } private void ThreadMain() { try { StaThreadId = Thread.CurrentThread.ManagedThreadId; comApartmentInitializer.Initialize(); comInitialized = true; MarkActivity(); startedEvent.Set(); while (!IsShutdownRequested()) { ProcessQueuedCommands(); messagePump.WaitForWorkOrMessages(commandWakeEvent, idlePumpInterval); messagePump.PumpPendingMessages(); MarkActivity(); } ProcessQueuedCommands(); } catch (Exception exception) { startupException = exception; startedEvent.Set(); } finally { CancelQueuedCommands(); try { if (comInitialized) { comApartmentInitializer.Uninitialize(); } } finally { MarkActivity(); stoppedEvent.Set(); } } } private void ProcessQueuedCommands() { while (commandQueue.TryDequeue(out IStaWorkItem? workItem)) { MarkActivity(); workItem.Execute(); MarkActivity(); } } private void CancelQueuedCommands() { while (commandQueue.TryDequeue(out IStaWorkItem? workItem)) { workItem.CancelBeforeExecution(); } } private bool IsShutdownRequested() { lock (gate) { return shutdownRequested; } } private void MarkActivity() { Volatile.Write(ref lastActivityUtcTicks, DateTimeOffset.UtcNow.UtcTicks); } private void ThrowIfDisposed() { if (disposed) { throw new ObjectDisposedException(nameof(StaRuntime)); } } }