fix(r2-01): extend reconnect fakes with token-honouring hang + probe-fault modes (task 0)

This commit is contained in:
Joseph Doherty
2026-07-13 09:48:42 -04:00
parent 1676c8f40f
commit fcdcf0e2fb
2 changed files with 63 additions and 10 deletions
@@ -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": []
},
{
@@ -143,6 +143,21 @@ public sealed class S7DriverReconnectTests
/// <summary>When set, the NEXT <see cref="Create"/>d connection throws this on OpenAsync (once).</summary>
public Exception? NextOpenThrows { get; set; }
/// <summary>
/// Number of subsequently-created connections whose <see cref="FakeS7Plc.OpenAsync"/>
/// hangs until its token is cancelled (simulating the unreachable-but-not-refusing host
/// — pulled cable / firewall DROP — that STAB-14 needs and <c>docker restart</c> can't
/// produce). Each <see cref="Create"/> decrements it while positive.
/// </summary>
public int OpenHangsUntilCancelledCount { get; set; }
/// <summary>
/// When set, copied onto every created connection's persistent
/// <see cref="FakeS7Plc.ReadStatusThrows"/> so a probe fails on every tick. Clear it
/// after init to arm connection #1 only.
/// </summary>
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
/// <summary>Thrown once by <see cref="OpenAsync"/>, then cleared.</summary>
public Exception? OpenThrowsOnce { get; set; }
/// <summary>
/// When true, <see cref="OpenAsync"/> hangs until its token is cancelled, then throws
/// <see cref="TaskCanceledException"/> — the connect-timeout shape (an unreachable host
/// whose SYNs are dropped) the live rig can't cheaply produce (STAB-14).
/// </summary>
public bool OpenHangsUntilCancelled { get; set; }
/// <summary>Thrown once by the next <see cref="ReadAsync"/>, then cleared.</summary>
public Exception? NextReadThrows { get; set; }
/// <summary>When true, <see cref="ReadAsync"/> hangs until its token is cancelled (STAB-15 cancel-mid-I/O).</summary>
public bool ReadHangsUntilCancelled { get; set; }
/// <summary>When true, <see cref="WriteAsync"/> hangs until its token is cancelled (STAB-15 cancel-mid-I/O).</summary>
public bool WriteHangsUntilCancelled { get; set; }
/// <summary>Thrown by EVERY <see cref="ReadStatusAsync"/> (persistent, not one-shot) — arms a probe fault (STAB-15b).</summary>
public Exception? ReadStatusThrows { get; set; }
/// <summary>When true, <see cref="ReadStatusAsync"/> hangs until its token is cancelled (probe-timeout, STAB-15b).</summary>
public bool ReadStatusHangsUntilCancelled { get; set; }
/// <summary>Boxed value returned by a good read (UInt16 tag → ushort).</summary>
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<object?> ReadAsync(string address, CancellationToken cancellationToken)
public async Task<object?> 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<object?>(ex);
throw ex;
}
return Task.FromResult(ReadValue);
return ReadValue;
}
public Task<byte[]> 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()
{