fix(twincat): retry failed replay intents on the next successful probe tick (05/STAB-16)

This commit is contained in:
Joseph Doherty
2026-07-13 12:37:20 -04:00
parent a06515ae91
commit de29759643
3 changed files with 121 additions and 1 deletions
@@ -223,6 +223,63 @@ public sealed class TwinCATReconnectReplayTests
health.LastSuccessfulRead.ShouldBe(lastGoodRead);
}
/// <summary>
/// STAB-16: a registration whose replay failed (dead handle) is re-registered on the next
/// successful probe tick — a subsequent push then reaches OnDataChange with Good, and no
/// duplicate registration is created.
/// </summary>
[Fact]
public async Task ReplayFailure_RetriedOnNextSuccessfulProbeTick()
{
var build = 0;
var factory = new FakeTwinCATClientFactory
{
// Only the 2nd client (the reconnect target) fails its first replay; flipped off below.
Customise = () => new FakeTwinCATClient { ThrowOnAddNotification = ++build == 2 },
};
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
Tags = [new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt)],
Probe = new TwinCATProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(40),
Timeout = TimeSpan.FromMilliseconds(100),
},
UseNativeNotifications = true,
}, "drv-1", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(250), TestContext.Current.CancellationToken);
await WaitForAsync(() => factory.Clients.Count >= 1, TimeSpan.FromSeconds(2));
// Drop the wire; the reconnect target (client2) fails its replay, leaving a dead registration.
factory.Clients[0].SimulateWireDrop();
_ = await drv.ReadAsync(["Speed"], TestContext.Current.CancellationToken);
await WaitForAsync(() => factory.Clients.Count >= 2, TimeSpan.FromSeconds(2));
var client2 = factory.Clients[1];
client2.Notifications.ShouldBeEmpty(); // replay failed — no live handle
// Device now accepts registrations again — the probe-tick retry must re-register the intent.
client2.ThrowOnAddNotification = false;
await WaitForAsync(() => client2.Notifications.Count == 1, TimeSpan.FromSeconds(3));
client2.Notifications.Count.ShouldBe(1); // re-registered exactly once (no duplicate)
// A fresh push on the recovered registration reaches OnDataChange with Good.
client2.FireNotification("MAIN.Speed", 4242);
await WaitForAsync(() => events.Any(e => e.FullReference == "Speed"
&& e.Snapshot.StatusCode == TwinCATStatusMapper.Good), TimeSpan.FromSeconds(2));
events.Any(e => e.FullReference == "Speed"
&& e.Snapshot.StatusCode == TwinCATStatusMapper.Good
&& Equals(e.Snapshot.Value, 4242)).ShouldBeTrue();
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;