diff --git a/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json b/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json index 60ce480f..1f102bf1 100644 --- a/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json +++ b/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json @@ -186,7 +186,7 @@ { "id": "B6.1", "subject": "B6: failing ReplayFailure_EmitsBadQuality_AndDegradesHealth in TwinCATReconnectReplayTests \u2014 STAB-16 repro", - "status": "pending", + "status": "completed", "blockedBy": [] }, { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReconnectReplayTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReconnectReplayTests.cs index d4940620..44724b8c 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReconnectReplayTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReconnectReplayTests.cs @@ -170,6 +170,59 @@ public sealed class TwinCATReconnectReplayTests await drv.ShutdownAsync(TestContext.Current.CancellationToken); } + // ---- STAB-16 / STAB-17 / STAB-12 replay hardening ---- + + /// + /// STAB-16: a replay failure must not be silent — the registration is marked dead, a Bad + /// snapshot is pushed to subscribers (instead of the frozen-Good-stale value), and health + /// degrades preserving the last successful read. + /// + [Fact] + public async Task ReplayFailure_EmitsBadQuality_AndDegradesHealth() + { + var build = 0; + var factory = new FakeTwinCATClientFactory + { + // client1 normal; client2 (the reconnect target) fails AddNotification (replay) + reads. + Customise = () => new FakeTwinCATClient + { + ThrowOnAddNotification = ++build >= 2, + ThrowOnRead = build >= 2, + Exception = new InvalidOperationException("ADS notification quota exceeded"), + }, + }; + var drv = new TwinCATDriver(new TwinCATDriverOptions + { + Devices = [new TwinCATDeviceOptions(Host)], + Tags = [new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt)], + Probe = new TwinCATProbeOptions { Enabled = false }, + UseNativeNotifications = true, + }, "drv-1", factory); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + var events = new ConcurrentQueue(); + drv.OnDataChange += (_, e) => events.Enqueue(e); + + _ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(250), TestContext.Current.CancellationToken); + // Establish a last-successful-read on the healthy client1. + _ = await drv.ReadAsync(["Speed"], TestContext.Current.CancellationToken); + var lastGoodRead = drv.GetHealth().LastSuccessfulRead; + lastGoodRead.ShouldNotBeNull(); + + // Drop the wire; the next read reconnects onto client2, whose replay AddNotification fails. + factory.Clients[0].SimulateWireDrop(); + _ = await drv.ReadAsync(["Speed"], TestContext.Current.CancellationToken); + + // STAB-16: subscribers see the tag go Bad (not frozen Good), and health degraded while + // preserving the last successful read. + var bad = events.SingleOrDefault(e => e.FullReference == "Speed" + && e.Snapshot.StatusCode == TwinCATStatusMapper.BadCommunicationError); + bad.ShouldNotBeNull("a failed replay must publish a Bad snapshot for the affected reference"); + var health = drv.GetHealth(); + health.State.ShouldBe(DriverState.Degraded); + health.LastSuccessfulRead.ShouldBe(lastGoodRead); + } + private static async Task WaitForAsync(Func condition, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout;