fix(s7): read-pass health is worst-state-wins — a mixed pass can no longer report Healthy
This commit is contained in:
@@ -544,6 +544,9 @@ public sealed class S7Driver
|
||||
RequireInitialized();
|
||||
var now = DateTime.UtcNow;
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
// Health is decided ONCE, at the end of the pass, from every outcome the pass observed —
|
||||
// never per item. See ReadPassOutcome.
|
||||
var outcome = new ReadPassOutcome();
|
||||
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
@@ -612,7 +615,7 @@ public sealed class S7Driver
|
||||
perTag.Add(span.Members[0].OriginalIndex);
|
||||
continue;
|
||||
}
|
||||
await ReadSpanAsync(plc, span, results, now, cancellationToken).ConfigureAwait(false);
|
||||
await ReadSpanAsync(plc, span, results, now, outcome, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
perTag.AddRange(plan.LeftoverIndexes);
|
||||
}
|
||||
@@ -635,7 +638,7 @@ public sealed class S7Driver
|
||||
if (_options.SpanReadMaxGapBytes >= 0)
|
||||
{
|
||||
perTag = await ReadLeftoversByMultiVarAsync(
|
||||
plc, resolved, perTag, results, now, cancellationToken).ConfigureAwait(false);
|
||||
plc, resolved, perTag, results, now, outcome, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var i in perTag)
|
||||
@@ -644,7 +647,7 @@ public sealed class S7Driver
|
||||
{
|
||||
var value = await ReadOneAsync(plc, resolved[i]!, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new DataValueSnapshot(value, 0u, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
outcome.RecordSuccess();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -660,14 +663,90 @@ public sealed class S7Driver
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, RecordReadFailure(ex), null, now);
|
||||
results[i] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome), null, now);
|
||||
}
|
||||
}
|
||||
|
||||
// THE single health write of the pass — worst-state-wins over every outcome above.
|
||||
// Deliberately the last statement of the try body rather than a finally: the three OCE
|
||||
// arms abandon the batch mid-pass, and a partially-observed pass must not stamp a
|
||||
// verdict. LastSuccessfulRead advances whenever ANYTHING read, even into a
|
||||
// Degraded/Faulted state — see ReadPassOutcome for why that is load-bearing.
|
||||
_health = new DriverHealth(
|
||||
outcome.WorstFailure,
|
||||
outcome.AnySuccess ? now : _health.LastSuccessfulRead,
|
||||
outcome.WorstFailure == DriverState.Healthy ? null : outcome.FirstError);
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The outcomes ONE <see cref="ReadAsync"/> pass observed, accumulated so the pass writes
|
||||
/// <see cref="_health"/> exactly once — <b>worst state wins</b>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Health used to be stamped per item, in three separate read loops, so the pass
|
||||
/// reported whatever its LAST item happened to be: a mixed pass read <c>Healthy</c>, and
|
||||
/// the access-denied <c>Faulted</c> config alert — usually a minority of the batch — was
|
||||
/// erased by any later good tag. Ranking (Healthy < Degraded < Faulted) and
|
||||
/// deciding once at the end makes the verdict a function of the whole pass rather than
|
||||
/// of its item ordering.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><see cref="AnySuccess"/> is tracked separately from the state on purpose.</b> The
|
||||
/// comms-loss watchdog trips only on a non-Healthy state AND a frozen
|
||||
/// <c>LastSuccessfulRead</c>; several drivers latch Degraded off a single unreadable
|
||||
/// address, so a pass that lands Degraded must still advance the timestamp when
|
||||
/// anything at all was read, or an entirely reachable CPU would be reconnected every
|
||||
/// 90 s forever.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Not thread-safe, and does not need to be: a pass runs wholly under <see cref="_gate"/>
|
||||
/// and every read loop inside it is sequential.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private sealed class ReadPassOutcome
|
||||
{
|
||||
/// <summary>True once any tag in the pass read successfully.</summary>
|
||||
public bool AnySuccess { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// The worst failure state recorded so far; <see cref="DriverState.Healthy"/> means no
|
||||
/// failure has been recorded at all.
|
||||
/// </summary>
|
||||
public DriverState WorstFailure { get; private set; } = DriverState.Healthy;
|
||||
|
||||
/// <summary>
|
||||
/// The FIRST failure's message — the causal one. A later failure is as likely to be a
|
||||
/// consequence of the first as an independent fault, so it does not overwrite it.
|
||||
/// </summary>
|
||||
public string? FirstError { get; private set; }
|
||||
|
||||
/// <summary>Records that one tag read successfully.</summary>
|
||||
public void RecordSuccess() => AnySuccess = true;
|
||||
|
||||
/// <summary>Records one failed tag, keeping the worst state and the first message.</summary>
|
||||
/// <param name="state">The state this failure would have stamped on its own.</param>
|
||||
/// <param name="message">The failure's operator-facing message.</param>
|
||||
public void RecordFailure(DriverState state, string message)
|
||||
{
|
||||
if (Rank(state) > Rank(WorstFailure)) WorstFailure = state;
|
||||
FirstError ??= message;
|
||||
}
|
||||
|
||||
/// <summary>Severity order the pass ranks by: Healthy (none) < Degraded < Faulted.</summary>
|
||||
/// <param name="state">The state to rank.</param>
|
||||
/// <returns>Its severity, higher being worse.</returns>
|
||||
private static int Rank(DriverState state) => state switch
|
||||
{
|
||||
DriverState.Faulted => 2,
|
||||
DriverState.Degraded => 1,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Items one multi-var request may carry. <b>The caller owns chunking — S7.Net does NOT
|
||||
/// split a multi-var read</b>, it throws when the request or the projected response exceeds
|
||||
@@ -743,6 +822,7 @@ public sealed class S7Driver
|
||||
/// <param name="leftovers">Indexes the span pass left for the per-tag path, ascending.</param>
|
||||
/// <param name="results">The batch's result array, written in place at each tag's original index.</param>
|
||||
/// <param name="now">The batch timestamp, shared by every tag in the batch.</param>
|
||||
/// <param name="outcome">The pass's outcome accumulator; every decoded item records into it.</param>
|
||||
/// <param name="ct">Cancellation token for the reads.</param>
|
||||
/// <returns>The indexes still owed an individual read, ascending.</returns>
|
||||
private async Task<List<int>> ReadLeftoversByMultiVarAsync(
|
||||
@@ -751,6 +831,7 @@ public sealed class S7Driver
|
||||
List<int> leftovers,
|
||||
DataValueSnapshot[] results,
|
||||
DateTime now,
|
||||
ReadPassOutcome outcome,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var stillPerTag = new List<int>();
|
||||
@@ -825,13 +906,13 @@ public sealed class S7Driver
|
||||
?? throw new System.IO.InvalidDataException(
|
||||
$"S7.Net returned null multi-var value for '{tag.Address}'");
|
||||
results[index] = new DataValueSnapshot(ReinterpretRawValue(tag, addr, raw), 0u, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
outcome.RecordSuccess();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Decoding is pure and synchronous — no cancellation can surface here, so there
|
||||
// is deliberately no OperationCanceledException arm to shadow the request one.
|
||||
results[index] = new DataValueSnapshot(null, RecordReadFailure(ex), null, now);
|
||||
results[index] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome), null, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -902,10 +983,12 @@ public sealed class S7Driver
|
||||
/// <param name="span">The planned block read.</param>
|
||||
/// <param name="results">The batch's result array, written in place at each member's original index.</param>
|
||||
/// <param name="now">The batch timestamp, shared by every tag in the batch.</param>
|
||||
/// <param name="outcome">The pass's outcome accumulator; every member records into it.</param>
|
||||
/// <param name="ct">Cancellation token for the read.</param>
|
||||
/// <returns>A task that completes once every member of the span has been stamped.</returns>
|
||||
private async Task ReadSpanAsync(
|
||||
IS7Plc plc, S7ReadSpanPlanner.Span span, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
|
||||
IS7Plc plc, S7ReadSpanPlanner.Span span, DataValueSnapshot[] results, DateTime now,
|
||||
ReadPassOutcome outcome, CancellationToken ct)
|
||||
{
|
||||
byte[] block;
|
||||
try
|
||||
@@ -926,7 +1009,7 @@ public sealed class S7Driver
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var status = RecordReadFailure(ex);
|
||||
var status = RecordReadFailure(ex, outcome);
|
||||
foreach (var member in span.Members)
|
||||
results[member.OriginalIndex] = new DataValueSnapshot(null, status, null, now);
|
||||
return;
|
||||
@@ -938,13 +1021,14 @@ public sealed class S7Driver
|
||||
{
|
||||
var value = DecodeMember(member, block);
|
||||
results[member.OriginalIndex] = new DataValueSnapshot(value, 0u, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
outcome.RecordSuccess();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Decoding is pure and synchronous — no cancellation can surface here, so there is
|
||||
// deliberately no OperationCanceledException arm to shadow the block-read one above.
|
||||
results[member.OriginalIndex] = new DataValueSnapshot(null, RecordReadFailure(ex), null, now);
|
||||
results[member.OriginalIndex] =
|
||||
new DataValueSnapshot(null, RecordReadFailure(ex, outcome), null, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -998,17 +1082,29 @@ public sealed class S7Driver
|
||||
/// <summary>
|
||||
/// The read-failure classification ladder, shared by the per-tag path and the span path so
|
||||
/// a failed span stamps every member with exactly the status the same exception would have
|
||||
/// produced on a single tag. Applies the health / dead-handle side effects and returns the
|
||||
/// status code the caller must stamp.
|
||||
/// produced on a single tag. Records the failure into the pass's outcome, applies the
|
||||
/// dead-handle side effect, and returns the status code the caller must stamp.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="OperationCanceledException"/> is deliberately NOT handled here: it is control
|
||||
/// flow rather than a per-tag status (it marks the handle dead and propagates), and
|
||||
/// rethrowing it from a helper would rewrite the stack. Both call sites catch it first.
|
||||
/// <para>
|
||||
/// <b>The health state is recorded, not applied.</b> It goes into
|
||||
/// <paramref name="outcome"/> so the pass can write <see cref="_health"/> once at the
|
||||
/// end, worst-state-wins — writing it here is what let a later good tag erase this
|
||||
/// failure. The <c>_plcDead</c> / <see cref="MarkConnectionDeadIfFatal"/> side effects
|
||||
/// stay IMMEDIATE: they govern whether the very next read reopens the handle, which
|
||||
/// cannot wait for the pass to finish.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="OperationCanceledException"/> is deliberately NOT handled here: it is
|
||||
/// control flow rather than a per-tag status (it marks the handle dead and propagates),
|
||||
/// and rethrowing it from a helper would rewrite the stack. Both call sites catch it
|
||||
/// first.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="ex">The exception observed on a read.</param>
|
||||
/// <param name="outcome">The pass's outcome accumulator this failure is recorded into.</param>
|
||||
/// <returns>The OPC UA status code every affected tag must be stamped with.</returns>
|
||||
private uint RecordReadFailure(Exception ex)
|
||||
private uint RecordReadFailure(Exception ex, ReadPassOutcome outcome)
|
||||
{
|
||||
switch (ex)
|
||||
{
|
||||
@@ -1020,7 +1116,7 @@ public sealed class S7Driver
|
||||
// fault, NOT a transient one. Blind retry is wasted effort, so map it to
|
||||
// BadNotSupported and flag the driver as a config alert (Faulted) rather than
|
||||
// Degraded — per driver-specs.md §5.
|
||||
_health = new DriverHealth(DriverState.Faulted, _health.LastSuccessfulRead,
|
||||
outcome.RecordFailure(DriverState.Faulted,
|
||||
"S7 access denied — enable PUT/GET communication in TIA Portal " +
|
||||
$"(Protection & Security) for this CPU. PLC reported: {pex.Message}");
|
||||
return StatusBadNotSupported;
|
||||
@@ -1031,14 +1127,14 @@ public sealed class S7Driver
|
||||
// PlcException (socket drop / ConnectionError) additionally marks the handle dead
|
||||
// so the next read reopens it.
|
||||
MarkConnectionDeadIfFatal(pex);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message);
|
||||
outcome.RecordFailure(DriverState.Degraded, pex.Message);
|
||||
return StatusBadDeviceFailure;
|
||||
|
||||
default:
|
||||
// Socket / timeout / transport fault — mark the handle dead when it's a connection
|
||||
// loss so EnsureConnectedAsync reopens on the next call.
|
||||
MarkConnectionDeadIfFatal(ex);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
outcome.RecordFailure(DriverState.Degraded, ex.Message);
|
||||
return StatusBadCommunicationError;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user