diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusSocketLifecycle.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusSocketLifecycle.cs new file mode 100644 index 00000000..024d28f9 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusSocketLifecycle.cs @@ -0,0 +1,221 @@ +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(); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs index e7701296..f6f94539 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs @@ -3,13 +3,19 @@ using System.Net.Sockets; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus; /// -/// Concrete Modbus TCP transport. Wraps a single and serializes -/// requests so at most one transaction is in-flight at a time — Modbus servers typically -/// support concurrent transactions, but the single-flight model keeps the wire trace +/// Concrete Modbus TCP transport. Wraps a single socket (owned by ) +/// and serializes requests so at most one transaction is in-flight at a time — Modbus servers +/// typically support concurrent transactions, but the single-flight model keeps the wire trace /// easy to diagnose and avoids interleaved-response correlation bugs. /// /// /// +/// Owns the MBAP framing (: 7-byte header + transaction-id +/// pairing) and composes for the connect / reconnect / +/// keep-alive / idle-disconnect machinery — the same lifecycle the RTU-over-TCP transport +/// reuses with different framing. +/// +/// /// Survives mid-transaction socket drops: when a send/read fails with a socket-level /// error (, , ) /// the transport disposes the dead socket, reconnects, and retries the PDU exactly @@ -26,19 +32,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus; /// public sealed class ModbusTcpTransport : IModbusTransport { - private readonly string _host; - private readonly int _port; + private readonly ModbusSocketLifecycle _life; private readonly TimeSpan _timeout; - private readonly bool _autoReconnect; - private readonly ModbusKeepAliveOptions _keepAlive; - private readonly TimeSpan? _idleDisconnect; - private readonly ModbusReconnectOptions _reconnect; private readonly SemaphoreSlim _gate = new(1, 1); - private TcpClient? _client; - private NetworkStream? _stream; private ushort _nextTx; private bool _disposed; - private DateTime _lastSuccessUtc = DateTime.UtcNow; /// Initializes a new instance of the class. /// The host address or hostname of the Modbus server. @@ -54,84 +52,18 @@ public sealed class ModbusTcpTransport : IModbusTransport TimeSpan? idleDisconnect = null, ModbusReconnectOptions? reconnect = null) { - _host = host; - _port = port; _timeout = timeout; - _autoReconnect = autoReconnect; - _keepAlive = keepAlive ?? new ModbusKeepAliveOptions(); - _idleDisconnect = idleDisconnect; - _reconnect = reconnect ?? new ModbusReconnectOptions(); + _life = new ModbusSocketLifecycle(host, port, timeout, autoReconnect, keepAlive, idleDisconnect, reconnect); } /// - 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; - } - - /// - /// 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; - } + public Task ConnectAsync(CancellationToken ct) => _life.ConnectAsync(ct); /// public async Task SendAsync(byte unitId, byte[] pdu, CancellationToken ct) { if (_disposed) throw new ObjectDisposedException(nameof(ModbusTcpTransport)); - if (_stream is null) throw new InvalidOperationException("Transport not connected"); + if (_life.Stream is null) throw new InvalidOperationException("Transport not connected"); await _gate.WaitAsync(ct).ConfigureAwait(false); try @@ -140,27 +72,27 @@ public sealed class ModbusTcpTransport : IModbusTransport // threshold, tear it down + reconnect before this PDU lands. Defends against silent // NAT / firewall reaping where the socket looks alive locally but the upstream side // dropped it minutes ago. - if (_idleDisconnect.HasValue && DateTime.UtcNow - _lastSuccessUtc > _idleDisconnect.Value) + if (_life.ShouldReconnectForIdle()) { - await TearDownAsync().ConfigureAwait(false); - await ConnectWithBackoffAsync(ct).ConfigureAwait(false); + await _life.TearDownAsync().ConfigureAwait(false); + await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false); } try { var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false); - _lastSuccessUtc = DateTime.UtcNow; + _life.MarkSuccess(); return result; } - catch (Exception ex) when (_autoReconnect && IsSocketLevelFailure(ex)) + catch (Exception ex) when (_life.AutoReconnect && ModbusSocketLifecycle.IsSocketLevelFailure(ex)) { // Mid-transaction drop: tear down the dead socket, reconnect (with backoff if // configured), resend. Single retry — if it fails again, let it propagate so // health/status reflect reality. - await TearDownAsync().ConfigureAwait(false); - await ConnectWithBackoffAsync(ct).ConfigureAwait(false); + await _life.TearDownAsync().ConfigureAwait(false); + await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false); var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false); - _lastSuccessUtc = DateTime.UtcNow; + _life.MarkSuccess(); return result; } } @@ -170,43 +102,6 @@ public sealed class ModbusTcpTransport : IModbusTransport } } - /// - /// 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. - /// - private 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; - } - } - } - } - /// /// Executes exactly one Modbus transaction on the current socket. /// @@ -230,7 +125,8 @@ public sealed class ModbusTcpTransport : IModbusTransport /// private async Task SendOnceAsync(byte unitId, byte[] pdu, CancellationToken ct) { - if (_stream is null) throw new InvalidOperationException("Transport not connected"); + var stream = _life.Stream; + if (stream is null) throw new InvalidOperationException("Transport not connected"); var txId = ++_nextTx; // MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU @@ -248,11 +144,11 @@ public sealed class ModbusTcpTransport : IModbusTransport cts.CancelAfter(_timeout); try { - await _stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false); - await _stream.FlushAsync(cts.Token).ConfigureAwait(false); + await stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false); + await stream.FlushAsync(cts.Token).ConfigureAwait(false); var header = new byte[7]; - await ReadExactlyAsync(_stream, header, cts.Token).ConfigureAwait(false); + await ReadExactlyAsync(stream, header, cts.Token).ConfigureAwait(false); var respTxId = (ushort)((header[0] << 8) | header[1]); if (respTxId != txId) throw new ModbusTransportDesyncException( @@ -264,7 +160,7 @@ public sealed class ModbusTcpTransport : IModbusTransport ModbusTransportDesyncException.DesyncReason.TruncatedFrame, $"Modbus response length too small: {respLen}"); var respPdu = new byte[respLen - 1]; - await ReadExactlyAsync(_stream, respPdu, cts.Token).ConfigureAwait(false); + await ReadExactlyAsync(stream, respPdu, cts.Token).ConfigureAwait(false); // Exception PDU: function code has high bit set. This is a well-formed protocol-level // error — the socket is still coherent, so it MUST propagate without teardown. @@ -280,7 +176,7 @@ public sealed class ModbusTcpTransport : IModbusTransport catch (ModbusTransportDesyncException) { // Framing violation: the socket is desynchronized — never reuse it. - await TearDownAsync().ConfigureAwait(false); + await _life.TearDownAsync().ConfigureAwait(false); throw; } catch (OperationCanceledException) when (!ct.IsCancellationRequested) @@ -288,33 +184,13 @@ public sealed class ModbusTcpTransport : IModbusTransport // Per-op timeout fired (NOT the caller). The response is unknown / may still land later // and corrupt the next read — tear the socket down and normalize as a desync so the // reconnect retry / status mapping engages. - await TearDownAsync().ConfigureAwait(false); + await _life.TearDownAsync().ConfigureAwait(false); throw new ModbusTransportDesyncException( ModbusTransportDesyncException.DesyncReason.Timeout, $"Modbus per-op response timeout after {_timeout.TotalMilliseconds:0}ms"); } } - /// - /// 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). - /// - private static bool IsSocketLevelFailure(Exception ex) => - ex is EndOfStreamException - || ex is IOException - || ex is SocketException - || ex is ObjectDisposedException; - - private 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; - } - private static async Task ReadExactlyAsync(Stream s, byte[] buf, CancellationToken ct) { var read = 0; @@ -332,12 +208,7 @@ public sealed class ModbusTcpTransport : IModbusTransport { if (_disposed) return; _disposed = true; - try - { - if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false); - } - catch { /* best-effort */ } - _client?.Dispose(); + await _life.DisposeAsync().ConfigureAwait(false); _gate.Dispose(); } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusEdgeCaseValidationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusEdgeCaseValidationTests.cs index a9879e8d..243c343d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusEdgeCaseValidationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusEdgeCaseValidationTests.cs @@ -14,7 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; /// (2) Sub-second values on ModbusKeepAliveOptions.Time / /// Interval — the int-cast in EnableKeepAlive truncated 500 ms to /// 0, which most OSes interpret as "use the default", silently defeating the -/// configured timing. ModbusTcpTransport.ClampToWholeSeconds rounds up to a minimum +/// configured timing. ModbusSocketLifecycle.ClampToWholeSeconds rounds up to a minimum /// of 1 second. /// [Trait("Category", "Unit")] @@ -70,7 +70,7 @@ public sealed class ModbusEdgeCaseValidationTests [InlineData(60_000, 60)] public void ClampToWholeSeconds_rounds_up_to_at_least_one_second(int ms, int expected) { - ModbusTcpTransport.ClampToWholeSeconds(TimeSpan.FromMilliseconds(ms)).ShouldBe(expected); + ModbusSocketLifecycle.ClampToWholeSeconds(TimeSpan.FromMilliseconds(ms)).ShouldBe(expected); } /// Verifies that negative time spans are treated as one second. @@ -80,6 +80,6 @@ public sealed class ModbusEdgeCaseValidationTests // Defensive — operators occasionally configure a negative TimeSpan thinking it disables // the feature. The OS would reject the negative int — clamping to 1 keeps the socket // valid until the operator fixes the config. - ModbusTcpTransport.ClampToWholeSeconds(TimeSpan.FromSeconds(-5)).ShouldBe(1); + ModbusSocketLifecycle.ClampToWholeSeconds(TimeSpan.FromSeconds(-5)).ShouldBe(1); } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusSocketLifecycleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusSocketLifecycleTests.cs new file mode 100644 index 00000000..210c9657 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusSocketLifecycleTests.cs @@ -0,0 +1,33 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; + +/// +/// Pins the socket-lifecycle surface extracted from into the +/// reusable (Task 3). The clamp helper moved with it, and a +/// connect to a dead port must still surface the underlying socket failure. +/// +[Trait("Category", "Unit")] +public sealed class ModbusSocketLifecycleTests +{ + /// Verifies the whole-seconds clamp rounds sub-second/negative durations up to a minimum of one. + /// The input duration in seconds. + /// The expected clamped value in whole seconds. + [Theory] + [InlineData(0.4, 1)] // sub-second rounds up to 1 (the int-cast truncation guard) + [InlineData(2.0, 2)] + [InlineData(-5.0, 1)] // negative clamps to 1 + public void ClampToWholeSeconds_rounds_up_min_one(double seconds, int expected) + => ModbusSocketLifecycle.ClampToWholeSeconds(TimeSpan.FromSeconds(seconds)).ShouldBe(expected); + + /// Verifies a connect to a dead port surfaces the underlying socket failure. + [Fact] + public async Task Connect_to_dead_port_surfaces_socket_failure() + { + var life = new ModbusSocketLifecycle("127.0.0.1", 1, TimeSpan.FromMilliseconds(300), + autoReconnect: false); + await Should.ThrowAsync( + life.ConnectAsync(TestContext.Current.CancellationToken)); + } +}