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, geometric-backoff /// reconnect, 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 / /// . /// /// /// 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; /// 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) { // 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, ct).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); using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(_timeout); await _client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false); _stream = _client.GetStream(); _lastSuccessUtc = DateTime.UtcNow; } /// /// Connect attempt with the configured geometric backoff. The first attempt fires after /// (default zero — immediate); each /// subsequent attempt sleeps for the previous delay times BackoffMultiplier, /// capped at MaxDelay. Caller's cancellation token aborts the loop. /// /// A cancellation token to observe for cancellation. /// A task representing the asynchronous reconnect operation. public async Task ConnectWithBackoffAsync(CancellationToken ct) { var delay = _reconnect.InitialDelay; var attempt = 0; while (true) { if (delay > TimeSpan.Zero) await Task.Delay(delay, ct).ConfigureAwait(false); try { await ConnectAsync(ct).ConfigureAwait(false); return; } catch (Exception ex) when (IsSocketLevelFailure(ex) && _autoReconnect) { attempt++; // Geometric growth, capped. Use Math.Min on ticks so we don't overflow with // pathological multipliers / long deployments. var nextTicks = (long)(Math.Max(delay.Ticks, TimeSpan.FromMilliseconds(100).Ticks) * _reconnect.BackoffMultiplier); delay = TimeSpan.FromTicks(Math.Min(nextTicks, _reconnect.MaxDelay.Ticks)); if (attempt >= 10) { // Bail after 10 attempts to surface persistent failure to the caller. With // the default backoff (1s base, 2.0x mult, 30s cap) this is roughly 4 minutes // of attempts; with InitialDelay=0 it's immediate up to the same cap. throw; } } } } /// /// 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() { try { if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false); } catch { /* best-effort */ } _client?.Dispose(); } }