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 262ff018..066c703e 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
@@ -16,7 +16,7 @@
{
"id": 2,
"subject": "RED: poll-loop-survival test T2 (subscription survives connect-timeout outage) + caller-cancel pinning test T4",
- "status": "pending",
+ "status": "completed",
"blockedBy": [0, 1]
},
{
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs
index 57567c54..d39ae618 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs
@@ -183,6 +183,70 @@ public sealed class S7DriverReconnectTests
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
+ ///
+ /// T2 (STAB-14 regression). A subscription poll loop that ticks during an
+ /// unreachable-host outage (each reconnect times out) must SURVIVE and recover: a Good
+ /// OnDataChange must arrive after a Bad one once the host returns. Before the fix
+ /// the timeout-born OCE escaped ReadAsync, the poll loop's bare
+ /// catch (OperationCanceledException) { return; } read it as teardown, and the loop
+ /// died permanently — no recovery event ever fired (10 s deadline trips).
+ ///
+ [Fact]
+ public async Task Subscription_poll_loop_survives_a_connect_timeout_outage_and_recovers()
+ {
+ var factory = new FakeS7PlcFactory();
+ using var drv = new S7Driver(Options(), "s7-poll-survive", factory);
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var sawBad = false;
+ drv.OnDataChange += (_, e) =>
+ {
+ if (e.Snapshot.StatusCode != 0u) sawBad = true;
+ else if (sawBad) tcs.TrySetResult();
+ };
+
+ // First poll tick drops the socket (marks dead); the next two reconnect attempts hang
+ // until the internal connect-timeout CTS fires; then opens succeed again.
+ factory.Created[0].NextReadThrows = new PlcException(ErrorCode.ConnectionError, "dropped");
+ factory.OpenHangsUntilCancelledCount = 2;
+
+ await drv.SubscribeAsync(["W0"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
+
+ // Must observe a Good change AFTER a Bad one within the deadline — proves the loop lived.
+ await tcs.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
+ sawBad.ShouldBeTrue();
+ }
+
+ ///
+ /// T4 (pinning). Real CALLER cancellation during a connect must still propagate as
+ /// — the STAB-14 fix must not over-rotate into
+ /// swallowing genuine cancellation. Passes before and after the fix.
+ ///
+ [Fact]
+ public async Task Read_still_propagates_caller_cancellation_during_connect()
+ {
+ var opts = new S7DriverOptions
+ {
+ Host = "192.0.2.1",
+ Timeout = TimeSpan.FromSeconds(5), // long, so the CALLER token fires first, not the internal timeout
+ Probe = new S7ProbeOptions { Enabled = false },
+ Tags = [new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)],
+ };
+ var factory = new FakeS7PlcFactory();
+ using var drv = new S7Driver(opts, "s7-caller-cancel", factory);
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+
+ factory.Created[0].NextReadThrows = new PlcException(ErrorCode.ConnectionError, "dropped");
+ await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken); // marks dead
+ factory.OpenHangsUntilCancelledCount = 1;
+
+ using var cts = new CancellationTokenSource();
+ cts.CancelAfter(50);
+ await Should.ThrowAsync(
+ async () => await drv.ReadAsync(["W0"], cts.Token));
+ }
+
// ---- fakes ----
private sealed class FakeS7PlcFactory : IS7PlcFactory