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));
+ }
+}