fix(s7): read-pass health is worst-state-wins — a mixed pass can no longer report Healthy

This commit is contained in:
Joseph Doherty
2026-08-17 07:14:32 -04:00
parent e9ba174156
commit 4c16118105
2 changed files with 261 additions and 24 deletions
@@ -387,10 +387,14 @@ public sealed class S7SpanReadParityTests
/// failed REQUEST, and firing it here would re-read tags that already have good values.
/// </summary>
/// <remarks>
/// Health ends <c>Healthy</c> rather than <c>Degraded</c> because the decode loop stamps
/// health per item in chunk order, so the good item after the bad one re-stamps it. That is
/// the same last-writer-wins behaviour the per-tag loop has always had; it is pinned here so
/// a change to it is a deliberate one.
/// Health ends <c>Degraded</c>, NOT <c>Healthy</c>. This pin used to assert the opposite:
/// the decode loop stamped health per item in chunk order, so the good item after the bad
/// one re-stamped it and a pass with failures reported <c>Healthy</c> — last-writer-wins.
/// That was deliberately replaced (perf follow-ups round 3) by <b>worst-state-wins</b>: the
/// whole read pass accumulates its outcomes and writes health ONCE at the end, so a mixed
/// pass can no longer report Healthy. Only the driver-level health claim changed — the
/// per-tag statuses below are exactly as they were, because a per-ITEM decode fault still
/// spoils only its own tag.
/// </remarks>
[Fact]
public async Task One_undecodable_multi_var_item_stamps_only_its_own_tag_Bad()
@@ -420,7 +424,135 @@ public sealed class S7SpanReadParityTests
factory.MultiVarCalls.ShouldBe(1);
factory.StringReadCalls.ShouldBe(0);
factory.ReadBytesCalls.ShouldBe(0);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
}
// ── Worst-state-wins read-pass health ────────────────────────────────────────────────
/// <summary>
/// A pass with BOTH failures and successes reports <c>Degraded</c> — the failure is not
/// erased by the later success — AND still advances <c>LastSuccessfulRead</c>, because
/// something genuinely was read.
/// </summary>
/// <remarks>
/// Advancing the timestamp on a Degraded pass is load-bearing, not cosmetic: the
/// comms-loss watchdog (CLAUDE.md §"Comms-loss detection") trips on non-Healthy state
/// <b>AND</b> a frozen <c>LastSuccessfulRead</c>. Several drivers latch Degraded off one
/// unreadable address, so freezing the timestamp on any non-Healthy pass would reconnect a
/// perfectly reachable PLC every 90 s forever.
/// </remarks>
[Fact]
public async Task A_mixed_read_pass_reports_Degraded_and_still_advances_LastSuccessfulRead()
{
var ct = TestContext.Current.CancellationToken;
var factory = new ImageS7PlcFactory(Images()) { NullItemIndex = 1 };
using var driver = new S7Driver(Options(ScatteredWords(3), maxGap: 2), "s7-mixed-pass", factory);
await driver.InitializeAsync("{}", ct);
// The connect stamped a LastSuccessfulRead; the pass must move it PAST that value, so a
// frozen timestamp is distinguishable from a merely non-null one.
var before = driver.GetHealth().LastSuccessfulRead;
before.ShouldNotBeNull();
var results = await driver.ReadAsync(ScatteredRefs(3), ct);
results[0].StatusCode.ShouldBe(0u);
results[1].StatusCode.ShouldBe(StatusBadCommunicationError);
results[2].StatusCode.ShouldBe(0u);
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Degraded, "a pass that had a failure must not report Healthy");
health.LastSuccessfulRead.ShouldNotBeNull();
health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(
before!.Value, "two of the three tags read fine — the watchdog's evidence must advance");
}
/// <summary>
/// The SPAN path obeys the same rule: one member that fails to decode leaves the pass
/// <c>Degraded</c> even though a later member of the same block decoded fine.
/// </summary>
/// <remarks>
/// The block read itself succeeds — this is the per-member decode guard, the span-path
/// sibling of the multi-var per-item guard above. The poisoned member is an S7 STRING laid
/// over the image's filler pattern, whose two-byte header declares a current length larger
/// than its capacity; <c>S7String.FromByteArray</c> rejects that. It is deliberately the
/// FIRST member (lower offset) so the good member decodes after it — the exact ordering
/// last-writer-wins used to erase.
/// </remarks>
[Fact]
public async Task One_undecodable_span_member_leaves_the_pass_Degraded()
{
var ct = TestContext.Current.CancellationToken;
// The STRING occupies DB1 bytes 100..111 (StringLength + 2), so the word at 112 is
// adjacent and lands in the SAME span.
var tags = S7RawTags.Entries(
new S7TagDefinition("BadStr", "DB1.DBB100", S7DataType.String, Writable: false, StringLength: 10),
new S7TagDefinition("GoodWord", "DB1.DBW112", S7DataType.UInt16, Writable: false));
var factory = new ImageS7PlcFactory(Images());
using var driver = new S7Driver(Options(tags, maxGap: 16), "s7-span-decode-fault", factory);
await driver.InitializeAsync("{}", ct);
var before = driver.GetHealth().LastSuccessfulRead;
var results = await driver.ReadAsync(["BadStr", "GoodWord"], ct);
// ONE block read served both members — this really is the span path, not two per-tag reads.
factory.ReadBytesCalls.ShouldBe(1);
factory.StringReadCalls.ShouldBe(0);
// Only the poisoned member is Bad; its block-mate keeps its value.
results[0].StatusCode.ShouldNotBe(0u);
results[0].Value.ShouldBeNull();
results[1].StatusCode.ShouldBe(0u);
results[1].Value.ShouldNotBeNull();
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Degraded, "a span member that failed to decode must not be erased");
health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value);
}
/// <summary>
/// Faulted outranks BOTH Degraded and Healthy: an access-denied read (the permanent PUT/GET
/// configuration fault) on one tag survives a later device-fault tag AND a later tag that
/// read successfully, all in the same pass.
/// </summary>
/// <remarks>
/// The ordering is Healthy &lt; Degraded &lt; Faulted, so the config alert — the one state
/// an operator must act on — is precisely the one last-writer-wins was most likely to
/// erase, since the denied tag is usually a minority of the batch. Both later outcomes are
/// present deliberately: the success falsifies "any success clears the failure", the
/// Degraded falsifies "the last failure wins". <c>LastSuccessfulRead</c> still advances,
/// because one tag really was read.
/// </remarks>
[Fact]
public async Task An_access_denied_tag_keeps_the_pass_Faulted_even_after_a_later_tag_succeeds()
{
var ct = TestContext.Current.CancellationToken;
var factory = new ImageS7PlcFactory(Images());
// A: "not allowed" is what IsAccessDenied matches — PUT/GET disabled on an S7-1200/1500,
// which is the Faulted config alert. B: an ordinary device-layer fault, merely Degraded,
// observed AFTER it. C: no fault at all.
factory.PerTagFaults["DB1.DBW0"] = new PlcException(ErrorCode.ReadData, "Accessing object not allowed");
factory.PerTagFaults["DB1.DBW2"] = new PlcException(ErrorCode.ReadData, "S7 CPU reported a device fault");
// maxGap -1 turns grouping off entirely, so every tag takes the per-tag read path and the
// faults can be aimed at individual addresses.
using var driver = new S7Driver(Options(NarrowTags(), maxGap: -1), "s7-access-denied", factory);
await driver.InitializeAsync("{}", ct);
var before = driver.GetHealth().LastSuccessfulRead;
var results = await driver.ReadAsync(["A", "B", "C"], ct);
results[0].StatusCode.ShouldNotBe(0u);
results[1].StatusCode.ShouldBe(StatusBadDeviceFailure);
results[2].StatusCode.ShouldBe(0u);
var health = driver.GetHealth();
health.State.ShouldBe(
DriverState.Faulted, "a config alert must outrank a later device fault AND a later success");
health.LastError.ShouldNotBeNull();
// The FIRST failure is the causal one — the later device fault does not overwrite it.
health.LastError!.ShouldContain("PUT/GET");
health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(
before!.Value, "one tag read fine, so the watchdog's evidence must still advance");
}
/// <summary>
@@ -611,6 +743,14 @@ public sealed class S7SpanReadParityTests
/// <summary>When set, only the MULTI-VAR read throws this — the per-tag fallback still serves the image.</summary>
public Exception? MultiVarFault { get; init; }
/// <summary>
/// Per-address read faults, keyed by the address string (e.g. <c>DB1.DBW0</c>): only
/// those addresses throw, every other one still serves the image. Lets a test put
/// faults of DIFFERENT severities and successes in ONE pass, which
/// <see cref="ReadFault"/> (all-or-nothing) cannot express.
/// </summary>
public Dictionary<string, Exception> PerTagFaults { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// When true, <c>ReadBytesAsync</c> hangs until its token is cancelled, so cancellation is
/// the only way out of a block read (STAB-15a). Settable so a test can turn it back off
@@ -745,6 +885,7 @@ public sealed class S7SpanReadParityTests
public Task<object?> ReadAsync(string address, CancellationToken ct)
{
Interlocked.Increment(ref owner.StringReadCalls);
if (owner.PerTagFaults.TryGetValue(address, out var aimed)) throw aimed;
if (owner.ReadFault is { } fault) throw fault;
var addr = S7AddressParser.Parse(address);
var image = owner.Images[(ToS7NetArea(addr.Area), addr.DbNumber)];