47 KiB
Design + Implementation Plan — R2-01 S7 Fault-Path Hardening
Source report: archreview/05-protocol-drivers.md (2026-07-12 re-review)
Plan verified against tree at: f6eaa267 (master, clean)
Scope: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/ (S7Driver.cs, IS7Plc.cs) + tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ + tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/
Action-list item: #1 in archreview/00-OVERALL.md — "S7: filter connect-timeout OCE in the ensure-wrapper + poll-loop catches; broaden fatal classification (framing/cancel); mark handle dead in probe catch. Add fake-factory connect-timeout test" (05/STAB-14 + 05/STAB-15; STAB-8's S7 seam noted for R2-09).
Both findings are residuals of the Critical-3 fix (
25c0c6f6, round-1 plan §A1/STAB-1,archreview/plans/05-protocol-drivers-plan.md). STAB-14 is a regression inside that fix — it re-opens the exact scenario class (transient outage permanently kills S7 subscriptions) that Critical 3 was merged to close, just via connect-timeout instead of no-reconnect. Modbus's twin defect (STAB-3, same desync class as STAB-15) is out of scope here — the report's remediation order pairs it with STAB-15 conceptually, but it lives inModbusTcpTransport.csand is tracked separately.
Verification summary
Every cited line was opened at f6eaa267. Both findings CONFIRMED. Three citations drifted
by ≤6 lines (the report was written against the same commit but a few ranges anchor on doc
comments rather than code); corrected refs below are used throughout this plan.
| Report citation | Status at f6eaa267 |
Corrected ref |
|---|---|---|
STAB-14 — S7Driver.cs:488 ensure-wrapper catch (OperationCanceledException) { throw; } in ReadAsync |
CONFIRMED exact — unconditional rethrow, no when filter |
:488 |
STAB-14 — S7Driver.cs:1000 same wrapper in WriteAsync |
CONFIRMED exact | :1000 |
STAB-14 — S7Driver.cs:1383, 1404 PollLoopAsync bare catch (OperationCanceledException) { return; } |
CONFIRMED exact (initial-read catch :1383, tick catch :1404; the Task.Delay catch at :1396-1397 is a third bare OCE-return) |
:1383, :1396-1397, :1404 |
STAB-14 — S7Driver.cs:1206-1208 linked CancelAfter(_options.Timeout) in EnsureConnectedAsync |
CONFIRMED, drifted −2 — the using var cts / CancelAfter / OpenAsync(cts.Token) block is at :1204-1206; method spans :1187-1218 |
:1204-1206 |
STAB-15 — S7Driver.cs:1239-1251 IsS7ConnectionFatal |
CONFIRMED, drifted +1..3 — the cited range starts inside the xmldoc; the method body is :1242-1252 (MarkConnectionDeadIfFatal at :1226-1229). Classifies only SocketException/IOException/ObjectDisposedException (inner-walking) + PlcException{ErrorCode.ConnectionError} — no framing types, no cancellation |
:1242-1252 |
STAB-15 — S7Driver.cs:1542 probe bare catch { } never marks dead |
CONFIRMED exact — catch { /* transport/timeout/exception — treated as Stopped below */ }; no MarkConnectionDeadIfFatal anywhere in ProbeLoopAsync (:1516-1549) |
:1542 |
STAB-8 (S7 part) — S7Driver.cs:1188-1224 unthrottled per-call reconnect |
CONFIRMED, drifted — EnsureConnectedAsync is :1187-1218; no attempt throttle, full OpenAsync bounded only by _options.Timeout per data call/probe tick while the PLC is down |
:1187-1218 |
Additional load-bearing facts verified for the design:
ReadAsyncper-item loop swallows OCE today — there is no per-item OCE catch; a mid-PDU cancellation falls intocatch (Exception ex)(S7Driver.cs:536-543) →MarkConnectionDeadIfFatal(a no-op for OCE) →BadCommunicationError, and the half-read socket is kept.WriteAsync's per-item loop does have an explicit OCE catch (:1043-1048) that rethrows — also without marking the handle dead. Both are STAB-15(b) sites.- The driver throws its own
System.IO.InvalidDataExceptionas the decode type-mismatch backstop inReinterpretRawValue(S7Driver.cs:973-975) — a config fault on a healthy socket. This constrains the classifier design (below): blanket-classifyingInvalidDataExceptionas connection-fatal would churn a reopen on every read of a mis-authored tag. - S7.Net 0.20.0 framing surface (verified against the shipped assembly,
~/.nuget/packages/s7netplus/0.20.0):TPKTInvalidException,TPDUInvalidException,WrongNumberOfBytesExceptionarepublicclasses with public parameterless ctors (directly constructible in tests), plusPlcExceptionwithErrorCode.WrongNumberReceivedBytes. None are classified fatal today. - The probe's own OCE handling is already correct for STAB-14 —
:1541catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }filters on the loop token, so a probe-timeout OCE falls to the bare catch and reads asStopped. Its only gap is STAB-15(b): the bare catch never marks the handle dead. InitializeAsynchas the same unfiltered connect pattern (:186-196): a connect-timeout OCE escapes to the outercatch (Exception ex)→Faulted+ rethrow — the health outcome is right, but the caller receives an OCE for what is a connection failure. Included as a consistency fix (low stakes; init callers treat any throw as init failure).- Test infra:
S7DriverReconnectTests(tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs) holds the privateFakeS7PlcFactory/FakeS7Plc(Options:Timeout = 250ms, probe disabled).InternalsVisibleTo("ZB.MOM.WW.OtOpcUa.Driver.S7.Tests")is in place (ZB.MOM.WW.OtOpcUa.Driver.S7.csproj:26). The live suiteS7_1500ReconnectTestsis double-gated onSnap7ServerFixture+S7_RECONNECT_BOUNCE_CMDand — as the report says — structurally blind to STAB-14:docker restartyields connection-refused (SocketException, immediate), never the SYN-blackhole connect-timeout the bug needs.
Finding 1 — STAB-14 (High, regression) — connect-timeout OCE permanently kills S7 subscription polls
Restatement. EnsureConnectedAsync bounds the reopen with a linked
CancelAfter(_options.Timeout) CTS (S7Driver.cs:1204-1206). Against an
unreachable-but-not-refusing host (pulled cable, powered-off PLC on a switched network,
firewall DROP — the classic field outage Critical 3 targeted), OpenAsync throws
TaskCanceledException when that internal timer fires. The read/write ensure-wrappers rethrow
any OCE unconditionally (:488, :1000), so the timeout escapes the driver as OCE. In the
poll loop, catch (OperationCanceledException) { return; } (:1383, :1404) treats every OCE
as teardown and exits — no log, no health change, no HandlePollFailure. Server-initiated
reads/writes self-heal on the next call (the Critical-3 fix working as designed), but every
subscription poll loop that ticks during the outage is silently and permanently dead until
redeploy.
Verification. Confirmed at the corrected refs above. Traced end-to-end: poll tick →
PollOnceAsync (:1458) → internal ReadAsync with the loop's ct → _gate →
EnsureConnectedAsync → hang → internal cts.CancelAfter fires → TaskCanceledException
(an OCE) → wrapper :488 rethrows (the caller's ct is not cancelled) → PollLoopAsync
tick catch :1404 → return. Nothing distinguishes this from teardown. Also confirmed the unit
suite can't currently see it: FakeS7Plc.OpenAsync never honours its token (returns
synchronously or Task.FromException), so no existing test produces a timeout-OCE from open.
Root cause. The Critical-3 fix introduced an internal timeout implemented as cancellation
(CancelAfter on a linked CTS) but let the resulting OCE escape with caller-cancellation
semantics. Downstream, the bespoke poll loop's bare OCE-catch (a CONV-1 fork divergence —
PollGroupEngine never had this shape) turned "one timed-out reconnect attempt" into "loop
exits forever". Two mistakes compound: timeout encoded as OCE at the source, and OCE interpreted
as teardown without consulting the loop token.
Proposed design. Fix at three layers — the source plus two defensive filters — so no single future edit can reintroduce the class:
- Source conversion (the root fix): in
EnsureConnectedAsync, catch the internal-timeout OCE and rethrow it asTimeoutException. A connect timeout is a connection failure, not a cancellation; converting at the source makes every caller (read wrapper, write wrapper, probe) handle it via their existing generic degrade paths with zero further changes, and puts a meaningful message in the logs/_health.LastErrorinstead of "operation was canceled". - Wrapper filters (belt):
:488and:1000becomecatch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }— caller cancellation still propagates; any other OCE (should no longer occur after #1, but e.g. an S7.Net-internal CTS in a future package bump) falls through to the existingcatch (Exception ex)→ whole-batchBadCommunicationError+Degraded. - Poll-loop filters (suspenders):
:1383,:1396-1397,:1404becomecatch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }. A non-teardown OCE at:1383/:1404then falls into the existingcatch (Exception ex)→HandlePollFailure(log + Degraded + capped backoff) — the loop lives. (TheTask.Delaycatch at:1396-1397can only seect-triggered OCE, so its filter is purely for uniformity.) - Consistency: apply the same timeout→
TimeoutExceptionconversion to the identical connect block inInitializeAsync(:186-196), so init-time and reconnect-time timeouts surface the same exception type.
Alternatives considered:
whenfilters only, no source conversion (the report's literal recommendation). Works, but leaves "timeout = OCE" encoded in the driver's exception contract: every current and future caller ofEnsureConnectedAsyncmust remember the filter idiom (the probe already has it; a fourth caller wouldn't). Converting at the source fixes the taxonomy once. Rejected as sole fix; kept as layers 2-3 because defense-in-depth here is nearly free and the filters also cover OCEs born belowEnsureConnectedAsync.- Retry inside
EnsureConnectedAsyncon timeout. Rejected — retry/backoff policy is exactly the fleet-wide primitive R2-09 owns (STAB-8); baking an ad-hoc retry here would pre-empt it. - Move S7 onto
PollGroupEnginenow (CONV-1) — the fork's OCE handling disappears with the fork. Right end-state, wrong scope: PollGroupEngine v2 (backoff + onError absorption) is the report's remediation item #5 and is a multi-driver change. This plan's two-line filters are the safe immediate stop-loss; they do not conflict with a later migration.
Implementation steps (exact code for each small bit):
-
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs,EnsureConnectedAsync— replace thetry/catcharoundOpenAsync(:1202-1212) with:try { using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(_options.Timeout); await plc.OpenAsync(cts.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { // The linked CancelAfter fired — a connect TIMEOUT, not a caller cancellation. // Surface it as a connection FAILURE (TimeoutException) so callers route it through // their degrade paths: an escaping OCE reads as teardown and permanently killed the // subscription poll loops (STAB-14, the unreachable-host regression in the Crit-3 fix). try { plc.Dispose(); } catch { /* best-effort */ } throw new TimeoutException( $"S7 connect to {_options.Host}:{_options.Port} timed out after " + $"{(int)_options.Timeout.TotalMilliseconds} ms."); } catch { try { plc.Dispose(); } catch { /* best-effort */ } throw; } -
Same file,
ReadAsync:488andWriteAsync:1000— changecatch (OperationCanceledException) { throw; }to
// Rethrow ONLY caller cancellation. A timeout-born OCE (none expected after the // EnsureConnectedAsync conversion, but e.g. an S7.Net-internal CTS) must degrade the // batch below, not escape as teardown (STAB-14). catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } -
Same file,
PollLoopAsync— addwhen (ct.IsCancellationRequested)to the three OCE catches (:1383,:1396-1397,:1404), e.g. the tick catch:catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; } catch (Exception ex) // now also receives any non-teardown OCE → backoff, not death { consecutiveFailures++; HandlePollFailure(ex, consecutiveFailures, initial: false); } -
Same file,
InitializeAsync:184-196— mirror step 1'swhen (!cancellationToken.IsCancellationRequested)→TimeoutExceptionconversion around the initialOpenAsync.
Tests (all in tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs,
extending the existing fakes — see Task 0; the driver's Options() fixture keeps
Timeout = 250ms so hang-until-cancelled opens cost 250 ms each):
- T1 (the mandated first-and-failing regression test)
Read_degrades_batch_on_connect_timeout_instead_of_throwing— connection #1 drops (NextReadThrows = PlcException(ConnectionError)→ marks dead), factory set to hang-until-cancelled opens; the nextReadAsynccurrently throwsTaskCanceledException; expected: returns aBadCommunicationErrorbatch +Degradedhealth, no throw. FAILS atf6eaa267. - T2
Subscription_poll_loop_survives_a_connect_timeout_outage_and_recovers— subscribe at 100 ms; drop the socket; let ≥1 poll tick hit a hanging reconnect (OpenHangsUntilCancelledCount = 2); then restore normal opens; assert a GoodOnDataChangearrives after a Bad one within a 10 s deadline (TaskCompletionSource+WaitAsync). FAILS atf6eaa267(loop exits on the first timeout tick; no recovery event ever fires). - T3
Write_degrades_batch_on_connect_timeout_instead_of_throwing— T1's shape throughWriteAsync. FAILS atf6eaa267. - T4 (pinning)
Read_still_propagates_caller_cancellation_during_connect— optionsTimeout = 5s, hang-until-cancelled open, caller CTS cancelled after 50 ms; assertOperationCanceledExceptionpropagates. PASSES before and after — pins the filter direction so the fix can't over-rotate into swallowing real cancellation.
Effort: S. Risk/blast-radius: Low. Four small catch-clause edits on one file; the happy
path is untouched. Behavior deltas: (a) connect-timeout now yields TimeoutException/Bad-batch
instead of OCE — strictly the documented intent of the Crit-3 degrade path; (b) poll loops
survive timeouts with backoff — the regression closed. Callers seeing TimeoutException where
OCE escaped before: only the poll loop (fixed to want this) and DriverHostActor dispatch
(treats both as capability failure).
Finding 2 — STAB-15 (High) — fatal classification misses framing/desync + cancellation-during-I/O; probe never marks dead
Restatement. IsS7ConnectionFatal (S7Driver.cs:1242-1252) classifies only
SocketException/IOException/ObjectDisposedException (inner-walking) and
PlcException{ConnectionError}. Two gaps: (a) a caller-token cancellation mid-PDU (server
request deadline, or a dispatch-layer Polly timeout now that CapabilityInvoker wraps driver
calls) abandons a half-read ISO-on-TCP response on the socket; OCE is not classified, the handle
is kept, and the next call reads stale bytes. S7.Net then surfaces framing faults
(TPKTInvalidException, TPDUInvalidException, WrongNumberOfBytesException,
PlcException{WrongNumberReceivedBytes}) — none classified fatal either — so the single gated
connection stays permanently desynchronized (the Modbus STAB-3 mode rebuilt in S7).
(b) The probe loop swallows every failure (:1542) without marking the handle dead, so its
reconnect-backstop role only works when TcpClient.Connected flips false on its own; a
wire-dead or desynced handle that still claims Connected is re-probed and re-fails forever
(contrast TwinCAT's probe, which force-recycles on failure).
Verification. Confirmed. IsS7ConnectionFatal body at :1242-1252 matches the report's
description exactly. Read path: no per-item OCE catch — OCE lands in catch (Exception ex)
(:536-543), MarkConnectionDeadIfFatal no-ops, socket kept. Write path: explicit per-item OCE
rethrow at :1043-1048, also without marking. Probe: gate-held body :1529-1539, teardown OCE
filter :1541, bare swallow :1542; MarkConnectionDeadIfFatal is absent from the whole loop.
S7.Net exception taxonomy verified against the 0.20.0 assembly (all three framing types public,
public parameterless ctors — unit-constructible). One nuance the report under-specifies: the
driver itself throws System.IO.InvalidDataException for a declared-type/address-size
mismatch (:973-975), so the report's suggestion to classify InvalidDataException as a
framing fault must be narrowed (see design).
Root cause. The Crit-3 classifier was written from S7.Net's connection-error surface
(socket-level types + ConnectionError) without walking its framing/parsing surface, and
without the "cancellation abandons an in-flight PDU on a single serialized stream" insight the
Modbus STAB-3 analysis had already produced. The probe predates the dead-handle flag and was
never taught to feed it.
Proposed design.
- (a1) Broaden the classifier to the framing surface. Add to the inner-walk of
IsS7ConnectionFatal:TPKTInvalidException,TPDUInvalidException,WrongNumberOfBytesException, andPlcException{ErrorCode.WrongNumberReceivedBytes}. A framing violation on ISO-on-TCP means the stream position is untrustworthy; only a reopen recovers. Deliberately NOTSystem.IO.InvalidDataException: the driver's own decode backstop (:973-975) throws it for a config mismatch on a healthy socket — classifying it would churn a full reopen on every read of a mis-authored tag. Residual risk accepted: if some S7.Net path signals desync only viaInvalidDataException, the desynced stream's very next PDU still lands in TPKT/TPDU/WrongNumber territory, which now tears down — self-heal within one extra failed call. - (a2) Cancellation observed during wire I/O marks the handle dead before propagating. Add a
per-item OCE catch in
ReadAsync's loop (_plcDead = true; throw;) and add_plcDead = true;toWriteAsync's existing per-item OCE catch (:1043-1048). Both run under_gate, honouring_plcDead's documented gate-guarded discipline. Marking is deliberately unconditional on OCE here (not routed through the type classifier): whether any bytes moved is unknowable from outside, and the worst case of a false positive is one cheap reopen — versus permanent desync for a false negative. Behavior change (read path): caller cancellation mid-read now propagates as OCE instead of being converted to aBadCommunicationErrorsnapshot — consistent withWriteAsync, with the pre-loop_gate.WaitAsync(cancellationToken), and with "the caller cancelled; nobody consumes the batch". - (b) Probe marks dead under the gate. Restructure the gate-held probe body with an inner
try/catch so classification happens while
_gateis still held (keeping_plcDeadgate-guarded — the current outer catch sits outside the gate): teardown OCE rethrows to the outer filter; a probe-timeout OCE (deadline fired mid-ReadStatusAsyncPDU) marks dead unconditionally (same abandonment argument as (a2)); any other exception goes throughMarkConnectionDeadIfFatal. Net effect: a desynced/hung handle whoseTcpClient.Connectedlies is disposed and reopened by the next probe tick'sEnsureConnectedAsync— the probe becomes the real backstop the Crit-3 design intended (mirrors TwinCAT's probe-recycle).
Alternatives considered:
- Classify OCE inside
IsS7ConnectionFatalinstead of at the catch sites — rejected: the classifier is also called for exceptions that wrap an OCE thrown before any I/O, and the ensure-wrapper OCE (connect timeout) has no live handle to mark; explicit_plcDead = trueat the three I/O catch sites is smaller and keeps the classifier a pure type-taxonomy function. - Force-recycle (dispose + null
Plc) directly in the probe catch à la TwinCAT — rejected: S7 already has the exactly-once-dispose discipline centralized inEnsureConnectedAsyncvia_plcDead; a second dispose site would reintroduce the race the flag was built to prevent. - Blanket-classify
InvalidDataException(report's literal text) — rejected as unsafe for the reason in (a1); revisit only if the decode backstop at:973-975is retyped first.
Implementation steps:
-
S7Driver.cs,IsS7ConnectionFatal(:1242-1252) — extend the walk:for (Exception? e = ex; e is not null; e = e.InnerException) { if (e is System.Net.Sockets.SocketException or System.IO.IOException or ObjectDisposedException) 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 // for a declared-type/address-size CONFIG mismatch on a healthy socket. if (e is TPKTInvalidException or TPDUInvalidException or WrongNumberOfBytesException) return true; if (e is PlcException { ErrorCode: ErrorCode.ConnectionError or ErrorCode.WrongNumberReceivedBytes }) return true; } return false;Update the method's xmldoc to match.
-
ReadAsyncper-item loop — insert beforecatch (NotSupportedException)(:511):catch (OperationCanceledException) { // Cancellation observed mid-PDU abandons a half-read ISO-on-TCP response on the // single gated stream — the handle must be reopened, not reused (STAB-15). Mark dead // (we hold _gate) and propagate: the caller cancelled, nobody consumes the batch. _plcDead = true; throw; } -
WriteAsyncper-item OCE catch (:1043-1048) — add_plcDead = true;above thethrow;(extend the comment with the same rationale). -
ProbeLoopAsync(:1516-1549) — restructure the gate-held section:await _gate.WaitAsync(probeCts.Token).ConfigureAwait(false); try { try { var plc = await EnsureConnectedAsync(probeCts.Token).ConfigureAwait(false); await plc.ReadStatusAsync(probeCts.Token).ConfigureAwait(false); success = true; } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } // teardown → outer filter catch (OperationCanceledException) { // Probe deadline fired mid-PDU: the CPU-status response may still be in flight, // so the handle can't be reused (half-read ⇒ desync). We hold _gate here. _plcDead = true; } catch (Exception ex) { // STAB-15(b): classify + mark while holding _gate so a wire-dead/desynced handle // whose TcpClient still claims Connected is reopened by the NEXT tick's // EnsureConnectedAsync instead of being re-probed forever. MarkConnectionDeadIfFatal(ex); } } finally { _gate.Release(); }The existing outer
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }(:1541) and barecatch { }(:1542) stay — the outer bare catch now handles only gate-wait timeouts (gate contention is not a connection fault; correctly not marked) and the rethrown teardown OCE exits via the outer filter afterfinallyreleases the gate.
Tests (same file/fakes as Finding 1):
- T5
Framing_faults_are_classified_connection_fatal_and_reopen—[Theory]overTPKTInvalidException,TPDUInvalidException,WrongNumberOfBytesException,PlcException(ErrorCode.WrongNumberReceivedBytes)asNextReadThrows; assert the next read opens connection #2 and reads Good (factory.Created.Count == 2). FAILS atf6eaa267(count stays 1). Companion negative:PlcException(ErrorCode.ReadData)still does not reopen (already covered byRead_does_not_reopen_on_a_data_address_error— keep green). - T6
Cancelled_read_marks_handle_dead_and_next_read_reopens— fake read hangs on its token; caller cancels at 50 ms; assert OCE propagates and the next (fresh-token) read creates connection #2. FAILS atf6eaa267(OCE →BadCommunicationError, same handle reused). - T7
Cancelled_write_marks_handle_dead_and_next_read_reopens— same via a hanging write (OCE already propagates today; the reopen assertion is what fails). FAILS atf6eaa267. - T8
Probe_failure_on_connected_socket_marks_dead_and_next_tick_reopens— probe enabled (Interval = 50ms,Timeout = 250ms); connection #1'sReadStatusAsyncthrowsSocketExceptionpersistently whileIsConnectedstays true; assertfactory.Created.Count >= 2and aRunninghost transition within a bounded window (the probe's own next tick reopens viaEnsureConnectedAsync). FAILS atf6eaa267(fast path returns the same lying handle forever). Variant in the same test class: T9Probe_timeout_marks_dead—ReadStatusAsynchangs on its token instead of throwing; same assertions. FAILS atf6eaa267.
Effort: S-M (the probe restructure is the only non-trivial edit). Risk/blast-radius:
Low-Medium, all inside S7Driver.cs. Deltas: (a) framing faults now cost one reopen instead of
permanent desync — reopen churn is bounded by the fault actually recurring; (b) read-path caller
cancellation now propagates (contract-consistent; the only in-repo poll-loop caller handles OCE
via the Finding-1 filters); (c) probe can now trigger reopens — bounded to one reopen per failed
probe tick, and only when the failure classifies fatal or times out (a healthy-but-slow PLC that
answers within Probe.Timeout is unaffected).
Finding 3 — STAB-8, S7-specific part (Medium, note-only here) — unthrottled per-call reconnect; the R2-09 seam
Restatement. While the PLC is down, every data call and every probe tick pays a full TCP
connect attempt bounded only by _options.Timeout (EnsureConnectedAsync, :1187-1218) — no
attempt throttle. The poll loop's capped backoff (:1393-1397, ComputeBackoffDelay :1447)
shields subscriptions only; server-initiated reads/writes and the probe retry unthrottled.
Verification. Confirmed at the corrected refs (report's :1188-1224 drifted; method is
:1187-1218). Note the Finding-1/2 changes slightly reduce dead-PLC cost already: poll ticks
that previously died (stopping their contribution) now back off to PollBackoffCap (30 s), and
timeout-OCEs no longer escape as anomalous exceptions.
Proposed design — no throttle implemented in this plan. The connect-attempt throttle is a
fleet-wide primitive (S7, AbCip, FOCAS, TwinCAT all need it) and belongs to R2-09
(PollGroupEngine v2 / reconnect-backoff consolidation). This plan only documents the seam:
EnsureConnectedAsync is the single choke-point every S7 reconnect flows through (data calls +
probe), already gate-serialized, so R2-09's throttle is a last-failed-attempt timestamp checked
at the top of its slow path — one field + one early-throw, no structural change. A <remarks>
paragraph on EnsureConnectedAsync names this so R2-09's implementer (and any interim reader
wondering about dead-PLC connect churn) lands here. Alternative — implement the throttle now:
rejected; a driver-local knob would immediately become fleet debt and prejudge R2-09's shape
(shared policy vs per-driver options).
Implementation steps: extend the EnsureConnectedAsync xmldoc <remarks> (:1180-1184)
with the seam note (exact text in Task 10). Tests: none (doc-only). Effort: S (trivial).
Risk: none.
Verification bar (house thesis: "unit-green ≠ wired")
Deterministic unit guards — every behavioral claim above has a fake-factory unit test that
fails at f6eaa267 and passes after (T1-T3, T5-T9), plus two pinning tests (T4, existing
data-address-error test) proving the fixes didn't over-rotate. The fakes drive the exact
production code paths (real S7Driver, real gate, real poll/probe loops) — only the wire is
faked, and the STAB-14 fakes specifically make OpenAsync honour its token, which is the
property the live rig cannot simulate cheaply.
Live gate (env-gated, operator-run) — the existing bounce test cannot cover STAB-14/15
because docker restart produces connection-refused (instant SocketException), never
connect-timeout. The timeout variant needs the SYN-blackhole shape: packets dropped, not
rejected. Design (Task 11), mirroring S7_RECONNECT_BOUNCE_CMD's double-gate:
- New
S7_1500ConnectTimeoutOutageTestsintests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/, triple-gated on the sim fixture + two env vars:S7_TIMEOUT_OUTAGE_START_CMD— begins dropping traffic to the sim port, e.g.ssh dohertj2@10.100.0.35 "sudo iptables -I INPUT -p tcp --dport 1102 -j DROP"S7_TIMEOUT_OUTAGE_STOP_CMD— removes the rule (-D INPUT …). Absent vars ⇒ cleanAssert.Skip(safe offline on macOS; afinallyalways runs the stop command once start succeeded, so the shared sim is never left blackholed).
- Body: subscribe against the live sim (
lmxopcua-fix up s7 s7_1500on 10.100.0.35) → Good baselineOnDataChange→ run start-cmd → observe ≥1 Bad tick (each reconnect attempt now times out rather than refusing) → run stop-cmd → assert a GoodOnDataChangeresumes on the same subscription within 90 s. This is precisely the outage class STAB-14 killed; before the fix this test would hang at the recovery assertion. - A
docker pause otopcua-python-snap7-s7_1500/unpausepair is an acceptable simpler START/STOP command choice (paused container = ACKless blackhole for established flows and unanswered SYNs for new connects) — document both in the test's remarks; the env-var design deliberately leaves the mechanism to the operator.
Run recipe for the operator (also in the test xmldoc):
lmxopcua-fix up s7 s7_1500
export S7_TIMEOUT_OUTAGE_START_CMD='ssh dohertj2@10.100.0.35 "docker pause otopcua-python-snap7-s7_1500"'
export S7_TIMEOUT_OUTAGE_STOP_CMD='ssh dohertj2@10.100.0.35 "docker unpause otopcua-python-snap7-s7_1500"'
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests --filter "Category=Reconnect"
Task breakdown
TDD discipline throughout: the failing test lands (and is observed failing) before the code it guards. Filter strings assume the repo root. Tasks 0-5 are Finding 1 (STAB-14); 6-9 are Finding 2 (STAB-15); 10-12 close out. Task 1 is the mandated connect-timeout regression test and must be run and seen RED before any driver edit.
Task 0 — Extend the reconnect-test fakes with token-honouring hang modes
Classification: trivial Estimated implement time: 5 min Parallelizable with: nothing (everything depends on it) Files:
- Modify:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs
Steps:
- In
FakeS7Plc: addpublic bool OpenHangsUntilCancelled { get; set; },public bool ReadHangsUntilCancelled { get; set; },public bool WriteHangsUntilCancelled { get; set; },public Exception? ReadStatusThrows { get; set; }(persistent, not one-shot),public bool ReadStatusHangsUntilCancelled { get; set; }. In each corresponding method, when the flag is set:await Task.Delay(System.Threading.Timeout.Infinite, cancellationToken);(throwsTaskCanceledExceptionwhen the token fires) /throw ReadStatusThrows— placed before the existing one-shot*ThrowsOncehandling. Convert the affected methods toasync Task. - In
FakeS7PlcFactory: addpublic int OpenHangsUntilCancelledCount { get; set; }(eachCreatedecrements it and setsplc.OpenHangsUntilCancelled = truewhile positive) andpublic Exception? ReadStatusThrowsForNewConnections { get; set; }(copied onto each created plc; lets T8 arm connection #1 only by clearing it after init). - Run
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests --filter "FullyQualifiedName~S7DriverReconnectTests"— expected: PASS (existing 4 tests unaffected; compile-only change). - Commit:
test(s7): extend reconnect fakes with token-honouring hang + probe-fault modes (R2-01 task 0)
Task 1 — RED: the connect-timeout regression tests (T1 + T3) — MUST FAIL on current code
Classification: small Estimated implement time: 5 min Parallelizable with: — Files:
- Test:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs
Steps:
- Write
Read_degrades_batch_on_connect_timeout_instead_of_throwing(T1, Finding 1 Tests) andWrite_degrades_batch_on_connect_timeout_instead_of_throwing(T3): drop connection #1 viaNextReadThrows = new PlcException(ErrorCode.ConnectionError, "dropped")+ one read to mark dead; setfactory.OpenHangsUntilCancelledCount = int.MaxValue; assert the nextReadAsync/WriteAsyncdoes not throw, returns non-zero statuses, and health isDegraded. - Run
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests --filter "FullyQualifiedName~connect_timeout"— expected: FAIL ×2 (TaskCanceledExceptionescapesReadAsync/WriteAsync). Record the failure output. - No commit yet (red tests commit together with Task 2's green).
Task 2 — RED: the poll-loop-survival test (T2) + caller-cancel pinning test (T4)
Classification: small Estimated implement time: 5 min Parallelizable with: — (same file as Task 1; sequence after it) Files:
- Test:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs
Steps:
- Write
Subscription_poll_loop_survives_a_connect_timeout_outage_and_recovers(T2): subscribe["W0"]at 100 ms; hookOnDataChange— setsawBadone.Snapshot.StatusCode != 0u, complete aTaskCompletionSourceon the first Good snapshot aftersawBad; drop connection #1;OpenHangsUntilCancelledCount = 2; await the TCS withWaitAsync(TimeSpan.FromSeconds(10)). - Write
Read_still_propagates_caller_cancellation_during_connect(T4): options withTimeout = TimeSpan.FromSeconds(5); mark dead;OpenHangsUntilCancelledCount = 1; callerCancellationTokenSourcewithCancelAfter(50);Should.ThrowAsync<OperationCanceledException>. - Run the two new tests — expected: T2 FAIL (10 s timeout — the loop died on the timeout tick), T4 PASS (pins existing correct behavior).
- No commit yet.
Task 3 — GREEN: EnsureConnectedAsync + InitializeAsync timeout→TimeoutException conversion
Classification: standard Estimated implement time: 5 min Parallelizable with: — Files:
- Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs(EnsureConnectedAsync:1202-1212;InitializeAsync:184-196)
Steps:
- Apply Finding 1 implementation step 1 (exact code above) and step 4 (the
InitializeAsyncmirror — filter on!cancellationToken.IsCancellationRequested). - Run
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests --filter "FullyQualifiedName~S7DriverReconnectTests"— expected: ALL PASS (T1/T2/T3 go green; T4 + the four pre-existing tests stay green). - Commit:
fix(s7): surface connect-timeout as TimeoutException, not OCE — poll loops no longer die on unreachable-host outages (STAB-14, R2-01)
Task 4 — Defensive when filters: ensure-wrappers + poll-loop catches
Classification: small Estimated implement time: 5 min Parallelizable with: Task 5 Files:
- Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs(:488,:1000,:1383,:1396-1397,:1404)
Steps:
- Apply Finding 1 implementation steps 2 and 3 (exact code above). These are belt-and-suspenders behind Task 3 — no reachable path produces a non-teardown OCE any more, so no new red test exists; the deterministic guards are T2 (loop survival) and T4 (cancellation direction), which must both stay green.
- Run
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests— expected: PASS (whole project). - Commit:
fix(s7): filter OCE catches on the owning token in read/write wrappers + poll loop (STAB-14 defense-in-depth, R2-01)
Task 5 — Full S7 unit-suite regression sweep for Finding 1
Classification: trivial Estimated implement time: 3 min Parallelizable with: Task 4 (can be its verify step); listed separately as the finding-close gate Files: none (test run only)
Steps:
- Run
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Testsanddotnet test tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests— expected: PASS. - No commit (gate only; if anything fails, fix within Tasks 3-4 scope before proceeding).
Task 6 — RED: framing-fault classification tests (T5)
Classification: small Estimated implement time: 5 min Parallelizable with: Task 8 (different finding sub-part, same file — prefer sequencing) Files:
- Test:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs
Steps:
- Write
[Theory]Framing_faults_are_classified_connection_fatal_and_reopenwith[MemberData]/switch over four cases:new TPKTInvalidException(),new TPDUInvalidException(),new WrongNumberOfBytesException(),new PlcException(ErrorCode.WrongNumberReceivedBytes, "short read")(all public ctors, verified against S7netplus 0.20.0). Shape: set asNextReadThrows; first read → non-zero status,Created.Countstill 1; second read → Good andCreated.Count == 2,Created[0].Disposedtrue. - Run
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests --filter "FullyQualifiedName~Framing_faults"— expected: FAIL ×4 (Created.Countstays 1). - No commit yet.
Task 7 — GREEN: broaden IsS7ConnectionFatal
Classification: small Estimated implement time: 5 min Parallelizable with: — Files:
- Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs(IsS7ConnectionFatal:1242-1252+ its xmldoc:1231-1241)
Steps:
- Apply Finding 2 implementation step 1 (exact code above), including the NOT-
InvalidDataExceptioncomment and the xmldoc update naming the framing surface. - Run
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests --filter "FullyQualifiedName~S7DriverReconnectTests"— expected: PASS (T5 ×4 green;Read_does_not_reopen_on_a_data_address_errorstill green — the negative control). - Commit:
fix(s7): classify ISO-on-TCP framing/desync faults as connection-fatal (STAB-15a, R2-01)
Task 8 — RED→GREEN: cancellation-during-I/O marks the handle dead (T6 + T7)
Classification: standard Estimated implement time: 5 min (tests) + 5 min (fix) — two sittings of ≤5 min Parallelizable with: — Files:
- Test:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs(ReadAsyncper-item loop, insert before:511;WriteAsyncOCE catch:1043-1048)
Steps:
- Write
Cancelled_read_marks_handle_dead_and_next_read_reopens(T6):Created[0].ReadHangsUntilCancelled = true; caller CTSCancelAfter(50); assertShould.ThrowAsync<OperationCanceledException>on the read; then a fresh-token read → Good,Created.Count == 2. WriteCancelled_write_marks_handle_dead_and_next_read_reopens(T7) withWriteHangsUntilCancelled. - Run
--filter "FullyQualifiedName~marks_handle_dead"— expected: FAIL ×2 (T6 additionally fails its throw assertion today — the read path converts OCE to a Bad snapshot; T7 fails the reopen assertion). - Apply Finding 2 implementation steps 2 and 3 (exact code above).
- Re-run — expected: PASS ×2; then the whole
S7DriverReconnectTestsclass — PASS. - Commit:
fix(s7): cancellation observed mid-PDU marks the connection dead before propagating (STAB-15a, R2-01)
Task 9 — RED→GREEN: probe marks the handle dead (T8 + T9)
Classification: standard Estimated implement time: 5 min (tests) + 5 min (fix) — two sittings of ≤5 min Parallelizable with: — Files:
- Test:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs(ProbeLoopAsync:1516-1549)
Steps:
- Write
Probe_failure_on_connected_socket_marks_dead_and_next_tick_reopens(T8): probe-enabled options (Probe = new S7ProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50), Timeout = TimeSpan.FromMilliseconds(250) }); after init, arm connection #1 with persistentReadStatusThrows = new SocketException()(leave later connections healthy); pollfactory.Created.Countunder a 5 s deadline until>= 2, then assert a subsequentGetHostStatuses()[0].State == HostState.Runningwithin a further bounded window. WriteProbe_timeout_marks_dead(T9): same shape withReadStatusHangsUntilCancelled = trueon connection #1. - Run
--filter "FullyQualifiedName~Probe_"(scoped to the reconnect class) — expected: FAIL ×2 (Created.Countpinned at 1 — the lying handle is re-probed forever). - Apply Finding 2 implementation step 4 (exact probe restructure above).
- Re-run — expected: PASS ×2; whole test project — PASS.
- Commit:
fix(s7): probe failures mark the handle dead under the gate so the next tick reopens (STAB-15b, R2-01)
Task 10 — Doc: the R2-09 connect-throttle seam note on EnsureConnectedAsync
Classification: trivial Estimated implement time: 3 min Parallelizable with: Tasks 6-9 Files:
- Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs(EnsureConnectedAsync<remarks>:1180-1184)
Steps:
- Append to the
<remarks>:While the PLC is down every data call and probe tick pays a full connect attempt bounded only by _options.Timeout (STAB-8). This method is the single choke-point all S7 reconnects flow through (already gate-serialized), so the fleet-wide connect-attempt throttle planned in R2-09 plugs in here: a last-failed-attempt timestamp checked at the top of the slow path. Deliberately NOT implemented driver-locally — see archreview/plans/R2-01-s7-fault-hardening-plan.md Finding 3. - Build-only check (
dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7) — expected: PASS. - Commit:
docs(s7): mark EnsureConnectedAsync as the R2-09 connect-throttle seam (STAB-8 note, R2-01)
Task 11 — Env-gated live connect-timeout outage test (the STAB-14 live gate)
Classification: standard Estimated implement time: 5 min (authoring; the live run is a separate operator action) Parallelizable with: Tasks 6-10 Files:
- Create:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500ConnectTimeoutOutageTests.cs
Steps:
- Author per the Verification-bar design:
[Collection(Snap7ServerCollection.Name)],[Trait("Category","Integration")],[Trait("Category","Reconnect")],[Trait("Device","S7_1500")]; triple-gated skip onsim.SkipReason+S7_TIMEOUT_OUTAGE_START_CMD+S7_TIMEOUT_OUTAGE_STOP_CMD; reuseS7_1500ReconnectTests.RunBounceCommandAsync's shell-exec shape (copy the private helper — or hoist it to the fixture if trivial);try/finallyguarantees STOP runs once START succeeded. Body: subscribe → Good baseline → START (blackhole) → observe ≥1 BadOnDataChange→ STOP → await a GoodOnDataChangeon the same subscription within 90 s. Xmldoc carries the operator recipe (docker pause/unpause and iptables DROP variants) and the explicit rationale:docker restart= connection-refused; this test exists because STAB-14 only fires on connect-timeout. - Run
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests --filter "FullyQualifiedName~ConnectTimeoutOutage"locally (no env vars) — expected: SKIP (cleanly). - Commit:
test(s7): env-gated live connect-timeout outage test — the STAB-14 outage class the bounce test cannot produce (R2-01) - Note for the operator (not a CI step): run the recipe from the Verification-bar section
against the 10.100.0.35 fixture (
lmxopcua-fix up s7 s7_1500) before trusting the fix live.
Task 12 — Close-out: whole-solution S7-adjacent sweep + bookkeeping
Classification: trivial Estimated implement time: 5 min Parallelizable with: — Files:
- Modify:
archreview/plans/STATUS.md(mark R2-01 done, note the live-gate env vars) - Modify:
archreview/05-protocol-drivers.md(prior-finding table: STAB-14/15 → FIXED with commit refs; STAB-8 row: note the documented S7 seam)
Steps:
- Run
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Testsand a solution build — expected: PASS. - Update the two archreview files (working-tree convention — these carry uncommitted re-review state; follow whatever commit discipline the archreview docs branch is using at execution time).
- Commit:
docs(archreview): R2-01 S7 fault-path hardening complete — STAB-14/15 fixed, STAB-8 seam documented
Effort & risk summary
| Finding | Effort | Risk / blast radius |
|---|---|---|
| STAB-14 (Tasks 0-5) | S | Low — four catch-clause edits, one file; regression guarded by T1-T4 + live gate |
| STAB-15 (Tasks 6-9) | S-M | Low-Medium — classifier + three catch sites + probe restructure, one file; guarded by T5-T9 + negative controls |
| STAB-8 note (Task 10) | S (trivial) | None — doc only |
| Live gate + close-out (Tasks 11-12) | S | None locally (env-gated skip); live run is operator-driven |
| Overall | S-M (~2-3 h implementer wall-time) | Single-file production change (S7Driver.cs); no contract/DI/schema changes |
Execution deviations (R2-01)
- Pre-existing, out-of-scope CLI test failure (Task 5/12 gate).
SubscribeCommandConsoleHandlerCommentTests.SubscribeCommand_explains_why_OnDataChange_uses_console_Output_synchronouslyintests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.TestsFAILS at the branch base (1676c8f4) — the source-scanning assertion expects the literal phrase"CliFx console"inSubscribeCommand.cs, which commit9cad9ed0(docs: strip tracking-ID comments) removed. R2-01 never touches the CLI project (git diff --stat 1676c8f4 HEAD -- src/Drivers/Cli tests/Drivers/Cliis empty). Left as-is: not caused by, nor in scope for, S7 fault-path hardening. The rest of the S7 CLI suite is green (48/49). The S7 unit suite (Driver.S7.Tests) is fully green (238/238).