using System.Diagnostics; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip; namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; /// /// One device's CIP channel: one EtherNet/IP session and, when , one /// class-3 connection. Opens lazily on first use, reconnects (through the ) /// after the session faults, and keeps an idle connection alive. /// /// /// 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. 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 /// Open), and the exchange by 2 × Timeout (waiting behind one in-flight exchange, then its own). /// Exceeding either surfaces as (). /// Connection identity. Every Forward Open attempt uses a fresh connection serial and T→O id, /// so a reopen is never mistaken by the target for a duplicate of a connection it has not yet expired. /// public sealed class CipConnection : ICipChannel, IAsyncDisposable { /// 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]; /// Largest unconnected message a target is guaranteed to accept. private const int UnconnectedMessageLimit = 504; private sealed record Open(EncapsulationClient Client, ForwardOpenReply? Conn, ushort ConnectionSerial, int NegotiatedSize); private readonly DeviceKey _key; private readonly ConnectionOptions _o; private readonly ForwardOpenThrottle _throttle; private readonly TimeProvider _time; private readonly Task _predecessorClosed; private readonly ConnectionDiagnostics _diagnostics; private readonly byte[] _route; private readonly object _sync = new(); // Cancels only the waits BEFORE an attempt (predecessor close, throttle interval), never an attempt // already on the wire. Never disposed: it has no timer, and late registrations must stay legal. private readonly CancellationTokenSource _disposing = new(); private readonly ITimer? _keepAlive; private Task? _pendingOpen; // the one shared open in progress; under _sync private Open? _open; // written under _sync (Volatile); read lock-free via Volatile private int _disposed; // set under _sync; read lock-free via Volatile private int _disposeStarted; private long _lastSendTimestamp; private int _keepAlivesSent; private int _connectionSerial = Random.Shared.Next(1, ushort.MaxValue); /// The device; is the route (ignored for Direct). /// Routing, sizes, timeouts and keep-alive settings. /// The device's process-lifetime Forward Open throttle. /// 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, 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)); _time = time ?? throw new ArgumentNullException(nameof(time)); _predecessorClosed = predecessorClosed ?? Task.CompletedTask; // Direct ignores the path entirely (B3), so it is not even parsed. _route = options.Routing == CipRouting.Direct ? [] : CipPath.EncodeRoute(key.CipPath); _lastSendTimestamp = time.GetTimestamp(); if (options.Routing == CipRouting.Connected) _keepAlive = time.CreateTimer(static s => ((CipConnection)s!).OnKeepAliveTimer(), this, options.KeepAliveInterval, Timeout.InfiniteTimeSpan); } /// The options this connection was built with. internal ConnectionOptions Options => _o; /// 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; public int MaxRequestBytes => _o.Routing switch { CipRouting.Connected => (Volatile.Read(ref _open)?.NegotiatedSize ?? Math.Min(_o.ConnectionSize, UnconnectedMessageLimit)) - 2, CipRouting.UnconnectedSend => UnconnectedMessageLimit - (12 + _route.Length), _ => UnconnectedMessageLimit, }; public async Task> SendAsync(ReadOnlyMemory cipRequest, CancellationToken cancellationToken) { ThrowIfDisposed(); var open = await EnsureOpenAsync(cancellationToken).ConfigureAwait(false); return await ExchangeAsync(open, cipRequest, cancellationToken).ConfigureAwait(false); } /// Returns the live (client, connection) pair, opening one if needed. The pair is returned /// rather than re-read from fields afterwards, so a concurrent dispose can never hand the caller a /// null (finding 16). /// All callers share ONE open, which runs under its own deadline only. A caller's token (or the /// scheduler's stop token) ends that caller's wait, never the open itself: cancelling a Forward /// Open mid-flight could leave a connection the target accepted but nobody ever closes. A completed /// open is stored even if disposal began meanwhile, so Forward Closes it. private Task EnsureOpenAsync(CancellationToken ct) { if (Volatile.Read(ref _open) is { Client.IsConnected: true } live) return Task.FromResult(live); Task pending; TaskCompletionSource? started = null; lock (_sync) { ThrowIfDisposed(); if (_open is { Client.IsConnected: true } current) return Task.FromResult(current); if (_pendingOpen is null) { started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _pendingOpen = started.Task; } pending = _pendingOpen; } if (started is not null) { // Every waiter may have abandoned the open (its own token); observe a failure so it never // surfaces as UnobservedTaskException. _ = started.Task.ContinueWith(static t => _ = t.Exception, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); _ = RunOpenAsync(started); } return pending.WaitAsync(ct); } /// The shared open. Bounded by (ruling R9-3); its outcome, success or /// failure, goes to every caller waiting on it. private async Task RunOpenAsync(TaskCompletionSource result) { using var deadline = new CancellationTokenSource(OpenBudget, _time); using var beforeAttempt = CancellationTokenSource.CreateLinkedTokenSource(deadline.Token, _disposing.Token); Open? opened = null; Exception? error = null; try { Open? stale; lock (_sync) { stale = _open; 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); } catch (Exception ex) { error = Volatile.Read(ref _disposed) != 0 ? new AbTransportException(AbStatus.Abort, "Connection disposed.", ex) : ex is OperationCanceledException && deadline.IsCancellationRequested ? new AbTransportException(AbStatus.Timeout, $"Opening {_key.Host}:{_key.Port} did not complete within {OpenBudget.TotalMilliseconds:F0} ms.", ex) : ex; } lock (_sync) { if (opened is not null) Volatile.Write(ref _open, opened); _pendingOpen = null; } if (opened is not null) result.SetResult(opened); else result.SetException(error!); } private async Task OpenAsync(CancellationToken ct) { ThrowIfDisposed(); // disposal may have begun while this attempt waited out the throttle 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) { // Disposal began during the TCP connect / RegisterSession: never put a Forward Open on the wire. await client.DisposeAsync().ConfigureAwait(false); throw new AbTransportException(AbStatus.Abort, "Connection disposed."); } if (_o.Routing != CipRouting.Connected) return new Open(client, null, 0, UnconnectedMessageLimit); try { var serial = NextConnectionSerial(); var toId = (uint)Random.Shared.NextInt64(1, uint.MaxValue); var large = _o.ConnectionSize > 511; var size = _o.ConnectionSize; var reply = await ForwardOpenAsync(client, large, size, serial, toId, ct).ConfigureAwait(false); if (large && ConnectionManagerServices.ShouldFallBackToRegularOpen(reply)) { // Pre-v20 Logix / small-buffer targets: retry once, inside this same throttled attempt. size = Math.Min(_o.ConnectionSize, UnconnectedMessageLimit); reply = await ForwardOpenAsync(client, false, size, serial, toId, ct).ConfigureAwait(false); } return new Open(client, ConnectionManagerServices.ParseForwardOpenReply(reply), serial, size); } catch { await client.DisposeAsync().ConfigureAwait(false); throw; } } private async Task ForwardOpenAsync(EncapsulationClient client, bool large, int size, ushort serial, uint toId, CancellationToken ct) { var p = new ForwardOpenParams(large, toId, serial, _o.VendorId, _o.OriginatorSerial, _o.TimeoutMultiplier, _o.RpiMicros, size, _route); var frame = ConnectionManagerServices.ForwardOpen(p); _diagnostics.CountForwardOpen(); var raw = await client.SendRRDataAsync(frame, _o.Timeout, ct).ConfigureAwait(false); _diagnostics.NotifyExchange(frame, raw); // evidence tap: the granted RPIs / connection ids (#566 gate) return CipReply.Parse(raw); } private ushort NextConnectionSerial() { var s = (ushort)Interlocked.Increment(ref _connectionSerial); return s == 0 ? (ushort)Interlocked.Increment(ref _connectionSerial) : s; } /// One request over an established pair, bounded by . private async Task> ExchangeAsync(Open open, ReadOnlyMemory cip, CancellationToken ct) { _diagnostics.CountRequest(); Interlocked.Exchange(ref _lastSendTimestamp, _time.GetTimestamp()); using var deadline = new CancellationTokenSource(ExchangeBudget, _time); using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, deadline.Token); var started = Stopwatch.GetTimestamp(); try { var reply = _o.Routing switch { CipRouting.Connected => await open.Client.SendUnitDataAsync(open.Conn!.OTConnectionId, cip, _o.Timeout, linked.Token).ConfigureAwait(false), CipRouting.UnconnectedSend => await open.Client.SendRRDataAsync(ConnectionManagerServices.UnconnectedSend(cip.Span, _route), _o.Timeout, linked.Token).ConfigureAwait(false), _ => await open.Client.SendRRDataAsync(cip, _o.Timeout, linked.Token).ConfigureAwait(false), }; _diagnostics.RecordExchange(Stopwatch.GetTimestamp() - started); _diagnostics.NotifyExchange(cip, reply); 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) { throw new AbTransportException(AbStatus.Timeout, $"Request to {_key.Host}:{_key.Port} did not complete within {ExchangeBudget.TotalMilliseconds:F0} ms."); } } /// /// 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() { if (Volatile.Read(ref _disposed) != 0) return; var interval = _o.KeepAliveInterval; var open = Volatile.Read(ref _open); if (open is not { Client.IsConnected: true }) { // Nothing to keep alive, and a keep-alive must never be what reopens a connection. Rearm(interval); return; } var idle = _time.GetElapsedTime(Interlocked.Read(ref _lastSendTimestamp)); if (idle < interval) { Rearm(interval - idle); return; } Interlocked.Increment(ref _keepAlivesSent); _ = KeepAliveAsync(open); } private async Task KeepAliveAsync(Open open) { try { await ExchangeAsync(open, KeepAliveRequest, CancellationToken.None).ConfigureAwait(false); } catch (Exception) { // A timeout or I/O failure has already faulted the session; the next real send reconnects // through the throttle. Nothing to report here: the keep-alive has no caller. } finally { Rearm(_o.KeepAliveInterval); } } private void Rearm(TimeSpan due) { if (Volatile.Read(ref _disposed) != 0) return; try { _keepAlive?.Change(due, Timeout.InfiniteTimeSpan); } catch (ObjectDisposedException) { /* disposed concurrently */ } } private void ThrowIfDisposed() { if (Volatile.Read(ref _disposed) != 0) throw new AbTransportException(AbStatus.Abort, "Connection disposed."); } /// Stops the keep-alive, sends Forward Close (Connected only) and closes the session, whose /// own dispose sends UnRegisterSession. Later sends throw . public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) return; // Under the lock, so no caller can start a new open after this point. Then cancel the shared // open's pre-attempt waits; an attempt already on the wire is allowed to finish (bounded by // OpenBudget) so that whatever it opened is Forward Closed below. Task? pending; lock (_sync) { Volatile.Write(ref _disposed, 1); pending = _pendingOpen; } await _disposing.CancelAsync().ConfigureAwait(false); if (pending is not null) { try { await pending.ConfigureAwait(false); } catch (Exception) { /* its callers have the error; nothing was opened */ } } Open? open; lock (_sync) { open = _open; Volatile.Write(ref _open, null); } if (_keepAlive is not null) await _keepAlive.DisposeAsync().ConfigureAwait(false); if (open is null) return; if (open.Conn is not null && open.Client.IsConnected) { try { var budget = TimeSpan.FromSeconds(1); using var cts = new CancellationTokenSource(budget, _time); _diagnostics.CountForwardCloseSent(); await open.Client.SendRRDataAsync( ConnectionManagerServices.ForwardClose(open.ConnectionSerial, _o.VendorId, _o.OriginatorSerial, _route), budget, cts.Token).ConfigureAwait(false); _diagnostics.CountForwardCloseAcked(); } catch (Exception ex) when (ex is AbTransportException or OperationCanceledException) { // The target expires the connection after RPI × multiplier anyway. } } await open.Client.DisposeAsync().ConfigureAwait(false); } }