fix(s7): access-denied is an answer; review round-1 minors (R07:H4 follow-through)

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
This commit is contained in:
Joseph Doherty
2026-10-22 15:57:53 -04:00
parent 04ad774235
commit 081fa693af
9 changed files with 438 additions and 34 deletions
@@ -201,6 +201,12 @@ public sealed class S7Driver
/// <summary>Clock the connect backoff is measured on. Internal so tests step it deterministically.</summary>
internal Func<DateTime> BackoffClock { get; set; } = static () => DateTime.UtcNow;
/// <summary>
/// Clock the read/write passes and the init publish stamp timestamps and
/// <c>LastSuccessfulRead</c> with. Internal so a test can step it and assert a STRICT advance.
/// </summary>
internal Func<DateTime> UtcNow { get; set; } = static () => DateTime.UtcNow;
/// <summary>
/// The sticky write-side config fault (access denied / PUT-GET disabled), or null. A read
/// pass that finds everything readable must NOT erase it (reads can succeed while writes are
@@ -364,7 +370,7 @@ public sealed class S7Driver
_connectBackoff.RecordSuccess();
Volatile.Write(ref _negotiatedPduSize, Plc.MaxPduSize);
_initialized = true;
TryWriteHealth(gen, new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
TryWriteHealth(gen, new DriverHealth(DriverState.Healthy, UtcNow(), null));
// Kick off the probe loop once the connection is up. Initial HostState stays Unknown
// until the first probe tick succeeds — avoids broadcasting a premature Running
@@ -509,9 +515,18 @@ public sealed class S7Driver
if (ReferenceEquals(_probeCts, probeCts))
{
probeCts?.Dispose();
_probeCts = null;
_probeTask = null;
}
else
{
// An initialize published (and started a probe loop) after this release took its
// snapshot — a teardown whose generation bump raced the publish block. Cancel that
// loop too; it exits on its next gate wait / delay. Not joined: it needs _gate, which
// we hold. Its CTS is left to the GC rather than disposed under a running loop.
try { _probeCts?.Cancel(); } catch { /* best-effort */ }
probeCts?.Dispose();
}
_probeCts = null;
_probeTask = null;
_initialized = false;
_plcDead = false;
try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ }
@@ -693,7 +708,7 @@ public sealed class S7Driver
// InvalidOperationException from the not-initialized check.
ArgumentNullException.ThrowIfNull(fullReferences);
RequireInitialized();
var now = DateTime.UtcNow;
var now = 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.
@@ -749,6 +764,8 @@ public sealed class S7Driver
List<int>? forcedPerTag = null;
// Members of each span refused this pass, and which of them were then refused on their own.
List<IReadOnlyList<int>>? refusedSpans = null;
// Members of each multi-var chunk that fell back per tag this pass (R07:P2's multi-var sibling).
List<IReadOnlyList<int>>? multiVarFallbacks = null;
HashSet<int>? refusedAlone = null;
if (_options.SpanReadMaxGapBytes >= 0)
@@ -820,7 +837,8 @@ public sealed class S7Driver
if (_options.SpanReadMaxGapBytes >= 0)
{
perTag = await ReadLeftoversByMultiVarAsync(
plc, resolved, perTag, results, now, outcome, cancellationToken).ConfigureAwait(false);
plc, resolved, perTag, results, now, outcome, cancellationToken,
fallback => (multiVarFallbacks ??= []).Add(fallback)).ConfigureAwait(false);
}
if (forcedPerTag is not null)
{
@@ -856,13 +874,28 @@ public sealed class S7Driver
}
catch (Exception ex)
{
if (refusedSpans is not null && IsRefusal(ex)) (refusedAlone ??= []).Add(i);
if ((refusedSpans is not null || multiVarFallbacks is not null) && IsRefusal(ex)) (refusedAlone ??= []).Add(i);
results[i] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome, [resolved[i]!.Name]), null, now);
}
}
// Remember what must not be grouped again (R07:P2). Skipped when the handle died mid-pass:
// the per-member reads then proved nothing about which member the CPU refuses.
// A multi-var chunk that fell back: memo only the members refused ON THEIR OWN — without one,
// the chunk's failure explains nothing about its members, and they keep batching.
if (multiVarFallbacks is not null && refusedAlone is not null && !_plcDead)
{
foreach (var members in multiVarFallbacks)
{
var bad = members.Where(refusedAlone.Contains).ToList();
if (bad.Count == 0) continue;
foreach (var m in bad) _noGroup.Add(resolved[m]!.Name);
_logger.LogInformation(
"S7 CPU refused a multi-var read of {MemberCount} tag(s); reading {Tags} individually from now on. Driver={DriverInstanceId}",
members.Count, string.Join(", ", bad.Select(m => resolved[m]!.Name)), _driverInstanceId);
}
}
if (refusedSpans is not null && !_plcDead)
{
foreach (var members in refusedSpans)
@@ -1092,6 +1125,8 @@ public sealed class S7Driver
/// <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>
/// <param name="onChunkFallback">Receives the member indexes of each chunk that fell back per tag, so
/// the caller can memo the members then refused on their own (the multi-var sibling of R07:P2).</param>
/// <returns>The indexes still owed an individual read, ascending.</returns>
private async Task<List<int>> ReadLeftoversByMultiVarAsync(
IS7Plc plc,
@@ -1100,7 +1135,8 @@ public sealed class S7Driver
DataValueSnapshot[] results,
DateTime now,
ReadPassOutcome outcome,
CancellationToken ct)
CancellationToken ct,
Action<IReadOnlyList<int>> onChunkFallback)
{
var stillPerTag = new List<int>();
List<(int Index, S7TagDefinition Tag, S7ParsedAddress Addr)>? batchable = null;
@@ -1180,6 +1216,7 @@ public sealed class S7Driver
"S7 driver {DriverInstanceId}: multi-var read of {ItemCount} leftover tag(s) failed; " +
"falling back to per-tag reads for that chunk", _driverInstanceId, chunk.Count);
foreach (var (index, _, _) in chunk) stillPerTag.Add(index);
onChunkFallback([.. chunk.Select(c => c.Index)]);
continue;
}
@@ -1429,6 +1466,11 @@ public sealed class S7Driver
// BadNotSupported and flag the driver as a config alert (Faulted) rather than
// Degraded — per driver-specs.md §5. Matched over the whole chain: S7.Net's READ
// paths raise the CPU's item error as a bare Exception, not a PlcException.
// It is ALSO an answer from a reachable CPU: LastSuccessfulRead must advance, or a
// factory-default CPU (every read denied) trips the comms-loss watchdog into a
// reconnect + re-subscribe every 90 s that can never fix a PUT/GET setting.
// Faulted still wins the pass (worst state), so the config alert is kept.
outcome.RecordAnswered();
outcome.RecordFailure(DriverState.Faulted,
"S7 access denied — enable PUT/GET communication in TIA Portal " +
$"(Protection & Security) for this CPU. PLC reported: {ex.Message}");
@@ -2115,6 +2157,9 @@ public sealed class S7Driver
// configuration fault as on read. BadNotSupported +
// a config-alert health state, not a transient device failure.
results[i] = new WriteResult(StatusBadNotSupported);
// An answer (see the read ladder): advances LastSuccessfulRead. RecordAnswered sets
// AnyAnswered only, so the sticky _writeFault still clears only on a real success.
outcome.RecordAnswered();
outcome.RecordFailure(DriverState.Faulted,
"S7 access denied — enable PUT/GET communication in TIA Portal " +
$"(Protection & Security) for this CPU. PLC reported: {ex.Message}");
@@ -2177,7 +2222,7 @@ public sealed class S7Driver
else if (outcome.AnySuccess)
_writeFault = null; // a write got through — the protection fault is gone
var answeredAt = outcome.AnyAnswered ? DateTime.UtcNow : (DateTime?)null;
var answeredAt = outcome.AnyAnswered ? UtcNow() : (DateTime?)null;
if (worst == DriverState.Healthy)
{
// Clean batch (successes and/or refusals): the CPU answered, so LastSuccessfulRead
@@ -2678,10 +2723,14 @@ public sealed class S7Driver
internal static bool IsAccessDenied(Exception ex)
{
if (ex is not PlcException && !IsS7ItemError(ex)) return false;
// A transport fault wins over any wording in the chain: a socket error whose text happens to
// contain the phrase must reopen (Degraded), not latch a sticky config alert.
if (IsS7ConnectionFatal(ex) || HasCancellationInChain(ex)) return false;
for (Exception? e = ex; e is not null; e = e.InnerException)
{
if (e.Message.Contains("Accessing object not allowed", StringComparison.OrdinalIgnoreCase)
|| e.Message.Contains("not allowed", StringComparison.OrdinalIgnoreCase))
// S7.Net's exact wording for the S7 AccessingObjectNotAllowed status — not the loose "not
// allowed", which any unrelated exception text can contain.
if (e.Message.Contains("Accessing object not allowed", StringComparison.OrdinalIgnoreCase))
{
return true;
}
@@ -2766,6 +2815,16 @@ public sealed class S7Driver
/// <param name="ex">The exception caught by the poll engine.</param>
private void HandlePollError(Exception ex)
{
// A retained poll that ran into a teardown / re-initialize IN PROGRESS ("not initialized" while a
// lifecycle operation holds _lifecycleGate) belongs to no live generation: that operation owns
// health, and writing Degraded over its Initializing (or its final Unknown) would break the "old
// work never overwrites newer health" rule. A poll against a driver nobody is initializing (a
// subscribe before init, a failed init) still surfaces Degraded (Driver.S7-009).
if (!_initialized && _lifecycleGate.CurrentCount == 0)
{
_logger.LogDebug(ex, "S7 poll reader failed during a lifecycle operation; no health write. Driver={DriverInstanceId}", _driverInstanceId);
return;
}
_logger.LogWarning(ex, "S7 poll reader failed. Driver={DriverInstanceId}", _driverInstanceId);
TryUpdateHealth(CurrentGeneration, h => h.State == DriverState.Faulted
? null
@@ -2957,6 +3016,7 @@ public sealed class S7Driver
_initialized = false;
_plcDead = false;
_writeFault = null;
_writeOwnedError = null;
_connectBackoff.RecordSuccess();
try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ }
Plc = null;
@@ -120,10 +120,7 @@ public sealed class S7TagConfigModel
if (TagConfigJson.GetBool(_bag, "isArray"))
{
var dt = DataType;
if (dt is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64
or S7DataType.String or S7DataType.DateTime or S7DataType.WString or S7DataType.Char
or S7DataType.WChar or S7DataType.Dtl or S7DataType.Date or S7DataType.Tod
or S7DataType.Time or S7DataType.S5Time)
if (S7TagShapeRules.IsByteAnchored(dt))
{
return $"S7 does not support {dt} arrays — use Bool (read-only), Byte, Int16, UInt16, Int32, "
+ "UInt32 or Float32 for an array tag, or a scalar tag.";