From fcdcf0e2fba10040e9a24ea4f9e3d67194358fda Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 09:48:42 -0400 Subject: [PATCH] fix(r2-01): extend reconnect fakes with token-honouring hang + probe-fault modes (task 0) --- ...2-01-s7-fault-hardening-plan.md.tasks.json | 2 +- .../S7DriverReconnectTests.cs | 71 ++++++++++++++++--- 2 files changed, 63 insertions(+), 10 deletions(-) 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 eef3b8a8..5f65d09b 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 @@ -4,7 +4,7 @@ { "id": 0, "subject": "Extend reconnect-test fakes with token-honouring hang + probe-fault modes (FakeS7Plc/FakeS7PlcFactory)", - "status": "pending", + "status": "completed", "blockedBy": [] }, { 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 e4195df6..ece17260 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 @@ -143,6 +143,21 @@ public sealed class S7DriverReconnectTests /// When set, the NEXT d connection throws this on OpenAsync (once). public Exception? NextOpenThrows { get; set; } + /// + /// Number of subsequently-created connections whose + /// hangs until its token is cancelled (simulating the unreachable-but-not-refusing host + /// — pulled cable / firewall DROP — that STAB-14 needs and docker restart can't + /// produce). Each decrements it while positive. + /// + public int OpenHangsUntilCancelledCount { get; set; } + + /// + /// When set, copied onto every created connection's persistent + /// so a probe fails on every tick. Clear it + /// after init to arm connection #1 only. + /// + public Exception? ReadStatusThrowsForNewConnections { get; set; } + public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) { var plc = new FakeS7Plc(); @@ -151,6 +166,13 @@ public sealed class S7DriverReconnectTests plc.OpenThrowsOnce = ex; NextOpenThrows = null; } + if (OpenHangsUntilCancelledCount > 0) + { + plc.OpenHangsUntilCancelled = true; + OpenHangsUntilCancelledCount--; + } + if (ReadStatusThrowsForNewConnections is { } rex) + plc.ReadStatusThrows = rex; Created.Add(plc); return plc; } @@ -164,45 +186,76 @@ public sealed class S7DriverReconnectTests /// Thrown once by , then cleared. public Exception? OpenThrowsOnce { get; set; } + /// + /// When true, hangs until its token is cancelled, then throws + /// — the connect-timeout shape (an unreachable host + /// whose SYNs are dropped) the live rig can't cheaply produce (STAB-14). + /// + public bool OpenHangsUntilCancelled { get; set; } + /// Thrown once by the next , then cleared. public Exception? NextReadThrows { get; set; } + /// When true, hangs until its token is cancelled (STAB-15 cancel-mid-I/O). + public bool ReadHangsUntilCancelled { get; set; } + + /// When true, hangs until its token is cancelled (STAB-15 cancel-mid-I/O). + public bool WriteHangsUntilCancelled { get; set; } + + /// Thrown by EVERY (persistent, not one-shot) — arms a probe fault (STAB-15b). + public Exception? ReadStatusThrows { get; set; } + + /// When true, hangs until its token is cancelled (probe-timeout, STAB-15b). + public bool ReadStatusHangsUntilCancelled { get; set; } + /// Boxed value returned by a good read (UInt16 tag → ushort). public object? ReadValue { get; set; } = (ushort)42; - public Task OpenAsync(CancellationToken cancellationToken) + public async Task OpenAsync(CancellationToken cancellationToken) { + if (OpenHangsUntilCancelled) + await Task.Delay(System.Threading.Timeout.Infinite, cancellationToken).ConfigureAwait(false); if (OpenThrowsOnce is { } ex) { OpenThrowsOnce = null; - return Task.FromException(ex); + throw ex; } IsConnected = true; - return Task.CompletedTask; } public void Close() => IsConnected = false; - public Task ReadAsync(string address, CancellationToken cancellationToken) + public async Task ReadAsync(string address, CancellationToken cancellationToken) { + if (ReadHangsUntilCancelled) + await Task.Delay(System.Threading.Timeout.Infinite, cancellationToken).ConfigureAwait(false); if (NextReadThrows is { } ex) { NextReadThrows = null; - return Task.FromException(ex); + throw ex; } - return Task.FromResult(ReadValue); + return ReadValue; } public Task ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken cancellationToken) => throw new NotSupportedException("FakeS7Plc: block reads not needed for the reconnect tests"); - public Task WriteAsync(string address, object value, CancellationToken cancellationToken) => - Task.CompletedTask; + public async Task WriteAsync(string address, object value, CancellationToken cancellationToken) + { + if (WriteHangsUntilCancelled) + await Task.Delay(System.Threading.Timeout.Infinite, cancellationToken).ConfigureAwait(false); + } public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken) => Task.CompletedTask; - public Task ReadStatusAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public async Task ReadStatusAsync(CancellationToken cancellationToken) + { + if (ReadStatusHangsUntilCancelled) + await Task.Delay(System.Threading.Timeout.Infinite, cancellationToken).ConfigureAwait(false); + if (ReadStatusThrows is { } ex) + throw ex; + } public void Dispose() {