Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/DeviceSessionPool.cs
T
Joseph Doherty 3edff3304b fix(ab-transport): per-pool diagnostics + per-device open-attempt limit, CIP-level close recycle, CipConnection/pool test gaps (#566, #581)
- Process-wide CipConnection statics (ForwardOpenCount, Forward Close counts, RequestsSent, exchange
  stats, OpenAttempts, OpenAttemptBudget) move to an instance ConnectionDiagnostics owned by each
  DeviceSessionPool (and by a stand-alone CipConnection). The open-attempt budget becomes a disposable
  per-DeviceKey limit (LimitOpenAttempts); the differential harness scopes one to the press under test.
- LibplctagOrphanRelease.ForceRootingForTest is gone: ReleaseIfOrphaned(tag, canReadHandles) overload.
- A connected reply with general 0x01 / extended 0x0107 or 0x0203 (target no longer holds the class-3
  connection, TCP still up) retires the session at once; the next send reopens through the throttle.
- New tests: dispose during RegisterSession, abandoned-open observer (falsified), scheduler-dispose-throws
  guard (DeviceSessionPool.CloseAsync), release-in-background with a shared open in flight, per-device
  limit scoping, CIP-close recycle (request + keep-alive + negative control).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:21:02 -04:00

198 lines
9.6 KiB
C#

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;
/// <summary>
/// Process-wide owner of device connections: exactly one <see cref="CipConnection"/> and one
/// <see cref="RequestScheduler"/> per <see cref="DeviceKey"/> (review B1), handed out as ref-counted
/// <see cref="ConnectionLease"/>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.
/// </summary>
/// <remarks>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.</remarks>
public sealed class DeviceSessionPool
{
/// <summary>The process-wide pool the drivers use. Tests build their own.</summary>
public static DeviceSessionPool Shared { get; } = new();
internal sealed class Entry(CipConnection connection, RequestScheduler scheduler, ConnectionOptions options,
Task predecessorClosed)
{
public CipConnection Connection { get; } = connection;
/// <summary>Completes when every earlier connection for this key has finished closing.</summary>
public Task PredecessorClosed { get; } = predecessorClosed;
public RequestScheduler Scheduler { get; } = scheduler;
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<DeviceKey, Entry> _entries = new();
private readonly Dictionary<DeviceKey, ForwardOpenThrottle> _throttles = new();
private readonly Dictionary<DeviceKey, Task> _closing = new();
private readonly TimeProvider _time;
private volatile ILogger _logger;
public DeviceSessionPool(TimeProvider? time = null, ILogger? logger = null)
{
_time = time ?? TimeProvider.System;
_logger = logger ?? NullLogger.Instance;
}
/// <summary>Give <see cref="Shared"/> 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.</summary>
public static void ConfigureSharedLogger(ILoggerFactory loggerFactory)
{
ArgumentNullException.ThrowIfNull(loggerFactory);
Shared._logger = loggerFactory.CreateLogger<DeviceSessionPool>();
}
/// <summary>Wiring-test hook: the logger this pool currently writes to (final review I2).</summary>
internal ILogger Logger => _logger;
/// <summary>Counters and open-attempt limits for this pool's connections (gate and harness diagnostics).</summary>
internal ConnectionDiagnostics Diagnostics { get; } = new();
/// <summary>Test hook: keys with a live entry.</summary>
internal int EntryCount { get { lock (_lock) return _entries.Count; } }
/// <summary>Lease the shared connection for <paramref name="key"/>, 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 <paramref name="options"/> is logged once per entry.</summary>
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);
}
}
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
}
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
}
/// <summary>A reference on a device's shared connection. Dispose exactly once (further disposes are no-ops).</summary>
public sealed class ConnectionLease : IAsyncDisposable
{
private readonly DeviceSessionPool _pool;
private readonly DeviceKey _key;
private readonly DeviceSessionPool.Entry _entry;
private int _disposed;
internal ConnectionLease(DeviceSessionPool pool, DeviceKey key, DeviceSessionPool.Entry entry)
{
_pool = pool;
_key = key;
_entry = entry;
}
public DeviceKey Key => _key;
/// <summary>The device's channel. Prefer <see cref="Scheduler"/>: it serializes and packs requests.</summary>
public ICipChannel Channel => _entry.Connection;
/// <summary>The device's single request scheduler, shared by every lease on the key.</summary>
public RequestScheduler Scheduler => _entry.Scheduler;
public ValueTask DisposeAsync() =>
Interlocked.Exchange(ref _disposed, 1) == 0 ? _pool.ReleaseAsync(_key, _entry) : ValueTask.CompletedTask;
/// <summary>
/// Releases this lease without waiting for the teardown — the one release shape both native AB tag
/// runtimes use from their synchronous <c>Dispose</c>, which the drivers call under a per-tag lock
/// (Gitea #579). Idempotent, like <see cref="DisposeAsync"/>.
/// </summary>
/// <remarks>
/// <para>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
/// <see cref="CipConnection"/>'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.</para>
/// <para>The returned task is the teardown; it never faults (the pool logs its own close failures), so
/// a caller may drop it.</para>
/// </remarks>
/// <returns>The teardown, for tests and callers that want to observe it.</returns>
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 */ }
}
}