Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs
T
Joseph Doherty 25c0c6f68f fix(s7): add lazy reconnect path (archreview STAB-1 / Critical 3)
The S7 Plc was opened once in InitializeAsync and never re-opened: a transient
PLC reboot / network blip permanently killed the driver until redeploy. Introduce
an IS7Plc / IS7PlcFactory seam (mirrors TwinCAT's ITwinCATClientFactory) and a lazy
EnsureConnectedAsync that disposes a dead handle and re-opens a fresh connection on
the next data call. Reads/writes mark the handle dead on a connection-fatal fault
(socket drop / ErrorCode.ConnectionError) but NOT on a data-address error; the probe
loop routes through EnsureConnectedAsync as a backstop.

The seam also closes the S7 TEST-1 gap — the reconnect state machine is now unit-
testable without a live PLC (S7.Net.Plc is sealed with no in-process fake).

Verification:
- 4 deterministic unit guards (fatal→reopen, data-error→no-reopen, raw socket→reopen,
  reopen-fail→degrade-then-recover); full S7 unit suite 234/234 green.
- LIVE end-to-end proof against real python-snap7 (S7_1500ReconnectTests, double-gated
  on sim reachability + S7_RECONNECT_BOUNCE_CMD): bounced the container, driver observed
  the outage and resumed Good reads with NO redeploy. Baseline smoke 3/3 still green.
2026-07-08 17:04:46 -04:00

214 lines
9.2 KiB
C#

using S7.Net;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using S7NetDataType = global::S7.Net.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Unit tests for the S7 reconnect path (STAB-1). The <see cref="IS7PlcFactory"/> seam lets
/// these exercise the lazy <see cref="S7Driver"/> reconnect logic — dispose-dead-handle +
/// re-open on the next data call — without a live PLC, which was previously impossible (the
/// driver <c>new</c>-ed a concrete <c>S7.Net.Plc</c> inline and had no reopen path at all).
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7DriverReconnectTests
{
private static S7DriverOptions Options() => new()
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
// Probe off — these tests drive reconnect through the read path deterministically; a
// background probe would race the created-connection assertions.
Probe = new S7ProbeOptions { Enabled = false },
Tags = [new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)],
};
/// <summary>
/// A connection-fatal fault on a read marks the handle dead; the NEXT read disposes it and
/// opens a fresh connection, and the tag recovers to Good with health Degraded→Healthy.
/// </summary>
[Fact]
public async Task Read_reopens_a_fresh_connection_after_a_connection_fatal_fault()
{
var factory = new FakeS7PlcFactory();
using var drv = new S7Driver(Options(), "s7-reconnect", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
factory.Created.Count.ShouldBe(1);
// Read #1 — good value, Healthy.
var r1 = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken);
r1[0].StatusCode.ShouldBe(0u);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
// The live socket drops on its next read. IsConnected deliberately stays true (a stale
// socket that hasn't noticed the drop) so the ONLY trigger for reopen is the driver's
// own dead-handle flag set by the connection-fatal classification.
factory.Created[0].NextReadThrows = new PlcException(ErrorCode.ConnectionError, "socket dropped");
// Read #2 — connection-fatal fault → Degraded, handle marked dead, but no reopen yet.
var r2 = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken);
r2[0].StatusCode.ShouldNotBe(0u);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
factory.Created.Count.ShouldBe(1);
// Read #3 — EnsureConnectedAsync disposes the dead handle and opens a SECOND connection,
// which reads Good; health recovers to Healthy.
var r3 = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken);
factory.Created.Count.ShouldBe(2);
r3[0].StatusCode.ShouldBe(0u);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
factory.Created[0].Disposed.ShouldBeTrue();
factory.Created[1].Disposed.ShouldBeFalse();
}
/// <summary>
/// A protocol / data-address error (S7.Net <see cref="ErrorCode.ReadData"/>) is NOT a
/// connection loss — the driver keeps the same connection and does not churn a reopen.
/// </summary>
[Fact]
public async Task Read_does_not_reopen_on_a_data_address_error()
{
var factory = new FakeS7PlcFactory();
using var drv = new S7Driver(Options(), "s7-dataerr", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
// A data error — not a socket drop.
factory.Created[0].NextReadThrows = new PlcException(ErrorCode.ReadData, "bad address");
var bad = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken);
bad[0].StatusCode.ShouldNotBe(0u);
// Next read reuses the SAME connection — no reopen for a data error.
var ok = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken);
ok[0].StatusCode.ShouldBe(0u);
factory.Created.Count.ShouldBe(1);
}
/// <summary>
/// A raw socket exception (no S7.Net wrapper) is also classified connection-fatal and
/// triggers a reopen — the driver doesn't depend on every drop arriving as a
/// <see cref="PlcException"/>.
/// </summary>
[Fact]
public async Task Read_reopens_after_a_raw_socket_exception()
{
var factory = new FakeS7PlcFactory();
using var drv = new S7Driver(Options(), "s7-socket", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
factory.Created[0].NextReadThrows = new System.Net.Sockets.SocketException();
await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
var recovered = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken);
recovered[0].StatusCode.ShouldBe(0u);
factory.Created.Count.ShouldBe(2);
}
/// <summary>
/// When the reopen itself fails (PLC still down), the batch degrades to a communication
/// error rather than throwing, and a later successful reopen recovers the tag.
/// </summary>
[Fact]
public async Task Read_degrades_when_reopen_fails_then_recovers()
{
var factory = new FakeS7PlcFactory();
using var drv = new S7Driver(Options(), "s7-reopen-fail", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
// Drop the live socket, and make the NEXT-created connection fail to open.
factory.Created[0].NextReadThrows = new PlcException(ErrorCode.ConnectionError, "dropped");
factory.NextOpenThrows = new PlcException(ErrorCode.ConnectionError, "still down");
await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken); // marks dead
// Reopen attempt #1 fails — whole batch is a comm error, no throw.
var down = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken);
down[0].StatusCode.ShouldNotBe(0u);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
// PLC comes back; the next call reopens cleanly and the tag reads Good.
var up = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken);
up[0].StatusCode.ShouldBe(0u);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
// ---- fakes ----
private sealed class FakeS7PlcFactory : IS7PlcFactory
{
/// <summary>Every connection handed out, in creation order.</summary>
public List<FakeS7Plc> Created { get; } = new();
/// <summary>When set, the NEXT <see cref="Create"/>d connection throws this on OpenAsync (once).</summary>
public Exception? NextOpenThrows { get; set; }
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout)
{
var plc = new FakeS7Plc();
if (NextOpenThrows is { } ex)
{
plc.OpenThrowsOnce = ex;
NextOpenThrows = null;
}
Created.Add(plc);
return plc;
}
}
private sealed class FakeS7Plc : IS7Plc
{
public bool IsConnected { get; private set; }
public bool Disposed { get; private set; }
/// <summary>Thrown once by <see cref="OpenAsync"/>, then cleared.</summary>
public Exception? OpenThrowsOnce { get; set; }
/// <summary>Thrown once by the next <see cref="ReadAsync"/>, then cleared.</summary>
public Exception? NextReadThrows { get; set; }
/// <summary>Boxed value returned by a good read (UInt16 tag → ushort).</summary>
public object? ReadValue { get; set; } = (ushort)42;
public Task OpenAsync(CancellationToken cancellationToken)
{
if (OpenThrowsOnce is { } ex)
{
OpenThrowsOnce = null;
return Task.FromException(ex);
}
IsConnected = true;
return Task.CompletedTask;
}
public void Close() => IsConnected = false;
public Task<object?> ReadAsync(string address, CancellationToken cancellationToken)
{
if (NextReadThrows is { } ex)
{
NextReadThrows = null;
return Task.FromException<object?>(ex);
}
return Task.FromResult(ReadValue);
}
public Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken cancellationToken) =>
throw new NotSupportedException("FakeS7Plc: block reads not needed for the reconnect tests");
public Task WriteAsync(string address, object value, CancellationToken cancellationToken) =>
Task.CompletedTask;
public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken) =>
Task.CompletedTask;
public Task ReadStatusAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public void Dispose()
{
Disposed = true;
IsConnected = false;
}
}
}