1891f5d6a7
Re-ran all seven domain reviews at master f6eaa267 (reports rewritten in
place, each with a prior-finding status table): all 4 round-1 Criticals
verified closed; new top findings are the S7 connect-timeout OCE regression
(05/STAB-14), the ResilienceConfig operator-authorable brick (01/S-6), and
a batch of resilience-seam Mediums. 00-OVERALL.md carries the updated
maturity matrix + 12-item action list.
Adds R2-01..R2-12 design/implementation plans (one per action item, house
format + bite-sized TDD task breakdowns + co-located .tasks.json; 193 tasks,
~18-24 dev-days). STATUS.md updated: round-1 topology marked historical
(all merged+pushed), re-review findings table + plan pointers added.
733 lines
46 KiB
Markdown
733 lines
46 KiB
Markdown
# 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 in
|
||
> `ModbusTcpTransport.cs` and 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:
|
||
|
||
- **`ReadAsync` per-item loop swallows OCE today** — there is no per-item OCE catch; a mid-PDU
|
||
cancellation falls into `catch (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.InvalidDataException`** as the decode type-mismatch
|
||
backstop in `ReinterpretRawValue` (`S7Driver.cs:973-975`) — a *config* fault on a healthy
|
||
socket. This constrains the classifier design (below): blanket-classifying
|
||
`InvalidDataException` as 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`,
|
||
`WrongNumberOfBytesException` are `public` classes with public parameterless ctors (directly
|
||
constructible in tests), plus `PlcException` with `ErrorCode.WrongNumberReceivedBytes`. None
|
||
are classified fatal today.
|
||
- **The probe's own OCE handling is already correct for STAB-14** — `:1541`
|
||
`catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }` filters on
|
||
the loop token, so a probe-*timeout* OCE falls to the bare catch and reads as `Stopped`. Its
|
||
only gap is STAB-15(b): the bare catch never marks the handle dead.
|
||
- **`InitializeAsync` has the same unfiltered connect pattern** (`:186-196`): a connect-timeout
|
||
OCE escapes to the outer `catch (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 private `FakeS7PlcFactory`/`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 suite `S7_1500ReconnectTests` is
|
||
double-gated on `Snap7ServerFixture` + `S7_RECONNECT_BOUNCE_CMD` and — as the report says —
|
||
**structurally blind to STAB-14**: `docker restart` yields 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:
|
||
|
||
1. **Source conversion (the root fix):** in `EnsureConnectedAsync`, catch the internal-timeout
|
||
OCE and rethrow it as `TimeoutException`. 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.LastError` instead of "operation was canceled".
|
||
2. **Wrapper filters (belt):** `:488` and `:1000` become
|
||
`catch (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 existing
|
||
`catch (Exception ex)` → whole-batch `BadCommunicationError` + `Degraded`.
|
||
3. **Poll-loop filters (suspenders):** `:1383`, `:1396-1397`, `:1404` become
|
||
`catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }`. A
|
||
non-teardown OCE at `:1383`/`:1404` then falls into the existing `catch (Exception ex)` →
|
||
`HandlePollFailure` (log + Degraded + capped backoff) — the loop lives. (The `Task.Delay`
|
||
catch at `:1396-1397` can only see `ct`-triggered OCE, so its filter is purely for uniformity.)
|
||
4. **Consistency:** apply the same timeout→`TimeoutException` conversion to the identical
|
||
connect block in `InitializeAsync` (`:186-196`), so init-time and reconnect-time timeouts
|
||
surface the same exception type.
|
||
|
||
*Alternatives considered:*
|
||
- **`when` filters 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 of `EnsureConnectedAsync` must 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 *below* `EnsureConnectedAsync`.
|
||
- **Retry inside `EnsureConnectedAsync` on 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 `PollGroupEngine` now (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):
|
||
|
||
1. `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs`, `EnsureConnectedAsync` — replace the
|
||
`try/catch` around `OpenAsync` (`:1202-1212`) with:
|
||
|
||
```csharp
|
||
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;
|
||
}
|
||
```
|
||
|
||
2. Same file, `ReadAsync` `:488` and `WriteAsync` `:1000` — change
|
||
|
||
```csharp
|
||
catch (OperationCanceledException) { throw; }
|
||
```
|
||
|
||
to
|
||
|
||
```csharp
|
||
// 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; }
|
||
```
|
||
|
||
3. Same file, `PollLoopAsync` — add `when (ct.IsCancellationRequested)` to the three OCE catches
|
||
(`:1383`, `:1396-1397`, `:1404`), e.g. the tick catch:
|
||
|
||
```csharp
|
||
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);
|
||
}
|
||
```
|
||
|
||
4. Same file, `InitializeAsync` `:184-196` — mirror step 1's `when (!cancellationToken.IsCancellationRequested)`
|
||
→ `TimeoutException` conversion around the initial `OpenAsync`.
|
||
|
||
**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 next `ReadAsync` **currently throws `TaskCanceledException`**;
|
||
expected: returns a `BadCommunicationError` batch + `Degraded` health, no throw. **FAILS at
|
||
`f6eaa267`.**
|
||
- **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 Good `OnDataChange`
|
||
arrives *after* a Bad one within a 10 s deadline (`TaskCompletionSource` + `WaitAsync`).
|
||
**FAILS at `f6eaa267`** (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 through
|
||
`WriteAsync`. **FAILS at `f6eaa267`.**
|
||
- **T4 (pinning)** `Read_still_propagates_caller_cancellation_during_connect` — options
|
||
`Timeout = 5s`, hang-until-cancelled open, caller CTS cancelled after 50 ms; assert
|
||
`OperationCanceledException` propagates. **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`, and `PlcException{ErrorCode.WrongNumberReceivedBytes}`. A
|
||
framing violation on ISO-on-TCP means the stream position is untrustworthy; only a reopen
|
||
recovers. **Deliberately NOT `System.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* via `InvalidDataException`, 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;` to `WriteAsync`'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 a
|
||
`BadCommunicationError` snapshot — consistent with `WriteAsync`, 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 `_gate` is still held (keeping `_plcDead`
|
||
gate-guarded — the current outer catch sits outside the gate): teardown OCE rethrows to the
|
||
outer filter; a probe-*timeout* OCE (deadline fired mid-`ReadStatusAsync` PDU) marks dead
|
||
unconditionally (same abandonment argument as (a2)); any other exception goes through
|
||
`MarkConnectionDeadIfFatal`. Net effect: a desynced/hung handle whose `TcpClient.Connected`
|
||
lies is disposed and reopened by the *next probe tick's* `EnsureConnectedAsync` — the probe
|
||
becomes the real backstop the Crit-3 design intended (mirrors TwinCAT's probe-recycle).
|
||
|
||
*Alternatives considered:*
|
||
- **Classify OCE inside `IsS7ConnectionFatal`** instead 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 = true` at 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 in `EnsureConnectedAsync` via
|
||
`_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-975` is retyped first.
|
||
|
||
**Implementation steps:**
|
||
|
||
1. `S7Driver.cs`, `IsS7ConnectionFatal` (`:1242-1252`) — extend the walk:
|
||
|
||
```csharp
|
||
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.
|
||
|
||
2. `ReadAsync` per-item loop — insert **before** `catch (NotSupportedException)` (`:511`):
|
||
|
||
```csharp
|
||
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;
|
||
}
|
||
```
|
||
|
||
3. `WriteAsync` per-item OCE catch (`:1043-1048`) — add `_plcDead = true;` above the `throw;`
|
||
(extend the comment with the same rationale).
|
||
|
||
4. `ProbeLoopAsync` (`:1516-1549`) — restructure the gate-held section:
|
||
|
||
```csharp
|
||
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 bare `catch { }` (`: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 after `finally` releases the gate.
|
||
|
||
**Tests** (same file/fakes as Finding 1):
|
||
|
||
- **T5** `Framing_faults_are_classified_connection_fatal_and_reopen` — `[Theory]` over
|
||
`TPKTInvalidException`, `TPDUInvalidException`, `WrongNumberOfBytesException`,
|
||
`PlcException(ErrorCode.WrongNumberReceivedBytes)` as `NextReadThrows`; assert the *next* read
|
||
opens connection #2 and reads Good (`factory.Created.Count == 2`). **FAILS at `f6eaa267`**
|
||
(count stays 1). Companion negative: `PlcException(ErrorCode.ReadData)` still does **not**
|
||
reopen (already covered by `Read_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 at `f6eaa267`** (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 at `f6eaa267`.**
|
||
- **T8** `Probe_failure_on_connected_socket_marks_dead_and_next_tick_reopens` — probe enabled
|
||
(`Interval = 50ms`, `Timeout = 250ms`); connection #1's `ReadStatusAsync` throws
|
||
`SocketException` persistently while `IsConnected` stays true; assert
|
||
`factory.Created.Count >= 2` and a `Running` host transition within a bounded window (the
|
||
probe's own next tick reopens via `EnsureConnectedAsync`). **FAILS at `f6eaa267`** (fast path
|
||
returns the same lying handle forever). Variant in the same test class:
|
||
**T9** `Probe_timeout_marks_dead` — `ReadStatusAsync` hangs on its token instead of throwing;
|
||
same assertions. **FAILS at `f6eaa267`.**
|
||
|
||
**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_1500ConnectTimeoutOutageTests` in
|
||
`tests/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 ⇒ clean `Assert.Skip` (safe offline on macOS; a `finally` always 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_1500` on 10.100.0.35) → Good
|
||
baseline `OnDataChange` → run start-cmd → observe ≥1 Bad tick (each reconnect attempt now
|
||
times out rather than refusing) → run stop-cmd → assert a Good `OnDataChange` resumes **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` / `unpause` pair 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):
|
||
|
||
```bash
|
||
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:
|
||
1. In `FakeS7Plc`: add `public 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);`
|
||
(throws `TaskCanceledException` when the token fires) / `throw ReadStatusThrows` — placed
|
||
*before* the existing one-shot `*ThrowsOnce` handling. Convert the affected methods to
|
||
`async Task`.
|
||
2. In `FakeS7PlcFactory`: add `public int OpenHangsUntilCancelledCount { get; set; }` (each
|
||
`Create` decrements it and sets `plc.OpenHangsUntilCancelled = true` while positive) and
|
||
`public Exception? ReadStatusThrowsForNewConnections { get; set; }` (copied onto each created
|
||
plc; lets T8 arm connection #1 only by clearing it after init).
|
||
3. Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests --filter "FullyQualifiedName~S7DriverReconnectTests"` — expected: **PASS** (existing 4 tests unaffected; compile-only change).
|
||
4. 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:
|
||
1. Write `Read_degrades_batch_on_connect_timeout_instead_of_throwing` (T1, Finding 1 Tests) and
|
||
`Write_degrades_batch_on_connect_timeout_instead_of_throwing` (T3): drop connection #1 via
|
||
`NextReadThrows = new PlcException(ErrorCode.ConnectionError, "dropped")` + one read to mark
|
||
dead; set `factory.OpenHangsUntilCancelledCount = int.MaxValue`; assert the next
|
||
`ReadAsync`/`WriteAsync` does **not** throw, returns non-zero statuses, and health is
|
||
`Degraded`.
|
||
2. Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests --filter "FullyQualifiedName~connect_timeout"` — expected: **FAIL ×2** (`TaskCanceledException` escapes `ReadAsync`/`WriteAsync`). Record the failure output.
|
||
3. 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:
|
||
1. Write `Subscription_poll_loop_survives_a_connect_timeout_outage_and_recovers` (T2): subscribe
|
||
`["W0"]` at 100 ms; hook `OnDataChange` — set `sawBad` on `e.Snapshot.StatusCode != 0u`,
|
||
complete a `TaskCompletionSource` on the first Good snapshot after `sawBad`; drop connection
|
||
#1; `OpenHangsUntilCancelledCount = 2`; await the TCS with `WaitAsync(TimeSpan.FromSeconds(10))`.
|
||
2. Write `Read_still_propagates_caller_cancellation_during_connect` (T4): options with
|
||
`Timeout = TimeSpan.FromSeconds(5)`; mark dead; `OpenHangsUntilCancelledCount = 1`; caller
|
||
`CancellationTokenSource` with `CancelAfter(50)`; `Should.ThrowAsync<OperationCanceledException>`.
|
||
3. Run the two new tests — expected: **T2 FAIL** (10 s timeout — the loop died on the timeout
|
||
tick), **T4 PASS** (pins existing correct behavior).
|
||
4. 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:
|
||
1. Apply Finding 1 implementation step 1 (exact code above) and step 4 (the `InitializeAsync`
|
||
mirror — filter on `!cancellationToken.IsCancellationRequested`).
|
||
2. 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).
|
||
3. 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:
|
||
1. 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.
|
||
2. Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests` — expected: **PASS** (whole project).
|
||
3. 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:
|
||
1. Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests` and
|
||
`dotnet test tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests` — expected: **PASS**.
|
||
2. 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:
|
||
1. Write `[Theory]` `Framing_faults_are_classified_connection_fatal_and_reopen` with
|
||
`[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 as `NextReadThrows`; first read → non-zero
|
||
status, `Created.Count` still 1; second read → **Good** and `Created.Count == 2`,
|
||
`Created[0].Disposed` true.
|
||
2. Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests --filter "FullyQualifiedName~Framing_faults"` — expected: **FAIL ×4** (`Created.Count` stays 1).
|
||
3. 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:
|
||
1. Apply Finding 2 implementation step 1 (exact code above), including the NOT-`InvalidDataException`
|
||
comment and the xmldoc update naming the framing surface.
|
||
2. 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_error` still green — the negative control).
|
||
3. 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` (`ReadAsync` per-item loop, insert before `:511`; `WriteAsync` OCE catch `:1043-1048`)
|
||
|
||
Steps:
|
||
1. Write `Cancelled_read_marks_handle_dead_and_next_read_reopens` (T6): `Created[0].ReadHangsUntilCancelled = true`;
|
||
caller CTS `CancelAfter(50)`; assert `Should.ThrowAsync<OperationCanceledException>` on the read;
|
||
then a fresh-token read → Good, `Created.Count == 2`. Write
|
||
`Cancelled_write_marks_handle_dead_and_next_read_reopens` (T7) with `WriteHangsUntilCancelled`.
|
||
2. 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).
|
||
3. Apply Finding 2 implementation steps 2 and 3 (exact code above).
|
||
4. Re-run — expected: **PASS ×2**; then the whole `S7DriverReconnectTests` class — **PASS**.
|
||
5. 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:
|
||
1. 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 persistent `ReadStatusThrows = new SocketException()`
|
||
(leave later connections healthy); poll `factory.Created.Count` under a 5 s deadline until
|
||
`>= 2`, then assert a subsequent `GetHostStatuses()[0].State == HostState.Running` within a
|
||
further bounded window. Write `Probe_timeout_marks_dead` (T9): same shape with
|
||
`ReadStatusHangsUntilCancelled = true` on connection #1.
|
||
2. Run `--filter "FullyQualifiedName~Probe_"` (scoped to the reconnect class) — expected:
|
||
**FAIL ×2** (`Created.Count` pinned at 1 — the lying handle is re-probed forever).
|
||
3. Apply Finding 2 implementation step 4 (exact probe restructure above).
|
||
4. Re-run — expected: **PASS ×2**; whole test project — **PASS**.
|
||
5. 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:
|
||
1. 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.`
|
||
2. Build-only check (`dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7`) — expected: **PASS**.
|
||
3. 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:
|
||
1. Author per the Verification-bar design: `[Collection(Snap7ServerCollection.Name)]`,
|
||
`[Trait("Category","Integration")]`, `[Trait("Category","Reconnect")]`,
|
||
`[Trait("Device","S7_1500")]`; triple-gated skip on `sim.SkipReason` +
|
||
`S7_TIMEOUT_OUTAGE_START_CMD` + `S7_TIMEOUT_OUTAGE_STOP_CMD`; reuse
|
||
`S7_1500ReconnectTests.RunBounceCommandAsync`'s shell-exec shape (copy the private helper —
|
||
or hoist it to the fixture if trivial); `try/finally` guarantees STOP runs once START
|
||
succeeded. Body: subscribe → Good baseline → START (blackhole) → observe ≥1 Bad
|
||
`OnDataChange` → STOP → await a Good `OnDataChange` on 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*.
|
||
2. Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests --filter "FullyQualifiedName~ConnectTimeoutOutage"` locally (no env vars) — expected: **SKIP (cleanly)**.
|
||
3. Commit: `test(s7): env-gated live connect-timeout outage test — the STAB-14 outage class the bounce test cannot produce (R2-01)`
|
||
4. 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:
|
||
1. Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests` and a solution build — expected: **PASS**.
|
||
2. 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).
|
||
3. 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 |
|