Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/CommsRecoveryTests.cs
T
Joseph Doherty 530f1b2f0b 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
2026-10-22 15:02:11 -04:00

89 lines
3.7 KiB
C#

using System.Diagnostics;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
/// <summary>
/// Comms-loss → recovery against the real pymodbus <c>standard</c> fixture, with a loopback
/// <see cref="TcpRelay"/> in front of it so the device can disappear and come back without touching the
/// shared container. Covers the review 07 High findings P3 (transport self-heal) and H2 (deadband
/// recovery publish).
/// </summary>
[Collection(ModbusSimulatorCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "Standard")]
public sealed class CommsRecoveryTests(ModbusSimulatorFixture sim)
{
private const uint StatusGood = 0u;
private void SkipUnlessStandardLive()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var profile = Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE");
if (profile is not null && !string.Equals(profile, "standard", StringComparison.OrdinalIgnoreCase))
Assert.Skip($"MODBUS_SIM_PROFILE={profile} — these tests need the standard profile (HR[0..31] = address).");
}
private ModbusDriverOptions Options(TcpRelay relay, params ModbusTagDefinition[] tags) => new()
{
Host = "127.0.0.1",
Port = relay.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
RawTags = ModbusRawTags.Entries(tags),
Probe = new ModbusProbeOptions { Enabled = false },
};
private static async Task<T> WaitFor<T>(Func<Task<T>> probe, Func<T, bool> done, TimeSpan budget)
{
var sw = Stopwatch.StartNew();
T last;
do
{
last = await probe();
if (done(last)) return last;
await Task.Delay(200, TestContext.Current.CancellationToken);
} while (sw.Elapsed < budget);
return last;
}
/// <summary>
/// P3: after the device drops (two failed calls in a row, socket torn down, reconnect refused) the
/// transport must reconnect ON ITS OWN once the device is back — before this fix every later call
/// failed fast with "Transport not connected" until the 90 s comms-loss watchdog re-initialised the
/// driver.
/// </summary>
[Fact]
public async Task Driver_reads_again_after_the_device_drops_and_returns_without_reinit()
{
SkipUnlessStandardLive();
await using var relay = new TcpRelay(sim.Host, sim.Port);
var tag = new ModbusTagDefinition("HR5", ModbusRegion.HoldingRegisters, 5, ModbusDataType.UInt16, Writable: false);
await using var driver = new ModbusDriver(Options(relay, tag), "modbus-p3");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var good = await driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken);
good[0].StatusCode.ShouldBe(StatusGood);
good[0].Value.ShouldBe((ushort)5);
relay.Cut();
for (var i = 0; i < 2; i++)
{
var bad = await driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken);
bad[0].StatusCode.ShouldNotBe(StatusGood);
}
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
relay.Restore();
var recovered = await WaitFor(
() => driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken),
r => r[0].StatusCode == StatusGood,
TimeSpan.FromSeconds(20));
recovered[0].StatusCode.ShouldBe(StatusGood, "the transport must reconnect by itself once the device is back");
recovered[0].Value.ShouldBe((ushort)5);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
}
}