fix(r2-01): probe failures mark the handle dead under the gate so the next tick reopens (STAB-15b, task 9)

This commit is contained in:
Joseph Doherty
2026-07-13 10:03:36 -04:00
parent 33044061c3
commit 8999c722a0
3 changed files with 107 additions and 8 deletions
@@ -351,6 +351,83 @@ public sealed class S7DriverReconnectTests
factory.Created[0].Disposed.ShouldBeTrue();
}
// ---- STAB-15b: the probe must mark a lying/hung handle dead so the next tick reopens ----
/// <summary>
/// T8 (STAB-15b). A probe failure on a handle whose <c>TcpClient.Connected</c> still lies
/// true (a wire-dead or desynced socket) must mark the handle dead under the gate, so the
/// NEXT probe tick's <c>EnsureConnectedAsync</c> disposes it and reopens. Before the fix the
/// probe swallowed the failure without marking dead, so the lying handle was re-probed
/// forever (<c>Created.Count</c> pinned at 1).
/// </summary>
[Fact]
public async Task Probe_failure_on_connected_socket_marks_dead_and_next_tick_reopens()
{
var opts = ProbeEnabledOptions();
var factory = new FakeS7PlcFactory();
using var drv = new S7Driver(opts, "s7-probe-fail", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
// Arm connection #1 to fail EVERY probe with a connection-fatal fault (persistent). Later
// connections stay healthy (default ReadStatusThrows == null).
factory.Created[0].ReadStatusThrows = new System.Net.Sockets.SocketException();
await WaitUntilAsync(() => factory.Created.Count >= 2, TimeSpan.FromSeconds(5));
factory.Created.Count.ShouldBeGreaterThanOrEqualTo(2);
factory.Created[0].Disposed.ShouldBeTrue();
await WaitUntilAsync(() => drv.GetHostStatuses()[0].State == HostState.Running, TimeSpan.FromSeconds(5));
drv.GetHostStatuses()[0].State.ShouldBe(HostState.Running);
}
/// <summary>
/// T9 (STAB-15b). A probe that TIMES OUT mid-<c>ReadStatusAsync</c> (the CPU-status PDU may
/// still be in flight ⇒ desync) likewise marks the handle dead under the gate so the next
/// tick reopens. Before the fix the probe-deadline OCE fell to the bare swallow and the
/// handle was reused forever.
/// </summary>
[Fact]
public async Task Probe_timeout_marks_dead()
{
var opts = ProbeEnabledOptions();
var factory = new FakeS7PlcFactory();
using var drv = new S7Driver(opts, "s7-probe-timeout", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
factory.Created[0].ReadStatusHangsUntilCancelled = true;
await WaitUntilAsync(() => factory.Created.Count >= 2, TimeSpan.FromSeconds(5));
factory.Created.Count.ShouldBeGreaterThanOrEqualTo(2);
factory.Created[0].Disposed.ShouldBeTrue();
await WaitUntilAsync(() => drv.GetHostStatuses()[0].State == HostState.Running, TimeSpan.FromSeconds(5));
drv.GetHostStatuses()[0].State.ShouldBe(HostState.Running);
}
private static S7DriverOptions ProbeEnabledOptions() => new()
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
Probe = new S7ProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(50),
Timeout = TimeSpan.FromMilliseconds(250),
},
Tags = [new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)],
};
/// <summary>Polls <paramref name="condition"/> until it holds or <paramref name="timeout"/> elapses (returns either way).</summary>
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (condition()) return;
await Task.Delay(25).ConfigureAwait(false);
}
}
// ---- fakes ----
private sealed class FakeS7PlcFactory : IS7PlcFactory