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 5f65d09b..262ff018 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
@@ -10,7 +10,7 @@
{
"id": 1,
"subject": "RED: connect-timeout regression tests T1+T3 (read/write degrade instead of throwing OCE) — must FAIL at f6eaa267",
- "status": "pending",
+ "status": "completed",
"blockedBy": [0]
},
{
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 ece17260..57567c54 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
@@ -133,6 +133,56 @@ public sealed class S7DriverReconnectTests
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
+ // ---- STAB-14: connect-timeout must degrade, not escape as OCE ----
+
+ ///
+ /// T1 (STAB-14 regression). When the reopen HANGS and is cut by the internal
+ /// CancelAfter(_options.Timeout) (the unreachable-host outage — SYNs dropped, not
+ /// refused), the read batch must degrade to a communication error with Degraded health,
+ /// NOT throw the timeout-born up to the caller.
+ /// Before the fix the OCE escaped .
+ ///
+ [Fact]
+ public async Task Read_degrades_batch_on_connect_timeout_instead_of_throwing()
+ {
+ var factory = new FakeS7PlcFactory();
+ using var drv = new S7Driver(Options(), "s7-read-timeout", factory);
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+
+ // Drop connection #1 so the next read reopens; every subsequent open hangs until the
+ // internal connect-timeout CTS fires.
+ factory.Created[0].NextReadThrows = new PlcException(ErrorCode.ConnectionError, "dropped");
+ await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken); // marks dead
+ factory.OpenHangsUntilCancelledCount = int.MaxValue;
+
+ // Must NOT throw — the connect timeout degrades the batch.
+ var timedOut = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken);
+ timedOut[0].StatusCode.ShouldNotBe(0u);
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded);
+ }
+
+ ///
+ /// T3 (STAB-14 regression). The same connect-timeout degrade through
+ /// — EnsureConnectedAsync is called before the
+ /// per-item loop, so a hanging reopen must degrade the whole write batch, not throw OCE.
+ ///
+ [Fact]
+ public async Task Write_degrades_batch_on_connect_timeout_instead_of_throwing()
+ {
+ var factory = new FakeS7PlcFactory();
+ using var drv = new S7Driver(Options(), "s7-write-timeout", 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 = int.MaxValue;
+
+ var timedOut = await drv.WriteAsync(
+ [new WriteRequest("W0", (ushort)1)], TestContext.Current.CancellationToken);
+ timedOut[0].StatusCode.ShouldNotBe(0u);
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded);
+ }
+
// ---- fakes ----
private sealed class FakeS7PlcFactory : IS7PlcFactory