5 Commits

Author SHA1 Message Date
Joseph Doherty a326a8cbde fix(lmxproxy): make MxAccess client name unique per instance
Multiple instances registering with the same name may cause MxAccess to
conflict on callback routing. ClientName is now configurable via
appsettings.json, defaulting to a GUID-suffixed name if not set.
Instances A and B use "LmxProxy-A" and "LmxProxy-B" respectively.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:18:09 -04:00
Joseph Doherty a59d4ad76c fix(lmxproxy): use raw Win32 message pump instead of WinForms Application.Run
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:18:09 -04:00
Joseph Doherty b6408726bc feat(lmxproxy): add STA thread with message pump for MxAccess COM callbacks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:18:09 -04:00
Joseph Doherty c96e71c83c Revert "fix(lmxproxy): resolve subscribe/unsubscribe race condition on client reconnect"
This reverts commit 9e9efbecab399fd7dcfb3e7e14e8b08418c3c3fc.
2026-03-22 23:18:09 -04:00
Joseph Doherty fa33e1acf1 fix(lmxproxy): resolve subscribe/unsubscribe race condition on client reconnect
Three fixes for the SubscriptionManager/MxAccessClient subscription pipeline:

1. Serialize Subscribe and UnsubscribeClient with a SemaphoreSlim gate to prevent
   race where old-session unsubscribe removes new-session COM subscriptions.
   CreateMxAccessSubscriptionsAsync is now awaited instead of fire-and-forget.

2. Fix dual VTQ delivery in MxAccessClient.OnDataChange — each update was delivered
   twice (once via stored callback, once via OnTagValueChanged property). Now uses
   stored callback as the single delivery path.

3. Store pending tag addresses when CreateMxAccessSubscriptionsAsync fails (MxAccess
   down) and retry them on reconnect via NotifyReconnection/RetryPendingSubscriptionsAsync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:18:08 -04:00
