using System.Net.Sockets; namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip; /// /// One EtherNet/IP TCP session: connects, registers, and sends SendRRData (unconnected) and /// SendUnitData (connected) requests with at most one request in flight. /// /// /// Single flight. Every exchange (write one frame, read until its reply) runs under a /// (1,1). The connected sequence count is advanced inside that gate, so /// concurrent callers can never put sequences on the wire out of order. /// Caller cancellation never faults the connection, and never misattributes a reply /// (rulings R5-1/R5-2). The caller gets at once, but the /// abandoned exchange keeps reading its own reply in the background while still holding the /// gate, bounded by the original timeout. The reply is discarded and the connection stays healthy; /// if the timeout elapses first the connection faults exactly as a normal timeout does. Because the /// next request is not written until the previous reply has been consumed, a late reply can never be /// taken for the next request's — which matters because ab_server zeroes the reply sender context, /// so context matching alone cannot tell two unconnected replies apart. /// Faults. A timeout, an I/O error or an encapsulation-level protocol error /// () closes the socket; turns false and /// every later call throws (). /// The owner reconnects with a fresh client. /// No half-written frames. A frame write never observes a cancellation token. It is /// nevertheless bounded: the exchange timeout also covers the write, and if it elapses first the /// connection is faulted (socket closed, which fails the pending write) rather than the write /// being cancelled — so a peer that stops reading cannot hold the single-flight gate forever, and a /// torn frame can only ever sit on a socket that is already dead. /// Timeouts are finite. Every entry point rejects a non-positive or infinite timeout /// (): the abandoned-exchange drain is bounded by it, so an /// infinite one would let a cancelled caller's drain hold the gate forever. The gate wait itself is /// bounded by the same timeout (, nothing written, connection intact). /// RegisterSession. It goes out with a zero sender context (ab_server refuses a non-zero /// one) and therefore relies on the unconnected "reply context == 0 is tolerated" rule in /// TryMatch to match its reply; both ends of that dependency are pinned by tests. /// public sealed class EncapsulationClient : IAsyncDisposable { private static readonly TimeSpan UnregisterBudget = TimeSpan.FromMilliseconds(500); private readonly TcpClient _tcp; private readonly NetworkStream _stream; // Never disposed: an abandoned exchange may release it after DisposeAsync returns, and // SemaphoreSlim.Release on a disposed instance throws. It holds no unmanaged resource unless // AvailableWaitHandle is touched, which this class never does. private readonly SemaphoreSlim _gate = new(1, 1); private long _ctx; private ushort _seq; // connected sequence count; only touched under _gate private volatile bool _faulted; private int _disposed; /// The session handle the target assigned in its RegisterSession reply. public uint SessionHandle { get; private set; } /// False once the connection has faulted or been disposed. public bool IsConnected => !_faulted && Volatile.Read(ref _disposed) == 0 && _tcp.Connected; private EncapsulationClient(TcpClient tcp) { _tcp = tcp; _stream = tcp.GetStream(); } /// Open the TCP connection and register an encapsulation session. /// bounds the TCP connect and the RegisterSession exchange separately. public static async Task ConnectAsync(string host, int port, TimeSpan timeout, CancellationToken ct) { ValidateTimeout(timeout); var tcp = new TcpClient { NoDelay = true }; try { using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(timeout); await tcp.ConnectAsync(host, port, cts.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { tcp.Dispose(); throw new AbTransportException(AbStatus.Timeout, $"TCP connect to {host}:{port} timed out."); } catch (SocketException ex) { tcp.Dispose(); throw new AbTransportException(AbStatus.BadConnection, $"TCP connect to {host}:{port} failed: {ex.SocketErrorCode}", ex); } catch { tcp.Dispose(); throw; } var client = new EncapsulationClient(tcp); try { var (header, _) = await client.ExchangeAsync(connected: false, (_, _) => EncapsulationCodec.RegisterSession(), timeout, ct).ConfigureAwait(false); client.SessionHandle = header.SessionHandle; return client; } catch { client.Abort(); throw; } } /// Send one unconnected (UCMM) CIP request and return the CIP reply bytes. public async Task> SendRRDataAsync(ReadOnlyMemory cip, TimeSpan timeout, CancellationToken ct) { ValidateTimeout(timeout); var secs = (ushort)Math.Clamp((int)Math.Ceiling(timeout.TotalSeconds), 1, ushort.MaxValue); var (_, reply) = await ExchangeAsync(connected: false, (ctx, _) => EncapsulationCodec.SendRRData(SessionHandle, ctx, cip.Span, secs), timeout, ct).ConfigureAwait(false); return reply; } /// Send one connected (class 3) CIP request over and return /// the CIP reply bytes. The client owns the connected sequence count (review B1). public async Task> SendUnitDataAsync(uint otConnectionId, ReadOnlyMemory cip, TimeSpan timeout, CancellationToken ct) { ValidateTimeout(timeout); var (_, reply) = await ExchangeAsync(connected: true, (ctx, seq) => EncapsulationCodec.SendUnitData(SessionHandle, ctx, otConnectionId, seq, cip.Span), timeout, ct) .ConfigureAwait(false); return reply; } private static void ValidateTimeout(TimeSpan timeout) { if (timeout <= TimeSpan.Zero || timeout == Timeout.InfiniteTimeSpan) throw new ArgumentOutOfRangeException(nameof(timeout), timeout, "Timeout must be finite and positive."); } /// The connected sequence count after : increments, wrapping /// 65535 → 1 (0 is never put on the wire). internal static ushort NextSequence(ushort current) => current == ushort.MaxValue ? (ushort)1 : (ushort)(current + 1); /// Acquire the gate, build the request (sequence assigned under the gate), then hand the /// gate to , which owns its release from that point on. private async Task<(EncapsulationHeader Header, ReadOnlyMemory Cip)> ExchangeAsync( bool connected, Func build, TimeSpan timeout, CancellationToken ct) { ThrowIfUnusable(); if (!await _gate.WaitAsync(timeout, ct).ConfigureAwait(false)) throw new AbTransportException(AbStatus.Timeout, $"Timed out after {timeout.TotalMilliseconds:F0} ms waiting for the session's single-flight gate."); Task<(EncapsulationHeader, ReadOnlyMemory)> exchange; try { ThrowIfUnusable(); // a drain we queued behind may have faulted ct.ThrowIfCancellationRequested(); // nothing written yet: cancel cleanly var ctx = (ulong)Interlocked.Increment(ref _ctx); ushort seq = 0; if (connected) seq = _seq = NextSequence(_seq); var request = build(ctx, seq); exchange = RunExchangeAsync(request, connected, ctx, seq, timeout); } catch { _gate.Release(); // RunExchangeAsync never started: release here throw; } try { return await exchange.WaitAsync(ct).ConfigureAwait(false); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { // Caller abandoned the wait (R5-1). The exchange keeps draining its own reply under the // gate and faults on its own timeout; observe its outcome so a fault is never unobserved. _ = exchange.ContinueWith(static t => _ = t.Exception, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); throw; } } /// Write one frame, then read frames until the matching reply arrives. Releases the gate /// exactly once, in its finally. Never throws : every /// failure surfaces as and faults the connection first. private async Task<(EncapsulationHeader, ReadOnlyMemory)> RunExchangeAsync( byte[] request, bool connected, ulong ctx, ushort seq, TimeSpan timeout) { var cmd = (ushort)(request[0] | (request[1] << 8)); try { using var timeoutCts = new CancellationTokenSource(timeout); try { // The write ignores cancellation (a half-written frame would desync the stream) but is // bounded: on expiry the catch below faults the connection, closing the socket, which // is what fails the still-pending write. var write = _stream.WriteAsync(request, CancellationToken.None).AsTask(); _ = write.ContinueWith(static t => _ = t.Exception, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); await write.WaitAsync(timeoutCts.Token).ConfigureAwait(false); while (true) { var frame = await ReadFrameAsync(timeoutCts.Token).ConfigureAwait(false); if (TryMatch(frame, cmd, connected, ctx, seq, out var reply)) return reply; } } catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) { Fault(); throw new AbTransportException(AbStatus.Timeout, $"No reply to command 0x{cmd:X4} within {timeout.TotalMilliseconds:F0} ms."); } catch (AbTransportException) { Fault(); throw; } catch (Exception ex) when (ex is IOException or SocketException or ObjectDisposedException or EndOfStreamException or InvalidOperationException) { Fault(); throw new AbTransportException(AbStatus.BadConnection, $"Connection lost: {ex.Message}", ex); } } finally { _gate.Release(); } } /// Does answer the request in flight? Unconnected replies match on /// command and sender context, tolerating a zeroed context (ab_server, R5-2); connected replies match /// on the echoed sequence count. Anything else is skipped. A matching frame is fully validated, so an /// encapsulation error throws BadReply. private static bool TryMatch(byte[] frame, ushort cmd, bool connected, ulong ctx, ushort seq, out (EncapsulationHeader, ReadOnlyMemory) reply) { reply = default; var h = EncapsulationCodec.ReadHeader(frame); if (h.Command != cmd) return false; if (!connected) { // "== 0" is load-bearing, not just ab_server tolerance: RegisterSession is sent with a ZERO // sender context (ab_server refuses non-zero), so its reply echoes 0 and matches ONLY here. if (h.SenderContext != ctx && h.SenderContext != 0) return false; var p = EncapsulationCodec.ParseReply(frame, expectConnected: false); reply = (p.Header, p.Cip); return true; } // A connected error reply (status != 0) carries no readable sequence; with one request in flight // and abandoned exchanges drained, it can only be ours — ParseReply throws BadReply for it. var c = EncapsulationCodec.ParseReply(frame, expectConnected: true); if (c.Sequence != seq) return false; reply = (c.Header, c.Cip); return true; } private async Task ReadFrameAsync(CancellationToken ct) { var head = new byte[EncapsulationHeader.Size]; await _stream.ReadExactlyAsync(head, ct).ConfigureAwait(false); var len = head[2] | (head[3] << 8); var frame = new byte[EncapsulationHeader.Size + len]; head.CopyTo(frame, 0); await _stream.ReadExactlyAsync(frame.AsMemory(EncapsulationHeader.Size), ct).ConfigureAwait(false); return frame; } private void ThrowIfUnusable() { if (Volatile.Read(ref _disposed) != 0) throw new AbTransportException(AbStatus.BadConnection, "Session is disposed."); if (_faulted) throw new AbTransportException(AbStatus.BadConnection, "Session is faulted; reconnect."); } private void Fault() { _faulted = true; try { _tcp.Close(); } catch { /* already closed */ } } /// Close without UnRegisterSession (registration never completed). private void Abort() { Interlocked.Exchange(ref _disposed, 1); Fault(); } /// Best-effort UnRegisterSession, then close. Waits briefly for an in-flight exchange so the /// UnRegisterSession frame is never interleaved with another frame; if the gate is not free in time /// the socket is closed without it. public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref _disposed, 1) != 0) return; var acquired = await _gate.WaitAsync(UnregisterBudget).ConfigureAwait(false); try { if (acquired && !_faulted && _tcp.Connected) { try { var bytes = EncapsulationCodec.UnRegisterSession(SessionHandle, (ulong)Interlocked.Increment(ref _ctx)); using var cts = new CancellationTokenSource(UnregisterBudget); await _stream.WriteAsync(bytes, cts.Token).ConfigureAwait(false); await _stream.FlushAsync(cts.Token).ConfigureAwait(false); } catch { /* best effort: the socket is closed next either way */ } } } finally { Fault(); _tcp.Dispose(); if (acquired) _gate.Release(); } } }