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:
@@ -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.";
|
||||
|
||||
Reference in New Issue
Block a user