fix(s7): bound async wire ops with a wall-clock deadline (R2-01 read leg)
v2-ci / build (pull_request) Successful in 5m5s
v2-ci / unit-tests (pull_request) Failing after 13m50s

The R2-01 live gate (S7_1500ConnectTimeoutOutageTests, docker-pause blackhole)
surfaced a real gap the offline fakes couldn't: S7.Net's ReadTimeout/WriteTimeout
map to the TcpClient's ReceiveTimeout/SendTimeout, which govern only SYNCHRONOUS
socket calls — the async read/write paths S7.Net uses ignore them. On an
established-but-frozen peer (frozen PLC / firewall DROP / cable pulled mid-flow,
TCP session still open) a read blocked until the OS TCP stack gave up (minutes),
silently wedging the poll loop: no Bad tick, no reconnect. STAB-14 fixed only the
CONNECT leg (EnsureConnectedAsync CancelAfter); this is its READ-leg sibling.

- New S7OperationDeadline: bounds every data-plane wire op with a wall-clock
  ceiling (= _options.Timeout), surfacing an overrun as TimeoutException. Applied
  in S7PlcAdapter to Read/ReadBytes/Write/WriteBytes/ReadStatus. OpenAsync is left
  to EnsureConnectedAsync's own CancelAfter (not double-bounded).
- IsS7ConnectionFatal now classifies TimeoutException fatal → handle marked dead →
  next EnsureConnectedAsync reopens (connect-timeout fix takes over from there).
- Tests: 5 S7OperationDeadline unit tests (deadline / token-honouring / caller-
  cancel-passthrough / resultless), 1 driver-reaction test (read TimeoutException
  → reopen). Driver.S7.Tests 260/260.
- Live gate S7_1500ConnectTimeoutOutageTests now GREEN against the real snap7 sim
  (8s: baseline Good -> pause blackhole -> Bad tick -> unpause -> recovered Good).
This commit is contained in:
Joseph Doherty
2026-07-15 07:11:42 -04:00
parent 152a5645ad
commit 88e0977ae6
5 changed files with 239 additions and 8 deletions
@@ -1287,7 +1287,9 @@ public sealed class S7Driver
/// 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
/// <see cref="System.Net.Sockets.SocketException"/> / <see cref="System.IO.IOException"/> /
/// <see cref="ObjectDisposedException"/>; an S7.Net framing violation
/// <see cref="ObjectDisposedException"/>; a <see cref="TimeoutException"/> from a wire op that
/// blew its <see cref="S7OperationDeadline"/> wall-clock ceiling (an unresponsive but still
/// TCP-established peer — the READ-leg sibling of the connect-timeout fix, STAB-14); an S7.Net framing violation
/// (<see cref="TPKTInvalidException"/> / <see cref="TPDUInvalidException"/> /
/// <see cref="WrongNumberOfBytesException"/>); or a <see cref="PlcException"/> carrying
/// <see cref="ErrorCode.ConnectionError"/> or <see cref="ErrorCode.WrongNumberReceivedBytes"/>.
@@ -1314,6 +1316,13 @@ public sealed class S7Driver
{
if (e is System.Net.Sockets.SocketException or System.IO.IOException or ObjectDisposedException)
return true;
// A wire op that blew its wall-clock deadline (S7OperationDeadline — the async read/write
// ceiling S7.Net's ignored socket ReadTimeout/WriteTimeout can't provide) means the
// connection is unresponsive: discard + reopen. This is the READ-leg sibling of the
// connect-timeout fix (STAB-14) — without it a frozen-but-established peer wedges the
// poll loop forever instead of surfacing Bad + reconnecting.
if (e is TimeoutException)
return true;
// ISO-on-TCP framing/desync surface (STAB-15a): the stream position is untrustworthy —
// a half-read PDU left by a timeout/cancellation makes every later response mis-frame.
// NOTE: deliberately NOT System.IO.InvalidDataException — ReinterpretRawValue throws it