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;