using System.Net; using System.Net.Sockets; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; /// /// Exercises against a real TCP listener that can close /// its socket mid-session on demand. Verifies the PR 53 reconnect-on-drop behavior: after /// the "first" socket is forcibly torn down, the next SendAsync must re-establish the /// connection and complete the PDU without bubbling an error to the caller. /// [Trait("Category", "Unit")] public sealed class ModbusTcpReconnectTests { /// /// Minimal in-process Modbus-TCP stub. Accepts one TCP connection at a time, reads an /// MBAP + PDU, replies with a canned FC03 response echoing the request quantity of /// zeroed bytes, then optionally closes the socket to simulate a NAT/firewall drop. /// private sealed class FlakeyModbusServer : IAsyncDisposable { private readonly TcpListener _listener; /// Gets the TCP port the server is listening on. public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; /// Gets or sets the number of transactions to complete before closing the connection. public int DropAfterNTransactions { get; set; } = int.MaxValue; private readonly CancellationTokenSource _stop = new(); private int _txCount; // --- Scriptable STAB-3 desync behaviors (each fires for the first N transactions, then // the server behaves normally). Decremented atomically because an old (torn-down) serve // task and a fresh one can briefly coexist. --- private int _stallRemaining; private int _wrongTxRemaining; private int _invalidLenRemaining; private int _acceptedConnections; /// The first N responses are withheld entirely — the client blocks until its per-op timeout / caller cancellation fires. public int StallFirstNResponses { set => _stallRemaining = value; } /// The first N responses echo a corrupted MBAP transaction id (framing violation). public int WrongTxIdFirstNResponses { set => _wrongTxRemaining = value; } /// The first N responses claim an MBAP length field of 0 (truncated-length framing violation). public int InvalidLengthFirstNResponses { set => _invalidLenRemaining = value; } /// Gets the number of TCP connections the listener has accepted (a reconnect increments this). public int AcceptedConnectionCount => Volatile.Read(ref _acceptedConnections); /// Initializes a new instance and starts listening on a loopback port. public FlakeyModbusServer() { _listener = new TcpListener(IPAddress.Loopback, 0); _listener.Start(); _ = Task.Run(AcceptLoopAsync); } private async Task AcceptLoopAsync() { while (!_stop.IsCancellationRequested) { TcpClient? client = null; try { client = await _listener.AcceptTcpClientAsync(_stop.Token); } catch { return; } Interlocked.Increment(ref _acceptedConnections); _ = Task.Run(() => ServeAsync(client!)); } } private async Task ServeAsync(TcpClient client) { try { using var _ = client; var stream = client.GetStream(); while (!_stop.IsCancellationRequested && client.Connected) { var header = new byte[7]; if (!await ReadExactly(stream, header)) return; var len = (ushort)((header[4] << 8) | header[5]); var pdu = new byte[len - 1]; if (!await ReadExactly(stream, pdu)) return; // Stall: read the request but never answer it — simulate a hung / unreachable // unit so the client's per-op CancelAfter (or caller token) fires. if (Interlocked.Decrement(ref _stallRemaining) >= 0) continue; var fc = pdu[0]; var qty = (ushort)((pdu[3] << 8) | pdu[4]); // Truncated-length framing violation: a 7-byte header whose length field is 0 // (< the mandatory 1 unit-id byte) — trips the transport's `respLen < 1` guard. if (Interlocked.Decrement(ref _invalidLenRemaining) >= 0) { var bad = new byte[7]; bad[0] = header[0]; bad[1] = header[1]; bad[4] = 0; bad[5] = 0; // length field = 0 bad[6] = header[6]; await stream.WriteAsync(bad); await stream.FlushAsync(); continue; } var respPdu = new byte[2 + qty * 2]; respPdu[0] = fc; respPdu[1] = (byte)(qty * 2); // data bytes stay 0 var respLen = (ushort)(1 + respPdu.Length); var adu = new byte[7 + respPdu.Length]; adu[0] = header[0]; adu[1] = header[1]; // TxId-mismatch framing violation: emit an otherwise valid, full frame but with // the transaction id flipped, so the transport reads the whole (unexpected) // response and rejects it on the TxId guard. if (Interlocked.Decrement(ref _wrongTxRemaining) >= 0) { adu[0] = (byte)~header[0]; adu[1] = (byte)~header[1]; } adu[4] = (byte)(respLen >> 8); adu[5] = (byte)(respLen & 0xFF); adu[6] = header[6]; Buffer.BlockCopy(respPdu, 0, adu, 7, respPdu.Length); await stream.WriteAsync(adu); await stream.FlushAsync(); _txCount++; if (_txCount >= DropAfterNTransactions) { // Simulate NAT/firewall silent close: slam the socket without a // protocol-level goodbye, which is what DL260 + an intermediate // middlebox would look like from the client's perspective. client.Client.Shutdown(SocketShutdown.Both); client.Close(); return; } } } catch { /* best-effort */ } } private static async Task ReadExactly(NetworkStream s, byte[] buf) { var read = 0; while (read < buf.Length) { var n = await s.ReadAsync(buf.AsMemory(read)); if (n == 0) return false; read += n; } return true; } /// Stops the server and releases resources. public async ValueTask DisposeAsync() { _stop.Cancel(); _listener.Stop(); await Task.CompletedTask; } } /// Verifies that the transport recovers from a mid-session socket drop with auto-reconnect enabled. [Fact] public async Task Transport_recovers_from_mid_session_drop_and_retries_successfully() { await using var server = new FlakeyModbusServer { DropAfterNTransactions = 1 }; await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: true); await transport.ConnectAsync(TestContext.Current.CancellationToken); // First transaction succeeds; server then closes the socket. var pdu = new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 }; var first = await transport.SendAsync(unitId: 1, pdu, TestContext.Current.CancellationToken); first[0].ShouldBe((byte)0x03); // Second transaction: the connection is dead, but auto-reconnect must transparently // spin up a new socket, resend, and produce a valid response. Before PR 53 this would // surface as EndOfStreamException / IOException to the caller. var second = await transport.SendAsync(unitId: 1, pdu, TestContext.Current.CancellationToken); second[0].ShouldBe((byte)0x03); } /// Verifies that socket drops propagate to the caller when auto-reconnect is disabled. [Fact] public async Task Transport_without_AutoReconnect_propagates_drop_to_caller() { await using var server = new FlakeyModbusServer { DropAfterNTransactions = 1 }; await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: false); await transport.ConnectAsync(TestContext.Current.CancellationToken); var pdu = new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 }; _ = await transport.SendAsync(unitId: 1, pdu, TestContext.Current.CancellationToken); await Should.ThrowAsync(async () => await transport.SendAsync(unitId: 1, pdu, TestContext.Current.CancellationToken)); } // --- STAB-3: per-op timeout + framing violations must be classified connection-fatal so the // socket is torn down instead of left desynchronized. With auto-reconnect on, the transport // transparently reconnects + resends once and returns the CORRECT response (no stale read); // the reconnect is observable as a second accepted connection on the server. Before the fix // the timeout OCE / InvalidDataException are not socket-level, so no teardown happens and the // SendAsync throws (and the socket stays desynchronized forever). --- private static readonly byte[] Fc03Qty1 = { 0x03, 0x00, 0x00, 0x00, 0x01 }; /// A per-op response timeout tears down the socket and (auto-reconnect) resends cleanly. [Fact] public async Task SendAsync_AfterResponseTimeout_TearsDownAndReconnects() { await using var server = new FlakeyModbusServer { StallFirstNResponses = 1 }; await using var transport = new ModbusTcpTransport( "127.0.0.1", server.Port, TimeSpan.FromMilliseconds(300), autoReconnect: true); await transport.ConnectAsync(TestContext.Current.CancellationToken); var resp = await transport.SendAsync(unitId: 1, Fc03Qty1, TestContext.Current.CancellationToken); resp[0].ShouldBe((byte)0x03); // correct FC, not stale bytes resp.Length.ShouldBe(2 + 1 * 2); // FC + byteCount + qty*2 data server.AcceptedConnectionCount.ShouldBe(2); // torn down + reconnected } /// A TxId-mismatch framing violation tears down the socket and (auto-reconnect) resends cleanly. [Fact] public async Task SendAsync_AfterTxIdMismatch_TearsDownSocket() { await using var server = new FlakeyModbusServer { WrongTxIdFirstNResponses = 1 }; await using var transport = new ModbusTcpTransport( "127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: true); await transport.ConnectAsync(TestContext.Current.CancellationToken); var resp = await transport.SendAsync(unitId: 1, Fc03Qty1, TestContext.Current.CancellationToken); resp[0].ShouldBe((byte)0x03); server.AcceptedConnectionCount.ShouldBe(2); } /// A truncated-length framing violation tears down the socket and (auto-reconnect) resends cleanly. [Fact] public async Task SendAsync_AfterTruncatedHeader_TearsDownSocket() { await using var server = new FlakeyModbusServer { InvalidLengthFirstNResponses = 1 }; await using var transport = new ModbusTcpTransport( "127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: true); await transport.ConnectAsync(TestContext.Current.CancellationToken); var resp = await transport.SendAsync(unitId: 1, Fc03Qty1, TestContext.Current.CancellationToken); resp[0].ShouldBe((byte)0x03); server.AcceptedConnectionCount.ShouldBe(2); } /// Caller cancellation propagates as OCE and must NOT tear down the socket (a legitimate shutdown is not a desync). [Fact] public async Task SendAsync_CallerCancellation_DoesNotTearDown() { await using var server = new FlakeyModbusServer { StallFirstNResponses = 1 }; await using var transport = new ModbusTcpTransport( "127.0.0.1", server.Port, TimeSpan.FromSeconds(5), autoReconnect: true); await transport.ConnectAsync(TestContext.Current.CancellationToken); using var cts = new CancellationTokenSource(); var pending = transport.SendAsync(unitId: 1, Fc03Qty1, cts.Token); await Task.Delay(150, TestContext.Current.CancellationToken); // let the request land + block on the response read await cts.CancelAsync(); await Should.ThrowAsync(async () => await pending); // The socket must still be usable — a subsequent transaction succeeds on the SAME connection // (no reconnect), proving caller-cancel did not trip the desync teardown. var resp = await transport.SendAsync(unitId: 1, Fc03Qty1, TestContext.Current.CancellationToken); resp[0].ShouldBe((byte)0x03); server.AcceptedConnectionCount.ShouldBe(1); } }