fix(opcuaclient): upstream session loss goes Bad and recovers (#670)
A bad keep-alive now degrades health (LastSuccessfulRead preserved) and, once per outage, publishes BadNoCommunication (value retained) for every subscribed ref plus a quality-only annotation on subscribed alarm conditions. Good keep-alives and data/event notifications advance LastSuccessfulRead, so an idle-but-alive subscription never looks dead to the #527 watchdog. A keep-alive that recovers on the same session re-reads the Bad refs and republishes them Good; a session that does not come back is recovered by the watchdog's ShutdownAsync + InitializeAsync + re-subscribe. Deletes the SessionReconnectHandler wiring (it retried forever in SDK 1.5.378, cloned subscriptions the driver never re-tracked and kept health Healthy), OnReconnectComplete, TryRearmReconnect and the reconnect_* counters (new keepalive_recovered). Teardown of a lost session skips wire deletes and bounds CloseSession. SDK warnings reach the driver log. Wire test reworked: restart => Bad, then watchdog-style reinit => Good.
This commit is contained in:
+45
-27
@@ -6,14 +6,19 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Reconnect + resume leg of the OpcUaClient driver's wire coverage (Gitea #622): the in-process server is
|
||||
/// stopped and a fresh one is booted on the SAME port / application URI / PKI store — a process restart as the
|
||||
/// driver sees it, with an empty address space the test then re-creates. The driver must detect the loss,
|
||||
/// rebuild a session, and its existing subscription must deliver again with no caller action.
|
||||
/// Outage + recovery leg of the OpcUaClient driver's wire coverage (Gitea #622, reworked for #670): the in-process
|
||||
/// server is stopped and a fresh one is booted on the SAME port / application URI / PKI store — a process restart
|
||||
/// as the driver sees it, with an empty address space the test then re-creates. The driver must REPORT the loss
|
||||
/// (subscribed value BadNoCommunication with the value retained, health non-Healthy — what the host's #527
|
||||
/// comms-loss watchdog trips on). Recovery is the watchdog's sequence, replayed here as <c>DriverInstanceActor</c>
|
||||
/// runs it: ShutdownAsync, InitializeAsync, re-subscribe — after which the value comes back Good. There is no
|
||||
/// in-driver reconnect any more (#670: the SDK reconnect handler kept everything Good and Healthy forever).
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class OpcUaClientDriverReconnectWireTests
|
||||
{
|
||||
private const uint BadNoCommunication = 0x80310000u;
|
||||
|
||||
private static async Task WaitAsync(Func<bool> condition, string because, TimeSpan? timeout = null)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(60));
|
||||
@@ -26,53 +31,66 @@ public sealed class OpcUaClientDriverReconnectWireTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task After_a_server_restart_the_driver_reconnects_and_the_subscription_resumes_delivering()
|
||||
public async Task A_server_restart_goes_Bad_then_the_watchdog_reinit_brings_the_value_back_Good()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await OpcUaClientDriverWireTests.StartRigAsync("drv-reconnect", ct);
|
||||
await using var _rig = rig;
|
||||
OpcUaClientDriverWireTests.Publish(rig, OpcUaClientDriverWireTests.SpeedId, 10.0f);
|
||||
|
||||
await using var drv = OpcUaClientDriverWireTests.NewDriver(
|
||||
rig, "wire-reconnect", reconnectPeriod: TimeSpan.FromSeconds(1));
|
||||
await using var drv = OpcUaClientDriverWireTests.NewDriver(rig, "wire-reconnect");
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
|
||||
var seen = new List<float>();
|
||||
var seen = new List<(uint Status, object? Value)>();
|
||||
drv.OnDataChange += (_, e) =>
|
||||
{
|
||||
if (e.FullReference == OpcUaClientDriverWireTests.SpeedRef && e.Snapshot.StatusCode == 0u)
|
||||
lock (seen) seen.Add(Convert.ToSingle(e.Snapshot.Value));
|
||||
if (e.FullReference == OpcUaClientDriverWireTests.SpeedRef)
|
||||
lock (seen) seen.Add((e.Snapshot.StatusCode, e.Snapshot.Value));
|
||||
};
|
||||
var states = new List<HostState>();
|
||||
drv.OnHostStatusChanged += (_, e) => { lock (states) states.Add(e.NewState); };
|
||||
|
||||
await drv.SubscribeAsync([OpcUaClientDriverWireTests.SpeedRef], TimeSpan.FromMilliseconds(100), ct);
|
||||
await WaitAsync(() => { lock (seen) return seen.Contains(10.0f); }, "initial value must arrive before the outage");
|
||||
await WaitAsync(() => { lock (seen) return seen.Any(s => s is (0u, 10.0f)); },
|
||||
"initial value must arrive before the outage");
|
||||
|
||||
// Outage: the loss must be DETECTED (host leaves Running) while the server is genuinely down.
|
||||
// Outage: the loss must be REPORTED while the server is genuinely down — Bad on the value with the value
|
||||
// retained, and a health state the watchdog can act on.
|
||||
await rig.RestartAsync(ct, whileDown: async () =>
|
||||
{
|
||||
await WaitAsync(
|
||||
() => { lock (states) return states.Any(s => s != HostState.Running); },
|
||||
"the driver must notice the server is gone", TimeSpan.FromSeconds(30));
|
||||
await WaitAsync(() => { lock (seen) return seen.Any(s => s.Status == BadNoCommunication); },
|
||||
"a lost session must publish BadNoCommunication for the subscribed ref", TimeSpan.FromSeconds(30));
|
||||
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 new server is empty: redeploy the nodes and publish a value no earlier delivery could have carried.
|
||||
// The restarted server knows nothing of the old session, so the driver stays down on its own …
|
||||
await Task.Delay(TimeSpan.FromSeconds(3), ct);
|
||||
drv.GetHealth().State.ShouldNotBe(DriverState.Healthy, "nothing in the driver may paper over a dead session");
|
||||
lock (seen) seen.Count(s => s.Status == BadNoCommunication).ShouldBe(1, "one Bad per outage, not per keep-alive");
|
||||
|
||||
// … until the host's comms-loss watchdog re-initialises it (DriverInstanceActor: Reconnecting → ShutdownAsync +
|
||||
// InitializeAsync → Connected → re-subscribe). The new server is empty: redeploy the nodes first, holding the
|
||||
// SAME value as before the outage.
|
||||
rig.EnsureRawVariable("Speed", "Float", writable: true);
|
||||
rig.EnsureRawVariable("Counter", "Int32", writable: false);
|
||||
OpcUaClientDriverWireTests.Publish(rig, OpcUaClientDriverWireTests.SpeedId, 77.0f);
|
||||
OpcUaClientDriverWireTests.Publish(rig, OpcUaClientDriverWireTests.SpeedId, 10.0f);
|
||||
|
||||
await WaitAsync(() => drv.GetHostStatuses().Single().State == HostState.Running,
|
||||
"the driver must reconnect after the server is back");
|
||||
await WaitAsync(() => { lock (seen) return seen.Contains(77.0f); },
|
||||
"the pre-outage subscription must resume and deliver the post-restart value");
|
||||
await drv.ShutdownAsync(ct);
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
int before;
|
||||
lock (seen) before = seen.Count;
|
||||
await drv.SubscribeAsync([OpcUaClientDriverWireTests.SpeedRef], TimeSpan.FromMilliseconds(100), ct);
|
||||
|
||||
// The IDENTICAL value comes back Good — a (value, status) dedup downstream lets it through.
|
||||
await WaitAsync(() => { lock (seen) return seen.Skip(before).Any(s => s is (0u, 10.0f)); },
|
||||
"the re-subscribed ref must come back Good with its value");
|
||||
|
||||
// Ongoing delivery, not just a one-off initial value.
|
||||
OpcUaClientDriverWireTests.Publish(rig, OpcUaClientDriverWireTests.SpeedId, 88.0f);
|
||||
await WaitAsync(() => { lock (seen) return seen.Contains(88.0f); },
|
||||
"a later change must also reach the resumed subscription");
|
||||
await WaitAsync(() => { lock (seen) return seen.Any(s => s is (0u, 88.0f)); },
|
||||
"a later change must also reach the new subscription");
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
|
||||
|
||||
// And the data plane works for plain reads on the new session.
|
||||
var back = await drv.ReadAsync([OpcUaClientDriverWireTests.SpeedRef], ct);
|
||||
back[0].StatusCode.ShouldBe(0u);
|
||||
Convert.ToSingle(back[0].Value).ShouldBe(88.0f);
|
||||
|
||||
Reference in New Issue
Block a user