fix(r2-01): RED poll-loop-survival T2 + caller-cancel pinning T4 (task 2)
This commit is contained in:
@@ -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]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -183,6 +183,70 @@ public sealed class S7DriverReconnectTests
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>OnDataChange</c> must arrive after a Bad one once the host returns. Before the fix
|
||||
/// the timeout-born OCE escaped <c>ReadAsync</c>, the poll loop's bare
|
||||
/// <c>catch (OperationCanceledException) { return; }</c> read it as teardown, and the loop
|
||||
/// died permanently — no recovery event ever fired (10 s deadline trips).
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// T4 (pinning). Real CALLER cancellation during a connect must still propagate as
|
||||
/// <see cref="OperationCanceledException"/> — the STAB-14 fix must not over-rotate into
|
||||
/// swallowing genuine cancellation. Passes before and after the fix.
|
||||
/// </summary>
|
||||
[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<OperationCanceledException>(
|
||||
async () => await drv.ReadAsync(["W0"], cts.Token));
|
||||
}
|
||||
|
||||
// ---- fakes ----
|
||||
|
||||
private sealed class FakeS7PlcFactory : IS7PlcFactory
|
||||
|
||||
Reference in New Issue
Block a user