Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500ReconnectTests.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

96 lines
4.6 KiB
C#

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);
}
}