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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user