317 lines
15 KiB
C#
317 lines
15 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);
|
|
}
|
|
|
|
// ---- STAB-14: connect-timeout must degrade, not escape as OCE ----
|
|
|
|
/// <summary>
|
|
/// T1 (STAB-14 regression). When the reopen HANGS and is cut by the internal
|
|
/// <c>CancelAfter(_options.Timeout)</c> (the unreachable-host outage — SYNs dropped, not
|
|
/// refused), the read batch must degrade to a communication error with Degraded health,
|
|
/// NOT throw the timeout-born <see cref="TaskCanceledException"/> up to the caller.
|
|
/// Before the fix the OCE escaped <see cref="S7Driver.ReadAsync"/>.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Read_degrades_batch_on_connect_timeout_instead_of_throwing()
|
|
{
|
|
var factory = new FakeS7PlcFactory();
|
|
using var drv = new S7Driver(Options(), "s7-read-timeout", factory);
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
|
|
// Drop connection #1 so the next read reopens; every subsequent open hangs until the
|
|
// internal connect-timeout CTS fires.
|
|
factory.Created[0].NextReadThrows = new PlcException(ErrorCode.ConnectionError, "dropped");
|
|
await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken); // marks dead
|
|
factory.OpenHangsUntilCancelledCount = int.MaxValue;
|
|
|
|
// Must NOT throw — the connect timeout degrades the batch.
|
|
var timedOut = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken);
|
|
timedOut[0].StatusCode.ShouldNotBe(0u);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
|
}
|
|
|
|
/// <summary>
|
|
/// T3 (STAB-14 regression). The same connect-timeout degrade through
|
|
/// <see cref="S7Driver.WriteAsync"/> — <c>EnsureConnectedAsync</c> is called before the
|
|
/// per-item loop, so a hanging reopen must degrade the whole write batch, not throw OCE.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Write_degrades_batch_on_connect_timeout_instead_of_throwing()
|
|
{
|
|
var factory = new FakeS7PlcFactory();
|
|
using var drv = new S7Driver(Options(), "s7-write-timeout", factory);
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
|
|
factory.Created[0].NextReadThrows = new PlcException(ErrorCode.ConnectionError, "dropped");
|
|
await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken); // marks dead
|
|
factory.OpenHangsUntilCancelledCount = int.MaxValue;
|
|
|
|
var timedOut = await drv.WriteAsync(
|
|
[new WriteRequest("W0", (ushort)1)], TestContext.Current.CancellationToken);
|
|
timedOut[0].StatusCode.ShouldNotBe(0u);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
|
}
|
|
|
|
// ---- 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; }
|
|
|
|
/// <summary>
|
|
/// Number of subsequently-created connections whose <see cref="FakeS7Plc.OpenAsync"/>
|
|
/// hangs until its token is cancelled (simulating the unreachable-but-not-refusing host
|
|
/// — pulled cable / firewall DROP — that STAB-14 needs and <c>docker restart</c> can't
|
|
/// produce). Each <see cref="Create"/> decrements it while positive.
|
|
/// </summary>
|
|
public int OpenHangsUntilCancelledCount { get; set; }
|
|
|
|
/// <summary>
|
|
/// When set, copied onto every created connection's persistent
|
|
/// <see cref="FakeS7Plc.ReadStatusThrows"/> so a probe fails on every tick. Clear it
|
|
/// after init to arm connection #1 only.
|
|
/// </summary>
|
|
public Exception? ReadStatusThrowsForNewConnections { 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;
|
|
}
|
|
if (OpenHangsUntilCancelledCount > 0)
|
|
{
|
|
plc.OpenHangsUntilCancelled = true;
|
|
OpenHangsUntilCancelledCount--;
|
|
}
|
|
if (ReadStatusThrowsForNewConnections is { } rex)
|
|
plc.ReadStatusThrows = rex;
|
|
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>
|
|
/// When true, <see cref="OpenAsync"/> hangs until its token is cancelled, then throws
|
|
/// <see cref="TaskCanceledException"/> — the connect-timeout shape (an unreachable host
|
|
/// whose SYNs are dropped) the live rig can't cheaply produce (STAB-14).
|
|
/// </summary>
|
|
public bool OpenHangsUntilCancelled { get; set; }
|
|
|
|
/// <summary>Thrown once by the next <see cref="ReadAsync"/>, then cleared.</summary>
|
|
public Exception? NextReadThrows { get; set; }
|
|
|
|
/// <summary>When true, <see cref="ReadAsync"/> hangs until its token is cancelled (STAB-15 cancel-mid-I/O).</summary>
|
|
public bool ReadHangsUntilCancelled { get; set; }
|
|
|
|
/// <summary>When true, <see cref="WriteAsync"/> hangs until its token is cancelled (STAB-15 cancel-mid-I/O).</summary>
|
|
public bool WriteHangsUntilCancelled { get; set; }
|
|
|
|
/// <summary>Thrown by EVERY <see cref="ReadStatusAsync"/> (persistent, not one-shot) — arms a probe fault (STAB-15b).</summary>
|
|
public Exception? ReadStatusThrows { get; set; }
|
|
|
|
/// <summary>When true, <see cref="ReadStatusAsync"/> hangs until its token is cancelled (probe-timeout, STAB-15b).</summary>
|
|
public bool ReadStatusHangsUntilCancelled { get; set; }
|
|
|
|
/// <summary>Boxed value returned by a good read (UInt16 tag → ushort).</summary>
|
|
public object? ReadValue { get; set; } = (ushort)42;
|
|
|
|
public async Task OpenAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (OpenHangsUntilCancelled)
|
|
await Task.Delay(System.Threading.Timeout.Infinite, cancellationToken).ConfigureAwait(false);
|
|
if (OpenThrowsOnce is { } ex)
|
|
{
|
|
OpenThrowsOnce = null;
|
|
throw ex;
|
|
}
|
|
IsConnected = true;
|
|
}
|
|
|
|
public void Close() => IsConnected = false;
|
|
|
|
public async Task<object?> ReadAsync(string address, CancellationToken cancellationToken)
|
|
{
|
|
if (ReadHangsUntilCancelled)
|
|
await Task.Delay(System.Threading.Timeout.Infinite, cancellationToken).ConfigureAwait(false);
|
|
if (NextReadThrows is { } ex)
|
|
{
|
|
NextReadThrows = null;
|
|
throw ex;
|
|
}
|
|
return 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 async Task WriteAsync(string address, object value, CancellationToken cancellationToken)
|
|
{
|
|
if (WriteHangsUntilCancelled)
|
|
await Task.Delay(System.Threading.Timeout.Infinite, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken) =>
|
|
Task.CompletedTask;
|
|
|
|
public async Task ReadStatusAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (ReadStatusHangsUntilCancelled)
|
|
await Task.Delay(System.Threading.Timeout.Infinite, cancellationToken).ConfigureAwait(false);
|
|
if (ReadStatusThrows is { } ex)
|
|
throw ex;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Disposed = true;
|
|
IsConnected = false;
|
|
}
|
|
}
|
|
}
|