feat(ab-transport): per-device connection with routing modes, shared scheduler, keep-alive and Forward Open throttle
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
|
||||
/// <summary>
|
||||
/// One device's CIP channel: one EtherNet/IP session and, when <see cref="CipRouting.Connected"/>, one
|
||||
/// class-3 connection. Opens lazily on first use, reconnects (through the <see cref="ForwardOpenThrottle"/>)
|
||||
/// after the session faults, and keeps an idle connection alive.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Reconnect.</b> Only a faulted session (timeout, I/O error, malformed frame) is reopened, and
|
||||
/// only by the <i>next</i> send; the failing call throws its own exception. Caller cancellation never
|
||||
/// faults the session (B2), so it never causes a reconnect.</para>
|
||||
/// <para><b>Bounded.</b> Every <see cref="SendAsync"/> is bounded even if the caller passes no token
|
||||
/// (ruling R9-3): opening (including the throttle wait and any wait behind another opener) is bounded by
|
||||
/// <c>MinForwardOpenInterval + 4 × Timeout</c> (connect, RegisterSession, Forward Open, fallback Forward
|
||||
/// Open), and the exchange by <c>2 × Timeout</c> (waiting behind one in-flight exchange, then its own).
|
||||
/// Exceeding either surfaces as <see cref="AbTransportException"/>(<see cref="AbStatus.Timeout"/>).</para>
|
||||
/// <para><b>Connection identity.</b> Every Forward Open attempt uses a fresh connection serial and T→O id,
|
||||
/// so a reopen is never mistaken by the target for a duplicate of a connection it has not yet expired.</para>
|
||||
/// </remarks>
|
||||
public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
{
|
||||
/// <summary>Forward Open frames sent by every connection in the process (fallback included). Gate diagnostics.</summary>
|
||||
internal static int ForwardOpenCount;
|
||||
|
||||
/// <summary>Channel sends by every connection in the process (keep-alives included). Gate diagnostics.</summary>
|
||||
internal static int RequestsSent;
|
||||
|
||||
/// <summary>Get Attribute Single, Identity (class 1) instance 1 attribute 1 (Vendor ID).</summary>
|
||||
internal static readonly byte[] KeepAliveRequest = [0x0E, 0x03, 0x20, 0x01, 0x24, 0x01, 0x30, 0x01];
|
||||
|
||||
/// <summary>Largest unconnected message a target is guaranteed to accept.</summary>
|
||||
private const int UnconnectedMessageLimit = 504;
|
||||
|
||||
private sealed record Open(EncapsulationClient Client, ForwardOpenReply? Conn, ushort ConnectionSerial, int NegotiatedSize);
|
||||
|
||||
private readonly DeviceKey _key;
|
||||
private readonly ConnectionOptions _o;
|
||||
private readonly ForwardOpenThrottle _throttle;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly Task _predecessorClosed;
|
||||
private readonly byte[] _route;
|
||||
|
||||
// Never disposed: callers may still be queued on it when DisposeAsync runs, and Release on a
|
||||
// disposed SemaphoreSlim throws. It owns no unmanaged handle unless AvailableWaitHandle is touched.
|
||||
private readonly SemaphoreSlim _state = new(1, 1);
|
||||
private readonly ITimer? _keepAlive;
|
||||
private Open? _open; // written under _state; read lock-free via Volatile
|
||||
private int _disposed; // checked under _state before any open; read lock-free elsewhere
|
||||
private int _disposeStarted;
|
||||
private long _lastSendTimestamp;
|
||||
private int _keepAlivesSent;
|
||||
private int _connectionSerial = Random.Shared.Next(1, ushort.MaxValue);
|
||||
|
||||
/// <param name="key">The device; <see cref="DeviceKey.CipPath"/> is the route (ignored for Direct).</param>
|
||||
/// <param name="options">Routing, sizes, timeouts and keep-alive settings.</param>
|
||||
/// <param name="throttle">The device's process-lifetime Forward Open throttle.</param>
|
||||
/// <param name="time">Clock for the throttle, deadlines and keep-alive.</param>
|
||||
/// <param name="predecessorClosed">Completes when the previous connection for the same key has
|
||||
/// finished closing, so two connections to one device never overlap.</param>
|
||||
public CipConnection(DeviceKey key, ConnectionOptions options, ForwardOpenThrottle throttle, TimeProvider time,
|
||||
Task? predecessorClosed = null)
|
||||
{
|
||||
_key = key ?? throw new ArgumentNullException(nameof(key));
|
||||
_o = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_throttle = throttle ?? throw new ArgumentNullException(nameof(throttle));
|
||||
_time = time ?? throw new ArgumentNullException(nameof(time));
|
||||
_predecessorClosed = predecessorClosed ?? Task.CompletedTask;
|
||||
// Direct ignores the path entirely (B3), so it is not even parsed.
|
||||
_route = options.Routing == CipRouting.Direct ? [] : CipPath.EncodeRoute(key.CipPath);
|
||||
_lastSendTimestamp = time.GetTimestamp();
|
||||
if (options.Routing == CipRouting.Connected)
|
||||
_keepAlive = time.CreateTimer(static s => ((CipConnection)s!).OnKeepAliveTimer(), this,
|
||||
options.KeepAliveInterval, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
|
||||
/// <summary>The options this connection was built with.</summary>
|
||||
internal ConnectionOptions Options => _o;
|
||||
|
||||
/// <summary>Test hook: keep-alives started (counted synchronously when the timer decides to send).</summary>
|
||||
internal int KeepAlivesSent => Volatile.Read(ref _keepAlivesSent);
|
||||
|
||||
private TimeSpan OpenBudget => _o.MinForwardOpenInterval + 4 * _o.Timeout;
|
||||
private TimeSpan ExchangeBudget => 2 * _o.Timeout;
|
||||
|
||||
public int MaxRequestBytes => _o.Routing switch
|
||||
{
|
||||
CipRouting.Connected => (Volatile.Read(ref _open)?.NegotiatedSize ?? Math.Min(_o.ConnectionSize, UnconnectedMessageLimit)) - 2,
|
||||
CipRouting.UnconnectedSend => UnconnectedMessageLimit - (12 + _route.Length),
|
||||
_ => UnconnectedMessageLimit,
|
||||
};
|
||||
|
||||
public async Task<ReadOnlyMemory<byte>> SendAsync(ReadOnlyMemory<byte> cipRequest, CancellationToken cancellationToken)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
var open = await EnsureOpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await ExchangeAsync(open, cipRequest, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Returns the live (client, connection) pair, opening one if needed. The pair is returned
|
||||
/// rather than re-read from fields afterwards, so a concurrent dispose can never hand the caller a
|
||||
/// null (finding 16).</summary>
|
||||
private async Task<Open> EnsureOpenAsync(CancellationToken ct)
|
||||
{
|
||||
if (Volatile.Read(ref _open) is { Client.IsConnected: true } live) return live;
|
||||
|
||||
using var deadline = new CancellationTokenSource(OpenBudget, _time);
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, deadline.Token);
|
||||
try
|
||||
{
|
||||
await _state.WaitAsync(linked.Token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
var current = _open;
|
||||
if (current is { Client.IsConnected: true }) return current;
|
||||
if (current is not null)
|
||||
{
|
||||
Volatile.Write(ref _open, null);
|
||||
await current.Client.DisposeAsync().ConfigureAwait(false); // faulted: closes without UnRegister
|
||||
}
|
||||
await _predecessorClosed.WaitAsync(linked.Token).ConfigureAwait(false);
|
||||
var opened = await _throttle.RunAsync(OpenAsync, linked.Token).ConfigureAwait(false);
|
||||
Volatile.Write(ref _open, opened);
|
||||
return opened;
|
||||
}
|
||||
finally { _state.Release(); }
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested && deadline.IsCancellationRequested)
|
||||
{
|
||||
throw new AbTransportException(AbStatus.Timeout,
|
||||
$"Opening {_key.Host}:{_key.Port} did not complete within {OpenBudget.TotalMilliseconds:F0} ms.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Open> OpenAsync(CancellationToken ct)
|
||||
{
|
||||
ThrowIfDisposed(); // disposal may have begun while this attempt waited out the throttle
|
||||
var client = await EncapsulationClient.ConnectAsync(_key.Host, _key.Port, _o.Timeout, ct).ConfigureAwait(false);
|
||||
if (_o.Routing != CipRouting.Connected) return new Open(client, null, 0, UnconnectedMessageLimit);
|
||||
try
|
||||
{
|
||||
var serial = NextConnectionSerial();
|
||||
var toId = (uint)Random.Shared.NextInt64(1, uint.MaxValue);
|
||||
var large = _o.ConnectionSize > 511;
|
||||
var size = _o.ConnectionSize;
|
||||
var reply = await ForwardOpenAsync(client, large, size, serial, toId, ct).ConfigureAwait(false);
|
||||
if (large && ConnectionManagerServices.ShouldFallBackToRegularOpen(reply))
|
||||
{
|
||||
// Pre-v20 Logix / small-buffer targets: retry once, inside this same throttled attempt.
|
||||
size = Math.Min(_o.ConnectionSize, UnconnectedMessageLimit);
|
||||
reply = await ForwardOpenAsync(client, false, size, serial, toId, ct).ConfigureAwait(false);
|
||||
}
|
||||
return new Open(client, ConnectionManagerServices.ParseForwardOpenReply(reply), serial, size);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await client.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CipReply> ForwardOpenAsync(EncapsulationClient client, bool large, int size, ushort serial,
|
||||
uint toId, CancellationToken ct)
|
||||
{
|
||||
var p = new ForwardOpenParams(large, toId, serial, _o.VendorId, _o.OriginatorSerial, _o.TimeoutMultiplier,
|
||||
_o.RpiMicros, size, _route);
|
||||
var frame = ConnectionManagerServices.ForwardOpen(p);
|
||||
Interlocked.Increment(ref ForwardOpenCount);
|
||||
return CipReply.Parse(await client.SendRRDataAsync(frame, _o.Timeout, ct).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
private ushort NextConnectionSerial()
|
||||
{
|
||||
var s = (ushort)Interlocked.Increment(ref _connectionSerial);
|
||||
return s == 0 ? (ushort)Interlocked.Increment(ref _connectionSerial) : s;
|
||||
}
|
||||
|
||||
/// <summary>One request over an established pair, bounded by <see cref="ExchangeBudget"/>.</summary>
|
||||
private async Task<ReadOnlyMemory<byte>> ExchangeAsync(Open open, ReadOnlyMemory<byte> cip, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref RequestsSent);
|
||||
Interlocked.Exchange(ref _lastSendTimestamp, _time.GetTimestamp());
|
||||
using var deadline = new CancellationTokenSource(ExchangeBudget, _time);
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, deadline.Token);
|
||||
try
|
||||
{
|
||||
return _o.Routing switch
|
||||
{
|
||||
CipRouting.Connected => await open.Client.SendUnitDataAsync(open.Conn!.OTConnectionId, cip, _o.Timeout, linked.Token).ConfigureAwait(false),
|
||||
CipRouting.UnconnectedSend => await open.Client.SendRRDataAsync(ConnectionManagerServices.UnconnectedSend(cip.Span, _route), _o.Timeout, linked.Token).ConfigureAwait(false),
|
||||
_ => await open.Client.SendRRDataAsync(cip, _o.Timeout, linked.Token).ConfigureAwait(false),
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested && deadline.IsCancellationRequested)
|
||||
{
|
||||
throw new AbTransportException(AbStatus.Timeout,
|
||||
$"Request to {_key.Host}:{_key.Port} did not complete within {ExchangeBudget.TotalMilliseconds:F0} ms.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One-shot timer, re-armed after every tick so the longest idle gap is one
|
||||
/// <see cref="ConnectionOptions.KeepAliveInterval"/> (half the connection expiry), not two.</summary>
|
||||
private void OnKeepAliveTimer()
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) != 0) return;
|
||||
var interval = _o.KeepAliveInterval;
|
||||
var open = Volatile.Read(ref _open);
|
||||
if (open is not { Client.IsConnected: true })
|
||||
{
|
||||
// Nothing to keep alive, and a keep-alive must never be what reopens a connection.
|
||||
Rearm(interval);
|
||||
return;
|
||||
}
|
||||
var idle = _time.GetElapsedTime(Interlocked.Read(ref _lastSendTimestamp));
|
||||
if (idle < interval)
|
||||
{
|
||||
Rearm(interval - idle);
|
||||
return;
|
||||
}
|
||||
Interlocked.Increment(ref _keepAlivesSent);
|
||||
_ = KeepAliveAsync(open);
|
||||
}
|
||||
|
||||
private async Task KeepAliveAsync(Open open)
|
||||
{
|
||||
try { await ExchangeAsync(open, KeepAliveRequest, CancellationToken.None).ConfigureAwait(false); }
|
||||
catch (Exception)
|
||||
{
|
||||
// A timeout or I/O failure has already faulted the session; the next real send reconnects
|
||||
// through the throttle. Nothing to report here: the keep-alive has no caller.
|
||||
}
|
||||
finally { Rearm(_o.KeepAliveInterval); }
|
||||
}
|
||||
|
||||
private void Rearm(TimeSpan due)
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) != 0) return;
|
||||
try { _keepAlive?.Change(due, Timeout.InfiniteTimeSpan); }
|
||||
catch (ObjectDisposedException) { /* disposed concurrently */ }
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) != 0)
|
||||
throw new AbTransportException(AbStatus.Abort, "Connection disposed.");
|
||||
}
|
||||
|
||||
/// <summary>Stops the keep-alive, sends Forward Close (Connected only) and closes the session, whose
|
||||
/// own dispose sends UnRegisterSession. Later sends throw <see cref="AbStatus.Abort"/>.</summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) return;
|
||||
|
||||
// Flag first: every caller still queued on _state sees it the moment it gets the lock and throws
|
||||
// Abort without opening, so this waits behind at most the one open already in progress (bounded
|
||||
// by OpenBudget), never behind a queue of them.
|
||||
Volatile.Write(ref _disposed, 1);
|
||||
await _state.WaitAsync().ConfigureAwait(false);
|
||||
Open? open;
|
||||
try
|
||||
{
|
||||
open = _open;
|
||||
Volatile.Write(ref _open, null);
|
||||
}
|
||||
finally { _state.Release(); }
|
||||
|
||||
if (_keepAlive is not null) await _keepAlive.DisposeAsync().ConfigureAwait(false);
|
||||
if (open is null) return;
|
||||
if (open.Conn is not null && open.Client.IsConnected)
|
||||
{
|
||||
try
|
||||
{
|
||||
var budget = TimeSpan.FromSeconds(1);
|
||||
using var cts = new CancellationTokenSource(budget);
|
||||
await open.Client.SendRRDataAsync(
|
||||
ConnectionManagerServices.ForwardClose(open.ConnectionSerial, _o.VendorId, _o.OriginatorSerial, _route),
|
||||
budget, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is AbTransportException or OperationCanceledException)
|
||||
{
|
||||
// The target expires the connection after RPI × multiplier anyway.
|
||||
}
|
||||
}
|
||||
await open.Client.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
|
||||
/// <summary>How CIP requests reach the target's message router (review finding B3; the captures decide
|
||||
/// each family's mode).</summary>
|
||||
public enum CipRouting
|
||||
{
|
||||
/// <summary>Forward Open a class-3 connection, then SendUnitData. Logix and Micro800 (empty route).</summary>
|
||||
Connected,
|
||||
|
||||
/// <summary>SendRRData wrapping each request in Unconnected Send (0x52) carrying the route.</summary>
|
||||
UnconnectedSend,
|
||||
|
||||
/// <summary>SendRRData carrying the request bare; any configured CIP path is ignored (PCCC families).</summary>
|
||||
Direct,
|
||||
}
|
||||
|
||||
/// <summary>Per-device connection settings. Validated on assignment so an unusable value fails at the
|
||||
/// configuration seam rather than on the wire.</summary>
|
||||
public sealed record ConnectionOptions
|
||||
{
|
||||
/// <summary>Press safety floor: no setting may let Forward Opens to one device start closer together.</summary>
|
||||
public static readonly TimeSpan MinimumForwardOpenInterval = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>Originator serial shared by every connection this process opens.</summary>
|
||||
private static readonly uint ProcessOriginatorSerial = (uint)Random.Shared.Next(1, int.MaxValue);
|
||||
|
||||
private readonly TimeSpan _timeout = TimeSpan.FromSeconds(5);
|
||||
private readonly int _connectionSize = 4002;
|
||||
private readonly TimeSpan _minForwardOpenInterval = MinimumForwardOpenInterval;
|
||||
private readonly uint _rpiMicros = 2_000_000;
|
||||
private readonly byte _timeoutMultiplier = 1;
|
||||
private readonly TimeSpan? _keepAliveInterval;
|
||||
|
||||
public CipRouting Routing { get; init; } = CipRouting.Connected;
|
||||
|
||||
/// <summary>Pack queued requests into Multiple Service Packets (the scheduler's switch).</summary>
|
||||
public bool AllowPacking { get; init; }
|
||||
|
||||
/// <summary>Bound on each single exchange (TCP connect, RegisterSession, Forward Open, request).
|
||||
/// Must be finite and positive (ruling R9-2): an infinite timeout would let an abandoned exchange
|
||||
/// hold the session's single-flight gate forever.</summary>
|
||||
public TimeSpan Timeout
|
||||
{
|
||||
get => _timeout;
|
||||
init => _timeout = value > TimeSpan.Zero && value != System.Threading.Timeout.InfiniteTimeSpan
|
||||
? value
|
||||
: throw new ArgumentOutOfRangeException(nameof(Timeout), value, "Timeout must be finite and positive.");
|
||||
}
|
||||
|
||||
/// <summary>Requested connection size in bytes. Above 511 the open is a Large Forward Open (0x5B).</summary>
|
||||
public int ConnectionSize
|
||||
{
|
||||
get => _connectionSize;
|
||||
init => _connectionSize = value is >= 1 and <= ushort.MaxValue
|
||||
? value
|
||||
: throw new ArgumentOutOfRangeException(nameof(ConnectionSize), value, "Connection size must be 1..65535.");
|
||||
}
|
||||
|
||||
/// <summary>Minimum time between Forward Open attempt starts for one device (≥ 1 s, press safety).</summary>
|
||||
public TimeSpan MinForwardOpenInterval
|
||||
{
|
||||
get => _minForwardOpenInterval;
|
||||
init => _minForwardOpenInterval = value >= MinimumForwardOpenInterval && value != System.Threading.Timeout.InfiniteTimeSpan
|
||||
? value
|
||||
: throw new ArgumentOutOfRangeException(nameof(MinForwardOpenInterval), value, "Must be finite and at least 1 s (press safety).");
|
||||
}
|
||||
|
||||
/// <summary>Requested packet interval for both directions, in microseconds.</summary>
|
||||
public uint RpiMicros
|
||||
{
|
||||
get => _rpiMicros;
|
||||
init => _rpiMicros = value > 0 ? value : throw new ArgumentOutOfRangeException(nameof(RpiMicros), value, "RPI must be positive.");
|
||||
}
|
||||
|
||||
/// <summary>Encoded connection timeout multiplier: the connection expires after RPI × (4 << value).
|
||||
/// The default 1 means ×8, so 2 s × 8 = 16 s.</summary>
|
||||
public byte TimeoutMultiplier
|
||||
{
|
||||
get => _timeoutMultiplier;
|
||||
init => _timeoutMultiplier = value <= 7 ? value : throw new ArgumentOutOfRangeException(nameof(TimeoutMultiplier), value, "Multiplier must be 0..7.");
|
||||
}
|
||||
|
||||
/// <summary>How long an idle connected session waits before the target would drop it.</summary>
|
||||
public TimeSpan ConnectionExpiry => TimeSpan.FromTicks(RpiMicros * 10L * (4L << TimeoutMultiplier));
|
||||
|
||||
/// <summary>Idle time after which a keep-alive is sent (Connected only). Defaults to half the
|
||||
/// connection expiry: RPI × 8 / 2 = 8 s.</summary>
|
||||
public TimeSpan KeepAliveInterval
|
||||
{
|
||||
get => _keepAliveInterval ?? ConnectionExpiry / 2;
|
||||
init => _keepAliveInterval = value > TimeSpan.Zero && value != System.Threading.Timeout.InfiniteTimeSpan
|
||||
? value
|
||||
: throw new ArgumentOutOfRangeException(nameof(KeepAliveInterval), value, "Keep-alive interval must be finite and positive.");
|
||||
}
|
||||
|
||||
public ushort VendorId { get; init; } = 0x1337;
|
||||
|
||||
/// <summary>Random per process; together with the vendor id and connection serial it names a connection.</summary>
|
||||
public uint OriginatorSerial { get; init; } = ProcessOriginatorSerial;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
|
||||
/// <summary>
|
||||
/// Identity of one physical CIP connection: exactly one <see cref="CipConnection"/> exists per key
|
||||
/// (press safety). <see cref="Host"/> and <see cref="PlcAttribute"/> compare case-insensitively;
|
||||
/// <see cref="Port"/> and <see cref="CipPath"/> compare exactly.
|
||||
/// </summary>
|
||||
public sealed record DeviceKey(string Host, int Port, string CipPath, string PlcAttribute)
|
||||
{
|
||||
public bool Equals(DeviceKey? other) =>
|
||||
other is not null
|
||||
&& string.Equals(Host, other.Host, StringComparison.OrdinalIgnoreCase)
|
||||
&& Port == other.Port
|
||||
&& string.Equals(CipPath, other.CipPath, StringComparison.Ordinal)
|
||||
&& string.Equals(PlcAttribute, other.PlcAttribute, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public override int GetHashCode() => HashCode.Combine(
|
||||
StringComparer.OrdinalIgnoreCase.GetHashCode(Host),
|
||||
Port,
|
||||
StringComparer.Ordinal.GetHashCode(CipPath),
|
||||
StringComparer.OrdinalIgnoreCase.GetHashCode(PlcAttribute));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
|
||||
/// <summary>Press safety: at most one Forward Open attempt in flight per device and a minimum
|
||||
/// interval between attempt starts. A misconfigured client once drove ~15 Forward Opens/s at a
|
||||
/// production press (ignitionoee vite-press-oee-bind.md, 2026-09-23); this is what prevents it.</summary>
|
||||
/// <remarks>One instance per <see cref="DeviceKey"/>, kept by <see cref="DeviceSessionPool"/> for the
|
||||
/// process lifetime so a reconnect after the last lease was released is still limited. Its semaphore is
|
||||
/// therefore never disposed. The interval is measured from the previous attempt's <i>start</i>, whatever
|
||||
/// that attempt's outcome.</remarks>
|
||||
public sealed class ForwardOpenThrottle
|
||||
{
|
||||
private readonly SemaphoreSlim _inFlight = new(1, 1);
|
||||
private readonly TimeSpan _minInterval;
|
||||
private readonly TimeProvider _time;
|
||||
private DateTimeOffset? _lastStart; // only touched while holding _inFlight
|
||||
|
||||
public ForwardOpenThrottle(TimeSpan minInterval, TimeProvider time)
|
||||
{
|
||||
if (minInterval < TimeSpan.Zero || minInterval == Timeout.InfiniteTimeSpan)
|
||||
throw new ArgumentOutOfRangeException(nameof(minInterval), minInterval, "Interval must be finite and non-negative.");
|
||||
_minInterval = minInterval;
|
||||
_time = time ?? throw new ArgumentNullException(nameof(time));
|
||||
}
|
||||
|
||||
/// <summary>Run one open attempt once the throttle allows it. Throws
|
||||
/// <see cref="AbTransportException"/>(<see cref="AbStatus.BadConnection"/>) when <paramref name="ct"/>
|
||||
/// fires before the attempt could start; the attempt itself sees <paramref name="ct"/> too.</summary>
|
||||
public async Task<T> RunAsync<T>(Func<CancellationToken, Task<T>> open, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(open);
|
||||
try { await _inFlight.WaitAsync(ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{ throw Throttled("another attempt is in flight"); }
|
||||
|
||||
try
|
||||
{
|
||||
if (_lastStart is { } last)
|
||||
{
|
||||
var wait = last + _minInterval - _time.GetUtcNow();
|
||||
if (wait > TimeSpan.Zero)
|
||||
{
|
||||
try { await Task.Delay(wait, _time, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{ throw Throttled("the minimum interval had not elapsed"); }
|
||||
}
|
||||
}
|
||||
_lastStart = _time.GetUtcNow();
|
||||
return await open(ct).ConfigureAwait(false);
|
||||
}
|
||||
finally { _inFlight.Release(); }
|
||||
}
|
||||
|
||||
private static AbTransportException Throttled(string why) =>
|
||||
new(AbStatus.BadConnection, $"Forward Open throttled; caller deadline reached before {why}.");
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
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)
|
||||
{
|
||||
public CipConnection Connection { get; } = connection;
|
||||
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 readonly ILogger _logger;
|
||||
|
||||
public DeviceSessionPool(TimeProvider? time = null, ILogger? logger = null)
|
||||
{
|
||||
_time = time ?? TimeProvider.System;
|
||||
_logger = logger ?? NullLogger.Instance;
|
||||
}
|
||||
|
||||
/// <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);
|
||||
var connection = new CipConnection(key, options, throttle, _time, predecessor);
|
||||
entry = new Entry(connection, new RequestScheduler(connection, options.AllowPacking), options);
|
||||
_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;
|
||||
lock (_lock)
|
||||
{
|
||||
if (--entry.RefCount > 0) return;
|
||||
_entries.Remove(key);
|
||||
closed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_closing[key] = closed.Task;
|
||||
}
|
||||
|
||||
// Outside the lock: scheduler first (fails its waiters, stops sending), then Forward Close +
|
||||
// UnRegisterSession via the connection.
|
||||
try
|
||||
{
|
||||
await entry.Scheduler.DisposeAsync().ConfigureAwait(false);
|
||||
await entry.Connection.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_closing.TryGetValue(key, out var t) && t == closed.Task) _closing.Remove(key);
|
||||
}
|
||||
closed.TrySetResult(); // never faults: a successor only needs to know the close is over
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
+3
@@ -15,5 +15,8 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests"/>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests"/>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests"/>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests"/>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user