using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; /// /// Process-wide owner of device connections: exactly one and one /// per (review B1), handed out as ref-counted /// s. The last lease's dispose closes the connection; a later acquire builds /// fresh instances, which do not open until the previous connection has finished closing. /// /// Forward Open throttles are kept per key for the process lifetime and never pruned, so a /// reconnect after the last release is still rate-limited against the previous attempt. public sealed class DeviceSessionPool { /// The process-wide pool the drivers use. Tests build their own. public static DeviceSessionPool Shared { get; } = new(); internal sealed class Entry(CipConnection connection, RequestScheduler scheduler, ConnectionOptions options, Task predecessorClosed) { /// The shared CIP connection for the device. public CipConnection Connection { get; } = connection; /// Completes when every earlier connection for this key has finished closing. public Task PredecessorClosed { get; } = predecessorClosed; /// The request scheduler serializing and packing traffic on . public RequestScheduler Scheduler { get; } = scheduler; /// The options the connection was created with. public ConnectionOptions Options { get; } = options; public int RefCount; // under the pool lock public bool MismatchLogged; // under the pool lock } private readonly object _lock = new(); private readonly Dictionary _entries = new(); private readonly Dictionary _throttles = new(); private readonly Dictionary _closing = new(); private readonly TimeProvider _time; private volatile ILogger _logger; /// Initializes a new instance of the class. /// Time source; defaults to . /// Logger for mismatch and close-failure warnings; defaults to a no-op logger. public DeviceSessionPool(TimeProvider? time = null, ILogger? logger = null) { _time = time ?? TimeProvider.System; _logger = logger ?? NullLogger.Instance; } /// Give a logger (it is built before any host exists), so its /// options-mismatch and close-failure warnings reach the service log. Call once at startup. /// The factory used to create the shared pool's logger. public static void ConfigureSharedLogger(ILoggerFactory loggerFactory) { ArgumentNullException.ThrowIfNull(loggerFactory); Shared._logger = loggerFactory.CreateLogger(); } /// Wiring-test hook: the logger this pool currently writes to (final review I2). internal ILogger Logger => _logger; /// Counters and open-attempt limits for this pool's connections (gate and harness diagnostics). internal ConnectionDiagnostics Diagnostics { get; } = new(); /// Test hook: keys with a live entry. internal int EntryCount { get { lock (_lock) return _entries.Count; } } /// Lease the shared connection for , creating it on first use. Performs /// no I/O: the connection opens on its first request. When the key already has an entry, that entry's /// options win; a differing is logged once per entry. /// The device to lease a connection for. /// The options to open the connection with if the key has no entry yet. /// A lease on the device's shared connection. public ConnectionLease Acquire(DeviceKey key, ConnectionOptions options) { ArgumentNullException.ThrowIfNull(key); ArgumentNullException.ThrowIfNull(options); lock (_lock) { if (!_entries.TryGetValue(key, out var entry)) { if (!_throttles.TryGetValue(key, out var throttle)) _throttles[key] = throttle = new ForwardOpenThrottle(options.MinForwardOpenInterval, _time); var predecessor = _closing.GetValueOrDefault(key) ?? Task.CompletedTask; var connection = new CipConnection(key, options, throttle, _time, predecessor, Diagnostics); entry = new Entry(connection, new RequestScheduler(connection, options.AllowPacking), options, predecessor); _entries[key] = entry; } else if (!entry.Options.Equals(options) && !entry.MismatchLogged) { entry.MismatchLogged = true; _logger.LogWarning( "Device {Host}:{Port} path '{CipPath}' is already connected with different connection options; " + "the existing options ({Existing}) stay in force and the requested ({Requested}) are ignored.", key.Host, key.Port, key.CipPath, entry.Options, options); } entry.RefCount++; return new ConnectionLease(this, key, entry); } } /// Drops one reference on ; the last release removes it and closes the connection. /// The device key the entry belongs to. /// The entry whose reference is released. /// A task that represents the asynchronous operation. internal async ValueTask ReleaseAsync(DeviceKey key, Entry entry) { TaskCompletionSource closed; Task chain; lock (_lock) { if (--entry.RefCount > 0) return; _entries.Remove(key); closed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); // The successor must wait for THIS close and for every earlier one still running. An entry // that never opened closes instantly; without the chain it would hide a slow predecessor // (A closing, B acquired+released unopened, C opens while A's Forward Close is still pending). chain = Task.WhenAll(entry.PredecessorClosed, closed.Task); _closing[key] = chain; } _ = chain.ContinueWith(_ => { lock (_lock) { if (_closing.TryGetValue(key, out var t) && t == chain) _closing.Remove(key); } }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); // Outside the lock. try { await CloseAsync(key, entry.Scheduler, entry.Connection, _logger).ConfigureAwait(false); } finally { closed.TrySetResult(); // never faults: a successor only needs to know the close is over } } /// /// Scheduler first (fails its waiters, stops sending), then Forward Close + UnRegisterSession via the /// connection. The connection is disposed even if the scheduler's dispose throws: otherwise its keep-alive /// would hold the press connection open forever. Never throws; each failure is logged. /// /// The device being closed (for logging). /// The request scheduler to dispose first. /// The connection to dispose after the scheduler. /// The logger that receives close failures. /// A task that represents the asynchronous operation. internal static async Task CloseAsync(DeviceKey key, IAsyncDisposable scheduler, IAsyncDisposable connection, ILogger logger) { try { await scheduler.DisposeAsync().ConfigureAwait(false); } catch (Exception ex) { logger.LogWarning(ex, "Disposing the request scheduler for {Host}:{Port} failed; closing the connection anyway.", key.Host, key.Port); } try { await connection.DisposeAsync().ConfigureAwait(false); } catch (Exception ex) { logger.LogWarning(ex, "Closing the connection to {Host}:{Port} failed.", key.Host, key.Port); } } } /// A reference on a device's shared connection. Dispose exactly once (further disposes are no-ops). public sealed class ConnectionLease : IAsyncDisposable { private readonly DeviceSessionPool _pool; private readonly DeviceKey _key; private readonly DeviceSessionPool.Entry _entry; private int _disposed; /// Initializes a new instance of the class. /// The owning pool. /// The device key leased. /// The pool entry this lease references. internal ConnectionLease(DeviceSessionPool pool, DeviceKey key, DeviceSessionPool.Entry entry) { _pool = pool; _key = key; _entry = entry; } /// The device this lease is on. public DeviceKey Key => _key; /// The device's channel. Prefer : it serializes and packs requests. public ICipChannel Channel => _entry.Connection; /// The device's single request scheduler, shared by every lease on the key. public RequestScheduler Scheduler => _entry.Scheduler; /// Releases the lease; further calls are no-ops. /// A task that represents the asynchronous operation. public ValueTask DisposeAsync() => Interlocked.Exchange(ref _disposed, 1) == 0 ? _pool.ReleaseAsync(_key, _entry) : ValueTask.CompletedTask; /// /// Releases this lease without waiting for the teardown — the one release shape both native AB tag /// runtimes use from their synchronous Dispose, which the drivers call under a per-tag lock. /// Idempotent, like . /// /// /// Nothing is lost by not waiting. The release's bookkeeping — the ref-count drop and, for the /// last lease, the entry's removal and the registration of its close in the pool's closing chain — runs /// synchronously, before this returns. So an acquire made the very next instant builds a connection that /// does not open until this one's Forward Close + UnRegisterSession have finished (the guarantee the old /// blocking 2 s wait in the AbCip runtime existed for), and the close itself is bounded by /// 's own budgets (the open budget for an in-flight open, 1 s for the Forward /// Close), so it always completes and the socket is always released. /// The returned task is the teardown; it never faults (the pool logs its own close failures), so /// a caller may drop it. /// /// The teardown, for tests and callers that want to observe it. public Task ReleaseInBackground() => Observe(DisposeAsync()); private static async Task Observe(ValueTask release) { try { await release.ConfigureAwait(false); } catch (Exception) { /* ReleaseAsync logs and swallows its own failures; never fault the observer */ } } }