diff --git a/archreview/plans/R2-01-s7-fault-hardening-plan.md.tasks.json b/archreview/plans/R2-01-s7-fault-hardening-plan.md.tasks.json index 8136a897..c6b2dd2e 100644 --- a/archreview/plans/R2-01-s7-fault-hardening-plan.md.tasks.json +++ b/archreview/plans/R2-01-s7-fault-hardening-plan.md.tasks.json @@ -28,7 +28,7 @@ { "id": 4, "subject": "Defensive when-filters on ensure-wrapper OCE rethrows (:488/:1000) + poll-loop OCE catches (:1383/:1396/:1404)", - "status": "pending", + "status": "completed", "blockedBy": [3] }, { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index 31afceed..1e93068b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -495,7 +495,10 @@ public sealed class S7Driver { plc = await EnsureConnectedAsync(cancellationToken).ConfigureAwait(false); } - catch (OperationCanceledException) { throw; } + // 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; } catch (Exception ex) { for (var i = 0; i < fullReferences.Count; i++) @@ -1007,7 +1010,9 @@ public sealed class S7Driver { plc = await EnsureConnectedAsync(cancellationToken).ConfigureAwait(false); } - catch (OperationCanceledException) { throw; } + // Rethrow ONLY caller cancellation — a timeout-born OCE degrades the batch below rather + // than escaping as teardown (STAB-14 defense-in-depth, mirrors ReadAsync). + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception ex) { for (var i = 0; i < writes.Count; i++) @@ -1401,7 +1406,10 @@ public sealed class S7Driver await PollOnceAsync(state, forceRaise: true, ct).ConfigureAwait(false); consecutiveFailures = 0; } - catch (OperationCanceledException) { return; } + // Only a teardown OCE (the loop's own token) exits the loop; any other OCE (e.g. a + // connect-timeout that slipped past the wrapper filter) falls to the backoff path below + // so the loop lives (STAB-14 defense-in-depth). + catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; } catch (Exception ex) { // First-read error — polling continues; log so the operator has an event trail. @@ -1415,14 +1423,17 @@ public sealed class S7Driver // ticks reset consecutiveFailures back to 0 so the cadence snaps back to Interval. var delay = ComputeBackoffDelay(state.Interval, consecutiveFailures); try { await Task.Delay(delay, ct).ConfigureAwait(false); } - catch (OperationCanceledException) { return; } + // Task.Delay can only observe ct-triggered OCE, so this filter is purely for uniformity. + catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; } try { await PollOnceAsync(state, forceRaise: false, ct).ConfigureAwait(false); consecutiveFailures = 0; } - catch (OperationCanceledException) { return; } + // Only teardown exits; any non-teardown OCE (e.g. a slipped-through connect timeout) + // backs off instead of killing the loop (STAB-14 defense-in-depth). + catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; } catch (Exception ex) { // Sustained polling error — loop continues with backoff; log + update health.