using System; using System.Collections.Generic; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Worker.Conversion; using ZB.MOM.WW.MxGateway.Worker.Sta; namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; public sealed class MxAccessStaSession : IWorkerRuntimeSession { /// /// Environment variable the gateway's WorkerProcessLauncher sets from /// MxGateway:Worker:WriteCompletionWaitMilliseconds. 0 disables the /// write-completion wait (pure fire-and-forget replies). /// internal const string WriteCompletionWaitEnvironmentVariableName = "MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS"; /// /// Environment variable the gateway's WorkerProcessLauncher sets from /// MxGateway:Alarms:PollIntervalMilliseconds. A missing or invalid /// value falls back to . /// internal const string AlarmPollIntervalEnvironmentVariableName = "MXGATEWAY_ALARM_POLL_INTERVAL_MS"; /// /// Floor on the resolved alarm poll cadence. Mirrors the gateway-side /// MxGateway:Alarms:PollIntervalMilliseconds minimum so a value that /// slipped past validation (or an environment set by hand) still can /// not starve the STA's command path. /// internal static readonly TimeSpan MinimumAlarmPollInterval = TimeSpan.FromMilliseconds(100); /// Default alarm poll cadence when the environment says nothing usable. internal static readonly TimeSpan DefaultAlarmPollInterval = TimeSpan.FromMilliseconds(500); private readonly TimeSpan alarmPollInterval = ResolveAlarmPollInterval(); private readonly IMxAccessComObjectFactory factory; private readonly IMxAccessEventSink eventSink; private readonly MxAccessEventQueue eventQueue; private readonly StaRuntime staRuntime; private readonly Func? alarmCommandHandlerFactory; private StaCommandDispatcher? commandDispatcher; private MxAccessSession? session; private IAlarmCommandHandler? alarmCommandHandler; private CancellationTokenSource? alarmPollCts; private Task? alarmPollTask; private int? alarmConsumerThreadId; // True on the STA thread exactly around the alarm PollOnce COM call. The alarm poll runs outside // the StaCommandDispatcher (so it does not inflate PendingCommandCount or perturb command dispatch // ordering), which means CaptureHeartbeat would otherwise see no in-flight activity during a long // poll and the watchdog would fault the session at the 15 s grace instead of the 75 s ceiling // granted to dispatched commands. Surfacing the poll on the heartbeat closes that asymmetry // (WRK-27). Volatile: written on the STA thread, read on the heartbeat thread. private volatile bool staAlarmPollInProgress; private bool disposed; /// /// Initializes a new instance of with default dependencies. /// The outbound event queue is sized from the launcher-provided /// MXGATEWAY_EVENT_QUEUE_CAPACITY (see ); /// callers that pass their own queue keep full control of its capacity. /// public MxAccessStaSession() : this( new StaRuntime(), new MxAccessComObjectFactory(), new MxAccessEventQueue(MxAccessEventQueue.ResolveCapacity())) { } /// /// Initializes a new instance of with default STA runtime, /// factory, and event queue, but with a custom alarm-command handler factory. The factory is /// invoked on the STA thread during /// ; pass null to opt out /// of alarm-side commands. /// /// Factory that constructs the alarm-command handler. internal MxAccessStaSession(Func? alarmCommandHandlerFactory) : this( new StaRuntime(), new MxAccessComObjectFactory(), new MxAccessEventQueue(MxAccessEventQueue.ResolveCapacity()), alarmCommandHandlerFactory) { } /// /// Initializes a new instance of with custom STA runtime and factory. /// /// STA thread runtime. /// MXAccess COM object factory. /// Event sink for MXAccess events. public MxAccessStaSession( StaRuntime staRuntime, IMxAccessComObjectFactory factory, IMxAccessEventSink eventSink) : this(staRuntime, factory, eventSink, new MxAccessEventQueue(MxAccessEventQueue.ResolveCapacity())) { } /// /// Initializes a new instance of with custom event queue. /// /// STA thread runtime. /// MXAccess COM object factory. /// Event queue for buffering MXAccess events. public MxAccessStaSession( StaRuntime staRuntime, IMxAccessComObjectFactory factory, MxAccessEventQueue eventQueue) : this(staRuntime, factory, new MxAccessBaseEventSink(eventQueue), eventQueue) { } /// /// Initializes a new instance of with custom event queue /// and an alarm-command handler factory. /// /// STA thread runtime. /// MXAccess COM object factory. /// Event queue for buffering MXAccess events. /// /// Factory that constructs the alarm-command handler from the event queue. /// Pass null to opt out of alarm-side commands. /// public MxAccessStaSession( StaRuntime staRuntime, IMxAccessComObjectFactory factory, MxAccessEventQueue eventQueue, Func? alarmCommandHandlerFactory) : this(staRuntime, factory, new MxAccessBaseEventSink(eventQueue), eventQueue, alarmCommandHandlerFactory) { } /// /// Initializes a new instance of with all dependencies. /// /// STA thread runtime. /// MXAccess COM object factory. /// Event sink for MXAccess events. /// Event queue for buffering MXAccess events. public MxAccessStaSession( StaRuntime staRuntime, IMxAccessComObjectFactory factory, IMxAccessEventSink eventSink, MxAccessEventQueue eventQueue) : this(staRuntime, factory, eventSink, eventQueue, alarmCommandHandlerFactory: null) { } /// /// Initializes a new instance of with all /// dependencies including an alarm-command handler factory. The factory is /// invoked on the STA thread during ; /// pass null to opt out of alarm-side commands (the worker rejects /// them with an "alarm consumer not configured" diagnostic). /// /// STA thread runtime. /// MXAccess COM object factory. /// Event sink for MXAccess events. /// Event queue for buffering MXAccess events. /// Factory that constructs the alarm-command handler. public MxAccessStaSession( StaRuntime staRuntime, IMxAccessComObjectFactory factory, IMxAccessEventSink eventSink, MxAccessEventQueue eventQueue, Func? 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; } /// /// Gets the event queue for this session. /// public MxAccessEventQueue EventQueue => eventQueue; /// /// Bounded WriteSecured/WriteSecured2 completion wait handed to the /// command executor at . /// Internal-settable as a test seam so Worker.Tests can shorten it /// without env-var plumbing. /// internal TimeSpan WriteCompletionTimeout { get; set; } = ResolveWriteCompletionTimeout(); /// /// Resolves the write-completion wait from the launcher-provided /// environment variable; a missing or invalid value falls back to /// . /// internal static TimeSpan ResolveWriteCompletionTimeout() { string? value = Environment.GetEnvironmentVariable(WriteCompletionWaitEnvironmentVariableName); return int.TryParse( value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out int milliseconds) && milliseconds >= 0 ? TimeSpan.FromMilliseconds(milliseconds) : MxAccessCommandExecutor.DefaultWriteCompletionTimeout; } /// /// Resolves the alarm poll cadence from the launcher-provided /// environment variable. A missing, unparseable, or out-of-range value /// falls back to rather than /// faulting the worker: an alarm poll that runs at the default cadence /// is always safe, and a session that refuses to start over a /// mistyped environment variable is not. /// /// The cadence the alarm poll loop waits between polls. internal static TimeSpan ResolveAlarmPollInterval() { string? value = Environment.GetEnvironmentVariable(AlarmPollIntervalEnvironmentVariableName); if (!int.TryParse( value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out int milliseconds)) { return DefaultAlarmPollInterval; } TimeSpan resolved = TimeSpan.FromMilliseconds(milliseconds); return resolved < MinimumAlarmPollInterval ? DefaultAlarmPollInterval : resolved; } /// /// Starts the MXAccess COM session asynchronously. /// /// Worker process identifier. /// Cancellation token. /// Worker ready message. public Task StartAsync( int workerProcessId, CancellationToken cancellationToken = default) { return StartAsync(string.Empty, workerProcessId, cancellationToken); } /// public async Task StartAsync( string sessionId, int workerProcessId, CancellationToken cancellationToken = default) { staRuntime.Start(); WorkerReady ready = await staRuntime.InvokeAsync( () => { if (session is not null) { throw new InvalidOperationException("MXAccess COM session has already been created."); } session = MxAccessSession.Create(factory, eventSink, sessionId); if (alarmCommandHandlerFactory is not null) { // STA-affinity invariant: the alarm consumer factory and // every IMxAccessAlarmConsumer call must run on the STA // thread, because the production wnwrap consumer holds an // Apartment-threaded COM object. The factory runs here // inside staRuntime.InvokeAsync, so this records the STA // thread id; RunAlarmPollLoopAsync then asserts each // PollOnce executes on the same thread. alarmConsumerThreadId = Environment.CurrentManagedThreadId; alarmCommandHandler = alarmCommandHandlerFactory( eventQueue, EnsureOnAlarmConsumerThread, factory); } commandDispatcher = new StaCommandDispatcher( staRuntime, new MxAccessCommandExecutor( session, new VariantConverter(), alarmCommandHandler, // ReadBulk and the write-completion wait need to pump // Windows messages while they wait for the inbound COM // callback (OnDataChange / OnWriteComplete) so it can // dispatch on this same STA thread. The pump step // closes over staRuntime so it always pumps the pump // tied to the apartment that owns this session. pumpStep: () => staRuntime.PumpPendingMessages(), writeCompletionTimeout: WriteCompletionTimeout)); return session.CreateWorkerReady(workerProcessId); }, cancellationToken).ConfigureAwait(false); if (alarmCommandHandler is not null) { alarmPollCts = new CancellationTokenSource(); alarmPollTask = RunAlarmPollLoopAsync(alarmCommandHandler, alarmPollCts.Token); } return ready; } private Task RunAlarmPollLoopAsync( IAlarmCommandHandler handler, CancellationToken cancellationToken) { return Task.Run(async () => { while (!cancellationToken.IsCancellationRequested) { try { await Task.Delay(alarmPollInterval, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { return; } if (cancellationToken.IsCancellationRequested) { return; } try { await staRuntime.InvokeAsync( () => { // Advertise the poll to the watchdog for exactly the span of the COM call // (WRK-27): set on the STA thread immediately before the affinity check and // PollOnce, clear in the finally so a heartbeat captured mid-poll reports // StaCallInProgress and one captured after does not. staAlarmPollInProgress = true; try { EnsureOnAlarmConsumerThread(); handler.PollOnce(); } finally { staAlarmPollInProgress = false; } }, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { return; } catch (ObjectDisposedException) { // STA runtime or alarm handler disposed — stop the loop gracefully. return; } catch (StaRuntimeShutdownException) { // STA runtime shutting down — stop the loop gracefully. // The dedicated shutdown type lets us distinguish this // graceful-stop signal from the STA-affinity assertion // raised by EnsureOnAlarmConsumerThread, // which is also an InvalidOperationException but signals // a programming-error regression — that case falls through // to the generic Exception arm below and is recorded as a // fault on the event queue, so an affinity regression // becomes observable on the IPC fault path instead of // silently stopping alarm delivery. return; } catch (Exception exception) { // A real alarm-poll failure (COMException from // GetXmlCurrentAlarms2, malformed-XML parse failure, an // STA-affinity InvalidOperationException from // EnsureOnAlarmConsumerThread, etc.). Record it as a // fault on the event queue so a broken alarm subscription // — or an affinity-invariant regression — becomes // observable on the IPC fault path instead of silently // faulting this never-awaited task. The loop then stops — // the subscription is dead. eventQueue.RecordFault(CreateAlarmPollFault(exception)); return; } } }, CancellationToken.None); } private void EnsureOnAlarmConsumerThread() { AssertOnAlarmConsumerThread(alarmConsumerThreadId, Environment.CurrentManagedThreadId); } /// /// Enforces the STA-affinity invariant for the alarm consumer: every /// call (and the consumer factory) /// must run on the same thread the consumer was created on (the worker's /// STA). Throws when a caller /// breaks affinity — a programming error that would otherwise risk a /// cross-apartment COM deadlock in the production wnwrap consumer, since /// its CLSID is registered ThreadingModel=Apartment. The check is /// a no-op until the consumer thread has been recorded (no alarm handler /// configured, or session not yet started). /// /// /// The managed thread id the alarm consumer was created on, or /// null if no alarm consumer is configured. /// /// The current managed thread id. internal static void AssertOnAlarmConsumerThread(int? expectedThreadId, int actualThreadId) { if (expectedThreadId is not null && actualThreadId != expectedThreadId.Value) { throw new InvalidOperationException( $"Alarm consumer accessed off its owning STA thread. Expected thread {expectedThreadId.Value}, " + $"actual {actualThreadId}. All IMxAccessAlarmConsumer calls must run on the STA that " + "created the consumer."); } } private static WorkerFault CreateAlarmPollFault(Exception exception) { string message = $"MXAccess alarm poll failed: {exception.Message}"; WorkerFault fault = new() { Category = WorkerFaultCategory.MxaccessEventConversionFailed, ExceptionType = exception.GetType().FullName ?? string.Empty, DiagnosticMessage = message, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.WorkerUnavailable, Message = message, }, }; if (exception is System.Runtime.InteropServices.COMException comException) { fault.Hresult = comException.HResult; } return fault; } /// public Task DispatchAsync(StaCommand command) { if (commandDispatcher is null) { throw new InvalidOperationException("MXAccess COM session has not been started."); } return commandDispatcher.DispatchAsync(command); } /// public WorkerRuntimeHeartbeatSnapshot CaptureHeartbeat() { uint pendingCommandCount = 0; string currentCommandCorrelationId = string.Empty; if (commandDispatcher is not null) { pendingCommandCount = (uint)commandDispatcher.PendingCommandCount; currentCommandCorrelationId = commandDispatcher.CurrentCommandCorrelationId; } return new WorkerRuntimeHeartbeatSnapshot( staRuntime.LastActivityUtc, pendingCommandCount, (uint)eventQueue.Count, eventQueue.LastEventSequence, currentCommandCorrelationId, staAlarmPollInProgress); } /// public void RequestShutdown() { commandDispatcher?.RequestShutdown(); } /// public IReadOnlyList DrainEvents(uint maxEvents) { return eventQueue.Drain(maxEvents); } /// public WorkerEventDrainResult DrainEvents(uint maxEvents, int maxTotalBytes) { return eventQueue.Drain(maxEvents, maxTotalBytes); } /// public Task WaitForEventsAsync(TimeSpan timeout, CancellationToken cancellationToken) { return eventQueue.WaitForEventsAsync(timeout, cancellationToken); } /// public WorkerFault? DrainFault() { return eventQueue.DrainFault(); } /// public bool CancelCommand(string correlationId) { return commandDispatcher?.CancelQueuedCommand(correlationId) ?? false; } /// /// Gets the registered server handles asynchronously. /// /// Cancellation token. /// Registered server handles. public Task> GetRegisteredServerHandlesAsync( CancellationToken cancellationToken = default) { if (session is null) { throw new InvalidOperationException("MXAccess COM session has not been started."); } return staRuntime.InvokeAsync( () => session.HandleRegistry.ServerHandles, cancellationToken); } /// /// Gets the registered item handles asynchronously. /// /// Cancellation token. /// Registered item handles. public Task> GetRegisteredItemHandlesAsync( CancellationToken cancellationToken = default) { if (session is null) { throw new InvalidOperationException("MXAccess COM session has not been started."); } return staRuntime.InvokeAsync( () => session.HandleRegistry.ItemHandles, cancellationToken); } /// /// Gets the registered advice handles asynchronously. /// /// Cancellation token. /// Registered advice handles. public Task> GetRegisteredAdviceHandlesAsync( CancellationToken cancellationToken = default) { if (session is null) { throw new InvalidOperationException("MXAccess COM session has not been started."); } return staRuntime.InvokeAsync( () => session.HandleRegistry.AdviceHandles, cancellationToken); } /// public async Task ShutdownGracefullyAsync( TimeSpan timeout, CancellationToken cancellationToken = default) { if (timeout <= TimeSpan.Zero) { throw new ArgumentOutOfRangeException( nameof(timeout), "MXAccess graceful shutdown timeout must be greater than zero."); } if (disposed) { return new MxAccessShutdownResult(Array.Empty()); } commandDispatcher?.RequestShutdown(); // Cancel the STA poll loop before disposing the alarm handler. // The loop references the alarm handler and must be stopped first // so that no further PollOnce calls race with disposal. CancellationTokenSource? pollCtsToDispose = alarmPollCts; Task? pollTaskToAwait = alarmPollTask; alarmPollCts = null; alarmPollTask = null; if (pollCtsToDispose is not null) { pollCtsToDispose.Cancel(); if (pollTaskToAwait is not null) { try { await pollTaskToAwait.ConfigureAwait(false); } catch { // Swallow — poll loop cancellation must not block data shutdown. } } pollCtsToDispose.Dispose(); } // 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) { result = new MxAccessShutdownResult(Array.Empty()); } else { using CancellationTokenSource shutdownCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); shutdownCancellation.CancelAfter(timeout); Task cleanupTask = staRuntime.InvokeAsync( () => session.ShutdownGracefully(), shutdownCancellation.Token); Task delayTask = Task.Delay(timeout, cancellationToken); Task completedTask = await Task.WhenAny(cleanupTask, delayTask).ConfigureAwait(false); if (completedTask != cleanupTask) { cancellationToken.ThrowIfCancellationRequested(); throw new TimeoutException($"MXAccess graceful shutdown exceeded {timeout}."); } try { result = await cleanupTask.ConfigureAwait(false); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { throw new TimeoutException($"MXAccess graceful shutdown exceeded {timeout}."); } } TimeSpan remaining = timeout - stopwatch.Elapsed; if (remaining <= TimeSpan.Zero || !staRuntime.Shutdown(remaining)) { throw new TimeoutException($"MXAccess graceful shutdown exceeded {timeout}."); } staRuntime.Dispose(); disposed = true; return result; } /// public void Dispose() { if (disposed) { return; } RequestShutdown(); // Cancel the STA poll loop and join it before disposing the alarm // handler. Joining (rather than discarding alarmPollTask) makes the // stop deterministic: once Dispose returns, no further PollOnce calls // can be in flight, so callers and tests can rely on a frozen poll // count instead of an elapsed-time "no further polls" window. CancellationTokenSource? pollCtsToDispose = alarmPollCts; Task? pollTaskToJoin = alarmPollTask; alarmPollCts = null; alarmPollTask = null; if (pollCtsToDispose is not null) { try { pollCtsToDispose.Cancel(); } catch { } if (pollTaskToJoin is not null) { try { pollTaskToJoin.Wait(TimeSpan.FromSeconds(5)); } catch (AggregateException) { } catch (ObjectDisposedException) { } } try { pollCtsToDispose.Dispose(); } catch { } } 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 { staRuntime.InvokeAsync(() => session.Dispose()) .Wait(TimeSpan.FromSeconds(2)); } catch (AggregateException) { } catch (ObjectDisposedException) { } } staRuntime.Dispose(); disposed = true; } }