rename: prefix gateway projects/namespaces with ZB.MOM.WW + sln→slnx
Apply the ZB.MOM.WW. prefix to all gateway-side projects, folders,
.csproj/.sln contents, C# namespaces, using directives, generated proto
C# (csharp_namespace + checked-in generated files), InternalsVisibleTo
attributes, project-name string literals (LoadProject, .sln lookups,
worker exe paths, staticwebassets manifest), and the install/script/doc
references that point at any of the above. Migrate the solution from
.sln to .slnx via `dotnet sln migrate` and delete the old file.
External-runtime identifiers are intentionally NOT prefixed so external
configuration keeps working:
- GatewayMetrics.cs MeterName ("MxGateway.Server")
- DashboardAuthenticationDefaults Scheme/Policy ("MxGateway.Dashboard")
- GatewayRequestLoggingMiddleware logger category ("MxGateway.Request")
- StaRuntime thread name ("MxGateway.Worker.STA")
- appsettings.json root section "MxGateway" + env-var prefix
MxGateway__... and secret-name MxGateway:ApiKeyPepper
- C:\ProgramData\MxGateway\ data dir paths
Also fixes two tests that were not rename-related but became visible
while validating the rename:
- WorkerLiveMxAccessSmokeTests.ShutDownAsync: cancellation that the
gateway service correctly maps to RpcException(Cancelled) per gRPC
convention was being misclassified as a stream fault. Added a sibling
catch on RpcException with StatusCode.Cancelled.
- IntegrationTestEnvironment.ResolveRepositoryRoot: extracted IsRepositoryRoot
and made it accept either a .git marker OR a .sln/.slnx next to src/
so the worker-exe walker works in non-git working copies.
clients/proto/proto-inputs.json's protoRoot updated to point at
src/ZB.MOM.WW.MxGateway.Contracts/Protos.
Verified by `dotnet build` and a full `dotnet test` of the .slnx with
MXGATEWAY_RUN_LIVE_{MXACCESS,LDAP,GALAXY}_TESTS=1:
Tests: 472/472 pass
Worker.Tests: 280/280 pass (4 dev-rig [Fact(Skip=...)] skipped)
IntegrationTests: 18/18 pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes and uninitializes the COM apartment for the STA thread.
|
||||
/// </summary>
|
||||
public interface IStaComApartmentInitializer
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the COM apartment on the STA thread.
|
||||
/// </summary>
|
||||
void Initialize();
|
||||
|
||||
/// <summary>
|
||||
/// Uninitializes the COM apartment on the STA thread.
|
||||
/// </summary>
|
||||
void Uninitialize();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
public interface IStaCommandExecutor
|
||||
{
|
||||
/// <summary>Executes a command on the STA thread.</summary>
|
||||
/// <param name="command">The command to execute.</param>
|
||||
/// <returns>The command reply.</returns>
|
||||
MxCommandReply Execute(StaCommand command);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
internal interface IStaWorkItem
|
||||
{
|
||||
/// <summary>Cancels the work item before it executes on the STA thread.</summary>
|
||||
void CancelBeforeExecution();
|
||||
|
||||
/// <summary>Executes the work item on the STA thread.</summary>
|
||||
void Execute();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
public sealed class StaComApartmentInitializer : IStaComApartmentInitializer
|
||||
{
|
||||
private const uint CoInitializeApartmentThreaded = 0x2;
|
||||
private const int SOk = 0;
|
||||
private const int SFalse = 1;
|
||||
|
||||
/// <summary>Initializes the COM apartment in single-threaded mode.</summary>
|
||||
public void Initialize()
|
||||
{
|
||||
int hresult = CoInitializeEx(IntPtr.Zero, CoInitializeApartmentThreaded);
|
||||
if (hresult != SOk && hresult != SFalse)
|
||||
{
|
||||
throw new COMException("Failed to initialize the worker STA COM apartment.", hresult);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Uninitializes the COM apartment.</summary>
|
||||
public void Uninitialize()
|
||||
{
|
||||
CoUninitialize();
|
||||
}
|
||||
|
||||
[DllImport("ole32.dll")]
|
||||
private static extern int CoInitializeEx(IntPtr reserved, uint coInit);
|
||||
|
||||
[DllImport("ole32.dll")]
|
||||
private static extern void CoUninitialize();
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
public sealed class StaCommand
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="StaCommand"/> class.</summary>
|
||||
/// <param name="sessionId">Identifier of the session.</param>
|
||||
/// <param name="correlationId">Correlation identifier for the command.</param>
|
||||
/// <param name="command">The MXAccess command to execute.</param>
|
||||
/// <param name="enqueueTimestamp">Timestamp when the command was enqueued.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
public StaCommand(
|
||||
string sessionId,
|
||||
string correlationId,
|
||||
MxCommand command,
|
||||
Timestamp? enqueueTimestamp = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
throw new ArgumentException("STA command requires a session id.", nameof(sessionId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(correlationId))
|
||||
{
|
||||
throw new ArgumentException("STA command requires a correlation id.", nameof(correlationId));
|
||||
}
|
||||
|
||||
SessionId = sessionId;
|
||||
CorrelationId = correlationId;
|
||||
Command = command ?? throw new ArgumentNullException(nameof(command));
|
||||
EnqueueTimestamp = enqueueTimestamp ?? Timestamp.FromDateTime(DateTime.UtcNow);
|
||||
CancellationToken = cancellationToken;
|
||||
}
|
||||
|
||||
/// <summary>Gets the session ID for the STA command.</summary>
|
||||
public string SessionId { get; }
|
||||
|
||||
/// <summary>Gets the correlation ID for the STA command.</summary>
|
||||
public string CorrelationId { get; }
|
||||
|
||||
/// <summary>Gets the MXAccess command to execute.</summary>
|
||||
public MxCommand Command { get; }
|
||||
|
||||
/// <summary>Gets the timestamp when the command was enqueued.</summary>
|
||||
public Timestamp EnqueueTimestamp { get; }
|
||||
|
||||
/// <summary>Gets the token to cancel the asynchronous operation.</summary>
|
||||
public CancellationToken CancellationToken { get; }
|
||||
|
||||
/// <summary>Gets the kind of the MXAccess command.</summary>
|
||||
public MxCommandKind Kind => Command.Kind;
|
||||
|
||||
/// <summary>Gets the method name of the command.</summary>
|
||||
public string MethodName => Kind.ToString();
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Conversion;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
public sealed class StaCommandDispatcher
|
||||
{
|
||||
public const int DefaultMaxPendingCommands = 128;
|
||||
|
||||
private readonly HResultConverter hresultConverter;
|
||||
private readonly IStaCommandExecutor commandExecutor;
|
||||
private readonly Queue<QueuedStaCommand> commandQueue = new();
|
||||
private readonly StaRuntime staRuntime;
|
||||
private readonly int maxPendingCommands;
|
||||
private readonly object gate = new();
|
||||
private bool drainActive;
|
||||
private bool shutdownRequested;
|
||||
private string currentCommandCorrelationId = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="StaCommandDispatcher"/> with default converter.
|
||||
/// </summary>
|
||||
/// <param name="staRuntime">STA thread runtime.</param>
|
||||
/// <param name="commandExecutor">Command executor.</param>
|
||||
public StaCommandDispatcher(
|
||||
StaRuntime staRuntime,
|
||||
IStaCommandExecutor commandExecutor)
|
||||
: this(staRuntime, commandExecutor, new HResultConverter())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="StaCommandDispatcher"/> with custom converter.
|
||||
/// </summary>
|
||||
/// <param name="staRuntime">STA thread runtime.</param>
|
||||
/// <param name="commandExecutor">Command executor.</param>
|
||||
/// <param name="hresultConverter">HResult converter.</param>
|
||||
public StaCommandDispatcher(
|
||||
StaRuntime staRuntime,
|
||||
IStaCommandExecutor commandExecutor,
|
||||
HResultConverter hresultConverter)
|
||||
: this(staRuntime, commandExecutor, hresultConverter, DefaultMaxPendingCommands)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="StaCommandDispatcher"/> with all parameters.
|
||||
/// </summary>
|
||||
/// <param name="staRuntime">STA thread runtime.</param>
|
||||
/// <param name="commandExecutor">Command executor.</param>
|
||||
/// <param name="hresultConverter">HResult converter.</param>
|
||||
/// <param name="maxPendingCommands">Maximum pending commands allowed.</param>
|
||||
public StaCommandDispatcher(
|
||||
StaRuntime staRuntime,
|
||||
IStaCommandExecutor commandExecutor,
|
||||
HResultConverter hresultConverter,
|
||||
int maxPendingCommands)
|
||||
{
|
||||
if (maxPendingCommands <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(maxPendingCommands),
|
||||
"Max pending STA commands must be greater than zero.");
|
||||
}
|
||||
|
||||
this.staRuntime = staRuntime ?? throw new ArgumentNullException(nameof(staRuntime));
|
||||
this.commandExecutor = commandExecutor ?? throw new ArgumentNullException(nameof(commandExecutor));
|
||||
this.hresultConverter = hresultConverter ?? throw new ArgumentNullException(nameof(hresultConverter));
|
||||
this.maxPendingCommands = maxPendingCommands;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of pending commands in the queue.
|
||||
/// </summary>
|
||||
public int PendingCommandCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return commandQueue.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the correlation ID of the currently executing command.
|
||||
/// </summary>
|
||||
public string CurrentCommandCorrelationId
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return currentCommandCorrelationId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches a command to the queue for asynchronous STA execution.
|
||||
/// </summary>
|
||||
/// <param name="command">The command to dispatch.</param>
|
||||
/// <returns>Task for the command reply.</returns>
|
||||
public Task<MxCommandReply> DispatchAsync(StaCommand command)
|
||||
{
|
||||
if (command is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(command));
|
||||
}
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (shutdownRequested)
|
||||
{
|
||||
return Task.FromResult(CreateRejectedReply(
|
||||
command,
|
||||
ProtocolStatusCode.WorkerUnavailable,
|
||||
"The STA command dispatcher is shutting down."));
|
||||
}
|
||||
|
||||
if (commandQueue.Count >= maxPendingCommands)
|
||||
{
|
||||
return Task.FromResult(CreateRejectedReply(
|
||||
command,
|
||||
ProtocolStatusCode.WorkerUnavailable,
|
||||
$"The STA command dispatcher already has {maxPendingCommands} pending command(s)."));
|
||||
}
|
||||
|
||||
QueuedStaCommand queuedCommand = new(command);
|
||||
commandQueue.Enqueue(queuedCommand);
|
||||
|
||||
if (!drainActive)
|
||||
{
|
||||
drainActive = true;
|
||||
_ = DrainAsync();
|
||||
}
|
||||
|
||||
return queuedCommand.Task;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a queued command by its correlation ID.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">Correlation ID of the command to cancel.</param>
|
||||
/// <returns>True if the command was canceled; otherwise false.</returns>
|
||||
public bool CancelQueuedCommand(string correlationId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(correlationId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (commandQueue.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool canceled = false;
|
||||
Queue<QueuedStaCommand> retainedCommands = new(commandQueue.Count);
|
||||
while (commandQueue.Count > 0)
|
||||
{
|
||||
QueuedStaCommand queuedCommand = commandQueue.Dequeue();
|
||||
if (!canceled
|
||||
&& string.Equals(
|
||||
queuedCommand.Command.CorrelationId,
|
||||
correlationId,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
queuedCommand.Complete(CreateRejectedReply(
|
||||
queuedCommand.Command,
|
||||
ProtocolStatusCode.Canceled,
|
||||
"The STA command was canceled before execution."));
|
||||
canceled = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
retainedCommands.Enqueue(queuedCommand);
|
||||
}
|
||||
|
||||
while (retainedCommands.Count > 0)
|
||||
{
|
||||
commandQueue.Enqueue(retainedCommands.Dequeue());
|
||||
}
|
||||
|
||||
return canceled;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requests graceful shutdown, rejecting all queued commands.
|
||||
/// </summary>
|
||||
public void RequestShutdown()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
shutdownRequested = true;
|
||||
while (commandQueue.Count > 0)
|
||||
{
|
||||
QueuedStaCommand queuedCommand = commandQueue.Dequeue();
|
||||
queuedCommand.Complete(CreateRejectedReply(
|
||||
queuedCommand.Command,
|
||||
ProtocolStatusCode.WorkerUnavailable,
|
||||
"The STA command dispatcher is shutting down."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populates the given heartbeat with current dispatcher state.
|
||||
/// </summary>
|
||||
/// <param name="heartbeat">Heartbeat to populate.</param>
|
||||
public void PopulateHeartbeat(WorkerHeartbeat heartbeat)
|
||||
{
|
||||
if (heartbeat is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(heartbeat));
|
||||
}
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
heartbeat.PendingCommandCount = (uint)commandQueue.Count;
|
||||
heartbeat.CurrentCommandCorrelationId = currentCommandCorrelationId;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DrainAsync()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
QueuedStaCommand queuedCommand;
|
||||
lock (gate)
|
||||
{
|
||||
if (commandQueue.Count == 0)
|
||||
{
|
||||
drainActive = false;
|
||||
return;
|
||||
}
|
||||
|
||||
queuedCommand = commandQueue.Dequeue();
|
||||
}
|
||||
|
||||
await ExecuteQueuedCommandAsync(queuedCommand).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExecuteQueuedCommandAsync(QueuedStaCommand queuedCommand)
|
||||
{
|
||||
StaCommand command = queuedCommand.Command;
|
||||
if (command.CancellationToken.IsCancellationRequested)
|
||||
{
|
||||
queuedCommand.Complete(CreateRejectedReply(
|
||||
command,
|
||||
ProtocolStatusCode.Canceled,
|
||||
"The STA command was canceled before execution."));
|
||||
return;
|
||||
}
|
||||
|
||||
SetCurrentCommand(command.CorrelationId);
|
||||
try
|
||||
{
|
||||
MxCommandReply reply = await staRuntime
|
||||
.InvokeAsync(() => commandExecutor.Execute(command))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
queuedCommand.Complete(NormalizeReply(command, reply));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
queuedCommand.Complete(CreateExceptionReply(command, exception));
|
||||
}
|
||||
finally
|
||||
{
|
||||
SetCurrentCommand(string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
private void SetCurrentCommand(string correlationId)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
currentCommandCorrelationId = correlationId;
|
||||
}
|
||||
}
|
||||
|
||||
private MxCommandReply NormalizeReply(
|
||||
StaCommand command,
|
||||
MxCommandReply reply)
|
||||
{
|
||||
if (reply is null)
|
||||
{
|
||||
return CreateRejectedReply(
|
||||
command,
|
||||
ProtocolStatusCode.ProtocolViolation,
|
||||
"STA command executor returned null.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(reply.SessionId))
|
||||
{
|
||||
reply.SessionId = command.SessionId;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(reply.CorrelationId))
|
||||
{
|
||||
reply.CorrelationId = command.CorrelationId;
|
||||
}
|
||||
|
||||
if (reply.Kind == MxCommandKind.Unspecified)
|
||||
{
|
||||
reply.Kind = command.Kind;
|
||||
}
|
||||
|
||||
if (reply.ProtocolStatus is null)
|
||||
{
|
||||
reply.ProtocolStatus = new ProtocolStatus
|
||||
{
|
||||
Code = ProtocolStatusCode.Ok,
|
||||
Message = "OK",
|
||||
};
|
||||
}
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
private MxCommandReply CreateExceptionReply(
|
||||
StaCommand command,
|
||||
Exception exception)
|
||||
{
|
||||
HResultConversion conversion = hresultConverter.Convert(exception);
|
||||
MxCommandReply reply = CreateBaseReply(command);
|
||||
reply.ProtocolStatus = conversion.ProtocolStatus;
|
||||
reply.Hresult = conversion.HResult;
|
||||
reply.DiagnosticMessage = conversion.DiagnosticMessage;
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
private static MxCommandReply CreateRejectedReply(
|
||||
StaCommand command,
|
||||
ProtocolStatusCode statusCode,
|
||||
string message)
|
||||
{
|
||||
MxCommandReply reply = CreateBaseReply(command);
|
||||
reply.ProtocolStatus = new ProtocolStatus
|
||||
{
|
||||
Code = statusCode,
|
||||
Message = message,
|
||||
};
|
||||
reply.DiagnosticMessage = message;
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
private static MxCommandReply CreateBaseReply(StaCommand command)
|
||||
{
|
||||
return new MxCommandReply
|
||||
{
|
||||
SessionId = command.SessionId,
|
||||
CorrelationId = command.CorrelationId,
|
||||
Kind = command.Kind,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class QueuedStaCommand
|
||||
{
|
||||
private readonly TaskCompletionSource<MxCommandReply> completion = new(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="QueuedStaCommand"/>.
|
||||
/// </summary>
|
||||
/// <param name="command">The STA command to queue.</param>
|
||||
public QueuedStaCommand(StaCommand command)
|
||||
{
|
||||
Command = command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the queued STA command.
|
||||
/// </summary>
|
||||
public StaCommand Command { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the task representing the command's completion.
|
||||
/// </summary>
|
||||
public Task<MxCommandReply> Task => completion.Task;
|
||||
|
||||
/// <summary>
|
||||
/// Completes the command with the given reply.
|
||||
/// </summary>
|
||||
/// <param name="reply">The command reply.</param>
|
||||
public void Complete(MxCommandReply reply)
|
||||
{
|
||||
completion.TrySetResult(reply);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
/// <summary>Pumps Windows messages on the STA thread to allow MXAccess COM events to deliver.</summary>
|
||||
public sealed class StaMessagePump
|
||||
{
|
||||
private const uint Infinite = 0xFFFFFFFF;
|
||||
private const uint MsgWaitFailed = 0xFFFFFFFF;
|
||||
private const uint MwmoInputAvailable = 0x0004;
|
||||
private const uint PmRemove = 0x0001;
|
||||
private const uint QsAllInput = 0x04FF;
|
||||
|
||||
/// <summary>Waits for a command wake event or Windows messages, pumping any pending messages.</summary>
|
||||
/// <param name="commandWakeEvent">Event to signal when work is available.</param>
|
||||
/// <param name="timeout">Maximum time to wait; InfiniteTimeSpan waits indefinitely.</param>
|
||||
public void WaitForWorkOrMessages(WaitHandle commandWakeEvent, TimeSpan timeout)
|
||||
{
|
||||
if (commandWakeEvent is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(commandWakeEvent));
|
||||
}
|
||||
|
||||
uint timeoutMilliseconds = ToTimeoutMilliseconds(timeout);
|
||||
|
||||
SafeWaitHandle safeHandle = commandWakeEvent.SafeWaitHandle;
|
||||
IntPtr[] handles = [safeHandle.DangerousGetHandle()];
|
||||
uint result = MsgWaitForMultipleObjectsEx(
|
||||
(uint)handles.Length,
|
||||
handles,
|
||||
timeoutMilliseconds,
|
||||
QsAllInput,
|
||||
MwmoInputAvailable);
|
||||
|
||||
if (result == MsgWaitFailed)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The worker STA message pump failed while waiting for command work or Windows messages.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Pumps and dispatches all pending Windows messages, returning the count processed.</summary>
|
||||
public int PumpPendingMessages()
|
||||
{
|
||||
int pumpedMessages = 0;
|
||||
|
||||
while (PeekMessage(out NativeMessage message, IntPtr.Zero, 0, 0, PmRemove))
|
||||
{
|
||||
TranslateMessage(ref message);
|
||||
DispatchMessage(ref message);
|
||||
pumpedMessages++;
|
||||
}
|
||||
|
||||
return pumpedMessages;
|
||||
}
|
||||
|
||||
private static uint ToTimeoutMilliseconds(TimeSpan timeout)
|
||||
{
|
||||
if (timeout == Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
return Infinite;
|
||||
}
|
||||
|
||||
if (timeout <= TimeSpan.Zero)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return timeout.TotalMilliseconds >= uint.MaxValue
|
||||
? uint.MaxValue - 1
|
||||
: (uint)Math.Ceiling(timeout.TotalMilliseconds);
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern uint MsgWaitForMultipleObjectsEx(
|
||||
uint count,
|
||||
IntPtr[] handles,
|
||||
uint milliseconds,
|
||||
uint wakeMask,
|
||||
uint flags);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool PeekMessage(
|
||||
out NativeMessage message,
|
||||
IntPtr windowHandle,
|
||||
uint messageFilterMin,
|
||||
uint messageFilterMax,
|
||||
uint removeMessage);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool TranslateMessage(ref NativeMessage message);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr DispatchMessage(ref NativeMessage message);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct NativeMessage
|
||||
{
|
||||
public IntPtr WindowHandle;
|
||||
public uint Message;
|
||||
public UIntPtr WParam;
|
||||
public IntPtr LParam;
|
||||
public uint Time;
|
||||
public NativePoint Point;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct NativePoint
|
||||
{
|
||||
public int X;
|
||||
public int Y;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
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<IStaWorkItem> 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;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="StaRuntime"/> with default dependencies.
|
||||
/// </summary>
|
||||
public StaRuntime()
|
||||
: this(new StaComApartmentInitializer(), new StaMessagePump(), TimeSpan.FromMilliseconds(50))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="StaRuntime"/> with custom dependencies.
|
||||
/// </summary>
|
||||
/// <param name="comApartmentInitializer">COM apartment initializer.</param>
|
||||
/// <param name="messagePump">Message pump for the STA thread.</param>
|
||||
/// <param name="idlePumpInterval">Interval for idle message pump waits.</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the managed thread ID of the STA thread.
|
||||
/// </summary>
|
||||
public int? StaThreadId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp of the last STA thread activity.
|
||||
/// </summary>
|
||||
public DateTimeOffset LastActivityUtc =>
|
||||
new(new DateTime(Volatile.Read(ref lastActivityUtcTicks), DateTimeKind.Utc));
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the STA runtime is currently running.
|
||||
/// </summary>
|
||||
public bool IsRunning => startedEvent.IsSet && !stoppedEvent.IsSet;
|
||||
|
||||
/// <summary>
|
||||
/// Pumps any pending Windows messages on the calling thread. Intended
|
||||
/// for commands that synchronously hold the STA (e.g. ReadBulk) and
|
||||
/// must allow inbound MXAccess COM events to dispatch while they
|
||||
/// wait. Callers must already be on the STA; the method is otherwise
|
||||
/// safe (PeekMessage simply finds no messages).
|
||||
/// </summary>
|
||||
public int PumpPendingMessages() => messagePump.PumpPendingMessages();
|
||||
|
||||
/// <summary>
|
||||
/// Starts the STA thread.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes an action on the STA thread asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="command">Action to invoke on the STA thread.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Task that completes when the action executes.</returns>
|
||||
public Task InvokeAsync(Action command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (command is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(command));
|
||||
}
|
||||
|
||||
return InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
command();
|
||||
return true;
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes a function on the STA thread asynchronously.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Return type of the function.</typeparam>
|
||||
/// <param name="command">Function to invoke on the STA thread.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Task that returns the function result.</returns>
|
||||
public Task<T> InvokeAsync<T>(Func<T> command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (command is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(command));
|
||||
}
|
||||
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return Task.FromCanceled<T>(cancellationToken);
|
||||
}
|
||||
|
||||
StaWorkItem<T> workItem = new(command, cancellationToken);
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (shutdownRequested)
|
||||
{
|
||||
return Task.FromException<T>(new StaRuntimeShutdownException());
|
||||
}
|
||||
|
||||
commandQueue.Enqueue(workItem);
|
||||
}
|
||||
|
||||
commandWakeEvent.Set();
|
||||
return workItem.Task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requests graceful shutdown of the STA runtime within a timeout.
|
||||
/// </summary>
|
||||
/// <param name="timeout">Maximum time to wait for shutdown.</param>
|
||||
/// <returns>True if shutdown completed; otherwise false.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases resources used by the STA runtime.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown by <see cref="StaRuntime"/> when an operation is rejected because
|
||||
/// the runtime is shutting down (or has already shut down). The dedicated
|
||||
/// type lets callers distinguish a graceful shutdown signal — which should
|
||||
/// stop their work loops without recording a fault — from a genuine
|
||||
/// programming-error <see cref="InvalidOperationException"/> such as the
|
||||
/// STA-affinity assertion in <c>MxAccessStaSession.AssertOnAlarmConsumerThread</c>.
|
||||
/// It inherits from <see cref="InvalidOperationException"/> so existing
|
||||
/// callers that catch the latter remain source-compatible.
|
||||
/// </summary>
|
||||
public sealed class StaRuntimeShutdownException : InvalidOperationException
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="StaRuntimeShutdownException"/>
|
||||
/// with a default message.
|
||||
/// </summary>
|
||||
public StaRuntimeShutdownException()
|
||||
: base("The worker STA runtime is shutting down.")
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="StaRuntimeShutdownException"/>
|
||||
/// with the specified message.
|
||||
/// </summary>
|
||||
/// <param name="message">Diagnostic message.</param>
|
||||
public StaRuntimeShutdownException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
/// <summary>
|
||||
/// Encapsulates a work item to be executed on an STA thread with cancellation support.
|
||||
/// </summary>
|
||||
internal sealed class StaWorkItem<T> : IStaWorkItem
|
||||
{
|
||||
private readonly Func<T> command;
|
||||
private readonly CancellationToken cancellationToken;
|
||||
private readonly CancellationTokenRegistration cancellationRegistration;
|
||||
private int started;
|
||||
|
||||
/// <summary>Initializes a work item with a command and cancellation token.</summary>
|
||||
/// <param name="command">Function to execute on the STA thread.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the work item.</param>
|
||||
public StaWorkItem(Func<T> command, CancellationToken cancellationToken)
|
||||
{
|
||||
this.command = command ?? throw new ArgumentNullException(nameof(command));
|
||||
this.cancellationToken = cancellationToken;
|
||||
Completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
if (cancellationToken.CanBeCanceled)
|
||||
{
|
||||
cancellationRegistration = cancellationToken.Register(
|
||||
() =>
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref started, 1, 0) == 0)
|
||||
{
|
||||
Completion.TrySetCanceled(cancellationToken);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets the task that completes when work completes.</summary>
|
||||
public Task<T> Task => Completion.Task;
|
||||
|
||||
private TaskCompletionSource<T> Completion { get; }
|
||||
|
||||
/// <summary>Cancels the work item before execution begins.</summary>
|
||||
public void CancelBeforeExecution()
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref started, 1, 0) == 0)
|
||||
{
|
||||
Completion.TrySetCanceled(cancellationToken);
|
||||
cancellationRegistration.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Executes the work item command.</summary>
|
||||
public void Execute()
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref started, 1, 0) != 0)
|
||||
{
|
||||
cancellationRegistration.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
cancellationRegistration.Dispose();
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Completion.TrySetCanceled(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Completion.TrySetResult(command());
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Completion.TrySetException(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user