using System.Net.Sockets; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus; /// /// Owns the raw TCP socket lifecycle shared by every Modbus-over-TCP transport (MBAP and /// RTU-over-TCP alike): the IPv4-preference connect, OS-level keep-alive, throttled /// reconnect (one attempt per geometric-backoff window), idle-disconnect tracking, and teardown. It exposes the current /// so the composing transport can drive its own framing on top — /// the lifecycle knows nothing about MBAP / RTU wire layout. /// /// /// /// Extracted verbatim from so the connect / reconnect / /// keep-alive / idle behaviour is written once and composed by both transports. The /// constructor is connection-free — all socket I/O happens in / /// (one throttled attempt per call — never a sleep loop). /// /// /// Why keep-alive matters for DL205/DL260: the AutomationDirect H2-ECOM100 does NOT send /// TCP keepalives per docs/v2/dl205.md §behavioral-oddities, so any NAT/firewall /// between the gateway and PLC can silently close an idle socket after 2-5 minutes. /// Enabling OS-level SO_KEEPALIVE lets the driver's own side detect a stuck socket /// in reasonable time even when the application is mostly idle. /// /// public sealed class ModbusSocketLifecycle : IAsyncDisposable { private readonly string _host; private readonly int _port; private readonly TimeSpan _timeout; private readonly bool _autoReconnect; private readonly ModbusKeepAliveOptions _keepAlive; private readonly TimeSpan? _idleDisconnect; private readonly ModbusReconnectOptions _reconnect; private TcpClient? _client; private NetworkStream? _stream; private DateTime _lastSuccessUtc = DateTime.UtcNow; /// Monotonic millisecond clock for the reconnect throttle (test seam). internal Func NowMs { get; set; } = () => Environment.TickCount64; /// Initializes a new instance of the class. /// The host address or hostname of the Modbus server. /// The TCP port of the Modbus server. /// The timeout for socket operations. /// Whether to automatically reconnect on socket failures. /// Optional keep-alive configuration for the socket. /// Optional duration after which an idle socket is disconnected. /// Optional reconnect backoff configuration. public ModbusSocketLifecycle( string host, int port, TimeSpan timeout, bool autoReconnect = true, ModbusKeepAliveOptions? keepAlive = null, TimeSpan? idleDisconnect = null, ModbusReconnectOptions? reconnect = null) { _host = host; _port = port; _timeout = timeout; _autoReconnect = autoReconnect; _keepAlive = keepAlive ?? new ModbusKeepAliveOptions(); _idleDisconnect = idleDisconnect; _reconnect = reconnect ?? new ModbusReconnectOptions(); } /// Gets the current live , or when not connected. public NetworkStream? Stream => _stream; /// Gets a value indicating whether auto-reconnect on socket failures is enabled. public bool AutoReconnect => _autoReconnect; /// Establishes a connection to the Modbus server, preferring IPv4. /// A cancellation token to observe for cancellation. /// A task representing the asynchronous connection operation. public async Task ConnectAsync(CancellationToken ct) { // A previous socket (failed, torn down or idle-reaped) is released FIRST: overwriting _client // used to strand the old TcpClient for the finalizer — up to nine per backoff loop. ThrowIfDisposed(); await TearDownAsync().ConfigureAwait(false); // DNS resolution runs under the same per-op deadline as the TCP connect: an unresolvable or // slow resolver must not hold the transport gate for the OS resolver's own (much longer) timeout. using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(_timeout); TcpClient? client = null; try { // Resolve the host explicitly + prefer IPv4. .NET's TcpClient default-constructor is // dual-stack (IPv6 first, fallback to IPv4) — but most Modbus TCP devices (PLCs and // simulators like pymodbus) bind 0.0.0.0 only, so the IPv6 attempt times out and we // burn the entire ConnectAsync budget before even trying IPv4. Resolving first + // dialing the IPv4 address directly sidesteps that. var addresses = await System.Net.Dns.GetHostAddressesAsync(_host, cts.Token).ConfigureAwait(false); var ipv4 = System.Linq.Enumerable.FirstOrDefault(addresses, a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork); var target = ipv4 ?? (addresses.Length > 0 ? addresses[0] : System.Net.IPAddress.Loopback); client = new TcpClient(target.AddressFamily); EnableKeepAlive(client, _keepAlive); await client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false); ThrowIfDisposed(); // disposed while dialling — the finally releases the fresh client _stream = client.GetStream(); _client = client; client = null; // ownership transferred _lastSuccessUtc = DateTime.UtcNow; } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { // The per-op deadline, not the caller: normalise to a socket-level failure so every // classifier treats a connect timeout as a comms fault. throw new ModbusTransportUnavailableException( $"Modbus connect to {_host}:{_port} timed out after {_timeout.TotalMilliseconds:0}ms"); } finally { // A failed attempt disposes its own client — never leave it for the finalizer. client?.Dispose(); } } // Reconnect throttle state. Guarded by the composing transport's single-flight gate (every caller of // EnsureConnectedAsync holds it), so no extra lock is needed. private long _connectNotBeforeMs; private TimeSpan _failureDelay = TimeSpan.Zero; /// /// Ensures a live socket for the next transaction with AT MOST ONE connect attempt, throttled by the /// configured geometric backoff: after a failed attempt, calls inside the backoff window fail fast /// with and never dial. The window starts at /// (floored at 1 s) and grows by /// BackoffMultiplier per consecutive failure up to MaxDelay; a successful connect /// resets it. Never sleeps — the composing transport calls it inside its gate, and a sleep there /// used to queue every read, write and probe behind one dead socket for up to ~81 s. /// /// A cancellation token to observe for cancellation. /// A task that completes when a socket is live. /// Auto-reconnect is off, or the call is inside the backoff window. public async Task EnsureConnectedAsync(CancellationToken ct) { if (_stream is not null) return; if (!_autoReconnect) throw new ModbusTransportUnavailableException( $"Modbus transport to {_host}:{_port} is not connected and auto-reconnect is disabled"); var now = NowMs(); if (now < _connectNotBeforeMs) throw new ModbusTransportUnavailableException( $"Modbus reconnect to {_host}:{_port} throttled for another {_connectNotBeforeMs - now}ms after a failed connect"); try { await ConnectAsync(ct).ConfigureAwait(false); _failureDelay = TimeSpan.Zero; _connectNotBeforeMs = 0; } catch (Exception) when (!ct.IsCancellationRequested) { _failureDelay = NextDelay(_failureDelay); _connectNotBeforeMs = NowMs() + (long)_failureDelay.TotalMilliseconds; throw; } } /// Minimum reconnect window after a failed connect — a dead device is dialled at most once per second. private static readonly TimeSpan MinReconnectWindow = TimeSpan.FromSeconds(1); private TimeSpan NextDelay(TimeSpan previous) { if (previous <= TimeSpan.Zero) { var first = _reconnect.InitialDelay > MinReconnectWindow ? _reconnect.InitialDelay : MinReconnectWindow; return first < _reconnect.MaxDelay ? first : _reconnect.MaxDelay > TimeSpan.Zero ? _reconnect.MaxDelay : first; } // Geometric growth, capped. Math.Min on ticks so a pathological multiplier cannot overflow. var multiplier = _reconnect.BackoffMultiplier >= 1.0 ? _reconnect.BackoffMultiplier : 1.0; var nextTicks = previous.Ticks * multiplier; var cap = _reconnect.MaxDelay > TimeSpan.Zero ? _reconnect.MaxDelay.Ticks : long.MaxValue; return TimeSpan.FromTicks(nextTicks >= cap ? cap : (long)nextTicks); } /// /// Reports whether the socket has been idle longer than the configured idle-disconnect /// threshold and should be proactively torn down + reconnected before the next transaction. /// Always when no idle-disconnect timeout is configured. /// /// when the idle threshold has been exceeded. public bool ShouldReconnectForIdle() => _idleDisconnect.HasValue && DateTime.UtcNow - _lastSuccessUtc > _idleDisconnect.Value; /// Records that a transaction just succeeded, resetting the idle-disconnect clock. public void MarkSuccess() => _lastSuccessUtc = DateTime.UtcNow; /// Tears down the current socket + stream, leaving the lifecycle ready to reconnect. /// A task representing the asynchronous teardown operation. public async Task TearDownAsync() { try { if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false); } catch { /* best-effort */ } _stream = null; try { _client?.Dispose(); } catch { } _client = null; } /// /// Enable SO_KEEPALIVE with aggressive probe timing. DL205/DL260 doesn't send keepalives /// itself; having the OS probe the socket every ~30s lets the driver notice a dead PLC /// or broken NAT path long before the default 2-hour Windows idle timeout fires. /// Non-fatal if the underlying OS rejects the option (some older Linux / container /// sandboxes don't expose the fine-grained timing levers — the driver still works, /// application-level probe still detects problems). /// private static void EnableKeepAlive(TcpClient client, ModbusKeepAliveOptions opts) { if (!opts.Enabled) return; try { client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); // A TimeSpan < 1s previously truncated to 0 via the int cast, // which Windows / Linux interpret as "use the default" — silently defeating the // configured keep-alive timing. Round up to at least 1 second so a sub-second // configuration still produces a real keep-alive cadence. Negative values are // also clamped to 1 to avoid surfacing as OS errors. client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, ClampToWholeSeconds(opts.Time)); client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, ClampToWholeSeconds(opts.Interval)); client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, opts.RetryCount); } catch { /* best-effort; older OSes may not expose the granular knobs */ } } /// /// Cast a to a whole number of seconds with a /// minimum of 1 — protects callers from the int-cast truncation that turned 500 ms /// keep-alive timing into "use the default" on most OSes. /// /// The timespan to clamp to whole seconds. /// The clamped duration expressed as a whole number of seconds, never less than 1. internal static int ClampToWholeSeconds(TimeSpan ts) { var seconds = (int)Math.Ceiling(ts.TotalSeconds); return seconds < 1 ? 1 : seconds; } /// /// Distinguish socket-layer failures (eligible for reconnect-and-retry) from /// protocol-layer failures (must propagate — retrying the same PDU won't help if the /// PLC just returned exception 02 Illegal Data Address). /// /// The exception to classify. /// when the exception is a socket-level failure. internal static bool IsSocketLevelFailure(Exception ex) => ex is EndOfStreamException || ex is IOException || ex is SocketException || ex is ObjectDisposedException; /// Asynchronously disposes the underlying socket + stream resources. /// A task that represents the asynchronous operation. public async ValueTask DisposeAsync() { // Latch first: a SendAsync still in flight (the driver tearing down under it) must not reconnect // a fresh socket after this point — ConnectAsync checks the latch both before and after dialling. Volatile.Write(ref _disposed, 1); await TearDownAsync().ConfigureAwait(false); } private int _disposed; private void ThrowIfDisposed() { if (Volatile.Read(ref _disposed) != 0) throw new ObjectDisposedException(nameof(ModbusSocketLifecycle)); } }