530f1b2f0b
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
140 lines
7.1 KiB
C#
140 lines
7.1 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
|
|
|
/// <summary>
|
|
/// Transport self-heal (review 07 P3), single in-gate connect attempt + failed-client disposal (L3)
|
|
/// and the response-PDU length check (H9).
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class ModbusTransportSelfHealTests
|
|
{
|
|
private static readonly byte[] ReadHr0 = [0x03, 0x00, 0x00, 0x00, 0x01];
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<ModbusTransportDesyncException>(
|
|
() => 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// P3 throttle: after a FAILED connect, calls inside the backoff window fail fast with
|
|
/// <see cref="ModbusTransportUnavailableException"/> 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.
|
|
/// </summary>
|
|
[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<Exception>(
|
|
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
|
|
ModbusSocketLifecycle.IsSocketLevelFailure(first).ShouldBeTrue();
|
|
|
|
server.Start();
|
|
var acceptedBefore = server.AcceptedConnections;
|
|
await Should.ThrowAsync<ModbusTransportUnavailableException>(
|
|
() => 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// L3: the reconnect never sleeps inside the transport gate. With a 30 s InitialDelay the old
|
|
/// <c>ConnectWithBackoffAsync</c> held the gate for 30 s before even dialling; now one attempt is made
|
|
/// and the call returns at once.
|
|
/// </summary>
|
|
[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<Exception>(() => call.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken));
|
|
ex.ShouldNotBeOfType<TimeoutException>("the send must not wait out the 30 s backoff inside the gate");
|
|
}
|
|
|
|
/// <summary>H9: an MBAP frame carrying no PDU is a truncated-frame desync, not an IndexOutOfRangeException.</summary>
|
|
[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<ModbusTransportDesyncException>(
|
|
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
|
|
ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.TruncatedFrame);
|
|
}
|
|
|
|
/// <summary>H9: an exception frame missing its exception code is a truncated-frame desync.</summary>
|
|
[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<ModbusTransportDesyncException>(
|
|
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
|
|
ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.TruncatedFrame);
|
|
}
|
|
|
|
/// <summary>Positive control for the two H9 tests: a well-formed exception frame still surfaces as a
|
|
/// <see cref="ModbusException"/> carrying its code.</summary>
|
|
[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<ModbusException>(
|
|
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
|
|
ex.ExceptionCode.ShouldBe((byte)0x02);
|
|
}
|
|
}
|