Merge branch 'fix/archreview-crit3-s7-reconnect'
This commit is contained in:
+95
@@ -0,0 +1,95 @@
|
||||
using System.Diagnostics;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
|
||||
|
||||
/// <summary>
|
||||
/// Live end-to-end proof of the STAB-1 reconnect path against the real python-snap7
|
||||
/// S7-1500 profile: a running <see cref="S7Driver"/> survives a full PLC bounce (container
|
||||
/// stop/start) and resumes reading <b>without a redeploy</b> — the exact failure the
|
||||
/// arch-review flagged (a transient drop permanently killed the driver until redeploy).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Double-gated so it never runs destructively by accident:
|
||||
/// <list type="number">
|
||||
/// <item><see cref="Snap7ServerFixture.SkipReason"/> — the sim must be reachable; and</item>
|
||||
/// <item><c>S7_RECONNECT_BOUNCE_CMD</c> — the shell command that bounces the PLC/container
|
||||
/// (e.g. <c>ssh dohertj2@10.100.0.35 docker restart otopcua-python-snap7-s7_1500</c>).
|
||||
/// Absent ⇒ the test skips, because bouncing the shared sim would disrupt other suites.</item>
|
||||
/// </list>
|
||||
/// There is no per-test container hook in the harness, so the bounce is delegated to this
|
||||
/// operator-supplied command — the automation the plan called a "manual recipe", captured in
|
||||
/// code so it is reproducible.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(Snap7ServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Category", "Reconnect")]
|
||||
[Trait("Device", "S7_1500")]
|
||||
public sealed class S7_1500ReconnectTests(Snap7ServerFixture sim)
|
||||
{
|
||||
private const string BounceCmdEnvVar = "S7_RECONNECT_BOUNCE_CMD";
|
||||
|
||||
/// <summary>
|
||||
/// Reads a seeded tag Good, bounces the PLC out-of-band, observes the read fail (Degraded),
|
||||
/// then asserts the driver reopens on its own and the tag returns to Good — no redeploy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Driver_recovers_reads_after_a_live_PLC_bounce_without_redeploy()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
var bounceCmd = Environment.GetEnvironmentVariable(BounceCmdEnvVar);
|
||||
if (string.IsNullOrWhiteSpace(bounceCmd))
|
||||
Assert.Skip($"Set {BounceCmdEnvVar} to the command that bounces the S7 sim to run this destructive test.");
|
||||
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var options = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
|
||||
await using var drv = new S7Driver(options, driverInstanceId: "s7-reconnect-live");
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
|
||||
// Baseline — a Good read against the live sim.
|
||||
(await drv.ReadAsync([S7_1500Profile.ProbeTag], ct))[0].StatusCode.ShouldBe(0u);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
|
||||
// Bounce the PLC out-of-band (stop → start), then confirm the driver both NOTICES the drop
|
||||
// (at least one non-Good read) and RECOVERS to Good on its own within a bounded window.
|
||||
await RunBounceCommandAsync(bounceCmd!, ct);
|
||||
|
||||
var sawOutage = false;
|
||||
var recovered = false;
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(60);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
var status = (await drv.ReadAsync([S7_1500Profile.ProbeTag], ct))[0].StatusCode;
|
||||
if (status != 0u) sawOutage = true;
|
||||
else if (sawOutage) { recovered = true; break; }
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(500), ct);
|
||||
}
|
||||
|
||||
sawOutage.ShouldBeTrue("the driver should have observed the PLC outage as a failed read");
|
||||
recovered.ShouldBeTrue("the driver should have reopened the connection and resumed Good reads without a redeploy");
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
private static async Task RunBounceCommandAsync(string command, CancellationToken ct)
|
||||
{
|
||||
// Run through the login shell so an SSH/docker one-liner in the env var works verbatim.
|
||||
using var proc = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo("/bin/sh", $"-c \"{command.Replace("\"", "\\\"")}\"")
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
},
|
||||
};
|
||||
proc.Start();
|
||||
await proc.WaitForExitAsync(ct);
|
||||
proc.ExitCode.ShouldBe(0, $"bounce command failed: {await proc.StandardError.ReadToEndAsync(ct)}");
|
||||
// Give the container a moment to release the port before the first post-bounce read.
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user