Merge #566/#581/#583: per-pool transport diagnostics + per-device open-attempt limit, CIP-level close recycle, positive-control absence tests
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -47,10 +47,7 @@ internal static class LibplctagOrphanRelease
|
||||
private static int _released;
|
||||
|
||||
/// <summary>True when the pinned wrapper's private fields were found, so a failed tag can be destroyed.</summary>
|
||||
internal static bool CanReadHandles => HandleField is not null && !ForceRootingForTest;
|
||||
|
||||
/// <summary>Test seam (review r1 M1): take the rooting fallback as if the wrapper's fields had been renamed.</summary>
|
||||
internal static bool ForceRootingForTest { get; set; }
|
||||
internal static bool CanReadHandles => HandleField is not null;
|
||||
|
||||
/// <summary>Orphaned native tags destroyed so far (process-wide; diagnostics and tests).</summary>
|
||||
internal static int Released => Volatile.Read(ref _released);
|
||||
@@ -117,12 +114,19 @@ internal static class LibplctagOrphanRelease
|
||||
/// </summary>
|
||||
/// <returns>The status the native tag held just before it was destroyed (what <c>GetStatus()</c> would have
|
||||
/// reported), or null when nothing was destroyed.</returns>
|
||||
internal static Status? ReleaseIfOrphaned(Tag tag)
|
||||
internal static Status? ReleaseIfOrphaned(Tag tag) => ReleaseIfOrphaned(tag, CanReadHandles);
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ReleaseIfOrphaned(Tag)"/> with the handle-readability decision passed in, so a test can take the
|
||||
/// rooting fallback (review r1 M1) without a process-wide switch (Gitea #581). Production always passes
|
||||
/// <see cref="CanReadHandles"/>.
|
||||
/// </summary>
|
||||
internal static Status? ReleaseIfOrphaned(Tag tag, bool canReadHandles)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (tag.IsInitialized) return null;
|
||||
if (!CanReadHandles)
|
||||
if (!canReadHandles)
|
||||
{
|
||||
Rooted.Add(tag);
|
||||
return null;
|
||||
|
||||
@@ -10,7 +10,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
/// <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>
|
||||
/// faults the session (B2), so it never causes a reconnect. A connected reply saying the target no longer
|
||||
/// holds the class-3 connection (general 0x01, extended 0x0107 / 0x0203 — the session itself still up) retires
|
||||
/// the pair at once, whether a request or a keep-alive got it, so the next send reopens it (Gitea #566).</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
|
||||
@@ -21,70 +23,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
/// </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>Forward Close requests put on the wire by every connection in the process. Gate diagnostics.</summary>
|
||||
internal static int ForwardClosesSent;
|
||||
|
||||
/// <summary>Forward Close requests that got a reply within their budget. Gate diagnostics.</summary>
|
||||
internal static int ForwardClosesAcked;
|
||||
|
||||
/// <summary>Channel sends by every connection in the process (keep-alives included). Gate diagnostics.</summary>
|
||||
internal static int RequestsSent;
|
||||
|
||||
// Round-trip statistics of completed exchanges, process-wide (gate diagnostics: T21 latency attribution).
|
||||
// Two Stopwatch reads and four interlocked ops per request; reset by the harness between timed passes.
|
||||
private static long _exchangeCount;
|
||||
private static long _exchangeTotalTicks;
|
||||
private static long _exchangeMinTicks = long.MaxValue;
|
||||
private static long _exchangeMaxTicks;
|
||||
|
||||
/// <summary>Round-trip statistics of the exchanges completed since the last <see cref="ResetExchangeStats"/>.</summary>
|
||||
internal readonly record struct ExchangeStats(long Count, double MinMs, double MaxMs, double MeanMs);
|
||||
|
||||
/// <summary>Clears the process-wide round-trip statistics. Gate diagnostics.</summary>
|
||||
internal static void ResetExchangeStats()
|
||||
{
|
||||
Interlocked.Exchange(ref _exchangeCount, 0);
|
||||
Interlocked.Exchange(ref _exchangeTotalTicks, 0);
|
||||
Interlocked.Exchange(ref _exchangeMinTicks, long.MaxValue);
|
||||
Interlocked.Exchange(ref _exchangeMaxTicks, 0);
|
||||
}
|
||||
|
||||
/// <summary>Snapshot of the process-wide round-trip statistics. Gate diagnostics.</summary>
|
||||
internal static ExchangeStats ReadExchangeStats()
|
||||
{
|
||||
var count = Interlocked.Read(ref _exchangeCount);
|
||||
if (count == 0) return new ExchangeStats(0, 0, 0, 0);
|
||||
static double Ms(long ticks) => ticks * 1000.0 / Stopwatch.Frequency;
|
||||
return new ExchangeStats(count, Ms(Interlocked.Read(ref _exchangeMinTicks)), Ms(Interlocked.Read(ref _exchangeMaxTicks)),
|
||||
Ms(Interlocked.Read(ref _exchangeTotalTicks)) / count);
|
||||
}
|
||||
|
||||
private static void RecordExchange(long ticks)
|
||||
{
|
||||
Interlocked.Increment(ref _exchangeCount);
|
||||
Interlocked.Add(ref _exchangeTotalTicks, ticks);
|
||||
long seen;
|
||||
while (ticks < (seen = Interlocked.Read(ref _exchangeMinTicks))
|
||||
&& Interlocked.CompareExchange(ref _exchangeMinTicks, ticks, seen) != seen) { }
|
||||
while (ticks > (seen = Interlocked.Read(ref _exchangeMaxTicks))
|
||||
&& Interlocked.CompareExchange(ref _exchangeMaxTicks, ticks, seen) != seen) { }
|
||||
}
|
||||
|
||||
/// <summary>Open attempts (TCP connect + RegisterSession + Forward Open, a fallback open included) started by
|
||||
/// every connection in the process — counted before any I/O, so a refused attempt counts too. Gate diagnostics.</summary>
|
||||
internal static int OpenAttempts;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide cap on <see cref="OpenAttempts"/>; <c>-1</c> (the default) is unlimited. An attempt beyond
|
||||
/// the cap fails with <see cref="AbStatus.Abort"/> before any I/O — no TCP connect, no Forward Open. Only the
|
||||
/// read-only differential harness (Task 18/21) sets it, so a production press that refuses or drops the
|
||||
/// connection sees exactly one open attempt instead of one per remaining operation.
|
||||
/// </summary>
|
||||
internal static int OpenAttemptBudget = -1;
|
||||
|
||||
/// <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];
|
||||
|
||||
@@ -98,6 +36,7 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
private readonly ForwardOpenThrottle _throttle;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly Task _predecessorClosed;
|
||||
private readonly ConnectionDiagnostics _diagnostics;
|
||||
private readonly byte[] _route;
|
||||
|
||||
private readonly object _sync = new();
|
||||
@@ -119,9 +58,11 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
/// <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>
|
||||
/// <param name="diagnostics">The owning pool's counters and open-attempt limits; a private set when null.</param>
|
||||
internal CipConnection(DeviceKey key, ConnectionOptions options, ForwardOpenThrottle throttle, TimeProvider time,
|
||||
Task? predecessorClosed = null)
|
||||
Task? predecessorClosed = null, ConnectionDiagnostics? diagnostics = null)
|
||||
{
|
||||
_diagnostics = diagnostics ?? new ConnectionDiagnostics();
|
||||
_key = key ?? throw new ArgumentNullException(nameof(key));
|
||||
_o = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_throttle = throttle ?? throw new ArgumentNullException(nameof(throttle));
|
||||
@@ -141,6 +82,12 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
/// <summary>Test hook: keep-alives started (counted synchronously when the timer decides to send).</summary>
|
||||
internal int KeepAlivesSent => Volatile.Read(ref _keepAlivesSent);
|
||||
|
||||
/// <summary>This connection's counters (its pool's, when it has one).</summary>
|
||||
internal ConnectionDiagnostics Diagnostics => _diagnostics;
|
||||
|
||||
/// <summary>Test hook: true while a shared open is in progress.</summary>
|
||||
internal bool OpenInProgress { get { lock (_sync) return _pendingOpen is not null; } }
|
||||
|
||||
private TimeSpan OpenBudget => _o.MinForwardOpenInterval + 4 * _o.Timeout;
|
||||
private TimeSpan ExchangeBudget => 2 * _o.Timeout;
|
||||
|
||||
@@ -210,6 +157,7 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
Volatile.Write(ref _open, null);
|
||||
}
|
||||
if (stale is not null) await stale.Client.DisposeAsync().ConfigureAwait(false); // faulted: no UnRegister
|
||||
if (!_predecessorClosed.IsCompleted) _diagnostics.CountPredecessorWait();
|
||||
await _predecessorClosed.WaitAsync(beforeAttempt.Token).ConfigureAwait(false);
|
||||
opened = await _throttle.RunAsync(_ => OpenAsync(deadline.Token), beforeAttempt.Token).ConfigureAwait(false);
|
||||
}
|
||||
@@ -235,11 +183,7 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
private async Task<Open> OpenAsync(CancellationToken ct)
|
||||
{
|
||||
ThrowIfDisposed(); // disposal may have begun while this attempt waited out the throttle
|
||||
var attempt = Interlocked.Increment(ref OpenAttempts);
|
||||
var budget = Volatile.Read(ref OpenAttemptBudget);
|
||||
if (budget >= 0 && attempt > budget)
|
||||
throw new AbTransportException(AbStatus.Abort,
|
||||
$"Open attempt {attempt} to {_key.Host}:{_key.Port} refused before any I/O: the process-wide open-attempt budget of {budget} is spent.");
|
||||
if (_diagnostics.AdmitOpenAttempt(_key) is { } refused) throw new AbTransportException(AbStatus.Abort, refused);
|
||||
var client = await EncapsulationClient.ConnectAsync(_key.Host, _key.Port, _o.Timeout, ct).ConfigureAwait(false);
|
||||
if (Volatile.Read(ref _disposed) != 0)
|
||||
{
|
||||
@@ -276,7 +220,7 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
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);
|
||||
_diagnostics.CountForwardOpen();
|
||||
return CipReply.Parse(await client.SendRRDataAsync(frame, _o.Timeout, ct).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
@@ -289,7 +233,7 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
/// <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);
|
||||
_diagnostics.CountRequest();
|
||||
Interlocked.Exchange(ref _lastSendTimestamp, _time.GetTimestamp());
|
||||
using var deadline = new CancellationTokenSource(ExchangeBudget, _time);
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, deadline.Token);
|
||||
@@ -302,7 +246,14 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
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),
|
||||
};
|
||||
RecordExchange(Stopwatch.GetTimestamp() - started);
|
||||
_diagnostics.RecordExchange(Stopwatch.GetTimestamp() - started);
|
||||
if (_o.Routing == CipRouting.Connected && ConnectionManagerServices.IsConnectionGone(reply.Span) is { } extended)
|
||||
{
|
||||
await RetireClosedByTargetAsync(open).ConfigureAwait(false);
|
||||
throw new AbTransportException(AbStatus.BadConnection,
|
||||
$"{_key.Host}:{_key.Port} no longer holds the CIP connection (general status 0x01, extended 0x{extended:X4}); " +
|
||||
"the session was closed and the next request reopens it.");
|
||||
}
|
||||
return reply;
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested && deadline.IsCancellationRequested)
|
||||
@@ -312,6 +263,24 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The target answered over a session that is still up, but says the class-3 connection itself is gone (it
|
||||
/// timed the connection out, or was restarted / re-downloaded and forgot it). Every later connected request
|
||||
/// would get the same answer while <see cref="EncapsulationClient.IsConnected"/> stays true, so nothing else
|
||||
/// would ever reopen it (Gitea #566): retire the pair now, so the next send reopens through the throttle.
|
||||
/// No Forward Close — the target has just said there is nothing to close.
|
||||
/// </summary>
|
||||
private async Task RetireClosedByTargetAsync(Open open)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (!ReferenceEquals(_open, open)) return; // a concurrent exchange (keep-alive) already retired it
|
||||
Volatile.Write(ref _open, null);
|
||||
}
|
||||
_diagnostics.CountClosedByTarget();
|
||||
await open.Client.DisposeAsync().ConfigureAwait(false); // UnRegisterSession, then close the socket
|
||||
}
|
||||
|
||||
/// <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()
|
||||
@@ -396,11 +365,11 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
{
|
||||
var budget = TimeSpan.FromSeconds(1);
|
||||
using var cts = new CancellationTokenSource(budget, _time);
|
||||
Interlocked.Increment(ref ForwardClosesSent);
|
||||
_diagnostics.CountForwardCloseSent();
|
||||
await open.Client.SendRRDataAsync(
|
||||
ConnectionManagerServices.ForwardClose(open.ConnectionSerial, _o.VendorId, _o.OriginatorSerial, _route),
|
||||
budget, cts.Token).ConfigureAwait(false);
|
||||
Interlocked.Increment(ref ForwardClosesAcked);
|
||||
_diagnostics.CountForwardCloseAcked();
|
||||
}
|
||||
catch (Exception ex) when (ex is AbTransportException or OperationCanceledException)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
using System.Diagnostics;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
|
||||
/// <summary>
|
||||
/// Counters and the open-attempt limit for the <see cref="CipConnection"/>s one <see cref="DeviceSessionPool"/>
|
||||
/// owns. Gate and harness diagnostics: nothing in production reads them, and a limit is only ever set by the
|
||||
/// read-only differential harness.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Instance-scoped (Gitea #581): every pool — and every connection a test builds on its own — has its own, so
|
||||
/// no process-wide mutable state ships in the transport, and two tests can never move each other's counts. The
|
||||
/// open-attempt limit is scoped to one <see cref="DeviceKey"/> (Gitea #566), so limiting the press under test
|
||||
/// can never refuse an open to any other device in the process.
|
||||
/// </remarks>
|
||||
internal sealed class ConnectionDiagnostics
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly Dictionary<DeviceKey, OpenAttemptLimit> _limits = new();
|
||||
|
||||
private int _forwardOpens;
|
||||
private int _forwardClosesSent;
|
||||
private int _forwardClosesAcked;
|
||||
private int _requestsSent;
|
||||
private int _openAttempts;
|
||||
private int _predecessorWaits;
|
||||
private int _connectionsClosedByTarget;
|
||||
|
||||
// Round-trip statistics of completed exchanges (T21 latency attribution). Two Stopwatch reads and four
|
||||
// interlocked ops per request; reset by the harness between timed passes.
|
||||
private long _exchangeCount;
|
||||
private long _exchangeTotalTicks;
|
||||
private long _exchangeMinTicks = long.MaxValue;
|
||||
private long _exchangeMaxTicks;
|
||||
|
||||
/// <summary>Forward Open frames sent (fallback included).</summary>
|
||||
public int ForwardOpens => Volatile.Read(ref _forwardOpens);
|
||||
|
||||
/// <summary>Forward Close requests put on the wire.</summary>
|
||||
public int ForwardClosesSent => Volatile.Read(ref _forwardClosesSent);
|
||||
|
||||
/// <summary>Forward Close requests that got a reply within their budget.</summary>
|
||||
public int ForwardClosesAcked => Volatile.Read(ref _forwardClosesAcked);
|
||||
|
||||
/// <summary>Channel sends (keep-alives included).</summary>
|
||||
public int RequestsSent => Volatile.Read(ref _requestsSent);
|
||||
|
||||
/// <summary>Open attempts (TCP connect + RegisterSession + Forward Open, a fallback open included), counted
|
||||
/// before any I/O, so an attempt refused by a limit counts too.</summary>
|
||||
public int OpenAttempts => Volatile.Read(ref _openAttempts);
|
||||
|
||||
/// <summary>Opens that found an earlier connection for the same device still closing and parked behind it.</summary>
|
||||
public int PredecessorWaits => Volatile.Read(ref _predecessorWaits);
|
||||
|
||||
/// <summary>Connections retired because the target reported the CIP connection itself gone (TCP still up).</summary>
|
||||
public int ConnectionsClosedByTarget => Volatile.Read(ref _connectionsClosedByTarget);
|
||||
|
||||
/// <summary>Zeroes the Forward Open / Forward Close counters (the harness, before its native phase).</summary>
|
||||
public void ResetForwardCounts()
|
||||
{
|
||||
Interlocked.Exchange(ref _forwardOpens, 0);
|
||||
Interlocked.Exchange(ref _forwardClosesSent, 0);
|
||||
Interlocked.Exchange(ref _forwardClosesAcked, 0);
|
||||
}
|
||||
|
||||
internal void CountForwardOpen() => Interlocked.Increment(ref _forwardOpens);
|
||||
internal void CountForwardCloseSent() => Interlocked.Increment(ref _forwardClosesSent);
|
||||
internal void CountForwardCloseAcked() => Interlocked.Increment(ref _forwardClosesAcked);
|
||||
internal void CountRequest() => Interlocked.Increment(ref _requestsSent);
|
||||
internal void CountPredecessorWait() => Interlocked.Increment(ref _predecessorWaits);
|
||||
internal void CountClosedByTarget() => Interlocked.Increment(ref _connectionsClosedByTarget);
|
||||
|
||||
/// <summary>Round-trip statistics of the exchanges completed since the last <see cref="ResetExchangeStats"/>.</summary>
|
||||
internal readonly record struct ExchangeStats(long Count, double MinMs, double MaxMs, double MeanMs);
|
||||
|
||||
/// <summary>Clears the round-trip statistics.</summary>
|
||||
public void ResetExchangeStats()
|
||||
{
|
||||
Interlocked.Exchange(ref _exchangeCount, 0);
|
||||
Interlocked.Exchange(ref _exchangeTotalTicks, 0);
|
||||
Interlocked.Exchange(ref _exchangeMinTicks, long.MaxValue);
|
||||
Interlocked.Exchange(ref _exchangeMaxTicks, 0);
|
||||
}
|
||||
|
||||
/// <summary>Snapshot of the round-trip statistics.</summary>
|
||||
public ExchangeStats ReadExchangeStats()
|
||||
{
|
||||
var count = Interlocked.Read(ref _exchangeCount);
|
||||
if (count == 0) return new ExchangeStats(0, 0, 0, 0);
|
||||
static double Ms(long ticks) => ticks * 1000.0 / Stopwatch.Frequency;
|
||||
return new ExchangeStats(count, Ms(Interlocked.Read(ref _exchangeMinTicks)), Ms(Interlocked.Read(ref _exchangeMaxTicks)),
|
||||
Ms(Interlocked.Read(ref _exchangeTotalTicks)) / count);
|
||||
}
|
||||
|
||||
internal void RecordExchange(long ticks)
|
||||
{
|
||||
Interlocked.Increment(ref _exchangeCount);
|
||||
Interlocked.Add(ref _exchangeTotalTicks, ticks);
|
||||
long seen;
|
||||
while (ticks < (seen = Interlocked.Read(ref _exchangeMinTicks))
|
||||
&& Interlocked.CompareExchange(ref _exchangeMinTicks, ticks, seen) != seen) { }
|
||||
while (ticks > (seen = Interlocked.Read(ref _exchangeMaxTicks))
|
||||
&& Interlocked.CompareExchange(ref _exchangeMaxTicks, ticks, seen) != seen) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Caps the open attempts to <paramref name="key"/> at <paramref name="attempts"/> until the returned scope is
|
||||
/// disposed. An attempt beyond the cap fails with <see cref="AbStatus.Abort"/> before any I/O — no TCP connect,
|
||||
/// no Forward Open — so a press that refuses or drops the connection sees exactly the budgeted attempts instead
|
||||
/// of one per remaining operation. Only the read-only differential harness (Task 18/21) sets one; one limit per
|
||||
/// key at a time.
|
||||
/// </summary>
|
||||
public OpenAttemptLimit LimitOpenAttempts(DeviceKey key, int attempts)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(attempts);
|
||||
var limit = new OpenAttemptLimit(this, key, attempts);
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_limits.TryAdd(key, limit))
|
||||
throw new InvalidOperationException($"An open-attempt limit for {key.Host}:{key.Port} is already in force.");
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
/// <summary>True while a limit is in force for <paramref name="key"/>.</summary>
|
||||
public bool HasOpenAttemptLimit(DeviceKey key) { lock (_lock) return _limits.ContainsKey(key); }
|
||||
|
||||
/// <summary>Counts one open attempt to <paramref name="key"/>; null when it may proceed, else why it may not.</summary>
|
||||
internal string? AdmitOpenAttempt(DeviceKey key)
|
||||
{
|
||||
var attempt = Interlocked.Increment(ref _openAttempts);
|
||||
OpenAttemptLimit? limit;
|
||||
lock (_lock) _limits.TryGetValue(key, out limit);
|
||||
return limit is null || limit.TryTake()
|
||||
? null
|
||||
: $"Open attempt {attempt} to {key.Host}:{key.Port} refused before any I/O: the open-attempt budget of {limit.Budget} for this device is spent.";
|
||||
}
|
||||
|
||||
private void Remove(OpenAttemptLimit limit)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_limits.TryGetValue(limit.Key, out var current) && current == limit) _limits.Remove(limit.Key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An open-attempt cap on one device; disposing it lifts the cap.</summary>
|
||||
internal sealed class OpenAttemptLimit(ConnectionDiagnostics owner, DeviceKey key, int budget) : IDisposable
|
||||
{
|
||||
private int _attempts;
|
||||
|
||||
public DeviceKey Key { get; } = key;
|
||||
|
||||
public int Budget { get; } = budget;
|
||||
|
||||
/// <summary>Attempts counted against this limit, the refused ones included.</summary>
|
||||
public int Attempts => Volatile.Read(ref _attempts);
|
||||
|
||||
internal bool TryTake() => Interlocked.Increment(ref _attempts) <= Budget;
|
||||
|
||||
public void Dispose() => owner.Remove(this);
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,26 @@ public static class ConnectionManagerServices
|
||||
r.GeneralStatus == 0x08
|
||||
|| (r.GeneralStatus == 0x01 && r.ExtendedStatus.Length > 0 && r.ExtendedStatus[0] == 0x0109);
|
||||
|
||||
/// <summary>Connection Manager extended status: target connection not found.</summary>
|
||||
public const ushort TargetConnectionNotFound = 0x0107;
|
||||
|
||||
/// <summary>Connection Manager extended status: the connection timed out.</summary>
|
||||
public const ushort ConnectionTimedOut = 0x0203;
|
||||
|
||||
/// <summary>
|
||||
/// For a reply received over a class-3 connection: the extended status when the target says the connection
|
||||
/// itself no longer exists — general 0x01 (Connection Failure) with extended 0x0107 (target connection not
|
||||
/// found) or 0x0203 (connection timed out) — else null. Any other status, a tag-level one included, describes
|
||||
/// the request and leaves the connection alone.
|
||||
/// </summary>
|
||||
public static ushort? IsConnectionGone(ReadOnlySpan<byte> reply)
|
||||
{
|
||||
// Reply header: service | reserved | general status | extended-status word count | words...
|
||||
if (reply.Length < 6 || reply[2] != 0x01 || reply[3] == 0) return null;
|
||||
var extended = (ushort)(reply[4] | (reply[5] << 8));
|
||||
return extended is TargetConnectionNotFound or ConnectionTimedOut ? extended : null;
|
||||
}
|
||||
|
||||
public static byte[] ForwardClose(ushort connectionSerial, ushort vendorId, uint originatorSerial, ReadOnlySpan<byte> route)
|
||||
{
|
||||
var connPath = new CipWriter().Bytes(route).Bytes(CipPath.MessageRouter).ToArray();
|
||||
|
||||
@@ -13,6 +13,8 @@ public sealed class ForwardOpenThrottle
|
||||
private readonly TimeSpan _minInterval;
|
||||
private readonly TimeProvider _time;
|
||||
private long? _lastStartTimestamp; // monotonic (TimeProvider.GetTimestamp); only touched while holding _inFlight
|
||||
private int _queued;
|
||||
private Task? _intervalWait; // the pending interval wait (test hook only)
|
||||
|
||||
/// <remarks>Internal: the only legitimate owner is <see cref="DeviceSessionPool"/>, which keeps exactly
|
||||
/// one per device. A second throttle for the same device would halve the guaranteed interval.</remarks>
|
||||
@@ -24,6 +26,13 @@ public sealed class ForwardOpenThrottle
|
||||
_time = time ?? throw new ArgumentNullException(nameof(time));
|
||||
}
|
||||
|
||||
/// <summary>Test hook: callers waiting behind an in-flight attempt.</summary>
|
||||
internal int Queued => Volatile.Read(ref _queued);
|
||||
|
||||
/// <summary>Test hook: true while the next attempt is parked on the minimum interval and that wait has not
|
||||
/// yet elapsed — no attempt is then in flight, and none can start until the clock moves.</summary>
|
||||
internal bool IsWaitingOutInterval => Volatile.Read(ref _intervalWait) is { IsCompleted: false };
|
||||
|
||||
/// <summary>Run one open attempt once the throttle allows it. <paramref name="ct"/> bounds only the wait
|
||||
/// BEFORE the attempt (queued behind an in-flight attempt, or the interval): when it fires there, this
|
||||
/// throws <see cref="AbTransportException"/>(<see cref="AbStatus.BadConnection"/>, "Forward Open
|
||||
@@ -33,9 +42,11 @@ public sealed class ForwardOpenThrottle
|
||||
public async Task<T> RunAsync<T>(Func<CancellationToken, Task<T>> open, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(open);
|
||||
Interlocked.Increment(ref _queued);
|
||||
try { await _inFlight.WaitAsync(ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{ throw Throttled("another attempt is in flight"); }
|
||||
finally { Interlocked.Decrement(ref _queued); }
|
||||
|
||||
try
|
||||
{
|
||||
@@ -45,9 +56,12 @@ public sealed class ForwardOpenThrottle
|
||||
var wait = _minInterval - _time.GetElapsedTime(last);
|
||||
if (wait > TimeSpan.Zero)
|
||||
{
|
||||
try { await Task.Delay(wait, _time, ct).ConfigureAwait(false); }
|
||||
var delay = Task.Delay(wait, _time, ct);
|
||||
Volatile.Write(ref _intervalWait, delay);
|
||||
try { await delay.ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{ throw Throttled("the minimum interval had not elapsed"); }
|
||||
finally { Volatile.Write(ref _intervalWait, null); }
|
||||
}
|
||||
}
|
||||
_lastStartTimestamp = _time.GetTimestamp();
|
||||
|
||||
@@ -52,6 +52,9 @@ public sealed class 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; } }
|
||||
|
||||
@@ -69,7 +72,7 @@ public sealed class DeviceSessionPool
|
||||
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);
|
||||
var connection = new CipConnection(key, options, throttle, _time, predecessor, Diagnostics);
|
||||
entry = new Entry(connection, new RequestScheduler(connection, options.AllowPacking), options, predecessor);
|
||||
_entries[key] = entry;
|
||||
}
|
||||
@@ -109,28 +112,36 @@ public sealed class DeviceSessionPool
|
||||
}
|
||||
}, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
|
||||
|
||||
// Outside the lock: 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.
|
||||
// Outside the lock.
|
||||
try
|
||||
{
|
||||
try { await entry.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 entry.Connection.DisposeAsync().ConfigureAwait(false); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Closing the connection to {Host}:{Port} failed.", key.Host, key.Port);
|
||||
}
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user