fix(modbus): transport self-heals with one throttled in-gate connect; PDU length check

P3: a torn-down socket is re-dialled inside the gate (one attempt per backoff
window) instead of failing fast with 'Transport not connected' until the 90 s
comms-loss watchdog re-initialised the driver.
L3: ConnectAsync disposes the previous/failed TcpClient, DNS runs under the
per-op timeout, and the reconnect never sleeps inside the gate (the old
10-attempt backoff held it for ~81 s).
H9: an MBAP response with no PDU, or an exception PDU with no code, is a
TruncatedFrame desync rather than an IndexOutOfRangeException.

Refs #741
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 15:02:11 -04:00
parent 7bcb6331ed
commit 530f1b2f0b
8 changed files with 655 additions and 70 deletions
@@ -0,0 +1,23 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Raised when a Modbus transport has no live socket and cannot get one for this call: the one
/// in-gate connect attempt failed, the reconnect is inside its backoff window, or auto-reconnect is
/// disabled. It says the ENDPOINT is unreachable, so the driver fails the rest of the batch fast
/// instead of paying the same failure once per tag.
/// </summary>
/// <remarks>
/// Derives from <see cref="IOException"/> so every existing socket-level classifier
/// (<c>ModbusSocketLifecycle.IsSocketLevelFailure</c>, the driver's comms-fault mapping) treats it as
/// a communication failure without extra plumbing.
/// </remarks>
public sealed class ModbusTransportUnavailableException : IOException
{
/// <summary>Initializes a new instance of the <see cref="ModbusTransportUnavailableException"/> class.</summary>
/// <param name="message">A human-readable description.</param>
/// <param name="inner">The originating exception, if any.</param>
public ModbusTransportUnavailableException(string message, Exception? inner = null)
: base(message, inner)
{
}
}