1f983d610f
A. #566 on a real CompactLogix (Z-35162, read-only): a class-3 connection idled 60 s past its 16 s timeout is closed at the TCP level, not with CIP 0x0107/0x0203; the next request fails on its write (Broken pipe -> BadConnection) in 9 ms and the one after reopens. The transport already handles this, so no production behaviour changed; the shape is pinned offline by CipConnectionTests.A_target_that_resets_an_idle_session_... (FakeEipServer.ResetAllConnections), proven red with a reconnect-skipping mutation. The diagnostics-only exchange tap now also sees Forward Open replies (granted RPIs). B. Native STRING write through the driver on Program:ES03_TemperatureControl._StrTmp: write, read back, restored byte-exact (user-approved). Array write NOT run: the census's unreferenced DINT[32] _SEQ_EP arrays are aliased sequencer step registers (raw L5X grep), so none qualifies. #567 still impossible (a 128 B array / 88 B STRING never fragments). New env-gated harness: AbCip.IntegrationTests/HardwareGate (IdleConnectionEvidenceTests, StringWriteGateTests). Docs: gate results (hw5 section + raw output), AbCip.md, CLAUDE.md. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
385 lines
20 KiB
C#
385 lines
20 KiB
C#
using System.Diagnostics;
|
||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||
|
||
/// <summary>
|
||
/// One device's CIP channel: one EtherNet/IP session and, when <see cref="CipRouting.Connected"/>, one
|
||
/// class-3 connection. Opens lazily on first use, reconnects (through the <see cref="ForwardOpenThrottle"/>)
|
||
/// after the session faults, and keeps an idle connection alive.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para><b>Reconnect.</b> Only a faulted session (timeout, I/O error, malformed frame) is reopened, and
|
||
/// only by the <i>next</i> send; the failing call throws its own exception. Caller cancellation never
|
||
/// faults the session (B2), so it never causes a reconnect. A connected reply saying the target no longer
|
||
/// holds the class-3 connection (general 0x01, extended 0x0107 / 0x0203 — the session itself still up) retires
|
||
/// the pair at once, whether a request or a keep-alive got it, so the next send reopens it (Gitea #566).</para>
|
||
/// <para><b>Bounded.</b> Every <see cref="SendAsync"/> is bounded even if the caller passes no token
|
||
/// (ruling R9-3): opening (including the throttle wait and any wait behind another opener) is bounded by
|
||
/// <c>MinForwardOpenInterval + 4 × Timeout</c> (connect, RegisterSession, Forward Open, fallback Forward
|
||
/// Open), and the exchange by <c>2 × Timeout</c> (waiting behind one in-flight exchange, then its own).
|
||
/// Exceeding either surfaces as <see cref="AbTransportException"/>(<see cref="AbStatus.Timeout"/>).</para>
|
||
/// <para><b>Connection identity.</b> 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.</para>
|
||
/// </remarks>
|
||
public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||
{
|
||
/// <summary>Get Attribute Single, Identity (class 1) instance 1 attribute 1 (Vendor ID).</summary>
|
||
internal static readonly byte[] KeepAliveRequest = [0x0E, 0x03, 0x20, 0x01, 0x24, 0x01, 0x30, 0x01];
|
||
|
||
/// <summary>Largest unconnected message a target is guaranteed to accept.</summary>
|
||
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<Open>? _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);
|
||
|
||
/// <param name="key">The device; <see cref="DeviceKey.CipPath"/> is the route (ignored for Direct).</param>
|
||
/// <param name="options">Routing, sizes, timeouts and keep-alive settings.</param>
|
||
/// <param name="throttle">The device's process-lifetime Forward Open throttle.</param>
|
||
/// <param name="time">Clock for the throttle, deadlines and keep-alive.</param>
|
||
/// <param name="predecessorClosed">Completes when the previous connection for the same key has
|
||
/// finished closing, so two connections to one device never overlap.</param>
|
||
/// <param name="diagnostics">The owning pool's counters and open-attempt limits; a private set when null.</param>
|
||
internal CipConnection(DeviceKey key, ConnectionOptions options, ForwardOpenThrottle throttle, TimeProvider time,
|
||
Task? predecessorClosed = null, 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);
|
||
}
|
||
|
||
/// <summary>The options this connection was built with.</summary>
|
||
internal ConnectionOptions Options => _o;
|
||
|
||
/// <summary>Test hook: keep-alives started (counted synchronously when the timer decides to send).</summary>
|
||
internal int KeepAlivesSent => Volatile.Read(ref _keepAlivesSent);
|
||
|
||
/// <summary>This connection's counters (its pool's, when it has one).</summary>
|
||
internal ConnectionDiagnostics Diagnostics => _diagnostics;
|
||
|
||
/// <summary>Test hook: true while a shared open is in progress.</summary>
|
||
internal bool OpenInProgress { get { lock (_sync) return _pendingOpen is not null; } }
|
||
|
||
private TimeSpan OpenBudget => _o.MinForwardOpenInterval + 4 * _o.Timeout;
|
||
private TimeSpan ExchangeBudget => 2 * _o.Timeout;
|
||
|
||
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<ReadOnlyMemory<byte>> SendAsync(ReadOnlyMemory<byte> cipRequest, CancellationToken cancellationToken)
|
||
{
|
||
ThrowIfDisposed();
|
||
var open = await EnsureOpenAsync(cancellationToken).ConfigureAwait(false);
|
||
return await ExchangeAsync(open, cipRequest, cancellationToken).ConfigureAwait(false);
|
||
}
|
||
|
||
/// <summary>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).</summary>
|
||
/// <remarks>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 <i>wait</i>, 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 <see cref="DisposeAsync"/> Forward Closes it.</remarks>
|
||
private Task<Open> EnsureOpenAsync(CancellationToken ct)
|
||
{
|
||
if (Volatile.Read(ref _open) is { Client.IsConnected: true } live) return Task.FromResult(live);
|
||
|
||
Task<Open> pending;
|
||
TaskCompletionSource<Open>? started = null;
|
||
lock (_sync)
|
||
{
|
||
ThrowIfDisposed();
|
||
if (_open is { Client.IsConnected: true } current) return Task.FromResult(current);
|
||
if (_pendingOpen is null)
|
||
{
|
||
started = new TaskCompletionSource<Open>(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);
|
||
}
|
||
|
||
/// <summary>The shared open. Bounded by <see cref="OpenBudget"/> (ruling R9-3); its outcome, success or
|
||
/// failure, goes to every caller waiting on it.</summary>
|
||
private async Task RunOpenAsync(TaskCompletionSource<Open> 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<Open> 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<CipReply> 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;
|
||
}
|
||
|
||
/// <summary>One request over an established pair, bounded by <see cref="ExchangeBudget"/>.</summary>
|
||
private async Task<ReadOnlyMemory<byte>> ExchangeAsync(Open open, ReadOnlyMemory<byte> cip, CancellationToken ct)
|
||
{
|
||
_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.");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// The target answered over a session that is still up, but says the class-3 connection itself is gone (it
|
||
/// timed the connection out, or was restarted / re-downloaded and forgot it). Every later connected request
|
||
/// would get the same answer while <see cref="EncapsulationClient.IsConnected"/> stays true, so nothing else
|
||
/// would ever reopen it (Gitea #566): retire the pair now, so the next send reopens through the throttle.
|
||
/// No Forward Close — the target has just said there is nothing to close.
|
||
/// </summary>
|
||
private async Task RetireClosedByTargetAsync(Open open)
|
||
{
|
||
lock (_sync)
|
||
{
|
||
if (!ReferenceEquals(_open, open)) return; // a concurrent exchange (keep-alive) already retired it
|
||
Volatile.Write(ref _open, null);
|
||
}
|
||
_diagnostics.CountClosedByTarget();
|
||
await open.Client.DisposeAsync().ConfigureAwait(false); // UnRegisterSession, then close the socket
|
||
}
|
||
|
||
/// <summary>One-shot timer, re-armed after every tick so the longest idle gap is one
|
||
/// <see cref="ConnectionOptions.KeepAliveInterval"/> (half the connection expiry), not two.</summary>
|
||
private void OnKeepAliveTimer()
|
||
{
|
||
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.");
|
||
}
|
||
|
||
/// <summary>Stops the keep-alive, sends Forward Close (Connected only) and closes the session, whose
|
||
/// own dispose sends UnRegisterSession. Later sends throw <see cref="AbStatus.Abort"/>.</summary>
|
||
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<Open>? 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);
|
||
}
|
||
}
|