fix(opcuaclient): #670 review — subscription loss with a live session, stale recovery latch

M1: a subscription that dies while the session lives (outage longer than
the subscription lifetime but shorter than the session timeout) used to
end Healthy with fresh keep-alive stamps and frozen values. PublishError
BadNoSubscription / BadSubscriptionIdInvalid naming a tracked
subscription, or a tracked subscription whose SDK PublishingStopped
persists one more keep-alive window + 5 s across good keep-alives, now
declares it lost: Degraded, outage published once, and nothing advances
LastSuccessfulRead until the session is released, so the #527 watchdog
re-initialises and re-subscribes. Both detectors wire-tested.

M2: in-place recovery is bound to the session whose keep-alive recovered;
one overtaken by a re-initialise no longer re-arms the new session's
outage latch or degrades its health (ReadCoreAsync expectedSession).

L1 CAS health updates; L2 re-check before each recovery publish; L3 skip
CloseSession on a lost session; L4 SDK log forwarding rate-limited
(20/min); L5 per-driver SDK LoggerFactory disposed with the driver.
Wire test asserts the read stamp stays frozen while the server is down.
This commit is contained in:
Joseph Doherty
2026-10-11 08:41:27 -04:00
parent 2a8f9af5e4
commit b028322568
6 changed files with 484 additions and 25 deletions
@@ -61,6 +61,12 @@ public sealed class OpcUaClientDriverReconnectWireTests
lock (seen) seen.Single(s => s.Status == BadNoCommunication).Value.ShouldBe(10.0f);
drv.GetHealth().State.ShouldNotBe(DriverState.Healthy);
drv.GetHostStatuses().Single().State.ShouldNotBe(HostState.Running);
// The read stamp must stay frozen for the whole outage — it is the watchdog's silence measure.
var frozen = drv.GetHealth().LastSuccessfulRead;
frozen.ShouldNotBeNull();
await Task.Delay(TimeSpan.FromSeconds(3), ct); // three keep-alive intervals
drv.GetHealth().LastSuccessfulRead.ShouldBe(frozen);
});
// The restarted server knows nothing of the old session, so the driver stays down on its own …
@@ -95,4 +101,54 @@ public sealed class OpcUaClientDriverReconnectWireTests
back[0].StatusCode.ShouldBe(0u);
Convert.ToSingle(back[0].Value).ShouldBe(88.0f);
}
/// <summary>
/// #670 review M1 — the subscription dies while the SESSION lives (in production: an outage longer than the
/// subscription lifetime but shorter than the session timeout; here: the server deletes it). Keep-alives stay
/// Good throughout, so a driver that only watched them would end Healthy with fresh read stamps and a frozen
/// value forever. It must go Bad, stay non-Healthy with the stamp frozen (so the #527 watchdog fires), and the
/// watchdog's re-initialise must bring the value back.
/// </summary>
[Fact]
public async Task A_subscription_lost_while_the_session_lives_is_reported_and_recovered_by_reinit()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await OpcUaClientDriverWireTests.StartRigAsync("drv-sublost", ct);
OpcUaClientDriverWireTests.Publish(rig, OpcUaClientDriverWireTests.SpeedId, 5.0f);
await using var drv = OpcUaClientDriverWireTests.NewDriver(rig, "wire-sublost");
await drv.InitializeAsync("{}", ct);
var seen = new List<(uint Status, object? Value)>();
drv.OnDataChange += (_, e) =>
{
if (e.FullReference == OpcUaClientDriverWireTests.SpeedRef)
lock (seen) seen.Add((e.Snapshot.StatusCode, e.Snapshot.Value));
};
await drv.SubscribeAsync([OpcUaClientDriverWireTests.SpeedRef], TimeSpan.FromMilliseconds(100), ct);
await WaitAsync(() => { lock (seen) return seen.Any(s => s is (0u, 5.0f)); }, "initial value");
// Delete the subscription on the server behind the SDK's back; the session itself stays healthy.
var session = drv.Session.ShouldNotBeNull();
var subId = session.Subscriptions.Single(s => s.Created).Id;
await session.DeleteSubscriptionsAsync(null, [subId], ct);
await WaitAsync(() => { lock (seen) return seen.Any(s => s.Status == BadNoCommunication); },
"a subscription the server dropped must be reported as comms loss", TimeSpan.FromSeconds(30));
drv.GetHealth().State.ShouldNotBe(DriverState.Healthy);
var frozen = drv.GetHealth().LastSuccessfulRead;
await Task.Delay(TimeSpan.FromSeconds(3), ct); // good keep-alives keep arriving …
drv.GetHealth().State.ShouldNotBe(DriverState.Healthy, "… and must not revive a driver whose data plane is dead");
drv.GetHealth().LastSuccessfulRead.ShouldBe(frozen);
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running, "the session itself is fine");
// The watchdog's re-initialise recreates the subscription and the value comes back Good.
await drv.ShutdownAsync(ct);
await drv.InitializeAsync("{}", ct);
int before;
lock (seen) before = seen.Count;
await drv.SubscribeAsync([OpcUaClientDriverWireTests.SpeedRef], TimeSpan.FromMilliseconds(100), ct);
await WaitAsync(() => { lock (seen) return seen.Skip(before).Any(s => s is (0u, 5.0f)); },
"the re-created subscription must deliver the value Good");
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
}