fix(s7): read-pass health is worst-state-wins — a mixed pass can no longer report Healthy
This commit is contained in:
@@ -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 < Degraded < 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)];
|
||||
|
||||
Reference in New Issue
Block a user