perf(s7): fail the rest of a pass fast once the handle is dead (R07:P1)

No read or write loop checked _plcDead between items: on a frozen-but-
established peer the first op blew its deadline and marked the handle dead,
and every remaining span / tag / write still went to the wire and could wait
its own deadline under _gate (a 20-span pass ~100 s), with Degraded and the
comms-loss watchdog's clock deferred until the pass ended.

- The span loop, the multi-var chunk loop, the per-tag loop and the write
  loop stamp BadCommunicationError without I/O once _plcDead is set.
- A multi-var chunk that dies on a transport loss (socket / IO / timeout /
  ConnectionError) marks the handle dead instead of falling back to one
  doomed per-tag read per member. The framing class is deliberately excluded
  there: snap7/S7.Net report a multi-var with one refused item as
  ReadData(WrongNumberReceivedBytes) over a healthy stream.
- Live gate via an in-test freezable TCP proxy (counts request frames, not
  seconds). Mutation shows S7.Net 0.20 already self-closes on a cancelled op,
  so the gate pins the end-to-end property; the driver guard itself is proven
  by the unit tests (an op that ignores its token keeps the socket open).

Refs #742
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 15:26:14 -04:00
parent a1f20e78f9
commit 1d83cde6bc
6 changed files with 422 additions and 1 deletions
@@ -762,6 +762,13 @@ public sealed class S7Driver
perTag.Add(span.Members[0].OriginalIndex);
continue;
}
// Fail fast once an earlier span marked the handle dead (R07:P1): every
// further wire op on it would pay the full deadline under the gate.
if (_plcDead)
{
foreach (var member in span.Members) results[member.OriginalIndex] = DeadHandleSnapshot(now);
continue;
}
await ReadSpanAsync(plc, span, results, now, outcome, cancellationToken).ConfigureAwait(false);
}
perTag.AddRange(plan.LeftoverIndexes);
@@ -790,6 +797,12 @@ public sealed class S7Driver
foreach (var i in perTag)
{
// Fail fast once the handle is dead (R07:P1) — see the span loop.
if (_plcDead)
{
results[i] = DeadHandleSnapshot(now);
continue;
}
try
{
var value = await ReadOneAsync(plc, resolved[i]!, cancellationToken).ConfigureAwait(false);
@@ -1069,6 +1082,13 @@ public sealed class S7Driver
{
var chunk = batchable.GetRange(start, Math.Min(MultiVarReadMaxItems, batchable.Count - start));
// Fail fast once the handle is dead (R07:P1) — see ReadAsync's span loop.
if (_plcDead)
{
foreach (var (index, _, _) in chunk) results[index] = DeadHandleSnapshot(now);
continue;
}
IReadOnlyList<S7DataItem> answered;
try
{
@@ -1094,6 +1114,17 @@ public sealed class S7Driver
_plcDead = true;
throw;
}
catch (Exception ex) when (IsS7TransportLoss(ex))
{
// A transport loss is the connection's fault, not the batch's: falling back would
// send one more doomed per-tag read per member, each paying the full deadline
// (R07:P1). Mark the handle dead and fail the chunk (and, via the guards, the rest
// of the pass) without I/O.
_plcDead = true;
outcome.RecordFailure(DriverState.Degraded, ex.Message);
foreach (var (index, _, _) in chunk) results[index] = DeadHandleSnapshot(now);
continue;
}
catch (Exception ex)
{
_logger.LogDebug(ex,
@@ -1974,6 +2005,13 @@ public sealed class S7Driver
results[i] = new WriteResult(StatusBadNotWritable);
continue;
}
// Fail the rest of the batch fast once a write marked the handle dead (R07:P1): the
// next call reopens; this batch must not serialise one deadline per remaining write.
if (_plcDead)
{
results[i] = new WriteResult(StatusBadCommunicationError);
continue;
}
try
{
await WriteOneAsync(plc, tag, w.Value, cancellationToken).ConfigureAwait(false);
@@ -2415,6 +2453,37 @@ public sealed class S7Driver
if (IsS7ConnectionFatal(ex)) _plcDead = true;
}
/// <summary>
/// The result stamped, without any I/O, on an item a pass skipped because an earlier item had
/// already marked the connection dead (R07:P1). The pass's health already carries the failure
/// that marked it.
/// </summary>
/// <param name="now">The pass timestamp.</param>
/// <returns>A <c>BadCommunicationError</c> snapshot.</returns>
private static DataValueSnapshot DeadHandleSnapshot(DateTime now) => new(null, StatusBadCommunicationError, null, now);
/// <summary>
/// The TRANSPORT subset of <see cref="IsS7ConnectionFatal"/>: a socket / IO / disposed handle /
/// blown deadline / <see cref="ErrorCode.ConnectionError"/> anywhere in the chain. Excludes the
/// framing class, because S7.Net reports a multi-var request with one refused item as
/// <c>PlcException(ReadData, PlcException(WrongNumberReceivedBytes))</c> (live-probed on the snap7
/// fixture) over a perfectly healthy stream — the multi-var path falls back per tag for that
/// shape, and a genuine desync still surfaces on the per-tag reads.
/// </summary>
/// <param name="ex">The exception to classify.</param>
/// <returns><see langword="true"/> for a transport loss.</returns>
private static bool IsS7TransportLoss(Exception ex)
{
for (Exception? e = ex; e is not null; e = e.InnerException)
{
if (e is System.Net.Sockets.SocketException or System.IO.IOException or ObjectDisposedException or TimeoutException)
return true;
if (e is PlcException { ErrorCode: ErrorCode.ConnectionError })
return true;
}
return false;
}
/// <summary>
/// True when <paramref name="ex"/> (or any inner exception) is a socket-level / connection
/// loss OR an ISO-on-TCP framing/desync fault that a reopen can repair — a