826312befe
Clears CommentChecker findings in src/ (3,280 -> 209, the remainder being false positives like PLC-5/IEEE-754/M0.0) by adding missing summaries/params/returns, normalising inheritdoc usage, and removing Gitea/review tracking IDs while keeping the rationale they annotated. Comment-only: comment-stripped sources are identical to the parent commit.
203 lines
10 KiB
C#
203 lines
10 KiB
C#
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: 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"/>, 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);
|
|
}
|
|
|
|
/// <summary>Counts one Forward Open frame sent.</summary>
|
|
internal void CountForwardOpen() => Interlocked.Increment(ref _forwardOpens);
|
|
/// <summary>Counts one Forward Close request put on the wire.</summary>
|
|
internal void CountForwardCloseSent() => Interlocked.Increment(ref _forwardClosesSent);
|
|
/// <summary>Counts one Forward Close that was acknowledged within its budget.</summary>
|
|
internal void CountForwardCloseAcked() => Interlocked.Increment(ref _forwardClosesAcked);
|
|
/// <summary>Counts one channel send.</summary>
|
|
internal void CountRequest() => Interlocked.Increment(ref _requestsSent);
|
|
/// <summary>Counts one open parked behind a still-closing predecessor.</summary>
|
|
internal void CountPredecessorWait() => Interlocked.Increment(ref _predecessorWaits);
|
|
/// <summary>Counts one connection retired because the target closed the CIP connection.</summary>
|
|
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>
|
|
/// <returns>The count and min/max/mean round-trip times in milliseconds.</returns>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Evidence tap for the gate harness: sees the CIP request and the raw CIP reply of every
|
|
/// completed exchange, Forward Opens included (their replies carry the granted RPIs). Null in production; set only by the read-only differential harness. A throwing
|
|
/// observer is swallowed so it can never affect a request.
|
|
/// </summary>
|
|
internal Action<ReadOnlyMemory<byte>, ReadOnlyMemory<byte>>? ExchangeObserver { get; set; }
|
|
|
|
/// <summary>Passes a completed exchange to <see cref="ExchangeObserver"/>, swallowing any observer fault.</summary>
|
|
/// <param name="request">The CIP request bytes.</param>
|
|
/// <param name="reply">The raw CIP reply bytes.</param>
|
|
internal void NotifyExchange(ReadOnlyMemory<byte> request, ReadOnlyMemory<byte> reply)
|
|
{
|
|
var observer = ExchangeObserver;
|
|
if (observer is null) return;
|
|
try { observer(request, reply); }
|
|
catch { /* evidence only */ }
|
|
}
|
|
|
|
/// <summary>Records the round-trip time of one completed exchange.</summary>
|
|
/// <param name="ticks">The elapsed Stopwatch ticks.</param>
|
|
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 sets one; one limit per
|
|
/// key at a time.
|
|
/// </summary>
|
|
/// <param name="key">The device whose open attempts are capped.</param>
|
|
/// <param name="attempts">The number of open attempts allowed.</param>
|
|
/// <returns>A scope that lifts the cap when disposed.</returns>
|
|
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>
|
|
/// <param name="key">The device to check.</param>
|
|
/// <returns><see langword="true"/> when a limit is in force; otherwise <see langword="false"/>.</returns>
|
|
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>
|
|
/// <param name="key">The device being opened.</param>
|
|
/// <returns>Null when the attempt may proceed; otherwise the refusal reason.</returns>
|
|
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;
|
|
|
|
/// <summary>The device this limit applies to.</summary>
|
|
public DeviceKey Key { get; } = key;
|
|
|
|
/// <summary>The number of open attempts allowed.</summary>
|
|
public int Budget { get; } = budget;
|
|
|
|
/// <summary>Attempts counted against this limit, the refused ones included.</summary>
|
|
public int Attempts => Volatile.Read(ref _attempts);
|
|
|
|
/// <summary>Counts one attempt against the budget.</summary>
|
|
/// <returns><see langword="true"/> while the attempt is within the budget.</returns>
|
|
internal bool TryTake() => Interlocked.Increment(ref _attempts) <= Budget;
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose() => owner.Remove(this);
|
|
}
|
|
}
|