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