7e958cd750
R07:L5 (Modbus half, former F2): ModbusTcpTransport builds the MBAP ADU in a pooled buffer and reads the reply header into one per-transport buffer (sends are single-flight under the gate). ModbusRtuOverTcpTransport writes its ADU into a pooled buffer (ModbusRtuFraming.WriteAdu, byte-identical to BuildAdu), and ReadResponsePduAsync reads and validates the whole frame in one pooled buffer instead of five arrays. Only the returned PDU is allocated. The exchange stays one async method: splitting it cost a state-machine box that ate the saving. Scratch A/B per op: RTU deframe 352 -> 184 B, RTU send 640 -> 408 B, TCP send 864 -> 800 B; timings within noise; Driver.Modbus.Tests 2 s before and after. NormalizeWordOrder lives in ModbusDriver (not a transport) and is left alone. Refs #747 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
251 lines
13 KiB
C#
251 lines
13 KiB
C#
using System.Buffers;
|
|
using System.Net.Sockets;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
|
|
|
/// <summary>
|
|
/// Concrete Modbus TCP transport. Wraps a single socket (owned by <see cref="ModbusSocketLifecycle"/>)
|
|
/// 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.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Owns the MBAP framing (<see cref="SendOnceAsync"/>: 7-byte header + transaction-id
|
|
/// pairing) and composes <see cref="ModbusSocketLifecycle"/> for the connect / reconnect /
|
|
/// keep-alive / idle-disconnect machinery — the same lifecycle the RTU-over-TCP transport
|
|
/// reuses with different framing.
|
|
/// </para>
|
|
/// <para>
|
|
/// Survives mid-transaction socket drops: when a send/read fails with a socket-level
|
|
/// error (<see cref="IOException"/>, <see cref="SocketException"/>, <see cref="EndOfStreamException"/>)
|
|
/// the transport disposes the dead socket, reconnects, and retries the PDU exactly
|
|
/// once. Deliberately limited to a single retry — further failures bubble up so the
|
|
/// driver's health surface reflects the real state instead of masking a dead PLC.
|
|
/// </para>
|
|
/// <para>
|
|
/// Why this matters for DL205/DL260: the AutomationDirect H2-ECOM100 does NOT send
|
|
/// TCP keepalives per <c>docs/v2/dl205.md</c> §behavioral-oddities, so any NAT/firewall
|
|
/// between the gateway and PLC can silently close an idle socket after 2-5 minutes.
|
|
/// Also enables OS-level <c>SO_KEEPALIVE</c> so the driver's own side detects a stuck
|
|
/// socket in reasonable time even when the application is mostly idle.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class ModbusTcpTransport : IModbusTransport
|
|
{
|
|
private readonly ModbusSocketLifecycle _life;
|
|
private readonly TimeSpan _timeout;
|
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
|
private ushort _nextTx;
|
|
private int _disposed;
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="ModbusTcpTransport"/> class.</summary>
|
|
/// <param name="host">The host address or hostname of the Modbus server.</param>
|
|
/// <param name="port">The TCP port of the Modbus server.</param>
|
|
/// <param name="timeout">The timeout for socket operations.</param>
|
|
/// <param name="autoReconnect">Whether to automatically reconnect on socket failures.</param>
|
|
/// <param name="keepAlive">Optional keep-alive configuration for the socket.</param>
|
|
/// <param name="idleDisconnect">Optional duration after which an idle socket is disconnected.</param>
|
|
/// <param name="reconnect">Optional reconnect backoff configuration.</param>
|
|
public ModbusTcpTransport(
|
|
string host, int port, TimeSpan timeout, bool autoReconnect = true,
|
|
ModbusKeepAliveOptions? keepAlive = null,
|
|
TimeSpan? idleDisconnect = null,
|
|
ModbusReconnectOptions? reconnect = null)
|
|
{
|
|
_timeout = timeout;
|
|
_life = new ModbusSocketLifecycle(host, port, timeout, autoReconnect, keepAlive, idleDisconnect, reconnect);
|
|
}
|
|
|
|
/// <summary>The socket lifecycle this transport composes (test seam for its clock).</summary>
|
|
internal ModbusSocketLifecycle Lifecycle => _life;
|
|
|
|
// R07:L5: the 7-byte MBAP header of the reply being read. SendOnceAsync runs only under _gate (single-flight), so
|
|
// one buffer per transport serves every transaction (it used to be a fresh array per reply).
|
|
private readonly byte[] _header = new byte[7];
|
|
|
|
/// <inheritdoc />
|
|
public Task ConnectAsync(CancellationToken ct) => _life.ConnectAsync(ct);
|
|
|
|
/// <inheritdoc />
|
|
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
|
{
|
|
if (Volatile.Read(ref _disposed) != 0) throw new ObjectDisposedException(nameof(ModbusTcpTransport));
|
|
|
|
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
|
try
|
|
{
|
|
// Proactive idle-disconnect: if the socket has been quiet longer than the configured
|
|
// 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 (_life.Stream is not null && _life.ShouldReconnectForIdle())
|
|
await _life.TearDownAsync().ConfigureAwait(false);
|
|
|
|
// Self-heal: a socket torn down by an earlier failure (two lost replies in a row, a refused
|
|
// reconnect) is re-dialled HERE, once, throttled by the backoff window. This used to throw
|
|
// "Transport not connected" before the gate, so the transport never recovered on its own and
|
|
// every call failed fast until the 90 s comms-loss watchdog re-initialised the whole driver.
|
|
var freshlyConnected = _life.Stream is null;
|
|
if (freshlyConnected)
|
|
await _life.EnsureConnectedAsync(ct).ConfigureAwait(false);
|
|
|
|
try
|
|
{
|
|
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
|
|
_life.MarkSuccess();
|
|
return result;
|
|
}
|
|
catch (Exception ex) when (!freshlyConnected && _life.AutoReconnect
|
|
&& !ct.IsCancellationRequested
|
|
&& ModbusSocketLifecycle.IsSocketLevelFailure(ex))
|
|
{
|
|
// Mid-transaction drop on a socket that had been live: tear down the dead socket, make ONE
|
|
// throttled reconnect attempt (never a sleep inside the gate), resend. Single retry — if it
|
|
// fails again, let it propagate so health/status reflect reality. A socket this call just
|
|
// dialled is not retried: it proved nothing about a stale connection.
|
|
await _life.TearDownAsync().ConfigureAwait(false);
|
|
await _life.EnsureConnectedAsync(ct).ConfigureAwait(false);
|
|
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
|
|
_life.MarkSuccess();
|
|
return result;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_gate.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Executes exactly one Modbus transaction on the current socket.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A per-op deadline (linked <see cref="CancellationTokenSource.CancelAfter(TimeSpan)"/>)
|
|
/// and framing violations (TxId mismatch / truncated MBAP length) are treated as
|
|
/// <b>connection-fatal</b>: the socket may have a half-read or still-in-flight response,
|
|
/// so reusing it would read stale bytes and desynchronize the stream permanently. Such
|
|
/// failures tear the socket down here (before propagating) and surface as
|
|
/// <see cref="ModbusTransportDesyncException"/> — which, being an
|
|
/// <see cref="IOException"/>, feeds <see cref="SendAsync"/>'s existing single reconnect
|
|
/// retry with no extra plumbing.
|
|
/// </para>
|
|
/// <para>
|
|
/// The timeout-vs-caller-cancel distinction is load-bearing: a <b>caller</b> cancellation
|
|
/// (<c>ct.IsCancellationRequested</c>) is a legitimate shutdown and propagates as a plain
|
|
/// <see cref="OperationCanceledException"/> with <b>no</b> teardown — only the linked
|
|
/// per-op timeout (the caller token is NOT cancelled) is a desync.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task<byte[]> SendOnceAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
|
{
|
|
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. Built in a pooled buffer (R07:L5): the request
|
|
// never escapes, and the write has completed (or been cancelled and the socket torn down) before the finally
|
|
// hands it back. Only the reply PDU, which the caller keeps, is allocated.
|
|
// (One async method on purpose: a second awaited helper costs a state-machine box per call, which ate the saving.)
|
|
var aduLength = 7 + pdu.Length;
|
|
var adu = ArrayPool<byte>.Shared.Rent(aduLength);
|
|
adu[0] = (byte)(txId >> 8);
|
|
adu[1] = (byte)(txId & 0xFF);
|
|
adu[2] = 0; // protocol id
|
|
adu[3] = 0;
|
|
var len = (ushort)(1 + pdu.Length); // unit id + pdu
|
|
adu[4] = (byte)(len >> 8);
|
|
adu[5] = (byte)(len & 0xFF);
|
|
adu[6] = unitId;
|
|
Buffer.BlockCopy(pdu, 0, adu, 7, pdu.Length);
|
|
|
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
cts.CancelAfter(_timeout);
|
|
try
|
|
{
|
|
await stream.WriteAsync(adu.AsMemory(0, aduLength), cts.Token).ConfigureAwait(false);
|
|
await stream.FlushAsync(cts.Token).ConfigureAwait(false);
|
|
|
|
var header = _header;
|
|
await ReadExactlyAsync(stream, header, cts.Token).ConfigureAwait(false);
|
|
var respTxId = (ushort)((header[0] << 8) | header[1]);
|
|
if (respTxId != txId)
|
|
throw new ModbusTransportDesyncException(
|
|
ModbusTransportDesyncException.DesyncReason.TxIdMismatch,
|
|
$"Modbus TxId mismatch: expected {txId} got {respTxId}");
|
|
var respLen = (ushort)((header[4] << 8) | header[5]);
|
|
if (respLen < 1)
|
|
throw new ModbusTransportDesyncException(
|
|
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
|
|
$"Modbus response length too small: {respLen}");
|
|
var respPdu = new byte[respLen - 1];
|
|
await ReadExactlyAsync(stream, respPdu, cts.Token).ConfigureAwait(false);
|
|
|
|
// Length check before indexing: a PDU must carry at least its function code, and an exception
|
|
// PDU its exception code too. Indexing past the end used to throw IndexOutOfRangeException —
|
|
// not an IOException, so no teardown/retry, and a malformed reply read as a driver bug.
|
|
if (respPdu.Length < 1)
|
|
throw new ModbusTransportDesyncException(
|
|
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
|
|
"Modbus response carried no PDU (MBAP length 1)");
|
|
|
|
// 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.
|
|
if ((respPdu[0] & 0x80) != 0)
|
|
{
|
|
if (respPdu.Length < 2)
|
|
throw new ModbusTransportDesyncException(
|
|
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
|
|
$"Modbus exception response fc=0x{respPdu[0]:X2} carried no exception code");
|
|
var fc = (byte)(respPdu[0] & 0x7F);
|
|
var ex = respPdu[1];
|
|
throw new ModbusException(fc, ex, $"Modbus exception fc={fc} code={ex}");
|
|
}
|
|
|
|
return respPdu;
|
|
}
|
|
catch (ModbusTransportDesyncException)
|
|
{
|
|
// Framing violation: the socket is desynchronized — never reuse it.
|
|
await _life.TearDownAsync().ConfigureAwait(false);
|
|
throw;
|
|
}
|
|
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
|
{
|
|
// 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 _life.TearDownAsync().ConfigureAwait(false);
|
|
throw new ModbusTransportDesyncException(
|
|
ModbusTransportDesyncException.DesyncReason.Timeout,
|
|
$"Modbus per-op response timeout after {_timeout.TotalMilliseconds:0}ms");
|
|
}
|
|
finally
|
|
{
|
|
// The write has completed, or was cancelled and the socket torn down, so nothing still reads it.
|
|
ArrayPool<byte>.Shared.Return(adu);
|
|
}
|
|
}
|
|
|
|
private static async Task ReadExactlyAsync(Stream s, byte[] buf, CancellationToken ct)
|
|
{
|
|
var read = 0;
|
|
while (read < buf.Length)
|
|
{
|
|
var n = await s.ReadAsync(buf.AsMemory(read), ct).ConfigureAwait(false);
|
|
if (n == 0) throw new EndOfStreamException("Modbus socket closed mid-response");
|
|
read += n;
|
|
}
|
|
}
|
|
|
|
/// <summary>Asynchronously disposes the transport and underlying socket resources.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
|
await _life.DisposeAsync().ConfigureAwait(false);
|
|
// _gate is deliberately NOT disposed: a SendAsync still in flight at teardown releases it in its finally.
|
|
}
|
|
}
|