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