9 changed files with 258 additions and 22 deletions
+2 -1
View File
@@ -8,12 +8,13 @@ Two instances of the LmxProxy v2 Host service are deployed on windev (10.100.0.4
|---|---|---|
| **Service Name** | `ZB.MOM.WW.LmxProxy.Host.V2` | `ZB.MOM.WW.LmxProxy.Host.V2B` |
| **Display Name** | SCADA Bridge LMX Proxy V2 | SCADA Bridge LMX Proxy V2B |
| **MxAccess Client Name** | `LmxProxy-A` | `LmxProxy-B` |
| **Publish Directory** | `C:\publish-v2\` | `C:\publish-v2b\` |
| **gRPC Port** | 50100 | 50101 |
| **HTTP Status Port** | 8081 | 8082 |
| **Log File Prefix** | `lmxproxy-v2-` | `lmxproxy-v2b-` |
| **Log Directory** | `C:\publish-v2\logs\` | `C:\publish-v2b\logs\` |
| **Health Probe Tag** | `DevAppEngine.Scheduler.ScanTime` | `DevAppEngine.Scheduler.ScanTime` |
| **Health Probe Tag** | `DevPlatform.Scheduler.ScanTime` | `DevPlatform.Scheduler.ScanTime` |
| **API Keys File** | `C:\publish-v2\apikeys.json` | `C:\publish-v2b\apikeys.json` |
| **Auto-Start** | Yes | Yes |
@@ -9,6 +9,9 @@ namespace ZB.MOM.WW.LmxProxy.Host.Configuration
/// <summary>Path to API key configuration file. Default: apikeys.json.</summary>
public string ApiKeyConfigFile { get; set; } = "apikeys.json";
/// <summary>Unique client name for MxAccess Register(). Must be unique per instance. Default: auto-generated.</summary>
public string? ClientName { get; set; }
/// <summary>MxAccess connection settings.</summary>
public ConnectionConfiguration Connection { get; set; } = new ConnectionConfiguration();
@@ -70,7 +70,8 @@ namespace ZB.MOM.WW.LmxProxy.Host
probeTestTagAddress: _config.HealthCheck.TestTagAddress,
probeTimeoutMs: _config.HealthCheck.ProbeTimeoutMs,
maxConsecutiveTransportFailures: _config.HealthCheck.MaxConsecutiveTransportFailures,
degradedProbeIntervalMs: _config.HealthCheck.DegradedProbeIntervalMs);
degradedProbeIntervalMs: _config.HealthCheck.DegradedProbeIntervalMs,
clientName: _config.ClientName);
// 5. Connect to MxAccess synchronously (with timeout)
Log.Information("Connecting to MxAccess (timeout: {Timeout}s)...",
@@ -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");
@@ -107,8 +107,9 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
_lmxProxy.OnDataChange += OnDataChange;
_lmxProxy.OnWriteComplete += OnWriteComplete;
// Register with MxAccess
_connectionHandle = _lmxProxy.Register("ZB.MOM.WW.LmxProxy.Host");
// Register with MxAccess using unique client name
_connectionHandle = _lmxProxy.Register(_clientName);
Log.Information("Registered with MxAccess as '{ClientName}'", _clientName);
if (_connectionHandle <= 0)
{
@@ -346,13 +347,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
{
@@ -25,11 +26,15 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
private readonly bool _autoReconnect;
private readonly string? _nodeName;
private readonly string? _galaxyName;
private readonly string _clientName;
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;
@@ -77,7 +82,8 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
string? probeTestTagAddress = null,
int probeTimeoutMs = 5000,
int maxConsecutiveTransportFailures = 3,
int degradedProbeIntervalMs = 30000)
int degradedProbeIntervalMs = 30000,
string? clientName = null)
{
_maxConcurrentOperations = maxConcurrentOperations;
_readTimeoutMs = readTimeoutSeconds * 1000;
@@ -90,9 +96,13 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
_probeTimeoutMs = probeTimeoutMs;
_maxConsecutiveTransportFailures = maxConsecutiveTransportFailures;
_degradedProbeIntervalMs = degradedProbeIntervalMs;
_clientName = clientName ?? "LmxProxy-" + Guid.NewGuid().ToString("N").Substring(0, 8);
_readSemaphore = new SemaphoreSlim(maxConcurrentOperations, maxConcurrentOperations);
_writeSemaphore = new SemaphoreSlim(maxConcurrentOperations, maxConcurrentOperations);
_staThread = new StaComThread();
_staThread.Start();
}
public bool IsConnected
@@ -152,6 +162,7 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
_readSemaphore.Dispose();
_writeSemaphore.Dispose();
_reconnectCts?.Dispose();
_staThread.Dispose();
}
}
}
@@ -0,0 +1,217 @@
using System;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
{
/// <summary>
/// Dedicated STA thread with a raw Win32 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 const uint WM_APP = 0x8000;
private const uint PM_NOREMOVE = 0x0000;
private static readonly ILogger Log = Serilog.Log.ForContext<StaComThread>();
private readonly Thread _thread;
private readonly TaskCompletionSource<bool> _ready = new TaskCompletionSource<bool>();
private readonly ConcurrentQueue<Action> _workItems = new ConcurrentQueue<Action>();
private volatile uint _nativeThreadId;
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>();
_workItems.Enqueue(() =>
{
try
{
action();
tcs.TrySetResult(true);
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
});
PostThreadMessage(_nativeThreadId, WM_APP, IntPtr.Zero, IntPtr.Zero);
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>();
_workItems.Enqueue(() =>
{
try
{
tcs.TrySetResult(func());
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
});
PostThreadMessage(_nativeThreadId, WM_APP, IntPtr.Zero, IntPtr.Zero);
return tcs.Task;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
try
{
if (_nativeThreadId != 0)
PostThreadMessage(_nativeThreadId, WM_APP + 1, IntPtr.Zero, IntPtr.Zero);
_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
{
_nativeThreadId = GetCurrentThreadId();
// Force message queue creation by peeking
MSG msg;
PeekMessage(out msg, IntPtr.Zero, 0, 0, PM_NOREMOVE);
_ready.TrySetResult(true);
// Run the message loop — blocks until WM_QUIT
while (GetMessage(out msg, IntPtr.Zero, 0, 0) > 0)
{
if (msg.message == WM_APP)
{
DrainQueue();
}
else if (msg.message == WM_APP + 1)
{
// Shutdown signal — drain remaining work then quit
DrainQueue();
PostQuitMessage(0);
}
else
{
TranslateMessage(ref msg);
DispatchMessage(ref msg);
}
}
}
catch (Exception ex)
{
Log.Error(ex, "STA COM thread crashed");
_ready.TrySetException(ex);
}
}
private void DrainQueue()
{
while (_workItems.TryDequeue(out var workItem))
{
try
{
workItem();
}
catch (Exception ex)
{
Log.Error(ex, "Unhandled exception in STA work item");
}
}
}
#region Win32 PInvoke
[StructLayout(LayoutKind.Sequential)]
private struct MSG
{
public IntPtr hwnd;
public uint message;
public IntPtr wParam;
public IntPtr lParam;
public uint time;
public POINT pt;
}
[StructLayout(LayoutKind.Sequential)]
private struct POINT
{
public int x;
public int y;
}
[DllImport("user32.dll")]
private static extern int GetMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool TranslateMessage(ref MSG lpMsg);
[DllImport("user32.dll")]
private static extern IntPtr DispatchMessage(ref MSG lpMsg);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PostThreadMessage(uint idThread, uint Msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")]
private static extern void PostQuitMessage(int nExitCode);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PeekMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg);
[DllImport("kernel32.dll")]
private static extern uint GetCurrentThreadId();
#endregion
}
}