From 3edff3304b24546b6c311b5685f1ab645831394b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 7 Oct 2026 23:21:02 -0400 Subject: [PATCH] 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 --- docs/drivers/AbCip.md | 10 + .../LibplctagOrphanRelease.cs | 16 +- .../Cip/CipConnection.cs | 121 ++++------ .../Cip/ConnectionDiagnostics.cs | 163 +++++++++++++ .../Cip/ConnectionManagerServices.cs | 20 ++ .../Cip/ForwardOpenThrottle.cs | 16 +- .../DeviceSessionPool.cs | 41 ++-- .../Cip/CipConnectionTests.cs | 218 ++++++++++++++++-- .../Cip/ForwardOpenThrottleTests.cs | 12 +- .../DeviceSessionPoolTests.cs | 72 +++++- .../Eip/EncapsulationClientTests.cs | 26 +-- .../Fakes/FakeEipServer.cs | 4 + .../Fakes/UnobservedTaskProbe.cs | 54 +++++ .../Differential/DifferentialRun.cs | 33 ++- .../Differential/DifferentialSafetyTests.cs | 39 +++- .../PollLatencyComparisonTests.cs | 2 +- .../Differential/PollLatencyRun.cs | 16 +- .../LibplctagOrphanReleaseTests.cs | 20 +- 18 files changed, 695 insertions(+), 188 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionDiagnostics.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/UnobservedTaskProbe.cs diff --git a/docs/drivers/AbCip.md b/docs/drivers/AbCip.md index af3f0497..2a8696b9 100644 --- a/docs/drivers/AbCip.md +++ b/docs/drivers/AbCip.md @@ -172,6 +172,16 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi two types into one value. - **Forward Open throttle:** at most one attempt in flight, at least 1 s between starts, bounded by the open budget. +- **A target-closed CIP connection is recycled at once (#566).** When a connected reply says the controller + no longer holds the class-3 connection while TCP stays up — general status `0x01` with extended `0x0107` + (target connection not found) or `0x0203` (connection timed out), e.g. after a controller restart or a + connection timeout — the session is closed (UnRegisterSession; no Forward Close, there is nothing to close) + and the next request reopens through the throttle. The request that got the reply fails + `BadCommunicationError` (transport `BadConnection`); a keep-alive that gets it recycles the connection + before any request fails. Every other status — tag-level errors, other `0x01` extended codes — is left to + the request. ⚠️ RESIDUAL: which of the two codes a real Logix controller returns for a forgotten + connection (and whether it answers at all rather than dropping TCP) is unobserved; the check is correct + whichever it does — a silent target still faults the session through the exchange timeout. - `ConnectionSize` / `Timeout` / `AllowPacking` are honoured per pooled connection, and the **first** driver/device to open a given device key wins — including a Test Connect probe on a fused `admin,driver` node (its connection carries the probe timeout). A later opener with different options is ignored and diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared/LibplctagOrphanRelease.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared/LibplctagOrphanRelease.cs index 5ac1f8d1..e484bf50 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared/LibplctagOrphanRelease.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared/LibplctagOrphanRelease.cs @@ -47,10 +47,7 @@ internal static class LibplctagOrphanRelease private static int _released; /// True when the pinned wrapper's private fields were found, so a failed tag can be destroyed. - internal static bool CanReadHandles => HandleField is not null && !ForceRootingForTest; - - /// Test seam (review r1 M1): take the rooting fallback as if the wrapper's fields had been renamed. - internal static bool ForceRootingForTest { get; set; } + internal static bool CanReadHandles => HandleField is not null; /// Orphaned native tags destroyed so far (process-wide; diagnostics and tests). internal static int Released => Volatile.Read(ref _released); @@ -117,12 +114,19 @@ internal static class LibplctagOrphanRelease /// /// The status the native tag held just before it was destroyed (what GetStatus() would have /// reported), or null when nothing was destroyed. - internal static Status? ReleaseIfOrphaned(Tag tag) + internal static Status? ReleaseIfOrphaned(Tag tag) => ReleaseIfOrphaned(tag, CanReadHandles); + + /// + /// 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 + /// . + /// + internal static Status? ReleaseIfOrphaned(Tag tag, bool canReadHandles) { try { if (tag.IsInitialized) return null; - if (!CanReadHandles) + if (!canReadHandles) { Rooted.Add(tag); return null; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs index 5004d731..eb0fa39b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs @@ -10,7 +10,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; /// /// Reconnect. Only a faulted session (timeout, I/O error, malformed frame) is reopened, and /// only by the next send; the failing call throws its own exception. Caller cancellation never -/// faults the session (B2), so it never causes a reconnect. +/// 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). /// Bounded. Every 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 /// MinForwardOpenInterval + 4 × Timeout (connect, RegisterSession, Forward Open, fallback Forward @@ -21,70 +23,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; /// public sealed class CipConnection : ICipChannel, IAsyncDisposable { - /// Forward Open frames sent by every connection in the process (fallback included). Gate diagnostics. - internal static int ForwardOpenCount; - - /// Forward Close requests put on the wire by every connection in the process. Gate diagnostics. - internal static int ForwardClosesSent; - - /// Forward Close requests that got a reply within their budget. Gate diagnostics. - internal static int ForwardClosesAcked; - - /// Channel sends by every connection in the process (keep-alives included). Gate diagnostics. - 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; - - /// Round-trip statistics of the exchanges completed since the last . - internal readonly record struct ExchangeStats(long Count, double MinMs, double MaxMs, double MeanMs); - - /// Clears the process-wide round-trip statistics. Gate diagnostics. - 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); - } - - /// Snapshot of the process-wide round-trip statistics. Gate diagnostics. - 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) { } - } - - /// 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. - internal static int OpenAttempts; - - /// - /// Process-wide cap on ; -1 (the default) is unlimited. An attempt beyond - /// the cap fails with 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. - /// - internal static int OpenAttemptBudget = -1; - /// Get Attribute Single, Identity (class 1) instance 1 attribute 1 (Vendor ID). 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 /// Clock for the throttle, deadlines and keep-alive. /// Completes when the previous connection for the same key has /// finished closing, so two connections to one device never overlap. + /// The owning pool's counters and open-attempt limits; a private set when null. 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 /// Test hook: keep-alives started (counted synchronously when the timer decides to send). internal int KeepAlivesSent => Volatile.Read(ref _keepAlivesSent); + /// This connection's counters (its pool's, when it has one). + internal ConnectionDiagnostics Diagnostics => _diagnostics; + + /// Test hook: true while a shared open is in progress. + 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 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 /// One request over an established pair, bounded by . private async Task> ExchangeAsync(Open open, ReadOnlyMemory 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 } } + /// + /// 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 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. + /// + 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 + } + /// One-shot timer, re-armed after every tick so the longest idle gap is one /// (half the connection expiry), not two. 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) { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionDiagnostics.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionDiagnostics.cs new file mode 100644 index 00000000..f89cf3f3 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionDiagnostics.cs @@ -0,0 +1,163 @@ +using System.Diagnostics; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; + +/// +/// Counters and the open-attempt limit for the s one +/// owns. Gate and harness diagnostics: nothing in production reads them, and a limit is only ever set by the +/// read-only differential harness. +/// +/// +/// 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 (Gitea #566), so limiting the press under test +/// can never refuse an open to any other device in the process. +/// +internal sealed class ConnectionDiagnostics +{ + private readonly object _lock = new(); + private readonly Dictionary _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; + + /// Forward Open frames sent (fallback included). + public int ForwardOpens => Volatile.Read(ref _forwardOpens); + + /// Forward Close requests put on the wire. + public int ForwardClosesSent => Volatile.Read(ref _forwardClosesSent); + + /// Forward Close requests that got a reply within their budget. + public int ForwardClosesAcked => Volatile.Read(ref _forwardClosesAcked); + + /// Channel sends (keep-alives included). + public int RequestsSent => Volatile.Read(ref _requestsSent); + + /// 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. + public int OpenAttempts => Volatile.Read(ref _openAttempts); + + /// Opens that found an earlier connection for the same device still closing and parked behind it. + public int PredecessorWaits => Volatile.Read(ref _predecessorWaits); + + /// Connections retired because the target reported the CIP connection itself gone (TCP still up). + public int ConnectionsClosedByTarget => Volatile.Read(ref _connectionsClosedByTarget); + + /// Zeroes the Forward Open / Forward Close counters (the harness, before its native phase). + 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); + + /// Round-trip statistics of the exchanges completed since the last . + internal readonly record struct ExchangeStats(long Count, double MinMs, double MaxMs, double MeanMs); + + /// Clears the round-trip statistics. + public void ResetExchangeStats() + { + Interlocked.Exchange(ref _exchangeCount, 0); + Interlocked.Exchange(ref _exchangeTotalTicks, 0); + Interlocked.Exchange(ref _exchangeMinTicks, long.MaxValue); + Interlocked.Exchange(ref _exchangeMaxTicks, 0); + } + + /// Snapshot of the round-trip statistics. + 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) { } + } + + /// + /// Caps the open attempts to at until the returned scope is + /// disposed. An attempt beyond the cap fails with 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. + /// + 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; + } + + /// True while a limit is in force for . + public bool HasOpenAttemptLimit(DeviceKey key) { lock (_lock) return _limits.ContainsKey(key); } + + /// Counts one open attempt to ; null when it may proceed, else why it may not. + 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); + } + } + + /// An open-attempt cap on one device; disposing it lifts the cap. + internal sealed class OpenAttemptLimit(ConnectionDiagnostics owner, DeviceKey key, int budget) : IDisposable + { + private int _attempts; + + public DeviceKey Key { get; } = key; + + public int Budget { get; } = budget; + + /// Attempts counted against this limit, the refused ones included. + public int Attempts => Volatile.Read(ref _attempts); + + internal bool TryTake() => Interlocked.Increment(ref _attempts) <= Budget; + + public void Dispose() => owner.Remove(this); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionManagerServices.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionManagerServices.cs index 58235c4c..96dcd63c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionManagerServices.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionManagerServices.cs @@ -83,6 +83,26 @@ public static class ConnectionManagerServices r.GeneralStatus == 0x08 || (r.GeneralStatus == 0x01 && r.ExtendedStatus.Length > 0 && r.ExtendedStatus[0] == 0x0109); + /// Connection Manager extended status: target connection not found. + public const ushort TargetConnectionNotFound = 0x0107; + + /// Connection Manager extended status: the connection timed out. + public const ushort ConnectionTimedOut = 0x0203; + + /// + /// 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. + /// + public static ushort? IsConnectionGone(ReadOnlySpan 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 route) { var connPath = new CipWriter().Bytes(route).Bytes(CipPath.MessageRouter).ToArray(); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ForwardOpenThrottle.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ForwardOpenThrottle.cs index b315aa06..c1179ae5 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ForwardOpenThrottle.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ForwardOpenThrottle.cs @@ -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) /// Internal: the only legitimate owner is , which keeps exactly /// one per device. A second throttle for the same device would halve the guaranteed interval. @@ -24,6 +26,13 @@ public sealed class ForwardOpenThrottle _time = time ?? throw new ArgumentNullException(nameof(time)); } + /// Test hook: callers waiting behind an in-flight attempt. + internal int Queued => Volatile.Read(ref _queued); + + /// 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. + internal bool IsWaitingOutInterval => Volatile.Read(ref _intervalWait) is { IsCompleted: false }; + /// Run one open attempt once the throttle allows it. bounds only the wait /// BEFORE the attempt (queued behind an in-flight attempt, or the interval): when it fires there, this /// throws (, "Forward Open @@ -33,9 +42,11 @@ public sealed class ForwardOpenThrottle public async Task RunAsync(Func> 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(); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/DeviceSessionPool.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/DeviceSessionPool.cs index 1572caa3..9d137d13 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/DeviceSessionPool.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/DeviceSessionPool.cs @@ -52,6 +52,9 @@ public sealed class DeviceSessionPool /// Wiring-test hook: the logger this pool currently writes to (final review I2). internal ILogger Logger => _logger; + /// Counters and open-attempt limits for this pool's connections (gate and harness diagnostics). + internal ConnectionDiagnostics Diagnostics { get; } = new(); + /// Test hook: keys with a live entry. 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 } } + + /// + /// 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. + /// + 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); + } + } } /// A reference on a device's shared connection. Dispose exactly once (further disposes are no-ops). diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipConnectionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipConnectionTests.cs index e76e9c98..5049e478 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipConnectionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipConnectionTests.cs @@ -2,11 +2,14 @@ using System.Buffers.Binary; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Cip; -/// Serializes every test class that asserts on 's process-wide -/// diagnostic counters, so no parallel test can move them mid-assertion. +/// Serializes the loopback-socket test classes. Their diagnostic counters are per connection / per +/// pool since Gitea #581 (no longer process-wide statics), but still +/// reconfigures 's logger, and the timing-sensitive storm test is kept +/// off a thread pool its neighbours are saturating. [CollectionDefinition(Name, DisableParallelization = true)] public sealed class CipConnectionStatics { @@ -66,7 +69,7 @@ public sealed class CipConnectionTests target.ForwardOpenReply = fo => fo[0] == 0x5B ? FakeCipTarget.Refuse(0x08)(fo) : FakeCipTarget.Accept(fo); var time = new ManualTimeProvider(); await using var conn = Create(target, Options(), time); - var before = Volatile.Read(ref CipConnection.ForwardOpenCount); + var before = conn.Diagnostics.ForwardOpens; await conn.SendAsync(ReadTag, TestCt); @@ -76,7 +79,7 @@ public sealed class CipConnectionTests fos[1][0].ShouldBe((byte)0x54); RegularOTParams(fos[1]).ShouldBe((ushort)(0x4200 | 504)); conn.MaxRequestBytes.ShouldBe(502); - (Volatile.Read(ref CipConnection.ForwardOpenCount) - before).ShouldBe(2); + (conn.Diagnostics.ForwardOpens - before).ShouldBe(2); target.ConnectedRequests.ShouldBe(1); } @@ -109,14 +112,12 @@ public sealed class CipConnectionTests [Fact] public async Task A_spent_open_attempt_budget_refuses_the_reconnect_before_any_io() { - // Task 18 review r1 (C1): the differential harness caps the process at one open attempt, so a press + // Task 18 review r1 (C1): the differential harness caps the press at one open attempt, so a press // that refuses the Forward Open sees one TCP connect and one Forward Open, never one per remaining tag. await using var target = new FakeCipTarget { ForwardOpenReply = FakeCipTarget.Refuse(0x01, 0x0100) }; var time = new ManualTimeProvider(); await using var conn = Create(target, Options(size: 504), time); - var before = Volatile.Read(ref CipConnection.OpenAttempts); - Volatile.Write(ref CipConnection.OpenAttemptBudget, before + 1); - try + using (conn.Diagnostics.LimitOpenAttempts(Key(target), 1)) { await Should.ThrowAsync(() => conn.SendAsync(ReadTag, TestCt)); var retry = conn.SendAsync(ReadTag, TestCt); @@ -128,13 +129,43 @@ public sealed class CipConnectionTests refused.Message.ShouldContain("budget"); target.Server.Connections.ShouldBe(1); target.ForwardOpens.ShouldBe(1); - } - finally - { - Volatile.Write(ref CipConnection.OpenAttemptBudget, -1); + conn.Diagnostics.OpenAttempts.ShouldBe(2); // the refused one is counted too } } + [Fact] + public async Task An_open_attempt_limit_is_scoped_to_one_device_and_lifted_by_its_scope() + { + // Gitea #566 / #581: the limit is per DeviceKey on one pool's diagnostics, not a process-wide static, so + // limiting the press under test can never refuse an open to any other device. + await using var limited = new FakeCipTarget(); + await using var other = new FakeCipTarget(); + var time = new ManualTimeProvider(); + var pool = new DeviceSessionPool(time); + var limitedKey = Key(limited); + + using (pool.Diagnostics.LimitOpenAttempts(limitedKey, 0)) + { + await using var otherLease = pool.Acquire(Key(other), Options()); + (await otherLease.Scheduler.SendAsync(ReadTag, TestCt)).GeneralStatus.ShouldBe((byte)0); + + await using var limitedLease = pool.Acquire(limitedKey, Options()); + var refused = await Should.ThrowAsync(() => limitedLease.Scheduler.SendAsync(ReadTag, TestCt)); + refused.Status.ShouldBe(AbStatus.Abort); + refused.Message.ShouldContain("budget"); + Should.Throw(() => pool.Diagnostics.LimitOpenAttempts(limitedKey, 5)); // one at a time + } + + pool.Diagnostics.HasOpenAttemptLimit(limitedKey).ShouldBeFalse(); + time.Advance(TimeSpan.FromSeconds(5)); // the refused attempt still started the device's throttle interval + await using var lifted = pool.Acquire(limitedKey, Options()); + (await lifted.Scheduler.SendAsync(ReadTag, TestCt).WaitAsync(Guard, TestCt)).GeneralStatus.ShouldBe((byte)0); + limited.ForwardOpens.ShouldBe(1); // the refused attempt never reached the wire; the lifted one did + other.ForwardOpens.ShouldBe(1); + } + + private static DeviceKey Key(FakeCipTarget target) => new("127.0.0.1", target.Port, "1,0", "ControlLogix"); + [Fact] public async Task Connection_in_use_does_not_fall_back() { @@ -245,7 +276,7 @@ public sealed class CipConnectionTests }; var time = new ManualTimeProvider(); await using var conn = Create(target, Options(), time); - var before = Volatile.Read(ref CipConnection.ForwardOpenCount); + var before = conn.Diagnostics.ForwardOpens; using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestCt); var pending = conn.SendAsync(ReadTag, cts.Token); @@ -256,7 +287,7 @@ public sealed class CipConnectionTests await conn.SendAsync(ReadTag, TestCt); - (Volatile.Read(ref CipConnection.ForwardOpenCount) - before).ShouldBe(1); + (conn.Diagnostics.ForwardOpens - before).ShouldBe(1); target.ForwardOpens.ShouldBe(1); target.Server.Connections.ShouldBe(1); target.ConnectedRequests.ShouldBe(2); @@ -321,7 +352,7 @@ public sealed class CipConnectionTests await using var target = new FakeCipTarget(); var time = new ManualTimeProvider(); await using var conn = Create(target, Options(), time); - var before = Volatile.Read(ref CipConnection.RequestsSent); + var before = conn.Diagnostics.RequestsSent; await conn.SendAsync(ReadTag, TestCt); time.Advance(TimeSpan.FromSeconds(8)); @@ -336,7 +367,7 @@ public sealed class CipConnectionTests time.Advance(TimeSpan.FromSeconds(1)); conn.KeepAlivesSent.ShouldBe(2); await FakeCipTarget.Until(() => target.ConnectedRequests == 3, Guard, "the second keep-alive"); - (Volatile.Read(ref CipConnection.RequestsSent) - before).ShouldBe(3); // keep-alives count + (conn.Diagnostics.RequestsSent - before).ShouldBe(3); // keep-alives count target.ForwardOpens.ShouldBe(1); } @@ -411,4 +442,159 @@ public sealed class CipConnectionTests await dispose.WaitAsync(Guard, TestCt); target.ForwardOpens.ShouldBe(1); // nothing is opened once disposal has begun } + + [Fact] + public async Task Disposing_during_the_session_registration_never_puts_a_Forward_Open_on_the_wire() + { + // Gitea #566: disposal that begins while the TCP connect / RegisterSession is on the wire lets that step + // finish (it is bounded), then releases the session instead of sending a Forward Open nobody would close. + await using var target = new FakeCipTarget(); + var registerHeld = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + target.Server.RegisterSessionGate = () => registerHeld.Task; + var time = new ManualTimeProvider(); + var conn = Create(target, Options(), time); + + var send = conn.SendAsync(ReadTag, TestCt); + await target.Server.WaitForFrameAsync(f => FakeEipServer.Command(f) == EncapCommand.RegisterSession, Guard); + var dispose = conn.DisposeAsync().AsTask(); + conn.OpenInProgress.ShouldBeTrue(); // disposal is waiting out the attempt on the wire... + dispose.IsCompleted.ShouldBeFalse(); // ...rather than abandoning it + registerHeld.SetResult(); + + (await Should.ThrowAsync(() => send.WaitAsync(Guard, TestCt))).Status.ShouldBe(AbStatus.Abort); + await dispose.WaitAsync(Guard, TestCt); + // The session is released (positive control); the server records frames in arrival order, so a Forward + // Open sent before the UnRegisterSession would already be counted. + await target.Server.WaitForFrameAsync(f => FakeEipServer.Command(f) == EncapCommand.UnRegisterSession, Guard); + target.ForwardOpens.ShouldBe(0); + target.ForwardCloses.ShouldBe(0); + conn.Diagnostics.OpenAttempts.ShouldBe(1); + } + + private const ushort AbandonedOpenRefusal = 0x7A11; // an extended status no other test uses + + [Fact] + public async Task An_open_every_waiter_abandoned_leaves_no_unobserved_task_exception() + { + // Gitea #566: the shared open fails AFTER its only waiter gave up (its own token). Nothing awaits the + // open's task then; without the observer it would surface as UnobservedTaskException at finalization. + var unobserved = await UnobservedTaskProbe.CollectAsync(AbandonAFailingOpenAsync, Guard); + + unobserved.ShouldNotContain(e => e.Message.Contains($"0x{AbandonedOpenRefusal:X4}")); + } + + // Kept out of the test body so no local in the test's own state machine roots the abandoned open. + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async Task AbandonAFailingOpenAsync() + { + await using var target = new FakeCipTarget { ForwardOpenReply = FakeCipTarget.Refuse(0x01, AbandonedOpenRefusal) }; + var openHeld = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + target.ForwardOpenGate = () => openHeld.Task; + var time = new ManualTimeProvider(); + await using var conn = Create(target, Options(size: 504), time); + using (var cts = new CancellationTokenSource()) + { + var waiter = conn.SendAsync(ReadTag, cts.Token); + await FakeCipTarget.Until(() => target.ForwardOpens == 1, Guard, "the Forward Open"); + await cts.CancelAsync(); + await Should.ThrowAsync(() => waiter); + } + openHeld.SetResult(); // the target refuses, after every waiter has gone + await FakeCipTarget.Until(() => !conn.OpenInProgress, Guard, "the abandoned open to fail"); + } + + // ---- CIP-level close (Gitea #566) ------------------------------------------------------------ + + private static byte[] Ok(byte[] cip) => [(byte)(cip[0] | 0x80), 0, 0, 0]; + + private static byte[] Status(byte[] cip, byte general, ushort extended) => + [(byte)(cip[0] | 0x80), 0, general, 1, (byte)extended, (byte)(extended >> 8)]; + + [Theory] + [InlineData(ConnectionManagerServices.TargetConnectionNotFound)] + [InlineData(ConnectionManagerServices.ConnectionTimedOut)] + public async Task A_reply_saying_the_CIP_connection_is_gone_retires_it_and_the_next_send_reopens(ushort extended) + { + // The target dropped the class-3 connection but the TCP session is still up: every later connected request + // would get the same answer with the session looking healthy, so the connection is recycled at once. + await using var target = new FakeCipTarget(); + var gone = 0; + target.ConnectedHandler = cip => Task.FromResult( + Interlocked.Exchange(ref gone, 0) == 1 ? Status(cip, 0x01, extended) : Ok(cip)); + var time = new ManualTimeProvider(); + await using var conn = Create(target, Options(), time); + await conn.SendAsync(ReadTag, TestCt); + + Volatile.Write(ref gone, 1); + var ex = await Should.ThrowAsync(() => conn.SendAsync(ReadTag, TestCt)); + + ex.Status.ShouldBe(AbStatus.BadConnection); + ex.Message.ShouldContain($"0x{extended:X4}"); + conn.Diagnostics.ConnectionsClosedByTarget.ShouldBe(1); + await target.Server.WaitForFrameAsync(f => FakeEipServer.Command(f) == EncapCommand.UnRegisterSession, Guard); + await FakeCipTarget.Until(() => target.Server.OpenConnections == 0, Guard, "the session to close"); + target.ForwardCloses.ShouldBe(0); // the target just said there is nothing to close + + var reopen = conn.SendAsync(ReadTag, TestCt); + await time.WaitForTimerDueAtAsync(time.Start + TimeSpan.FromSeconds(1), Guard); // through the throttle + time.Advance(TimeSpan.FromSeconds(1)); + CipReply.Parse(await reopen.WaitAsync(Guard, TestCt)).GeneralStatus.ShouldBe((byte)0); + target.ForwardOpens.ShouldBe(2); + target.Server.Connections.ShouldBe(2); + } + + [Fact] + public async Task A_keepalive_told_the_connection_is_gone_recycles_it_before_any_request_fails() + { + await using var target = new FakeCipTarget(); + target.ConnectedHandler = cip => Task.FromResult( + cip.SequenceEqual(CipConnection.KeepAliveRequest) ? Status(cip, 0x01, 0x0107) : Ok(cip)); + var time = new ManualTimeProvider(); + await using var conn = Create(target, Options(), time); + await conn.SendAsync(ReadTag, TestCt); + + time.Advance(TimeSpan.FromSeconds(8)); // idle: the keep-alive goes out and is told the connection is gone + await FakeCipTarget.Until(() => conn.Diagnostics.ConnectionsClosedByTarget == 1, Guard, "the retirement"); + await FakeCipTarget.Until(() => target.Server.OpenConnections == 0, Guard, "the session to close"); + + // The next request reopens (the throttle interval is long past) instead of failing on a dead connection. + CipReply.Parse(await conn.SendAsync(ReadTag, TestCt).WaitAsync(Guard, TestCt)).GeneralStatus.ShouldBe((byte)0); + target.ForwardOpens.ShouldBe(2); + } + + [Fact] + public async Task Other_error_statuses_describe_the_request_and_leave_the_connection_alone() + { + // Negative control for the recycle: a tag-level error and a connection-failure status that is not about + // this connection come back as ordinary replies, and the next request rides the same connection. + await using var target = new FakeCipTarget(); + var replies = new Queue>( + [ + cip => Status(cip, 0x04, 0x0000), // path segment error: the tag + cip => Status(cip, 0x01, 0x0204), // unconnected request timed out: not this connection + Ok, + ]); + target.ConnectedHandler = cip => Task.FromResult(replies.Dequeue()(cip)); + var time = new ManualTimeProvider(); + await using var conn = Create(target, Options(), time); + + CipReply.Parse(await conn.SendAsync(ReadTag, TestCt)).GeneralStatus.ShouldBe((byte)0x04); + CipReply.Parse(await conn.SendAsync(ReadTag, TestCt)).GeneralStatus.ShouldBe((byte)0x01); + CipReply.Parse(await conn.SendAsync(ReadTag, TestCt)).GeneralStatus.ShouldBe((byte)0); // same connection + + conn.Diagnostics.ConnectionsClosedByTarget.ShouldBe(0); + target.ForwardOpens.ShouldBe(1); + target.Server.Connections.ShouldBe(1); + } + + [Fact] + public void Only_connection_failure_0107_and_0203_mean_the_connection_is_gone() + { + ConnectionManagerServices.IsConnectionGone([0xCC, 0, 0x01, 1, 0x07, 0x01]).ShouldBe((ushort)0x0107); + ConnectionManagerServices.IsConnectionGone([0xCC, 0, 0x01, 1, 0x03, 0x02]).ShouldBe((ushort)0x0203); + ConnectionManagerServices.IsConnectionGone([0xCC, 0, 0x01, 1, 0x04, 0x02]).ShouldBeNull(); // 0x0204 + ConnectionManagerServices.IsConnectionGone([0xCC, 0, 0x01, 0]).ShouldBeNull(); // no extended word + ConnectionManagerServices.IsConnectionGone([0xCC, 0, 0x05, 1, 0x07, 0x01]).ShouldBeNull(); // not 0x01 + ConnectionManagerServices.IsConnectionGone([0xCC, 0, 0, 0]).ShouldBeNull(); + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ForwardOpenThrottleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ForwardOpenThrottleTests.cs index 6a4d3d74..77797cae 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ForwardOpenThrottleTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ForwardOpenThrottleTests.cs @@ -69,7 +69,8 @@ public sealed class ForwardOpenThrottleTests var first = throttle.RunAsync(Attempt, TestCt); time.Advance(TimeSpan.FromSeconds(5)); // the interval is no obstacle; only the in-flight one is var others = Enumerable.Range(0, 4).Select(_ => throttle.RunAsync(Attempt, TestCt)).ToArray(); - await Task.Delay(50, TestCt); + // Positive control (#583): all four have reached the throttle and are parked behind the in-flight attempt. + await FakeCipTarget.Until(() => throttle.Queued == 4, Guard, "four callers queued behind the first"); Volatile.Read(ref inFlight).ShouldBe(1); others.ShouldAllBe(t => !t.IsCompleted); @@ -131,8 +132,8 @@ public sealed class ForwardOpenThrottleTests await using var target = new FakeCipTarget { ForwardOpenReply = FakeCipTarget.Refuse(0x01, 0x0113) }; var time = new ManualTimeProvider(); var options = new ConnectionOptions { ConnectionSize = 504, Timeout = TimeSpan.FromSeconds(2) }; - var conn = new CipConnection(new DeviceKey("127.0.0.1", target.Port, "1,0", "ControlLogix"), options, - new ForwardOpenThrottle(options.MinForwardOpenInterval, time), time); + var throttle = new ForwardOpenThrottle(options.MinForwardOpenInterval, time); + var conn = new CipConnection(new DeviceKey("127.0.0.1", target.Port, "1,0", "ControlLogix"), options, throttle, time); using var stop = CancellationTokenSource.CreateLinkedTokenSource(TestCt); var failures = 0; @@ -151,7 +152,10 @@ public sealed class ForwardOpenThrottleTests await Task.Delay(10, TestCt); time.Advance(TimeSpan.FromMilliseconds(100)); } - await Task.Delay(100, TestCt); // let any attempt released by the last step reach the wire + // Positive control (#583), instead of a fixed settle: wait until the next attempt is parked on the interval. + // Then no attempt is in flight and none can start until the clock moves, so every attempt the last step + // released has already reached the wire and been counted. + await FakeCipTarget.Until(() => throttle.IsWaitingOutInterval, Guard, "the next attempt to park on the interval"); await stop.CancelAsync(); await Task.WhenAll(callers).WaitAsync(Guard, TestCt); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/DeviceSessionPoolTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/DeviceSessionPoolTests.cs index 618ccb4c..bfbc3295 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/DeviceSessionPoolTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/DeviceSessionPoolTests.cs @@ -203,7 +203,9 @@ public sealed class DeviceSessionPoolTests await using var c = pool.Acquire(key, Options()); var cSend = c.Scheduler.SendAsync(ReadTag, TestCt); - await Task.Delay(300, TestCt); + // Positive control (#583): C's open has reached the predecessor wait and parked there, so the absence of + // its Forward Open below is the wait holding it, not C merely not having got that far yet. + await FakeCipTarget.Until(() => pool.Diagnostics.PredecessorWaits == 1, Guard, "C to park behind A's close"); target.ForwardOpens.ShouldBe(1); // C is held back behind A cSend.IsCompleted.ShouldBeFalse(); @@ -292,7 +294,7 @@ public sealed class DeviceSessionPoolTests time.Advance(TimeSpan.FromSeconds(5)); // the throttle is no obstacle await using var b = pool.Acquire(key, Options()); var bSend = b.Scheduler.SendAsync(ReadTag, TestCt); - await Task.Delay(300, TestCt); + await FakeCipTarget.Until(() => pool.Diagnostics.PredecessorWaits == 1, Guard, "B to park behind A's close"); target.ForwardOpens.ShouldBe(1); // held behind A's close bSend.IsCompleted.ShouldBeFalse(); @@ -304,6 +306,72 @@ public sealed class DeviceSessionPoolTests pool.EntryCount.ShouldBe(1); } + /// + /// Gitea #566: releasing a lease while the device's shared open is still on the wire does not wait for that + /// open (up to its whole open budget) — (#579) returns at + /// once, and the teardown it hands back still Forward Closes what the open produced once it lands. + /// + [Fact] + public async Task Release_in_background_does_not_wait_behind_an_in_flight_shared_open() + { + await using var target = new FakeCipTarget(); + var openHeld = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + target.ForwardOpenGate = () => openHeld.Task; + var pool = new DeviceSessionPool(new ManualTimeProvider()); + var key = new DeviceKey("127.0.0.1", target.Port, "1,0", "ControlLogix"); + + var lease = pool.Acquire(key, Options()); + var send = lease.Scheduler.SendAsync(ReadTag, TestCt); + await FakeCipTarget.Until(() => target.ForwardOpens == 1, Guard, "the Forward Open"); + ((CipConnection)lease.Channel).OpenInProgress.ShouldBeTrue(); + + var teardown = lease.ReleaseInBackground(); // returns synchronously: the open is still held + + pool.EntryCount.ShouldBe(0); + teardown.IsCompleted.ShouldBeFalse("the teardown waits for the open on the wire; the release call did not"); + (await Should.ThrowAsync(() => send.WaitAsync(Guard, TestCt))).Status.ShouldBe(AbStatus.BadConnection); + + openHeld.SetResult(); + await teardown.WaitAsync(Guard, TestCt); + target.ForwardCloses.ShouldBe(1); // what the late-accepted open created is still closed + } + + /// Gitea #566: the close path disposes the connection even when the scheduler's dispose throws — + /// otherwise the connection's keep-alive would hold the press connection open forever. + [Fact] + public async Task Close_disposes_the_connection_even_when_the_scheduler_dispose_throws() + { + var logger = new CapturingLogger(); + var scheduler = new Disposable(() => throw new InvalidOperationException("scheduler dispose failed")); + var connection = new Disposable(() => { }); + var key = new DeviceKey("plc1", 44818, "1,0", "ControlLogix"); + + await DeviceSessionPool.CloseAsync(key, scheduler, connection, logger); + + scheduler.Disposed.ShouldBe(1); + connection.Disposed.ShouldBe(1); + logger.Warnings.ShouldBe(1); + + // And a throwing connection dispose is logged, never propagated to the release. + var failing = new Disposable(() => throw new InvalidOperationException("close failed")); + await DeviceSessionPool.CloseAsync(key, new Disposable(() => { }), failing, logger); + failing.Disposed.ShouldBe(1); + logger.Warnings.ShouldBe(2); + } + + private sealed class Disposable(Action onDispose) : IAsyncDisposable + { + private int _disposed; + public int Disposed => Volatile.Read(ref _disposed); + + public ValueTask DisposeAsync() + { + Interlocked.Increment(ref _disposed); + onDispose(); + return ValueTask.CompletedTask; + } + } + [Fact] public void Shared_is_one_process_wide_instance() => DeviceSessionPool.Shared.ShouldBeSameAs(DeviceSessionPool.Shared); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationClientTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationClientTests.cs index 56bcda11..e70fb159 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationClientTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationClientTests.cs @@ -271,28 +271,10 @@ public sealed class EncapsulationClientTests [Fact] public async Task An_abandoned_exchange_that_times_out_leaves_no_unobserved_task_exception() { - var unobserved = new List(); - EventHandler onUnobserved = (_, e) => - { - if (e.Exception.Flatten().InnerExceptions.Any(x => x.Message.Contains("within 317 ms"))) - lock (unobserved) unobserved.Add(e.Exception); - }; - TaskScheduler.UnobservedTaskException += onUnobserved; - try - { - await AbandonAnExchangeThatTimesOut(); - for (var i = 0; i < 5; i++) - { - GC.Collect(); - GC.WaitForPendingFinalizers(); - await Task.Delay(20, TestCt); - } - lock (unobserved) unobserved.ShouldBeEmpty(); - } - finally - { - TaskScheduler.UnobservedTaskException -= onUnobserved; - } + // Gitea #583: collected against a positive control (a deliberately unobserved fault that MUST be reported), + // not after a fixed number of GC cycles. + var unobserved = await UnobservedTaskProbe.CollectAsync(AbandonAnExchangeThatTimesOut, Guard); + unobserved.ShouldNotContain(x => x.Message.Contains("within 317 ms")); } // Kept out of the test body so no local in the test's own state machine roots the abandoned task. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeEipServer.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeEipServer.cs index b6866fcd..b8ff62cb 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeEipServer.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeEipServer.cs @@ -61,6 +61,9 @@ internal sealed class FakeEipServer : IAsyncDisposable /// Synchronous convenience setter for . public Func Handler { set => HandlerAsync = req => Task.FromResult(value(req)); } + /// Awaited before a RegisterSession is answered (the request is already recorded). + public Func? RegisterSessionGate { get; set; } + /// Never answer non-session requests. public bool Stall { get; set; } @@ -160,6 +163,7 @@ internal sealed class FakeEipServer : IAsyncDisposable switch (Command(frame)) { case EncapCommand.RegisterSession: + if (RegisterSessionGate is { } gate) await gate(); reply = (byte[])frame.Clone(); BinaryPrimitives.WriteUInt32LittleEndian(reply.AsSpan(4), Session); break; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/UnobservedTaskProbe.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/UnobservedTaskProbe.cs new file mode 100644 index 00000000..1313e9d0 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/UnobservedTaskProbe.cs @@ -0,0 +1,54 @@ +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; + +/// +/// Collects s raised after some code abandons work, with a +/// positive control (Gitea #583): after the abandon, a task is deliberately faulted and dropped, and garbage is +/// collected until THAT task's exception has been reported. Only then is the collection returned — so "nothing +/// of ours was reported" means the finalizer pass that would have reported it really ran, not merely that a +/// fixed number of GC cycles went by before the runtime got round to it. +/// +internal static class UnobservedTaskProbe +{ + public static async Task> CollectAsync(Func abandon, TimeSpan timeout) + { + var marker = $"unobserved-task-probe-control-{Guid.NewGuid():N}"; + var seen = new ConcurrentQueue(); + var controlReported = 0; + EventHandler handler = (_, e) => + { + foreach (var ex in e.Exception.Flatten().InnerExceptions) + { + if (ex.Message == marker) Volatile.Write(ref controlReported, 1); + else seen.Enqueue(ex); + } + }; + TaskScheduler.UnobservedTaskException += handler; + try + { + await abandon(); + AbandonControl(marker); + var giveUp = DateTime.UtcNow + timeout; + while (Volatile.Read(ref controlReported) == 0) + { + if (DateTime.UtcNow > giveUp) + throw new TimeoutException("The positive control's unobserved exception was never reported."); + GC.Collect(); + GC.WaitForPendingFinalizers(); + await Task.Delay(10); + } + // One more full pass, so anything that became garbage in the same cycle as the control is reported too. + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + finally + { + TaskScheduler.UnobservedTaskException -= handler; + } + return [.. seen]; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void AbandonControl(string marker) => _ = Task.FromException(new InvalidOperationException(marker)); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialRun.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialRun.cs index ee9e9b7c..4450b63a 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialRun.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialRun.cs @@ -92,9 +92,9 @@ internal sealed class DifferentialAbortedException(string message, Exception? in /// Stop on first failure (review r1 C1). The first connection-level failure on either side — /// connect / RegisterSession / Forward Open refused, timeout, session lost — aborts the whole run with the /// operation and status; nothing is retried. The native side is additionally capped at ONE open attempt -/// process-wide (), so a reconnect cannot reach the wire even +/// to the press under test (), so a reconnect cannot reach the wire even /// from inside an enumerator that swallows a per-program failure, and the run aborts the moment -/// passes 1. +/// passes 1. /// internal static class DifferentialRun { @@ -222,9 +222,9 @@ internal static class DifferentialRun /// /// Runs with every native operation sharing ONE connection, held open by an anchor - /// lease: the process-wide Forward Open counter is reset, the open-attempt budget is set to exactly one - /// more attempt, and receives a check to call after each operation that aborts the - /// moment a second Forward Open reaches the wire. The anchor is released and the budget restored in + /// lease: the shared pool's Forward Open counters are reset, the press is limited to exactly one + /// open attempt, and receives a check to call after each operation that aborts the + /// moment a second Forward Open reaches the wire. The anchor is released and the limit lifted in /// finally. Shared by the differential and the latency comparison. /// /// The body's result, the Forward Opens counted between the reset and the anchor release, and the @@ -233,43 +233,38 @@ internal static class DifferentialRun string phase, DifferentialTarget target, Func, Task> body) { var anchorParams = target.Params("@tags"); + var key = NativeAbCipDevice.Key(anchorParams); + var diagnostics = DeviceSessionPool.Shared.Diagnostics; void CheckOpens(string operation) { - var opens = Volatile.Read(ref CipConnection.ForwardOpenCount); + var opens = diagnostics.ForwardOpens; if (opens > 1) throw new DifferentialAbortedException( $"Differential ABORTED in the {phase} phase after {operation}: {opens} Forward Open frames on the wire, " + "expected exactly one (a reconnect, or a Large→regular fallback). Nothing was retried."); } - Interlocked.Exchange(ref CipConnection.ForwardOpenCount, 0); - Interlocked.Exchange(ref CipConnection.ForwardClosesSent, 0); - Interlocked.Exchange(ref CipConnection.ForwardClosesAcked, 0); - Volatile.Write(ref CipConnection.OpenAttemptBudget, Volatile.Read(ref CipConnection.OpenAttempts) + 1); + diagnostics.ResetForwardCounts(); T result; int forwardOpens; NativeCloses forwardCloses; - try + // Exactly one open attempt to the press under test; lifted when the scope ends, whatever happens. + using (diagnostics.LimitOpenAttempts(key, 1)) { // Acquire does no I/O; the key and options carry no tag-specific field, so every lease shares this entry. - var anchor = DeviceSessionPool.Shared.Acquire(NativeAbCipDevice.Key(anchorParams), NativeAbCipDevice.Options(anchorParams)); + var anchor = DeviceSessionPool.Shared.Acquire(key, NativeAbCipDevice.Options(anchorParams)); try { result = await body(CheckOpens).ConfigureAwait(false); } finally { - forwardOpens = Volatile.Read(ref CipConnection.ForwardOpenCount); + forwardOpens = diagnostics.ForwardOpens; await anchor.DisposeAsync().ConfigureAwait(false); - forwardCloses = new NativeCloses( - Volatile.Read(ref CipConnection.ForwardClosesSent), Volatile.Read(ref CipConnection.ForwardClosesAcked)); + forwardCloses = new NativeCloses(diagnostics.ForwardClosesSent, diagnostics.ForwardClosesAcked); } } - finally - { - Volatile.Write(ref CipConnection.OpenAttemptBudget, -1); - } return (result, forwardOpens, forwardCloses); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialSafetyTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialSafetyTests.cs index d7842605..a1b36ebe 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialSafetyTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialSafetyTests.cs @@ -30,12 +30,28 @@ public sealed class DifferentialSafetyTests () => DifferentialRun.RunNativePhaseAsync(target, selection, TestContext.Current.CancellationToken)); ex.Message.ShouldContain("native phase at browse"); - // Long enough for a throttled (≥ 1 s) reconnect to have reached the listener, had one been allowed. - await Task.Delay(TimeSpan.FromSeconds(2.5), TestContext.Current.CancellationToken); - endpoint.Connections.ShouldBe(1); - Volatile.Read(ref CipConnection.OpenAttemptBudget).ShouldBe(-1, "the budget is restored after the phase"); + DeviceSessionPool.Shared.Diagnostics.HasOpenAttemptLimit(Key(target)).ShouldBeFalse("the limit is lifted after the phase"); + await ControlOpenAsync(target); + endpoint.Connections.ShouldBe(2, "the phase's one connect, then the control's — no reconnect from the phase"); } + /// + /// Positive control (Gitea #583) for "no reconnect reached the listener": one more open to the same device, + /// made after the phase, once its limit is lifted. It goes through the same per-device Forward Open throttle, + /// whose waiters are served in arrival order, so by the time it has reached the listener (it is counted on + /// accept, before the hang-up that fails it) any reconnect the phase had queued would already have got there + /// — and, the limit being gone, nothing would have refused it. + /// + private static async Task ControlOpenAsync(DifferentialTarget target) + { + var p = target.Params("@tags"); + await using var lease = DeviceSessionPool.Shared.Acquire(NativeAbCipDevice.Key(p), NativeAbCipDevice.Options(p)); + await Should.ThrowAsync( + () => lease.Scheduler.SendAsync([0x4C, 0x00], TestContext.Current.CancellationToken)); + } + + private static DeviceKey Key(DifferentialTarget target) => NativeAbCipDevice.Key(target.Params("@tags")); + [Fact] public async Task A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read() { @@ -146,13 +162,13 @@ public sealed class DifferentialSafetyTests public async Task The_open_attempt_budget_alone_stops_a_second_open_even_when_nothing_aborts_the_phase() { // Unlike the abort test above, the body here SWALLOWS every failure and keeps going, so the harness's - // abort-on-first-failure can never end the phase: only CipConnection.OpenAttemptBudget stands between + // abort-on-first-failure can never end the phase: only the per-device open-attempt limit stands between // the second request and the wire. await using var endpoint = HangUpListener.Start(); var target = Target(endpoint.Port); var statuses = new List(); string? secondMessage = null; - var attemptsBefore = Volatile.Read(ref CipConnection.OpenAttempts); + var attemptsBefore = DeviceSessionPool.Shared.Diagnostics.OpenAttempts; var tags = AbCipTransportFactories.For(AbCipTransport.Native).Tags; await DifferentialRun.WithNativeAnchorAsync("native", target, async _ => @@ -178,11 +194,12 @@ public sealed class DifferentialSafetyTests statuses.Count.ShouldBe(2, "both requests failed; neither aborted the phase"); statuses[1].ShouldBe(AbStatus.Abort); secondMessage.ShouldNotBeNull().ShouldContain("budget"); - Volatile.Read(ref CipConnection.OpenAttempts).ShouldBe(attemptsBefore + 2, "the second attempt was counted, then refused"); - // Long enough for a throttled reconnect to have reached the listener had the budget let one through. - await Task.Delay(TimeSpan.FromSeconds(1.5), TestContext.Current.CancellationToken); - endpoint.Connections.ShouldBe(1, "exactly the one budgeted open attempt reached the wire"); - Volatile.Read(ref CipConnection.OpenAttemptBudget).ShouldBe(-1, "the budget is restored afterwards"); + // The pool is process-wide, so another test's opens could move this counter; at least these two are in it. + (DeviceSessionPool.Shared.Diagnostics.OpenAttempts - attemptsBefore).ShouldBeGreaterThanOrEqualTo(2, + "the second attempt was counted, then refused"); + DeviceSessionPool.Shared.Diagnostics.HasOpenAttemptLimit(Key(target)).ShouldBeFalse("the limit is lifted afterwards"); + await ControlOpenAsync(target); + endpoint.Connections.ShouldBe(2, "exactly the one budgeted open attempt reached the wire, then the control's"); } [Fact] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs index 283cd63a..68a828b1 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs @@ -126,7 +126,7 @@ public sealed class PollLatencyComparisonTests(DifferentialRunFixture fixture) $"{passes.Sum(p => p.TagFailures)} |"; } - private static string Rtt(ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip.CipConnection.ExchangeStats? s) => + private static string Rtt(ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip.ConnectionDiagnostics.ExchangeStats? s) => s is not { Count: > 0 } x ? "-" : $"{Ms(x.MinMs)}/{Ms(x.MeanMs)}/{Ms(x.MaxMs)}"; private static string Ms(double ms) => ms.ToString("0.0", CultureInfo.InvariantCulture); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyRun.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyRun.cs index c774cb60..7e339d49 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyRun.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyRun.cs @@ -5,13 +5,13 @@ using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential; /// One timed burst: every tag read once, concurrently. -/// Native only: delta over the pass (keep-alives included). +/// Native only: delta over the pass (keep-alives included). /// Reads that ended with a tag-level status (a connection-level one aborts instead). /// Collections that ran during the pass, per generation (T21 review r1 M8: a client-side stall shows here). /// Native only: round trip of each CIP exchange in the pass, from (a slow /// controller shows here; a slow pass with fast exchanges points at the client). internal sealed record LatencyPass( - int Index, TimeSpan Wall, int? Requests, int TagFailures, GcDelta Gc = default, CipConnection.ExchangeStats? Rtt = null); + int Index, TimeSpan Wall, int? Requests, int TagFailures, GcDelta Gc = default, ConnectionDiagnostics.ExchangeStats? Rtt = null); /// GC collections per generation over a window (gen0 counts include gen1/gen2 collections, as the runtime reports). internal readonly record struct GcDelta(int Gen0, int Gen1, int Gen2) @@ -137,7 +137,7 @@ internal static class PollLatencyRun var runtimes = new List<(SelectedTag Tag, IAbCipTagRuntime Runtime)>(); try { - var before = Volatile.Read(ref CipConnection.RequestsSent); + var before = DeviceSessionPool.Shared.Diagnostics.RequestsSent; foreach (var tag in tags) { var rt = await CreateAsync("latency native", target, nativeFactory, tag, ct).ConfigureAwait(false); @@ -145,7 +145,7 @@ internal static class PollLatencyRun if (rt is null) { problems.Add($"native could not read '{tag.Name}', which libplctag read"); continue; } runtimes.Add((tag, rt)); } - var setup = Volatile.Read(ref CipConnection.RequestsSent) - before; + var setup = DeviceSessionPool.Shared.Diagnostics.RequestsSent - before; var passes = await RunPassesAsync("latency native", target, runtimes, countRequests: true, checkOpens, ct) .ConfigureAwait(false); return (passes, setup); @@ -214,15 +214,15 @@ internal static class PollLatencyRun { if (p > 1 && target.LatencyPassGapMs > 0) await Task.Delay(target.LatencyPassGapMs, ct).ConfigureAwait(false); - var requestsBefore = Volatile.Read(ref CipConnection.RequestsSent); - CipConnection.ResetExchangeStats(); + var requestsBefore = DeviceSessionPool.Shared.Diagnostics.RequestsSent; + DeviceSessionPool.Shared.Diagnostics.ResetExchangeStats(); var gcBefore = GcDelta.Now(); var clock = Stopwatch.StartNew(); var outcomes = await Task.WhenAll(runtimes.Select(r => ReadOnceAsync(r.Runtime, ct))).ConfigureAwait(false); clock.Stop(); var gc = GcDelta.Now() - gcBefore; - var rtt = countRequests ? CipConnection.ReadExchangeStats() : (CipConnection.ExchangeStats?)null; - var requests = Volatile.Read(ref CipConnection.RequestsSent) - requestsBefore; + var rtt = countRequests ? DeviceSessionPool.Shared.Diagnostics.ReadExchangeStats() : (ConnectionDiagnostics.ExchangeStats?)null; + var requests = DeviceSessionPool.Shared.Diagnostics.RequestsSent - requestsBefore; var failures = 0; for (var i = 0; i < outcomes.Length; i++) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagOrphanReleaseTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagOrphanReleaseTests.cs index 228a602c..4adc8de5 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagOrphanReleaseTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagOrphanReleaseTests.cs @@ -18,7 +18,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; [CollectionDefinition(Name, DisableParallelization = true)] public sealed class LibplctagOrphanReleaseCollection { - /// The release counters and the rooting seam are process-wide: nothing else may run alongside. + /// The release counters are process-wide: nothing else may run alongside. public const string Name = "libplctag orphan release"; } @@ -90,23 +90,29 @@ public sealed class LibplctagOrphanReleaseTests [Fact] public async Task Without_a_readable_handle_the_failed_tag_is_rooted_instead() { + // Gitea #581: the "unreadable handle" decision is passed in, not flipped through a process-wide switch. var rooted = LibplctagOrphanRelease.RootedCount; var released = LibplctagOrphanRelease.Released; - var rt = new LibplctagTagRuntime(Refused()); - LibplctagOrphanRelease.ForceRootingForTest = true; + var tag = LibplctagTagRuntime.BuildTag(Refused()); try { - await Should.ThrowAsync(() => rt.InitializeAsync(CancellationToken.None)); + await Should.ThrowAsync(() => tag.InitializeAsync(CancellationToken.None)); + var handle = LibplctagOrphanRelease.HandleOf(tag); + handle.ShouldBeGreaterThan(0, "plc_tag_create_ex returned a handle before the failure"); + + LibplctagOrphanRelease.ReleaseIfOrphaned(tag, canReadHandles: false).ShouldBeNull(); + LibplctagOrphanRelease.RootedCount.ShouldBe(rooted + 1); LibplctagOrphanRelease.Released.ShouldBe(released); + NativeTagExists(handle).ShouldBeTrue("the rooting fallback destroys nothing"); + LibplctagOrphanRelease.HandleOf(tag).ShouldBe(handle); } finally { - LibplctagOrphanRelease.ForceRootingForTest = false; // Clean up the native tag this test deliberately leaked (its callback was unregistered by the timeout). - var handle = rt.NativeHandle; + var handle = LibplctagOrphanRelease.HandleOf(tag); if (handle > 0) plctag.plc_tag_destroy(handle); - rt.Dispose(); + tag.Dispose(); } }