Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionDiagnostics.cs
T
Joseph Doherty 826312befe docs: fill missing XML docs and strip tracking IDs from src comments
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.
2026-10-15 06:14:58 -04:00

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);
}
}