feat(lmxproxy): add STA thread with message pump for MxAccess COM callbacks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-22 18:00:24 -04:00
parent c96e71c83c
commit b6408726bc
7 changed files with 161 additions and 17 deletions

View File

@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
public sealed partial class MxAccessClient
{
/// <summary>
/// Connects to MxAccess via Task.Run (thread pool).
/// Connects to MxAccess on the dedicated STA thread.
/// </summary>
public async Task ConnectAsync(CancellationToken ct = default)
{
@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
try
{
await Task.Run(() => ConnectInternal(), ct);
await _staThread.RunAsync(() => ConnectInternal());
lock (_lock)
{
@@ -46,7 +46,7 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
}
/// <summary>
/// Disconnects from MxAccess via Task.Run (thread pool).
/// Disconnects from MxAccess on the dedicated STA thread.
/// </summary>
public async Task DisconnectAsync(CancellationToken ct = default)
{
@@ -56,7 +56,7 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
try
{
await Task.Run(() => DisconnectInternal());
await _staThread.RunAsync(() => DisconnectInternal());
SetState(ConnectionState.Disconnected);
Log.Information("Disconnected from MxAccess");
@@ -346,13 +346,13 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
}
/// <summary>
/// Cleans up COM objects via Task.Run after a failed connection.
/// Cleans up COM objects on the dedicated STA thread after a failed connection.
/// </summary>
private async Task CleanupComObjectsAsync()
{
try
{
await Task.Run(() =>
await _staThread.RunAsync(() =>
{
lock (_lock)
{

View File

@@ -1,4 +1,5 @@
using System;
using System.Threading;
using ArchestrA.MxAccess;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Domain;
@@ -27,6 +28,7 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
{
try
{
Log.Information("OnDataChange FIRED: handle={Handle}", phItemHandle);
var quality = MapQuality(pwItemQuality);
var timestamp = ConvertTimestamp(pftItemTimeStamp);

View File

@@ -183,14 +183,14 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
}
/// <summary>
/// Internal write implementation using Task.Run for COM calls.
/// Internal write implementation dispatched on the STA thread.
/// MxAccess completes supervisory writes synchronously — the Write() call
/// succeeding (not throwing) confirms the write. The OnWriteComplete callback
/// is kept wired for diagnostic logging but is not awaited.
/// </summary>
private async Task WriteInternalAsync(string address, object value, CancellationToken ct)
{
await Task.Run(() =>
await _staThread.RunAsync(() =>
{
lock (_lock)
{
@@ -243,7 +243,7 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
}
}
}
}, ct);
});
}
/// <summary>

View File

@@ -13,7 +13,7 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
/// <summary>
/// Subscribes to value changes for the specified addresses.
/// Stores subscription state for reconnect replay.
/// COM calls dispatched via Task.Run.
/// COM calls dispatched on the dedicated STA thread.
/// </summary>
public async Task<IAsyncDisposable> SubscribeAsync(
IEnumerable<string> addresses,
@@ -25,7 +25,7 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
var addressList = addresses.ToList();
await Task.Run(() =>
await _staThread.RunAsync(() =>
{
lock (_lock)
{
@@ -40,7 +40,7 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
_storedSubscriptions[address] = callback;
}
}
}, ct);
});
Log.Information("Subscribed to {Count} tags", addressList.Count);
@@ -63,7 +63,7 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
{
var addressList = addresses.ToList();
await Task.Run(() =>
await _staThread.RunAsync(() =>
{
lock (_lock)
{
@@ -93,7 +93,7 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
Log.Information("Recreating {Count} stored subscriptions after reconnect", subscriptions.Count);
await Task.Run(() =>
await _staThread.RunAsync(() =>
{
lock (_lock)
{

View File

@@ -10,8 +10,9 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
{
/// <summary>
/// Wraps the ArchestrA MXAccess COM API. All COM operations
/// execute via Task.Run (thread pool / MTA), relying on COM
/// marshaling to handle cross-apartment calls.
/// execute on a dedicated STA thread with a Windows message pump
/// so that COM callbacks (OnDataChange, OnWriteComplete) are
/// delivered correctly.
/// </summary>
public sealed partial class MxAccessClient : IScadaClient
{
@@ -29,7 +30,10 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
private readonly SemaphoreSlim _readSemaphore;
private readonly SemaphoreSlim _writeSemaphore;
// COM objects
// STA thread for COM interop
private readonly StaComThread _staThread;
// COM objects — only accessed on the STA thread
private LMXProxyServer? _lmxProxy;
private int _connectionHandle;
@@ -93,6 +97,9 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
_readSemaphore = new SemaphoreSlim(maxConcurrentOperations, maxConcurrentOperations);
_writeSemaphore = new SemaphoreSlim(maxConcurrentOperations, maxConcurrentOperations);
_staThread = new StaComThread();
_staThread.Start();
}
public bool IsConnected
@@ -152,6 +159,7 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
_readSemaphore.Dispose();
_writeSemaphore.Dispose();
_reconnectCts?.Dispose();
_staThread.Dispose();
}
}
}

View File

@@ -0,0 +1,133 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using Serilog;
namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
{
/// <summary>
/// Dedicated STA thread with a Windows message pump for COM interop.
/// All MxAccess COM objects must be created and called on this thread
/// so that COM callbacks (OnDataChange, OnWriteComplete) are delivered
/// via the message loop.
/// </summary>
public sealed class StaComThread : IDisposable
{
private static readonly ILogger Log = Serilog.Log.ForContext<StaComThread>();
private readonly Thread _thread;
private readonly TaskCompletionSource<bool> _ready = new TaskCompletionSource<bool>();
private SynchronizationContext _syncContext = null!;
private bool _disposed;
public StaComThread()
{
_thread = new Thread(ThreadEntry)
{
Name = "MxAccess-STA",
IsBackground = true
};
_thread.SetApartmentState(ApartmentState.STA);
}
/// <summary>
/// Starts the STA thread and waits until the message pump is running.
/// </summary>
public void Start()
{
_thread.Start();
_ready.Task.GetAwaiter().GetResult();
Log.Information("STA COM thread started (ThreadId={ThreadId})", _thread.ManagedThreadId);
}
/// <summary>
/// Marshals a synchronous action onto the STA thread and returns a Task
/// that completes when the action finishes.
/// </summary>
public Task RunAsync(Action action)
{
if (_disposed) throw new ObjectDisposedException(nameof(StaComThread));
var tcs = new TaskCompletionSource<bool>();
_syncContext.Post(_ =>
{
try
{
action();
tcs.TrySetResult(true);
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
}, null);
return tcs.Task;
}
/// <summary>
/// Marshals a synchronous function onto the STA thread and returns
/// a Task&lt;T&gt; with the result.
/// </summary>
public Task<T> RunAsync<T>(Func<T> func)
{
if (_disposed) throw new ObjectDisposedException(nameof(StaComThread));
var tcs = new TaskCompletionSource<T>();
_syncContext.Post(_ =>
{
try
{
tcs.TrySetResult(func());
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
}, null);
return tcs.Task;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
try
{
// Post Application.ExitThread to break out of the message loop
_syncContext?.Post(_ => Application.ExitThread(), null);
_thread.Join(TimeSpan.FromSeconds(5));
}
catch (Exception ex)
{
Log.Warning(ex, "Error shutting down STA COM thread");
}
Log.Information("STA COM thread stopped");
}
private void ThreadEntry()
{
try
{
// Install a WindowsFormsSynchronizationContext so that
// Post/Send dispatches onto this thread's message loop
Application.OleRequired();
var ctx = new WindowsFormsSynchronizationContext();
SynchronizationContext.SetSynchronizationContext(ctx);
_syncContext = ctx;
_ready.TrySetResult(true);
// Run the message loop — this blocks until Application.ExitThread()
Application.Run();
}
catch (Exception ex)
{
Log.Error(ex, "STA COM thread crashed");
_ready.TrySetException(ex);
}
}
}
}

View File

@@ -42,6 +42,7 @@
</ItemGroup>
<ItemGroup>
<Reference Include="System.Windows.Forms" />
<Reference Include="ArchestrA.MXAccess">
<HintPath>..\..\lib\ArchestrA.MXAccess.dll</HintPath>
<Private>true</Private>