From 78133041997635275dc211e7adbc00b2fd199556 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 09:51:57 -0400 Subject: [PATCH] fix(r2-01): RED poll-loop-survival T2 + caller-cancel pinning T4 (task 2) --- ...2-01-s7-fault-hardening-plan.md.tasks.json | 2 +- .../S7DriverReconnectTests.cs | 64 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) 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