fix(s7): worst-severity error message + OCE flushes recorded pass evidence (review round)

This commit is contained in:
Joseph Doherty
2026-08-18 07:10:51 -04:00
parent f4b91dbbab
commit 1765e30063
2 changed files with 146 additions and 18 deletions
@@ -666,25 +666,33 @@ public sealed class S7Driver
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.
}
finally
{
// THE single health write of the pass — worst-state-wins over every outcome recorded
// above. In a FINALLY, so evidence the pass already gathered survives an abandoned
// batch: the three OCE arms rethrow mid-pass, and on a partially-working device a
// repeatedly-cancelled poll would otherwise freeze LastSuccessfulRead while tags were
// demonstrably still being read — tripping the comms-loss watchdog into a reconnect
// loop, the exact false positive the AnySuccess design exists to prevent.
//
// A pass that observed NOTHING — an empty batch, or one whose every reference was
// unresolvable — reports nothing: it writes no health at all, preserving a prior
// Faulted (e.g. the access-denied config alert) that a Healthy write would have erased.
if (outcome.AnySuccess || outcome.WorstFailure != DriverState.Healthy)
// A pass that observed NOTHING reports nothing (HasObservations), which is also what
// makes flushing here safe on the two paths that reach the finally with no outcomes:
// the connect-failure early return, whose own Degraded write must NOT be overwritten,
// and an OCE that fired before any tag was stamped. Both no-op.
//
// LastSuccessfulRead advances whenever ANYTHING read, even into a Degraded/Faulted
// state — see ReadPassOutcome for why that is load-bearing.
if (outcome.HasObservations)
{
_health = new DriverHealth(
outcome.WorstFailure,
outcome.AnySuccess ? now : _health.LastSuccessfulRead,
outcome.WorstFailure == DriverState.Healthy ? null : outcome.FirstError);
outcome.WorstFailure == DriverState.Healthy ? null : outcome.WorstError);
}
_gate.Release();
}
finally { _gate.Release(); }
return results;
}
@@ -726,21 +734,47 @@ public sealed class S7Driver
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.
/// The first message recorded <b>at the worst severity</b> — the one that explains
/// <see cref="WorstFailure"/>.
/// </summary>
public string? FirstError { get; private set; }
/// <remarks>
/// It must move WITH the state, not independently of it. Keeping the pass's
/// chronologically-first message instead desynced the pair whenever a Degraded fault
/// preceded a Faulted one: the driver reported the access-denied config alert while its
/// message talked about some unrelated device fault and never mentioned PUT/GET, which
/// is the one thing the operator has to act on. Within a severity the FIRST message
/// still wins — a later peer failure is as likely to be a consequence of it as an
/// independent fault.
/// </remarks>
public string? WorstError { get; private set; }
/// <summary>
/// True once the pass observed anything at all — a success or a failure. False for a
/// degenerate pass (empty batch, or every reference unresolvable), which must report
/// nothing rather than invent a verdict.
/// </summary>
public bool HasObservations => AnySuccess || WorstFailure != DriverState.Healthy;
/// <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>
/// <summary>
/// Records one failed tag, keeping the worst state and the message that explains it.
/// </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;
if (Rank(state) > Rank(WorstFailure))
{
// Escalating the severity MUST re-point the message at the new cause.
WorstFailure = state;
WorstError = message;
}
else
{
WorstError ??= message;
}
}
/// <summary>Severity order the pass ranks by: Healthy (none) &lt; Degraded &lt; Faulted.</summary>