081fa693af
I-1: a PUT/GET-disabled CPU (every read denied) stayed Faulted with LastSuccessfulRead frozen, so the comms-loss watchdog reconnected and re-subscribed it every 90 s forever. Both access-denied arms now also record an answer: Faulted is kept (worst state wins), the stamp advances, and the sticky write fault still clears only on a real success. Pinned end to end in Runtime.Tests: the real S7Driver under the real DriverInstanceActor watchdog is not reconnected across 2x the threshold; the control (every read timing out) is. Minors: - m-1: injectable pass clock (UtcNow); "LastSuccessfulRead advanced" is now asserted strictly, including the write-refusal case. - m-2: the editor uses S7TagShapeRules.IsByteAnchored instead of its own list. - m-3: a release that races an init publish also cancels the probe loop the publish started after the release took its snapshot. - m-4: a retained poll failing during a lifecycle operation writes no health. - m-5: a multi-var chunk that fell back memoises its members refused alone. - m-7: access-denied needs S7.Net's exact wording and no transport fault in the chain. - m-8: sync teardown clears the write-side Degraded owner; a sync Dispose racing a running shutdown is tested. Refs #742 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
294 lines
14 KiB
C#
294 lines
14 KiB
C#
using S7.Net;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
|
|
|
/// <summary>
|
|
/// R07:H4 — a deterministic PLC refusal (S7.Net <c>ReadData</c> / <c>WriteData</c> carrying an S7
|
|
/// item error such as "object does not exist", not a socket/timeout/framing fault) proves the CPU
|
|
/// answered. It is per-tag Bad with a once-per-tag Warning and NO Degraded; it advances
|
|
/// <c>LastSuccessfulRead</c>, so a driver whose every tag is refused (wrong DB number) no longer
|
|
/// trips the 90 s comms-loss watchdog against a reachable PLC.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class S7RefusalHealthTests
|
|
{
|
|
private const uint BadDeviceFailure = 0x808B0000u;
|
|
|
|
/// <summary>
|
|
/// The shape S7.Net's READ paths really raise for a CPU item error — a BARE
|
|
/// <see cref="Exception"/>, live-probed against the snap7 fixture (not a PlcException).
|
|
/// </summary>
|
|
private static Exception ReadRefusal() => new("Received error from PLC: Object does not exist.");
|
|
|
|
private static PlcException WriteRefusal() =>
|
|
new(ErrorCode.WriteData, new Exception("Received error from PLC: Address out of range."));
|
|
|
|
private static S7DriverOptions Options() => new()
|
|
{
|
|
Host = "192.0.2.1",
|
|
Timeout = TimeSpan.FromSeconds(5),
|
|
Probe = new S7ProbeOptions { Enabled = false },
|
|
// Grouping off: one wire request per tag keeps each tag's outcome its own.
|
|
SpanReadMaxGapBytes = -1,
|
|
RawTags = S7RawTags.Entries(
|
|
new S7TagDefinition("Missing", "DB99.DBW0", S7DataType.UInt16),
|
|
new S7TagDefinition("Good", "DB1.DBW0", S7DataType.UInt16)),
|
|
};
|
|
|
|
/// <summary>A stepped pass clock, so "LastSuccessfulRead advanced" is asserted STRICTLY.</summary>
|
|
private sealed class PassClock
|
|
{
|
|
public DateTime Now { get; private set; } = new(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc);
|
|
|
|
/// <summary>Moves the clock one second on and returns the new time.</summary>
|
|
public DateTime Step() => Now = Now.AddSeconds(1);
|
|
}
|
|
|
|
private static async Task<(S7Driver Driver, ScriptedS7PlcFactory Factory, CapturingS7Logger Log, PassClock Clock)> StartAsync()
|
|
{
|
|
var factory = new ScriptedS7PlcFactory();
|
|
var log = new CapturingS7Logger();
|
|
var clock = new PassClock();
|
|
var drv = new S7Driver(Options(), "s7-refusal", factory, log) { UtcNow = () => clock.Now };
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
drv.GetHealth().LastSuccessfulRead.ShouldBe(clock.Now, "init stamps the pass clock");
|
|
return (drv, factory, log, clock);
|
|
}
|
|
|
|
/// <summary>A refused tag is Bad, health stays Healthy, LastSuccessfulRead advances, and it warns once.</summary>
|
|
[Fact]
|
|
public async Task A_read_refusal_is_per_tag_bad_with_no_health_impact_and_one_warning()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var (drv, factory, log, clock) = await StartAsync();
|
|
using var _ = drv;
|
|
factory.Read = (addr, _) => addr == "DB99.DBW0" ? throw ReadRefusal() : Task.FromResult<object?>((ushort)7);
|
|
|
|
for (var pass = 0; pass < 3; pass++)
|
|
{
|
|
var at = clock.Step();
|
|
var r = await drv.ReadAsync(["Missing"], ct);
|
|
r[0].StatusCode.ShouldBe(BadDeviceFailure);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a refusal is not a comms fault");
|
|
drv.GetHealth().LastSuccessfulRead.ShouldBe(at,
|
|
"the CPU answered, so the all-refused pass advances LastSuccessfulRead to the pass time");
|
|
}
|
|
log.Warnings.Count(w => w.Contains("Missing")).ShouldBe(1, "one Warning per refused tag, not one per poll");
|
|
|
|
// Positive control: the good tag still reads, and nothing reopened the connection.
|
|
(await drv.ReadAsync(["Good"], ct))[0].StatusCode.ShouldBe(0u);
|
|
factory.Created.Count.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A refusal after a comms fault restores Healthy — the CPU is back and answering. Before the
|
|
/// fix the refusal latched Degraded with LastSuccessfulRead frozen, which is exactly the
|
|
/// watchdog's trip condition.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_refusal_after_a_comms_fault_restores_healthy()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var (drv, factory, _, clock) = await StartAsync();
|
|
using var __ = drv;
|
|
|
|
// Positive control: a genuine comms fault DOES degrade, and leaves LastSuccessfulRead frozen.
|
|
var initStamp = clock.Now;
|
|
clock.Step();
|
|
factory.Read = (_, _) => throw new TimeoutException("frozen peer");
|
|
await drv.ReadAsync(["Missing"], ct);
|
|
var degraded = drv.GetHealth();
|
|
degraded.State.ShouldBe(DriverState.Degraded);
|
|
degraded.LastSuccessfulRead.ShouldBe(initStamp);
|
|
|
|
var at = clock.Step();
|
|
factory.Read = (addr, _) => addr == "DB99.DBW0" ? throw ReadRefusal() : Task.FromResult<object?>((ushort)7);
|
|
var r = await drv.ReadAsync(["Missing"], ct);
|
|
r[0].StatusCode.ShouldBe(BadDeviceFailure);
|
|
var after = drv.GetHealth();
|
|
after.State.ShouldBe(DriverState.Healthy);
|
|
after.LastSuccessfulRead.ShouldBe(at);
|
|
}
|
|
|
|
/// <summary>The ReadData-wrapped shape of a refusal is classified the same way.</summary>
|
|
[Fact]
|
|
public async Task A_read_data_wrapped_refusal_is_also_a_refusal()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var (drv, factory, _, clock) = await StartAsync();
|
|
using var __ = drv;
|
|
factory.Read = (_, _) => throw new PlcException(ErrorCode.ReadData, ReadRefusal());
|
|
|
|
(await drv.ReadAsync(["Missing"], ct))[0].StatusCode.ShouldBe(BadDeviceFailure);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The bare read-path shape of access denied (PUT/GET disabled) is the Faulted config alert.
|
|
/// Before, only a PlcException-wrapped "not allowed" was recognised, so a real CPU's read
|
|
/// refusal fell through to BadCommunicationError + Degraded.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_bare_access_denied_read_is_the_faulted_config_alert()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var (drv, factory, _, clock) = await StartAsync();
|
|
using var __ = drv;
|
|
factory.Read = (_, _) => throw new Exception("Received error from PLC: Accessing object not allowed.");
|
|
|
|
(await drv.ReadAsync(["Good"], ct))[0].StatusCode.ShouldBe(0x803D0000u); // BadNotSupported
|
|
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
|
drv.GetHealth().LastError!.ShouldContain("PUT/GET");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Review I-1: an access-denied CPU (PUT/GET left at its factory default — EVERY read denied) is
|
|
/// reachable and answering, so each pass advances LastSuccessfulRead while staying Faulted.
|
|
/// Before, the pass stayed Faulted with the stamp frozen — the comms-loss watchdog's trip
|
|
/// condition, so the actor reconnected + re-subscribed every 90 s, forever, which can never fix
|
|
/// a PUT/GET setting. The control: a comms fault afterwards leaves the stamp frozen.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task An_all_access_denied_pass_stays_faulted_but_advances_last_successful_read()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var (drv, factory, _, clock) = await StartAsync();
|
|
using var __ = drv;
|
|
factory.Read = (_, _) => throw new Exception("Received error from PLC: Accessing object not allowed.");
|
|
|
|
DateTime last = default;
|
|
for (var pass = 0; pass < 2; pass++)
|
|
{
|
|
last = clock.Step();
|
|
await drv.ReadAsync(["Good", "Missing"], ct);
|
|
var h = drv.GetHealth();
|
|
h.State.ShouldBe(DriverState.Faulted, "the config alert is kept");
|
|
h.LastSuccessfulRead.ShouldBe(last, "the CPU answered, so the stamp advances every pass");
|
|
}
|
|
|
|
// Positive control: a real comms loss does NOT advance it (and the sticky Faulted outranks it).
|
|
clock.Step();
|
|
factory.Read = (_, _) => throw new TimeoutException("frozen peer");
|
|
await drv.ReadAsync(["Good"], ct);
|
|
drv.GetHealth().LastSuccessfulRead.ShouldBe(last);
|
|
}
|
|
|
|
/// <summary>The write-side access-denied is an answer too, and its sticky Faulted still clears only on a real success.</summary>
|
|
[Fact]
|
|
public async Task A_write_access_denied_advances_last_successful_read_and_stays_sticky()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var (drv, factory, _, clock) = await StartAsync();
|
|
using var __ = drv;
|
|
factory.Write = (_, _, _) => throw new PlcException(ErrorCode.WriteData,
|
|
new Exception("Received error from PLC: Accessing object not allowed."));
|
|
|
|
var at = clock.Step();
|
|
(await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct))[0].StatusCode.ShouldBe(0x803D0000u);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
|
drv.GetHealth().LastSuccessfulRead.ShouldBe(at);
|
|
|
|
// A clean read leaves the write-side Faulted in place (sticky) …
|
|
clock.Step();
|
|
await drv.ReadAsync(["Good"], ct);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
|
|
|
// … and a real write success clears it.
|
|
factory.Write = (_, _, _) => Task.CompletedTask;
|
|
clock.Step();
|
|
await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Review m-7: a transport fault whose text happens to contain "not allowed" reopens (Degraded),
|
|
/// and only S7.Net's exact "Accessing object not allowed" is the access-denied alert.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Only_the_exact_access_denied_wording_without_a_transport_fault_is_access_denied()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var (drv, factory, _, clock) = await StartAsync();
|
|
using var __ = drv;
|
|
|
|
clock.Step();
|
|
factory.Write = (_, _, _) => throw new PlcException(ErrorCode.WriteData,
|
|
new System.IO.IOException("Accessing object not allowed: socket closed by peer"));
|
|
(await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct))[0].StatusCode
|
|
.ShouldNotBe(0x803D0000u, "not the access-denied BadNotSupported");
|
|
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "a transport fault is not a config alert");
|
|
|
|
S7Driver.IsAccessDenied(new PlcException(ErrorCode.WriteData, new Exception("operation not allowed"))).ShouldBeFalse();
|
|
S7Driver.IsAccessDenied(new Exception("Received error from PLC: Accessing object not allowed.")).ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>A ReadData wrapping a socket fault is still a comms fault (Degraded), not a refusal.</summary>
|
|
[Fact]
|
|
public async Task A_read_data_wrapping_a_socket_fault_still_degrades()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var (drv, factory, _, clock) = await StartAsync();
|
|
using var __ = drv;
|
|
factory.Read = (_, _) => throw new PlcException(ErrorCode.ReadData, new System.IO.IOException("connection reset"));
|
|
|
|
await drv.ReadAsync(["Good"], ct);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
|
}
|
|
|
|
/// <summary>A write refusal is per-tag Bad, counts as answered and leaves health alone.</summary>
|
|
[Fact]
|
|
public async Task A_write_refusal_is_per_tag_bad_and_counts_as_answered()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var (drv, factory, log, clock) = await StartAsync();
|
|
using var _ = drv;
|
|
factory.Write = (addr, _, _) => addr == "DB99.DBW0" ? throw WriteRefusal() : Task.CompletedTask;
|
|
|
|
// Put the driver into a comms-fault Degraded first; the refusal must lift it.
|
|
factory.Read = (_, _) => throw new TimeoutException("frozen peer");
|
|
await drv.ReadAsync(["Good"], ct);
|
|
var degraded = drv.GetHealth();
|
|
degraded.State.ShouldBe(DriverState.Degraded);
|
|
|
|
for (var i = 0; i < 2; i++)
|
|
{
|
|
var at = clock.Step();
|
|
var w = await drv.WriteAsync([new WriteRequest("Missing", (ushort)1)], ct);
|
|
w[0].StatusCode.ShouldBe(BadDeviceFailure);
|
|
var h = drv.GetHealth();
|
|
h.LastSuccessfulRead.ShouldBe(at, "a write refusal is an answer: the stamp advances strictly");
|
|
// The Degraded was written by the READ side (its own message), so it is the read pass's to clear.
|
|
h.State.ShouldBe(DriverState.Degraded);
|
|
}
|
|
|
|
var after = drv.GetHealth();
|
|
after.LastSuccessfulRead!.Value.ShouldBeGreaterThan(degraded.LastSuccessfulRead!.Value);
|
|
log.Warnings.Count(m => m.Contains("Missing")).ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A good write clears a Degraded the write side wrote. Before the fix a success-only batch
|
|
/// never wrote health, so a transient write fault latched Degraded until some read pass ran.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_good_write_clears_a_write_side_degraded()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var (drv, factory, _, clock) = await StartAsync();
|
|
using var _ = drv;
|
|
|
|
factory.Write = (_, _, _) => throw new System.IO.IOException("connection reset");
|
|
var bad = await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct);
|
|
bad[0].StatusCode.ShouldNotBe(0u);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "positive control: a comms fault on write degrades");
|
|
|
|
factory.Write = (_, _, _) => Task.CompletedTask;
|
|
var good = await drv.WriteAsync([new WriteRequest("Good", (ushort)2)], ct);
|
|
good[0].StatusCode.ShouldBe(0u);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
}
|