using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; /// /// Transport self-heal (review 07 P3), single in-gate connect attempt + failed-client disposal (L3) /// and the response-PDU length check (H9). /// [Trait("Category", "Unit")] public sealed class ModbusTransportSelfHealTests { private static readonly byte[] ReadHr0 = [0x03, 0x00, 0x00, 0x00, 0x01]; /// /// P3: two lost replies in a row leave the socket torn down. The NEXT call must reconnect on its own /// instead of failing fast with "Transport not connected" until the 90 s comms-loss watchdog. /// [Fact] public async Task After_two_consecutive_timeouts_the_next_call_reconnects() { await using var server = new ScriptedModbusTcpServer { Script = i => i < 2 ? ScriptedModbusTcpServer.Action.Stall : ScriptedModbusTcpServer.Action.Answer, }; await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromMilliseconds(500)); await transport.ConnectAsync(TestContext.Current.CancellationToken); // First call: stall → teardown → reconnect → resend → stall again → propagates. await Should.ThrowAsync( () => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken)); // Second call: the socket is gone; the transport must dial again and succeed. var resp = await transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken); resp[0].ShouldBe((byte)0x03); server.AcceptedConnections.ShouldBe(3); } /// /// P3 throttle: after a FAILED connect, calls inside the backoff window fail fast with /// and make no connect attempt; once the window has /// passed the next call dials again. Positive control: the device is back before the throttled call, /// so an un-throttled transport would have reconnected. /// [Fact] public async Task Failed_connect_is_throttled_then_retried_after_the_window() { await using var server = new ScriptedModbusTcpServer(); await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2)); var now = 1_000_000L; transport.Lifecycle.NowMs = () => now; await transport.ConnectAsync(TestContext.Current.CancellationToken); (await transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken))[0].ShouldBe((byte)0x03); server.Stop(); // Dead socket → EOF → teardown → ONE reconnect attempt → refused. var first = await Should.ThrowAsync( () => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken)); ModbusSocketLifecycle.IsSocketLevelFailure(first).ShouldBeTrue(); server.Start(); var acceptedBefore = server.AcceptedConnections; await Should.ThrowAsync( () => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken)); server.AcceptedConnections.ShouldBe(acceptedBefore, "a throttled call must not dial"); now += 60_000; // past any window (MaxDelay default 30 s) (await transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken))[0].ShouldBe((byte)0x03); server.AcceptedConnections.ShouldBe(acceptedBefore + 1); } /// /// L3: the reconnect never sleeps inside the transport gate. With a 30 s InitialDelay the old /// ConnectWithBackoffAsync held the gate for 30 s before even dialling; now one attempt is made /// and the call returns at once. /// [Fact] public async Task Reconnect_after_drop_does_not_sleep_inside_the_gate() { await using var server = new ScriptedModbusTcpServer(); await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2), reconnect: new ModbusReconnectOptions { InitialDelay = TimeSpan.FromSeconds(30) }); await transport.ConnectAsync(TestContext.Current.CancellationToken); server.Stop(); var call = transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken); var ex = await Should.ThrowAsync(() => call.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken)); ex.ShouldNotBeOfType("the send must not wait out the 30 s backoff inside the gate"); } /// H9: an MBAP frame carrying no PDU is a truncated-frame desync, not an IndexOutOfRangeException. [Fact] public async Task Empty_response_pdu_is_a_truncated_frame_desync() { await using var server = new ScriptedModbusTcpServer { RawReply = (_, header, _) => ScriptedModbusTcpServer.Adu(header, []), }; await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: false); await transport.ConnectAsync(TestContext.Current.CancellationToken); var ex = await Should.ThrowAsync( () => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken)); ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.TruncatedFrame); } /// H9: an exception frame missing its exception code is a truncated-frame desync. [Fact] public async Task Exception_frame_without_code_is_a_truncated_frame_desync() { await using var server = new ScriptedModbusTcpServer { RawReply = (_, header, _) => ScriptedModbusTcpServer.Adu(header, [0x83]), }; await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: false); await transport.ConnectAsync(TestContext.Current.CancellationToken); var ex = await Should.ThrowAsync( () => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken)); ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.TruncatedFrame); } /// Positive control for the two H9 tests: a well-formed exception frame still surfaces as a /// carrying its code. [Fact] public async Task Well_formed_exception_frame_still_surfaces_as_ModbusException() { await using var server = new ScriptedModbusTcpServer { RawReply = (_, header, _) => ScriptedModbusTcpServer.Adu(header, [0x83, 0x02]), }; await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: false); await transport.ConnectAsync(TestContext.Current.CancellationToken); var ex = await Should.ThrowAsync( () => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken)); ex.ExceptionCode.ShouldBe((byte)0x02); } }