fix(galaxy,s7): R2 review minors — subscribe-once deadline, probe vs backoff, write-fault ownership, null array elements, write opt-in

- Galaxy: deadline bounds the wait, not the subscribe RPC; BadTimeout for all tags and late handles unsubscribed; doc comment relocated
- S7: probe connect runs on the connect deadline so a timeout opens the backoff window
- S7: a successful write only retires a Faulted the write side owns
- S7: null array elements rejected (BadTypeMismatch); multi-PDU array writes documented non-atomic
- S7 integration: array write test gated on S7_SIM_ALLOW_WRITES=1

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 07:31:22 -04:00
parent ca0f1e7735
commit e77bca385a
9 changed files with 208 additions and 13 deletions
@@ -1869,6 +1869,7 @@ public sealed class S7Driver
if (!outcome.HasObservations) return;
var worst = outcome.WorstFailure;
var priorWriteFault = _writeFault;
if (worst == DriverState.Faulted)
_writeFault = (DriverState.Faulted, outcome.WorstError!);
else if (outcome.AnySuccess)
@@ -1878,7 +1879,10 @@ public sealed class S7Driver
{
// Success-only batch: no health write, except to retire a Faulted that WE (the write
// side) were holding — a clean write is the only evidence the alert has cleared.
if (outcome.AnySuccess && _writeFault is null && Health.State == DriverState.Faulted)
// Only a Faulted the write side owns: a read-side Faulted (e.g. access denied on another DB) is the
// read pass's to clear, and Health still carries the write fault's own message iff it is ours.
if (outcome.AnySuccess && _writeFault is null && priorWriteFault is { } owned
&& Health.State == DriverState.Faulted && Health.LastError == owned.Message)
Health = new DriverHealth(DriverState.Healthy, Health.LastSuccessfulRead, null);
return;
}
@@ -2004,6 +2008,9 @@ public sealed class S7Driver
var elements = new List<object?>(count);
foreach (var e in items) elements.Add(e);
// Convert.ToXxx(null) is 0 — a null element must not silently write zero to the PLC.
if (elements.Contains(null))
throw new S7WriteValueException($"S7 array tag '{tag.Name}': an element is null.");
if (elements.Count != count)
throw new S7WriteValueException(
$"S7 array tag '{tag.Name}' has {count} elements but the write supplied {elements.Count}.");
@@ -2361,10 +2368,10 @@ public sealed class S7Driver
// Probe via S7.Net's low-cost GetCpuStatus — returns the CPU state (Run/Stop)
// and is intentionally light on the comms mailbox. Single-word Plc.ReadAsync
// would also work but GetCpuStatus doubles as a "PLC actually up" check.
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
probeCts.CancelAfter(_options.Probe.Timeout);
using var gateCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
gateCts.CancelAfter(_options.Probe.Timeout);
await _gate.WaitAsync(probeCts.Token).ConfigureAwait(false);
await _gate.WaitAsync(gateCts.Token).ConfigureAwait(false);
try
{
try
@@ -2372,7 +2379,14 @@ public sealed class S7Driver
// EnsureConnectedAsync doubles as the reconnect backstop: if a read/write
// marked the handle dead (or the socket dropped between ticks), the probe
// reopens it here so recovery doesn't wait for the next data call.
var plc = await EnsureConnectedAsync(probeCts.Token).ConfigureAwait(false);
// It runs on the LOOP token, not the (shorter) probe deadline: the connect is bounded by
// _options.Timeout inside, and a timeout there must count as a failure so STAB-21's
// backoff window opens — cutting it with the probe deadline looked like a caller cancel,
// recorded nothing, and let a black-holed host cost a connect attempt every probe tick.
var plc = await EnsureConnectedAsync(ct).ConfigureAwait(false);
// The probe deadline bounds only the CPU-status round trip.
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
probeCts.CancelAfter(_options.Probe.Timeout);
await plc.ReadStatusAsync(probeCts.Token).ConfigureAwait(false);
success = true;
}