Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs
T
Joseph Doherty 1f983d610f test(abcip): hw5 hardware gate — #566 idle close drops TCP; native STRING write on Z-35162 (#592)
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
2026-10-09 06:16:16 -04:00

385 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}