# Phase 2: Host Core Components — Implementation Plan ## Prerequisites - Phase 1 complete and passing: all projects build, all unit tests pass, cross-stack serialization verified. - The following Phase 1 artifacts exist and are used throughout this phase: - `src/ZB.MOM.WW.LmxProxy.Host/Domain/` — Quality, Vtq, ConnectionState, IScadaClient, TypedValueConverter, QualityCodeMapper - `src/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto` — v2 proto (generates `Scada.*` classes) ## Guardrails 1. **COM calls only on STA thread** — no `Task.Run` for COM operations. All go through the STA dispatch queue. 2. **No v1 code** — reference `src-reference/` for patterns but write fresh. 3. **status_code is canonical for quality** — use `QualityCodeMapper` for all quality conversions. 4. **No string serialization heuristics** — use `TypedValueConverter` for all value conversions. 5. **Unit tests for every component** — test before moving to Phase 3. 6. **Each step must compile** before proceeding to the next. --- ## Step 1: MxAccessClient — STA Dispatch Thread **File**: `src/ZB.MOM.WW.LmxProxy.Host/MxAccess/StaDispatchThread.cs` **Namespace**: `ZB.MOM.WW.LmxProxy.Host.MxAccess` This is the foundation for all COM interop. MxAccess is an STA COM component — all COM calls must execute on a dedicated STA thread with a message pump. ### Class Design ```csharp using System; using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; using Serilog; namespace ZB.MOM.WW.LmxProxy.Host.MxAccess { /// /// Dedicated STA thread with a message pump for COM interop. /// All COM operations are dispatched to this thread via a BlockingCollection. /// public sealed class StaDispatchThread : IDisposable { private static readonly ILogger Log = Serilog.Log.ForContext(); private readonly BlockingCollection _workQueue = new BlockingCollection(); private readonly Thread _staThread; private volatile bool _disposed; public StaDispatchThread(string threadName = "MxAccess-STA") { _staThread = new Thread(StaThreadLoop) { Name = threadName, IsBackground = true }; _staThread.SetApartmentState(ApartmentState.STA); _staThread.Start(); Log.Information("STA dispatch thread '{ThreadName}' started", threadName); } /// /// Dispatches an action to the STA thread and returns a Task that completes /// when the action finishes. /// public Task DispatchAsync(Action action) { if (_disposed) throw new ObjectDisposedException(nameof(StaDispatchThread)); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _workQueue.Add(() => { try { action(); tcs.TrySetResult(true); } catch (Exception ex) { tcs.TrySetException(ex); } }); return tcs.Task; } /// /// Dispatches a function to the STA thread and returns its result. /// public Task DispatchAsync(Func func) { if (_disposed) throw new ObjectDisposedException(nameof(StaDispatchThread)); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _workQueue.Add(() => { try { var result = func(); tcs.TrySetResult(result); } catch (Exception ex) { tcs.TrySetException(ex); } }); return tcs.Task; } private void StaThreadLoop() { Log.Debug("STA thread loop started"); // Process the work queue. GetConsumingEnumerable blocks until // items are available or the collection is marked complete. foreach (var action in _workQueue.GetConsumingEnumerable()) { try { action(); } catch (Exception ex) { // Should not happen — actions set TCS exceptions internally. Log.Error(ex, "Unhandled exception on STA thread"); } // Pump COM messages between work items Application.DoEvents(); } Log.Debug("STA thread loop exited"); } public void Dispose() { if (_disposed) return; _disposed = true; _workQueue.CompleteAdding(); // Wait for the STA thread to drain and exit if (_staThread.IsAlive && !_staThread.Join(TimeSpan.FromSeconds(10))) { Log.Warning("STA thread did not exit within 10 seconds"); } _workQueue.Dispose(); Log.Information("STA dispatch thread disposed"); } } } ``` **Key design decisions**: - `BlockingCollection` is the dispatch queue (thread-safe, blocking consumer). - `TaskCompletionSource` bridges the STA thread back to async callers. - `Application.DoEvents()` pumps COM messages between work items (required for MxAccess callbacks like OnDataChange). - `RunContinuationsAsynchronously` prevents continuations from running on the STA thread. - On dispose, `CompleteAdding()` signals the loop to exit, then `Join(10s)` waits for drain. **Dependency**: The Host project already references `System.Windows.Forms` implicitly through .NET Framework 4.8. If the build fails with a missing reference, add `` to the csproj ``. --- ## Step 2: MxAccessClient — Connection **File**: `src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.cs` (main partial class) **File**: `src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.Connection.cs` (connection partial) ### 2.1 Main class file ```csharp using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Serilog; using ZB.MOM.WW.LmxProxy.Host.Domain; namespace ZB.MOM.WW.LmxProxy.Host.MxAccess { /// /// Wraps the ArchestrA MXAccess COM API. All COM operations /// execute on a dedicated STA thread via . /// public sealed partial class MxAccessClient : IScadaClient { private static readonly ILogger Log = Serilog.Log.ForContext(); private readonly StaDispatchThread _staThread; private readonly object _lock = new object(); private readonly int _maxConcurrentOperations; private readonly int _readTimeoutMs; private readonly int _writeTimeoutMs; private readonly int _monitorIntervalMs; private readonly bool _autoReconnect; private readonly string? _nodeName; private readonly string? _galaxyName; private readonly SemaphoreSlim _readSemaphore; private readonly SemaphoreSlim _writeSemaphore; // COM objects — only accessed on STA thread private ArchestrA.MxAccess.LMXProxyServerClass? _lmxProxy; private int _connectionHandle; // State private ConnectionState _connectionState = ConnectionState.Disconnected; private DateTime _connectedSince; private bool _disposed; // Reconnect private CancellationTokenSource? _reconnectCts; // Stored subscriptions for reconnect replay private readonly Dictionary> _storedSubscriptions = new Dictionary>(StringComparer.OrdinalIgnoreCase); public MxAccessClient( int maxConcurrentOperations = 10, int readTimeoutSeconds = 5, int writeTimeoutSeconds = 5, int monitorIntervalSeconds = 5, bool autoReconnect = true, string? nodeName = null, string? galaxyName = null) { _maxConcurrentOperations = maxConcurrentOperations; _readTimeoutMs = readTimeoutSeconds * 1000; _writeTimeoutMs = writeTimeoutSeconds * 1000; _monitorIntervalMs = monitorIntervalSeconds * 1000; _autoReconnect = autoReconnect; _nodeName = nodeName; _galaxyName = galaxyName; _readSemaphore = new SemaphoreSlim(maxConcurrentOperations, maxConcurrentOperations); _writeSemaphore = new SemaphoreSlim(maxConcurrentOperations, maxConcurrentOperations); _staThread = new StaDispatchThread(); } public bool IsConnected { get { lock (_lock) { return _lmxProxy != null && _connectionState == ConnectionState.Connected && _connectionHandle > 0; } } } public ConnectionState ConnectionState { get { lock (_lock) { return _connectionState; } } } public event EventHandler? ConnectionStateChanged; private void SetState(ConnectionState newState, string? message = null) { ConnectionState previousState; lock (_lock) { previousState = _connectionState; _connectionState = newState; } if (previousState != newState) { Log.Information("Connection state changed: {Previous} -> {Current} {Message}", previousState, newState, message ?? ""); ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(previousState, newState, message)); } } public async ValueTask DisposeAsync() { if (_disposed) return; _disposed = true; _reconnectCts?.Cancel(); try { await DisconnectAsync(); } catch (Exception ex) { Log.Warning(ex, "Error during disposal disconnect"); } _readSemaphore.Dispose(); _writeSemaphore.Dispose(); _staThread.Dispose(); _reconnectCts?.Dispose(); } } } ``` ### 2.2 Connection partial class ```csharp using System; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Serilog; using ZB.MOM.WW.LmxProxy.Host.Domain; namespace ZB.MOM.WW.LmxProxy.Host.MxAccess { public sealed partial class MxAccessClient { /// /// Connects to MxAccess on the STA thread. /// public async Task ConnectAsync(CancellationToken ct = default) { if (_disposed) throw new ObjectDisposedException(nameof(MxAccessClient)); if (IsConnected) return; SetState(ConnectionState.Connecting); try { await _staThread.DispatchAsync(() => { // Create COM object _lmxProxy = new ArchestrA.MxAccess.LMXProxyServerClass(); // Wire event handlers _lmxProxy.DataChanged += OnDataChange; _lmxProxy.WriteCompleted += OnWriteComplete; // Register with MxAccess _connectionHandle = _lmxProxy.Register("ZB.MOM.WW.LmxProxy.Host"); }); lock (_lock) { _connectedSince = DateTime.UtcNow; } SetState(ConnectionState.Connected); Log.Information("Connected to MxAccess (handle={Handle})", _connectionHandle); // Recreate any stored subscriptions from a previous connection await RecreateStoredSubscriptionsAsync(); } catch (Exception ex) { Log.Error(ex, "Failed to connect to MxAccess"); await CleanupComObjectsAsync(); SetState(ConnectionState.Error, ex.Message); throw; } } /// /// Disconnects from MxAccess on the STA thread. /// public async Task DisconnectAsync(CancellationToken ct = default) { if (!IsConnected) return; SetState(ConnectionState.Disconnecting); try { await _staThread.DispatchAsync(() => { if (_lmxProxy != null && _connectionHandle > 0) { try { // Remove event handlers first _lmxProxy.DataChanged -= OnDataChange; _lmxProxy.WriteCompleted -= OnWriteComplete; // Unregister _lmxProxy.Unregister(_connectionHandle); } catch (Exception ex) { Log.Warning(ex, "Error during MxAccess unregister"); } finally { // Force-release COM object Marshal.ReleaseComObject(_lmxProxy); _lmxProxy = null; _connectionHandle = 0; } } }); SetState(ConnectionState.Disconnected); Log.Information("Disconnected from MxAccess"); } catch (Exception ex) { Log.Error(ex, "Error during disconnect"); SetState(ConnectionState.Error, ex.Message); } } /// /// Starts the auto-reconnect monitor loop. /// Call this after initial ConnectAsync succeeds. /// public void StartMonitorLoop() { if (!_autoReconnect) return; _reconnectCts = new CancellationTokenSource(); Task.Run(() => MonitorConnectionAsync(_reconnectCts.Token)); } /// /// Stops the auto-reconnect monitor loop. /// Waits up to 5 seconds for the loop to exit. /// public void StopMonitorLoop() { _reconnectCts?.Cancel(); } /// /// Auto-reconnect monitor loop. Checks connection every monitorInterval. /// On disconnect, attempts reconnect. On failure, retries at next interval. /// private async Task MonitorConnectionAsync(CancellationToken ct) { Log.Information("Connection monitor loop started (interval={IntervalMs}ms)", _monitorIntervalMs); while (!ct.IsCancellationRequested) { try { await Task.Delay(_monitorIntervalMs, ct); } catch (OperationCanceledException) { break; } if (IsConnected) continue; Log.Information("MxAccess disconnected, attempting reconnect..."); SetState(ConnectionState.Reconnecting); try { await ConnectAsync(ct); Log.Information("Reconnected to MxAccess successfully"); } catch (OperationCanceledException) { break; } catch (Exception ex) { Log.Warning(ex, "Reconnect attempt failed, will retry in {IntervalMs}ms", _monitorIntervalMs); } } Log.Information("Connection monitor loop exited"); } /// /// Cleans up COM objects on the STA thread after a failed connection. /// private async Task CleanupComObjectsAsync() { try { await _staThread.DispatchAsync(() => { if (_lmxProxy != null) { try { _lmxProxy.DataChanged -= OnDataChange; } catch { } try { _lmxProxy.WriteCompleted -= OnWriteComplete; } catch { } try { Marshal.ReleaseComObject(_lmxProxy); } catch { } _lmxProxy = null; } _connectionHandle = 0; }); } catch (Exception ex) { Log.Warning(ex, "Error during COM object cleanup"); } } /// Gets the UTC time when the connection was established. public DateTime ConnectedSince { get { lock (_lock) { return _connectedSince; } } } } } ``` **Note**: The exact COM interop method names (`Register`, `Unregister`, `DataChanged`, `WriteCompleted`) come from the ArchestrA.MXAccess COM interop assembly. Consult `src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Connection.cs` for the exact method signatures and event wiring patterns. The reference code uses `_lmxProxy.DataChanged += OnDataChange` style — match that exactly. --- ## Step 3: MxAccessClient — Read/Write **File**: `src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.ReadWrite.cs` ```csharp using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Serilog; using ZB.MOM.WW.LmxProxy.Host.Domain; namespace ZB.MOM.WW.LmxProxy.Host.MxAccess { public sealed partial class MxAccessClient { /// /// Reads a single tag value from MxAccess. /// Dispatched to STA thread with semaphore concurrency control. /// public async Task ReadAsync(string address, CancellationToken ct = default) { if (!IsConnected) return Vtq.New(null, Quality.Bad_NotConnected); await _readSemaphore.WaitAsync(ct); try { return await _staThread.DispatchAsync(() => ReadInternal(address)); } catch (Exception ex) { Log.Error(ex, "ReadAsync failed for tag {Address}", address); return Vtq.New(null, Quality.Bad_CommFailure); } finally { _readSemaphore.Release(); } } /// /// Reads multiple tags with semaphore-controlled concurrency (max 10 concurrent). /// Each tag is read independently. Partial failures return Bad quality for failed tags. /// public async Task> ReadBatchAsync( IEnumerable addresses, CancellationToken ct = default) { var addressList = addresses.ToList(); var results = new Dictionary(addressList.Count, StringComparer.OrdinalIgnoreCase); var tasks = addressList.Select(async address => { var vtq = await ReadAsync(address, ct); return (address, vtq); }); foreach (var task in await Task.WhenAll(tasks)) { results[task.address] = task.vtq; } return results; } /// /// Writes a single tag value to MxAccess. /// Value should be a native .NET type (not string). Uses TypedValueConverter /// on the gRPC layer; here the value is the boxed .NET object. /// public async Task WriteAsync(string address, object value, CancellationToken ct = default) { if (!IsConnected) throw new InvalidOperationException("Not connected to MxAccess"); await _writeSemaphore.WaitAsync(ct); try { await _staThread.DispatchAsync(() => WriteInternal(address, value)); } finally { _writeSemaphore.Release(); } } /// /// Writes multiple tag values with semaphore-controlled concurrency. /// public async Task WriteBatchAsync( IReadOnlyDictionary values, CancellationToken ct = default) { var tasks = values.Select(async kvp => { await WriteAsync(kvp.Key, kvp.Value, ct); }); await Task.WhenAll(tasks); } /// /// Writes a batch, then polls flagTag until it equals flagValue or timeout expires. /// Uses type-aware comparison via TypedValueEquals. /// public async Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync( IReadOnlyDictionary values, string flagTag, object flagValue, int timeoutMs, int pollIntervalMs, CancellationToken ct = default) { // Write all values first await WriteBatchAsync(values, ct); // Poll flag tag var sw = System.Diagnostics.Stopwatch.StartNew(); var effectiveTimeout = timeoutMs > 0 ? timeoutMs : 5000; var effectiveInterval = pollIntervalMs > 0 ? pollIntervalMs : 100; while (sw.ElapsedMilliseconds < effectiveTimeout) { ct.ThrowIfCancellationRequested(); var vtq = await ReadAsync(flagTag, ct); if (vtq.Quality.IsGood() && TypedValueEquals(vtq.Value, flagValue)) { return (true, (int)sw.ElapsedMilliseconds); } await Task.Delay(effectiveInterval, ct); } return (false, (int)sw.ElapsedMilliseconds); } /// /// Type-aware equality comparison for WriteBatchAndWait flag matching. /// Both values must be the same CLR type. Mismatched types are never equal. /// private static bool TypedValueEquals(object? a, object? b) { if (a == null && b == null) return true; if (a == null || b == null) return false; if (a.GetType() != b.GetType()) return false; // Array types need element-by-element comparison if (a is Array arrA && b is Array arrB) { if (arrA.Length != arrB.Length) return false; for (int i = 0; i < arrA.Length; i++) { if (!Equals(arrA.GetValue(i), arrB.GetValue(i))) return false; } return true; } return Equals(a, b); } // ── Internal COM calls (execute on STA thread) ────────── /// /// Reads a single tag from MxAccess COM API. /// Must be called on the STA thread. /// private Vtq ReadInternal(string address) { // This is a skeleton — the exact MxAccess COM API call depends on the // ArchestrA.MXAccess interop assembly. Consult src-reference for the exact // method calls. The pattern is: // // object value = null; // int quality = 0; // DateTime timestamp = DateTime.MinValue; // _lmxProxy.Read(_connectionHandle, address, ref value, ref quality, ref timestamp); // // Then convert the COM value to a Vtq: // return new Vtq(value, timestamp.ToUniversalTime(), MapQuality(quality)); // // For now, this throws NotImplementedException. The actual COM call will be // implemented when testing on the windev machine with MxAccess available. throw new NotImplementedException( "ReadInternal must be implemented using ArchestrA.MXAccess COM API. " + "See src-reference/Implementation/MxAccessClient.ReadWrite.cs for the exact pattern."); } /// /// Writes a single tag via MxAccess COM API. /// Must be called on the STA thread. /// private void WriteInternal(string address, object value) { // Similar to ReadInternal — the exact COM call is: // _lmxProxy.Write(_connectionHandle, address, value); // // Consult src-reference for the exact method signature. throw new NotImplementedException( "WriteInternal must be implemented using ArchestrA.MXAccess COM API. " + "See src-reference/Implementation/MxAccessClient.ReadWrite.cs for the exact pattern."); } /// /// Maps an MxAccess OPC DA quality integer to the domain Quality enum. /// The quality integer from MxAccess is the OPC DA quality byte. /// private static Quality MapQuality(int opcDaQuality) { // OPC DA quality is a byte value that directly maps to our Quality enum if (Enum.IsDefined(typeof(Quality), (byte)opcDaQuality)) return (Quality)(byte)opcDaQuality; // Fallback: use category bits if (opcDaQuality >= 192) return Quality.Good; if (opcDaQuality >= 64) return Quality.Uncertain; return Quality.Bad; } } } ``` **Important note on ReadInternal/WriteInternal**: These methods contain `throw new NotImplementedException()` because the exact MxAccess COM API signatures depend on the ArchestrA.MXAccess interop assembly, which is only available on the Windows development machine. The implementing session should: 1. Read `src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.ReadWrite.cs` to find the exact COM method signatures. 2. Replace the `throw` with the actual COM calls. 3. The pattern is well-established in the reference code — it's a direct translation, not a redesign. --- ## Step 4: MxAccessClient — Subscriptions **File**: `src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.Subscription.cs` ```csharp using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Serilog; using ZB.MOM.WW.LmxProxy.Host.Domain; namespace ZB.MOM.WW.LmxProxy.Host.MxAccess { public sealed partial class MxAccessClient { /// /// Subscribes to value changes for the specified addresses. /// Stores subscription state for reconnect replay. /// public async Task SubscribeAsync( IEnumerable addresses, Action callback, CancellationToken ct = default) { if (!IsConnected) throw new InvalidOperationException("Not connected to MxAccess"); var addressList = addresses.ToList(); await _staThread.DispatchAsync(() => { foreach (var address in addressList) { SubscribeInternal(address); // Store for reconnect replay lock (_lock) { _storedSubscriptions[address] = callback; } } }); Log.Information("Subscribed to {Count} tags", addressList.Count); return new SubscriptionHandle(this, addressList, callback); } /// /// Unsubscribes specific addresses. /// internal async Task UnsubscribeAsync(IEnumerable addresses) { var addressList = addresses.ToList(); await _staThread.DispatchAsync(() => { foreach (var address in addressList) { UnsubscribeInternal(address); lock (_lock) { _storedSubscriptions.Remove(address); } } }); Log.Information("Unsubscribed from {Count} tags", addressList.Count); } /// /// Recreates all stored subscriptions after a reconnect. /// Does not re-store them (they're already stored). /// private async Task RecreateStoredSubscriptionsAsync() { Dictionary> subscriptions; lock (_lock) { if (_storedSubscriptions.Count == 0) return; subscriptions = new Dictionary>(_storedSubscriptions); } Log.Information("Recreating {Count} stored subscriptions after reconnect", subscriptions.Count); await _staThread.DispatchAsync(() => { foreach (var kvp in subscriptions) { try { SubscribeInternal(kvp.Key); } catch (Exception ex) { Log.Warning(ex, "Failed to recreate subscription for {Address}", kvp.Key); } } }); } // ── Internal COM calls (execute on STA thread) ────────── /// /// Registers a tag subscription with MxAccess COM API (Advise). /// Must be called on the STA thread. /// private void SubscribeInternal(string address) { // The exact MxAccess COM API call is something like: // _lmxProxy.Advise(_connectionHandle, address); // // Consult src-reference/Implementation/MxAccessClient.Subscription.cs throw new NotImplementedException( "SubscribeInternal must be implemented using ArchestrA.MXAccess COM API. " + "See src-reference/Implementation/MxAccessClient.Subscription.cs for the exact pattern."); } /// /// Unregisters a tag subscription from MxAccess COM API (Unadvise). /// Must be called on the STA thread. /// private void UnsubscribeInternal(string address) { // The exact MxAccess COM API call is something like: // _lmxProxy.Unadvise(_connectionHandle, address); throw new NotImplementedException( "UnsubscribeInternal must be implemented using ArchestrA.MXAccess COM API."); } /// /// Disposable subscription handle that unsubscribes on disposal. /// private sealed class SubscriptionHandle : IAsyncDisposable { private readonly MxAccessClient _client; private readonly List _addresses; private readonly Action _callback; private bool _disposed; public SubscriptionHandle(MxAccessClient client, List addresses, Action callback) { _client = client; _addresses = addresses; _callback = callback; } public async ValueTask DisposeAsync() { if (_disposed) return; _disposed = true; await _client.UnsubscribeAsync(_addresses); } } } } ``` --- ## Step 5: MxAccessClient — Event Handlers **File**: `src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.EventHandlers.cs` ```csharp using System; using System.Collections.Generic; using Serilog; using ZB.MOM.WW.LmxProxy.Host.Domain; namespace ZB.MOM.WW.LmxProxy.Host.MxAccess { public sealed partial class MxAccessClient { /// /// Callback invoked by the SubscriptionManager when it needs to deliver /// data change events. Set by the SubscriptionManager during initialization. /// public Action? OnTagValueChanged { get; set; } /// /// COM event handler for MxAccess DataChanged events. /// Called on the STA thread when a subscribed tag value changes. /// private void OnDataChange( int hConnect, int numberOfItems, // The exact parameter types depend on the COM interop assembly. // Consult src-reference/Implementation/MxAccessClient.EventHandlers.cs // for the exact signature. The pattern is: // object[] addresses, object[] values, object[] qualities, object[] timestamps // or it may use SAFEARRAY parameters. object addresses, object values, object qualities, object timestamps) { // This handler fires on the STA thread. // Parse the COM arrays and dispatch to OnTagValueChanged for each item. // // Skeleton implementation: try { var addrArray = (object[])addresses; var valArray = (object[])values; var qualArray = (object[])qualities; var tsArray = (object[])timestamps; for (int i = 0; i < numberOfItems; i++) { var address = addrArray[i]?.ToString() ?? ""; var value = valArray[i]; var quality = MapQuality(Convert.ToInt32(qualArray[i])); var timestamp = Convert.ToDateTime(tsArray[i]).ToUniversalTime(); var vtq = new Vtq(value, timestamp, quality); // Route to stored callback Action? callback = null; lock (_lock) { _storedSubscriptions.TryGetValue(address, out callback); } callback?.Invoke(address, vtq); // Also route to the SubscriptionManager's global handler OnTagValueChanged?.Invoke(address, vtq); } } catch (Exception ex) { Log.Error(ex, "Error processing OnDataChange event"); } } /// /// COM event handler for MxAccess WriteCompleted events. /// private void OnWriteComplete( int hConnect, int numberOfItems, object addresses, object results) { // Write completion is currently fire-and-forget. // Log for diagnostics. try { Log.Debug("WriteCompleted: {Count} items", numberOfItems); } catch (Exception ex) { Log.Error(ex, "Error processing OnWriteComplete event"); } } } } ``` **Important**: The exact COM event handler signatures (`OnDataChange`, `OnWriteComplete`) depend on the ArchestrA.MXAccess COM interop assembly's event definitions. The implementing session MUST consult `src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.EventHandlers.cs` for the exact parameter types. The skeleton above uses a common pattern but may need adjustment. --- ## Step 6: SessionManager **File**: `src/ZB.MOM.WW.LmxProxy.Host/Sessions/SessionManager.cs` ```csharp using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; using Serilog; namespace ZB.MOM.WW.LmxProxy.Host.Sessions { /// /// Tracks active client sessions in memory. /// Thread-safe via ConcurrentDictionary. /// public sealed class SessionManager : IDisposable { private static readonly ILogger Log = Serilog.Log.ForContext(); private readonly ConcurrentDictionary _sessions = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); private readonly Timer? _scavengingTimer; private readonly TimeSpan _inactivityTimeout; /// /// Creates a SessionManager with optional inactivity scavenging. /// /// /// Sessions inactive for this many minutes are automatically terminated. /// Set to 0 to disable scavenging. /// public SessionManager(int inactivityTimeoutMinutes = 5) { _inactivityTimeout = TimeSpan.FromMinutes(inactivityTimeoutMinutes); if (inactivityTimeoutMinutes > 0) { // Check every 60 seconds _scavengingTimer = new Timer(ScavengeInactiveSessions, null, TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)); } } /// Gets the count of active sessions. public int ActiveSessionCount => _sessions.Count; /// /// Creates a new session. /// Returns the 32-character hex GUID session ID. /// public string CreateSession(string clientId, string apiKey) { var sessionId = Guid.NewGuid().ToString("N"); // 32-char lowercase hex, no hyphens var sessionInfo = new SessionInfo(sessionId, clientId, apiKey); _sessions[sessionId] = sessionInfo; Log.Information("Session created: {SessionId} for client {ClientId}", sessionId, clientId); return sessionId; } /// /// Validates a session ID. Updates LastActivity on success. /// Returns true if the session exists. /// public bool ValidateSession(string sessionId) { if (_sessions.TryGetValue(sessionId, out var session)) { session.TouchLastActivity(); return true; } return false; } /// /// Terminates a session. Returns true if the session existed. /// public bool TerminateSession(string sessionId) { if (_sessions.TryRemove(sessionId, out _)) { Log.Information("Session terminated: {SessionId}", sessionId); return true; } return false; } /// Gets session info by ID, or null if not found. public SessionInfo? GetSession(string sessionId) { _sessions.TryGetValue(sessionId, out var session); return session; } /// Gets a snapshot of all active sessions. public IReadOnlyList GetAllSessions() { return _sessions.Values.ToList().AsReadOnly(); } /// /// Scavenges sessions that have been inactive for longer than the timeout. /// private void ScavengeInactiveSessions(object? state) { if (_inactivityTimeout <= TimeSpan.Zero) return; var cutoff = DateTime.UtcNow - _inactivityTimeout; var expired = _sessions.Where(kvp => kvp.Value.LastActivity < cutoff).ToList(); foreach (var kvp in expired) { if (_sessions.TryRemove(kvp.Key, out _)) { Log.Information("Session {SessionId} scavenged (inactive since {LastActivity})", kvp.Key, kvp.Value.LastActivity); } } } public void Dispose() { _scavengingTimer?.Dispose(); _sessions.Clear(); } } /// /// Information about an active client session. /// public class SessionInfo { public SessionInfo(string sessionId, string clientId, string apiKey) { SessionId = sessionId; ClientId = clientId; ApiKey = apiKey; ConnectedAt = DateTime.UtcNow; LastActivity = DateTime.UtcNow; } public string SessionId { get; } public string ClientId { get; } public string ApiKey { get; } public DateTime ConnectedAt { get; } public DateTime LastActivity { get; private set; } public long ConnectedSinceUtcTicks => ConnectedAt.Ticks; /// Updates the last activity timestamp to now. public void TouchLastActivity() { LastActivity = DateTime.UtcNow; } } } ``` --- ## Step 7: SubscriptionManager **File**: `src/ZB.MOM.WW.LmxProxy.Host/Subscriptions/SubscriptionManager.cs` ```csharp using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Channels; using Serilog; using ZB.MOM.WW.LmxProxy.Host.Domain; namespace ZB.MOM.WW.LmxProxy.Host.Subscriptions { /// /// Manages per-client subscription channels with shared MxAccess subscriptions. /// Ref-counted tag subscriptions: first client creates, last client disposes. /// public sealed class SubscriptionManager : IDisposable { private static readonly ILogger Log = Serilog.Log.ForContext(); private readonly IScadaClient _scadaClient; private readonly int _channelCapacity; private readonly BoundedChannelFullMode _channelFullMode; // Client ID → ClientSubscription private readonly ConcurrentDictionary _clientSubscriptions = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); // Tag address → TagSubscription (shared, ref-counted) private readonly ConcurrentDictionary _tagSubscriptions = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); private readonly ReaderWriterLockSlim _rwLock = new ReaderWriterLockSlim(); public SubscriptionManager(IScadaClient scadaClient, int channelCapacity = 1000, BoundedChannelFullMode channelFullMode = BoundedChannelFullMode.DropOldest) { _scadaClient = scadaClient; _channelCapacity = channelCapacity; _channelFullMode = channelFullMode; } /// /// Creates a subscription for a client. Returns a ChannelReader to stream from. /// public ChannelReader<(string address, Vtq vtq)> Subscribe( string clientId, IEnumerable addresses, CancellationToken ct) { var channel = Channel.CreateBounded<(string address, Vtq vtq)>( new BoundedChannelOptions(_channelCapacity) { FullMode = _channelFullMode, SingleReader = true, SingleWriter = false }); var addressSet = new HashSet(addresses, StringComparer.OrdinalIgnoreCase); var clientSub = new ClientSubscription(clientId, channel, addressSet); _clientSubscriptions[clientId] = clientSub; _rwLock.EnterWriteLock(); try { foreach (var address in addressSet) { if (_tagSubscriptions.TryGetValue(address, out var tagSub)) { tagSub.ClientIds.Add(clientId); } else { _tagSubscriptions[address] = new TagSubscription(address, new HashSet(StringComparer.OrdinalIgnoreCase) { clientId }); } } } finally { _rwLock.ExitWriteLock(); } // Register cancellation cleanup ct.Register(() => UnsubscribeClient(clientId)); Log.Information("Client {ClientId} subscribed to {Count} tags", clientId, addressSet.Count); return channel.Reader; } /// /// Called from MxAccessClient's OnDataChange handler. /// Fans out the update to all subscribed clients. /// public void OnTagValueChanged(string address, Vtq vtq) { _rwLock.EnterReadLock(); HashSet? clientIds = null; try { if (_tagSubscriptions.TryGetValue(address, out var tagSub)) { clientIds = new HashSet(tagSub.ClientIds); } } finally { _rwLock.ExitReadLock(); } if (clientIds == null || clientIds.Count == 0) return; foreach (var clientId in clientIds) { if (_clientSubscriptions.TryGetValue(clientId, out var clientSub)) { if (!clientSub.Channel.Writer.TryWrite((address, vtq))) { clientSub.IncrementDropped(); Log.Debug("Dropped message for client {ClientId} on tag {Address} (channel full)", clientId, address); } else { clientSub.IncrementDelivered(); } } } } /// /// Removes a client's subscriptions and cleans up tag subscriptions /// when the last client unsubscribes. /// public void UnsubscribeClient(string clientId) { if (!_clientSubscriptions.TryRemove(clientId, out var clientSub)) return; _rwLock.EnterWriteLock(); try { foreach (var address in clientSub.Addresses) { if (_tagSubscriptions.TryGetValue(address, out var tagSub)) { tagSub.ClientIds.Remove(clientId); // Last client unsubscribed — remove the tag subscription if (tagSub.ClientIds.Count == 0) { _tagSubscriptions.TryRemove(address, out _); } } } } finally { _rwLock.ExitWriteLock(); } // Complete the channel (signals end of stream to the gRPC handler) clientSub.Channel.Writer.TryComplete(); Log.Information("Client {ClientId} unsubscribed ({Delivered} delivered, {Dropped} dropped)", clientId, clientSub.DeliveredCount, clientSub.DroppedCount); } /// /// Sends a bad-quality notification to all subscribed clients for all their tags. /// Called when MxAccess disconnects. /// public void NotifyDisconnection() { var badVtq = Vtq.New(null, Quality.Bad_NotConnected); foreach (var kvp in _clientSubscriptions) { foreach (var address in kvp.Value.Addresses) { kvp.Value.Channel.Writer.TryWrite((address, badVtq)); } } } /// Returns subscription statistics. public SubscriptionStats GetStats() { return new SubscriptionStats( _clientSubscriptions.Count, _tagSubscriptions.Count, _clientSubscriptions.Values.Sum(c => c.Addresses.Count)); } public void Dispose() { foreach (var kvp in _clientSubscriptions) { kvp.Value.Channel.Writer.TryComplete(); } _clientSubscriptions.Clear(); _tagSubscriptions.Clear(); _rwLock.Dispose(); } // ── Nested types ───────────────────────────────────────── private class ClientSubscription { public ClientSubscription(string clientId, Channel<(string address, Vtq vtq)> channel, HashSet addresses) { ClientId = clientId; Channel = channel; Addresses = addresses; } public string ClientId { get; } public Channel<(string address, Vtq vtq)> Channel { get; } public HashSet Addresses { get; } public long DeliveredCount { get; private set; } public long DroppedCount { get; private set; } public void IncrementDelivered() => Interlocked.Increment(ref _delivered); public void IncrementDropped() => Interlocked.Increment(ref _dropped); // Use backing fields for Interlocked private long _delivered; private long _dropped; } private class TagSubscription { public TagSubscription(string address, HashSet clientIds) { Address = address; ClientIds = clientIds; } public string Address { get; } public HashSet ClientIds { get; } } } } ``` **Note**: The `ClientSubscription` class has a minor issue — `DeliveredCount` and `DroppedCount` properties read the old field values, not the `_delivered`/`_dropped` backing fields. Fix by changing the properties to: ```csharp public long DeliveredCount => Interlocked.Read(ref _delivered); public long DroppedCount => Interlocked.Read(ref _dropped); ``` Also need to add `SubscriptionStats` to the Domain: **File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/SubscriptionStats.cs` ```csharp namespace ZB.MOM.WW.LmxProxy.Host.Domain { /// Subscription statistics for monitoring. public class SubscriptionStats { public SubscriptionStats(int totalClients, int totalTags, int activeSubscriptions) { TotalClients = totalClients; TotalTags = totalTags; ActiveSubscriptions = activeSubscriptions; } public int TotalClients { get; } public int TotalTags { get; } public int ActiveSubscriptions { get; } } } ``` --- ## Step 8: Unit tests ### 8.1 SessionManager tests **File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Sessions/SessionManagerTests.cs` ```csharp using System; using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Xunit; using ZB.MOM.WW.LmxProxy.Host.Sessions; namespace ZB.MOM.WW.LmxProxy.Host.Tests.Sessions { public class SessionManagerTests { [Fact] public void CreateSession_Returns32CharHexId() { using var sm = new SessionManager(inactivityTimeoutMinutes: 0); var id = sm.CreateSession("client1", "key1"); id.Should().HaveLength(32); id.Should().MatchRegex("^[0-9a-f]{32}$"); } [Fact] public void CreateSession_IncrementsCount() { using var sm = new SessionManager(inactivityTimeoutMinutes: 0); sm.ActiveSessionCount.Should().Be(0); sm.CreateSession("c1", "k1"); sm.ActiveSessionCount.Should().Be(1); sm.CreateSession("c2", "k2"); sm.ActiveSessionCount.Should().Be(2); } [Fact] public void ValidateSession_ReturnsTrueForExistingSession() { using var sm = new SessionManager(inactivityTimeoutMinutes: 0); var id = sm.CreateSession("c1", "k1"); sm.ValidateSession(id).Should().BeTrue(); } [Fact] public void ValidateSession_ReturnsFalseForUnknownSession() { using var sm = new SessionManager(inactivityTimeoutMinutes: 0); sm.ValidateSession("nonexistent").Should().BeFalse(); } [Fact] public void ValidateSession_UpdatesLastActivity() { using var sm = new SessionManager(inactivityTimeoutMinutes: 0); var id = sm.CreateSession("c1", "k1"); var session = sm.GetSession(id); var initialActivity = session!.LastActivity; Thread.Sleep(50); // Small delay to ensure time passes sm.ValidateSession(id); session.LastActivity.Should().BeAfter(initialActivity); } [Fact] public void TerminateSession_RemovesSession() { using var sm = new SessionManager(inactivityTimeoutMinutes: 0); var id = sm.CreateSession("c1", "k1"); sm.TerminateSession(id).Should().BeTrue(); sm.ActiveSessionCount.Should().Be(0); sm.ValidateSession(id).Should().BeFalse(); } [Fact] public void TerminateSession_ReturnsFalseForUnknownSession() { using var sm = new SessionManager(inactivityTimeoutMinutes: 0); sm.TerminateSession("nonexistent").Should().BeFalse(); } [Fact] public void GetSession_ReturnsNullForUnknown() { using var sm = new SessionManager(inactivityTimeoutMinutes: 0); sm.GetSession("nonexistent").Should().BeNull(); } [Fact] public void GetSession_ReturnsCorrectInfo() { using var sm = new SessionManager(inactivityTimeoutMinutes: 0); var id = sm.CreateSession("client-abc", "key-xyz"); var session = sm.GetSession(id); session.Should().NotBeNull(); session!.ClientId.Should().Be("client-abc"); session.ApiKey.Should().Be("key-xyz"); session.SessionId.Should().Be(id); session.ConnectedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(2)); } [Fact] public void GetAllSessions_ReturnsSnapshot() { using var sm = new SessionManager(inactivityTimeoutMinutes: 0); sm.CreateSession("c1", "k1"); sm.CreateSession("c2", "k2"); var all = sm.GetAllSessions(); all.Should().HaveCount(2); } [Fact] public void ConcurrentAccess_IsThreadSafe() { using var sm = new SessionManager(inactivityTimeoutMinutes: 0); var tasks = new Task[100]; for (int i = 0; i < 100; i++) { int idx = i; tasks[i] = Task.Run(() => { var id = sm.CreateSession($"client-{idx}", $"key-{idx}"); sm.ValidateSession(id); if (idx % 3 == 0) sm.TerminateSession(id); }); } Task.WaitAll(tasks); // Should have ~67 sessions remaining (100 - ~33 terminated) sm.ActiveSessionCount.Should().BeInRange(60, 70); } [Fact] public void Dispose_ClearsAllSessions() { var sm = new SessionManager(inactivityTimeoutMinutes: 0); sm.CreateSession("c1", "k1"); sm.CreateSession("c2", "k2"); sm.Dispose(); sm.ActiveSessionCount.Should().Be(0); } [Fact] public void ConnectedSinceUtcTicks_ReturnsCorrectValue() { using var sm = new SessionManager(inactivityTimeoutMinutes: 0); var id = sm.CreateSession("c1", "k1"); var session = sm.GetSession(id); session!.ConnectedSinceUtcTicks.Should().Be(session.ConnectedAt.Ticks); } } } ``` ### 8.2 SubscriptionManager tests **File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Subscriptions/SubscriptionManagerTests.cs` ```csharp using System; using System.Collections.Generic; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; using FluentAssertions; using Xunit; using ZB.MOM.WW.LmxProxy.Host.Domain; using ZB.MOM.WW.LmxProxy.Host.Subscriptions; namespace ZB.MOM.WW.LmxProxy.Host.Tests.Subscriptions { public class SubscriptionManagerTests { /// Fake IScadaClient for testing (no COM dependency). private class FakeScadaClient : IScadaClient { public bool IsConnected => true; public ConnectionState ConnectionState => ConnectionState.Connected; public event EventHandler? ConnectionStateChanged; public Task ConnectAsync(CancellationToken ct = default) => Task.CompletedTask; public Task DisconnectAsync(CancellationToken ct = default) => Task.CompletedTask; public Task ReadAsync(string address, CancellationToken ct = default) => Task.FromResult(Vtq.Good(42.0)); public Task> ReadBatchAsync(IEnumerable addresses, CancellationToken ct = default) => Task.FromResult>(new Dictionary()); public Task WriteAsync(string address, object value, CancellationToken ct = default) => Task.CompletedTask; public Task WriteBatchAsync(IReadOnlyDictionary values, CancellationToken ct = default) => Task.CompletedTask; public Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync( IReadOnlyDictionary values, string flagTag, object flagValue, int timeoutMs, int pollIntervalMs, CancellationToken ct = default) => Task.FromResult((false, 0)); public Task SubscribeAsync(IEnumerable addresses, Action callback, CancellationToken ct = default) => Task.FromResult(new FakeSubscriptionHandle()); public ValueTask DisposeAsync() => ValueTask.CompletedTask; private class FakeSubscriptionHandle : IAsyncDisposable { public ValueTask DisposeAsync() => ValueTask.CompletedTask; } } [Fact] public void Subscribe_ReturnsChannelReader() { using var sm = new SubscriptionManager(new FakeScadaClient()); using var cts = new CancellationTokenSource(); var reader = sm.Subscribe("client1", new[] { "Tag1", "Tag2" }, cts.Token); reader.Should().NotBeNull(); } [Fact] public async Task OnTagValueChanged_FansOutToSubscribedClients() { using var sm = new SubscriptionManager(new FakeScadaClient()); using var cts = new CancellationTokenSource(); var reader = sm.Subscribe("client1", new[] { "Motor.Speed" }, cts.Token); var vtq = Vtq.Good(42.0); sm.OnTagValueChanged("Motor.Speed", vtq); var result = await reader.ReadAsync(cts.Token); result.address.Should().Be("Motor.Speed"); result.vtq.Value.Should().Be(42.0); result.vtq.Quality.Should().Be(Quality.Good); } [Fact] public async Task OnTagValueChanged_MultipleClients_BothReceive() { using var sm = new SubscriptionManager(new FakeScadaClient()); using var cts = new CancellationTokenSource(); var reader1 = sm.Subscribe("client1", new[] { "Motor.Speed" }, cts.Token); var reader2 = sm.Subscribe("client2", new[] { "Motor.Speed" }, cts.Token); sm.OnTagValueChanged("Motor.Speed", Vtq.Good(99.0)); var r1 = await reader1.ReadAsync(cts.Token); var r2 = await reader2.ReadAsync(cts.Token); r1.vtq.Value.Should().Be(99.0); r2.vtq.Value.Should().Be(99.0); } [Fact] public async Task OnTagValueChanged_NonSubscribedTag_NoDelivery() { using var sm = new SubscriptionManager(new FakeScadaClient()); using var cts = new CancellationTokenSource(); var reader = sm.Subscribe("client1", new[] { "Motor.Speed" }, cts.Token); sm.OnTagValueChanged("Motor.Torque", Vtq.Good(10.0)); // Channel should be empty reader.TryRead(out _).Should().BeFalse(); } [Fact] public void UnsubscribeClient_CompletesChannel() { using var sm = new SubscriptionManager(new FakeScadaClient()); using var cts = new CancellationTokenSource(); var reader = sm.Subscribe("client1", new[] { "Motor.Speed" }, cts.Token); sm.UnsubscribeClient("client1"); // Channel should be completed reader.Completion.IsCompleted.Should().BeTrue(); } [Fact] public void UnsubscribeClient_RemovesFromTagSubscriptions() { using var sm = new SubscriptionManager(new FakeScadaClient()); using var cts = new CancellationTokenSource(); sm.Subscribe("client1", new[] { "Motor.Speed" }, cts.Token); sm.UnsubscribeClient("client1"); var stats = sm.GetStats(); stats.TotalClients.Should().Be(0); stats.TotalTags.Should().Be(0); } [Fact] public void RefCounting_LastClientUnsubscribeRemovesTag() { using var sm = new SubscriptionManager(new FakeScadaClient()); using var cts = new CancellationTokenSource(); sm.Subscribe("client1", new[] { "Motor.Speed" }, cts.Token); sm.Subscribe("client2", new[] { "Motor.Speed" }, cts.Token); sm.GetStats().TotalTags.Should().Be(1); sm.UnsubscribeClient("client1"); sm.GetStats().TotalTags.Should().Be(1); // client2 still subscribed sm.UnsubscribeClient("client2"); sm.GetStats().TotalTags.Should().Be(0); // last client gone } [Fact] public void NotifyDisconnection_SendsBadQualityToAll() { using var sm = new SubscriptionManager(new FakeScadaClient()); using var cts = new CancellationTokenSource(); var reader = sm.Subscribe("client1", new[] { "Motor.Speed", "Motor.Torque" }, cts.Token); sm.NotifyDisconnection(); // Should receive 2 bad quality messages reader.TryRead(out var r1).Should().BeTrue(); r1.vtq.Quality.Should().Be(Quality.Bad_NotConnected); reader.TryRead(out var r2).Should().BeTrue(); r2.vtq.Quality.Should().Be(Quality.Bad_NotConnected); } [Fact] public void Backpressure_DropOldest_DropsWhenFull() { using var sm = new SubscriptionManager(new FakeScadaClient(), channelCapacity: 3); using var cts = new CancellationTokenSource(); var reader = sm.Subscribe("client1", new[] { "Motor.Speed" }, cts.Token); // Fill the channel beyond capacity for (int i = 0; i < 10; i++) { sm.OnTagValueChanged("Motor.Speed", Vtq.Good((double)i)); } // Should have exactly 3 messages (capacity limit) int count = 0; while (reader.TryRead(out _)) count++; count.Should().Be(3); } [Fact] public void GetStats_ReturnsCorrectCounts() { using var sm = new SubscriptionManager(new FakeScadaClient()); using var cts = new CancellationTokenSource(); sm.Subscribe("c1", new[] { "Tag1", "Tag2" }, cts.Token); sm.Subscribe("c2", new[] { "Tag2", "Tag3" }, cts.Token); var stats = sm.GetStats(); stats.TotalClients.Should().Be(2); stats.TotalTags.Should().Be(3); // Tag1, Tag2, Tag3 stats.ActiveSubscriptions.Should().Be(4); // c1:Tag1, c1:Tag2, c2:Tag2, c2:Tag3 } } } ``` ### 8.3 StaDispatchThread tests **File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/MxAccess/StaDispatchThreadTests.cs` ```csharp using System; using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Xunit; using ZB.MOM.WW.LmxProxy.Host.MxAccess; namespace ZB.MOM.WW.LmxProxy.Host.Tests.MxAccess { public class StaDispatchThreadTests { [Fact] public async Task DispatchAsync_ExecutesOnStaThread() { using var sta = new StaDispatchThread("Test-STA"); var threadId = await sta.DispatchAsync(() => Thread.CurrentThread.ManagedThreadId); threadId.Should().NotBe(Thread.CurrentThread.ManagedThreadId); } [Fact] public async Task DispatchAsync_ReturnsResult() { using var sta = new StaDispatchThread("Test-STA"); var result = await sta.DispatchAsync(() => 42); result.Should().Be(42); } [Fact] public async Task DispatchAsync_PropagatesException() { using var sta = new StaDispatchThread("Test-STA"); var act = () => sta.DispatchAsync(() => throw new InvalidOperationException("test error")); await act.Should().ThrowAsync().WithMessage("test error"); } [Fact] public async Task DispatchAsync_Action_Completes() { using var sta = new StaDispatchThread("Test-STA"); int value = 0; await sta.DispatchAsync(() => { value = 99; }); value.Should().Be(99); } [Fact] public void Dispose_CompletesGracefully() { var sta = new StaDispatchThread("Test-STA"); sta.Dispose(); // Should not throw } [Fact] public void DispatchAfterDispose_ThrowsObjectDisposedException() { var sta = new StaDispatchThread("Test-STA"); sta.Dispose(); var act = () => sta.DispatchAsync(() => 42); act.Should().ThrowAsync(); } [Fact] public async Task MultipleDispatches_ExecuteInOrder() { using var sta = new StaDispatchThread("Test-STA"); var results = new System.Collections.Concurrent.ConcurrentBag(); var tasks = new Task[10]; for (int i = 0; i < 10; i++) { int idx = i; tasks[i] = sta.DispatchAsync(() => { results.Add(idx); }); } await Task.WhenAll(tasks); results.Count.Should().Be(10); } [Fact] public async Task StaThread_HasStaApartmentState() { using var sta = new StaDispatchThread("Test-STA"); var apartmentState = await sta.DispatchAsync(() => Thread.CurrentThread.GetApartmentState()); apartmentState.Should().Be(ApartmentState.STA); } } } ``` ### 8.4 MxAccessClient TypedValueEquals tests **File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/MxAccess/TypedValueEqualsTests.cs` Since `TypedValueEquals` is private in `MxAccessClient`, test it indirectly or extract it to a helper. For testability, create a public static helper: **File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/TypedValueComparer.cs` ```csharp using System; namespace ZB.MOM.WW.LmxProxy.Host.Domain { /// /// Type-aware equality comparison for WriteBatchAndWait flag matching. /// public static class TypedValueComparer { /// /// Returns true if both values are the same type and equal. /// Mismatched types are never equal. /// Null equals null only. /// public static bool Equals(object? a, object? b) { if (a == null && b == null) return true; if (a == null || b == null) return false; if (a.GetType() != b.GetType()) return false; if (a is Array arrA && b is Array arrB) { if (arrA.Length != arrB.Length) return false; for (int i = 0; i < arrA.Length; i++) { if (!object.Equals(arrA.GetValue(i), arrB.GetValue(i))) return false; } return true; } return object.Equals(a, b); } } } ``` Then the test file: ```csharp using FluentAssertions; using Xunit; using ZB.MOM.WW.LmxProxy.Host.Domain; namespace ZB.MOM.WW.LmxProxy.Host.Tests.MxAccess { public class TypedValueEqualsTests { [Fact] public void NullEqualsNull() => TypedValueComparer.Equals(null, null).Should().BeTrue(); [Fact] public void NullNotEqualsValue() => TypedValueComparer.Equals(null, 42).Should().BeFalse(); [Fact] public void ValueNotEqualsNull() => TypedValueComparer.Equals(42, null).Should().BeFalse(); [Fact] public void SameTypeAndValue() => TypedValueComparer.Equals(42.5, 42.5).Should().BeTrue(); [Fact] public void SameTypeDifferentValue() => TypedValueComparer.Equals(42.5, 43.0).Should().BeFalse(); [Fact] public void DifferentTypes_NeverEqual() => TypedValueComparer.Equals(1, 1.0).Should().BeFalse(); [Fact] public void BoolTrue() => TypedValueComparer.Equals(true, true).Should().BeTrue(); [Fact] public void BoolFalse() => TypedValueComparer.Equals(false, true).Should().BeFalse(); [Fact] public void String_CaseSensitive() { TypedValueComparer.Equals("DONE", "DONE").Should().BeTrue(); TypedValueComparer.Equals("done", "DONE").Should().BeFalse(); } [Fact] public void Array_SameElements() { TypedValueComparer.Equals(new[] { 1, 2, 3 }, new[] { 1, 2, 3 }).Should().BeTrue(); } [Fact] public void Array_DifferentElements() { TypedValueComparer.Equals(new[] { 1, 2, 3 }, new[] { 1, 2, 4 }).Should().BeFalse(); } [Fact] public void Array_DifferentLengths() { TypedValueComparer.Equals(new[] { 1, 2 }, new[] { 1, 2, 3 }).Should().BeFalse(); } [Fact] public void Int32_NotEqual_ToDouble() { TypedValueComparer.Equals(1, 1.0).Should().BeFalse(); } [Fact] public void Long_Equality() { TypedValueComparer.Equals(long.MaxValue, long.MaxValue).Should().BeTrue(); } [Fact] public void DateTime_TickPrecision() { var dt1 = new System.DateTime(638789000000000000, System.DateTimeKind.Utc); var dt2 = new System.DateTime(638789000000000000, System.DateTimeKind.Utc); TypedValueComparer.Equals(dt1, dt2).Should().BeTrue(); } } } ``` --- ## Step 9: Build verification ```bash cd /Users/dohertj2/Desktop/scadalink-design/lmxproxy # Build Client (works on macOS) dotnet build src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj # Run Client tests dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj # Host builds on Windows only (net48/x86): # dotnet build src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj # dotnet test tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj ``` --- ## Completion Criteria - [ ] `StaDispatchThread` compiles and tests pass (STA apartment, dispatch, exception propagation) - [ ] `MxAccessClient` main class compiles with all partial files - [ ] `MxAccessClient.Connection.cs` compiles (ConnectAsync, DisconnectAsync, MonitorConnectionAsync) - [ ] `MxAccessClient.ReadWrite.cs` compiles (ReadAsync, ReadBatchAsync, WriteAsync, WriteBatchAsync, WriteBatchAndWaitAsync) - [ ] `MxAccessClient.Subscription.cs` compiles (SubscribeAsync, UnsubscribeAsync, RecreateStoredSubscriptionsAsync) - [ ] `MxAccessClient.EventHandlers.cs` compiles (OnDataChange, OnWriteComplete) - [ ] `SessionManager` compiles and all tests pass (CRUD, scavenging, concurrency) - [ ] `SubscriptionManager` compiles and all tests pass (subscribe, fan-out, unsubscribe, ref-counting, backpressure, disconnect notification) - [ ] `TypedValueComparer` tests pass (all comparison rules from design doc) - [ ] COM method bodies are marked with `NotImplementedException` and clear instructions to consult reference code - [ ] No references to old namespaces