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:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
133
lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/MxAccess/StaComThread.cs
Normal file
133
lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/MxAccess/StaComThread.cs
Normal 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<T> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="System.Windows.Forms" />
|
||||
<Reference Include="ArchestrA.MXAccess">
|
||||
<HintPath>..\..\lib\ArchestrA.MXAccess.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
|
||||
Reference in New Issue
Block a user