Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs
T
Joseph Doherty 2d20aeb779 fix(opcua,drivers): carry driver status sub-codes to clients (#519) + health-write ordering (#517)
#519 — a driver's OPC UA status code now reaches a client with its sub-code intact.
DriverInstanceActor projected the driver's uint onto a 3-state Good/Uncertain/Bad enum
and OtOpcUaNodeManager re-expanded Bad to a bare 0x80000000, so BadNoCommunication,
BadTypeMismatch and BadNotSupported were indistinguishable on the wire and every
driver's status constants (incl. the 16 #497 had just corrected) were diagnostic in
logs only. Resolved by option (b): the raw uint rides AttributeValuePublished ->
AttributeValueUpdate -> IOpcUaAddressSpaceSink.WriteValue, OpcUaQuality is deleted, and
severity classification is a helper (OpcUaStatus.IsGood/IsUncertain/IsBad).

The surface was wider than the issue costed (41 files, not "the sink and 3
implementations"): OpcUaQuality had non-driver producers with no uint to carry.
Each got its natural code — BadInternalError for a failed virtual-tag script,
BadNoCommunication for driver comms loss — and ScriptedAlarmHostActor's pair of
mutually-inverse uint<->enum mappers is deleted, having round-tripped a code the
engine already held. AddressSpaceApplier's removal writes deliberately keep the
generic Bad: not losing a code the driver had is this issue; inventing one for the
deploy path is a separate call.

Behaviour change: WriteAlarmQuality fires its Part 9 event on a status-code change
rather than a severity-bucket change. While the annotation was 3-state those were the
same predicate; now a move between two Bad reasons changes what a subscriber reads.

New SDK-free named constants live on Commons.OpcUa.OpcUaStatus, and
StatusCodeParityTests is widened to cover them — that guard is reflection-driven, so
it can only see a named constant, never an inline literal.

#517 — the symptom is real but the issue named the wrong path and its fix was unsafe.
ShutdownAsync cannot race: DriverInstanceActor calls it from PostStop, after the
mailbox drains. (Core/Hosting/DriverHost, the one class with a concurrent GetHealth,
has no production call site.) InitializeAsync can: it runs on a fire-and-forget
Task.Run, and MTConnectDriver writes Healthy then starts loops that can throw into a
catch which disposes the client before writing Faulted.

Moving the write earlier, as suggested, inverts the terminal state — teardown drains
in-flight loops that write health as they unwind, so a late loop could leave Healthy at
rest with every connection closed. The fix is a bracket (conservative before, terminal
after), applied to MTConnect's init-failure path only; shutdown keeps its single
trailing write, and the rule plus the reachability table are documented on
IDriver.ShutdownAsync.

CalculationDriver.GetHealth returned a hard-coded Healthy that consulted nothing —
Healthy before initialize, after shutdown and after dispose. Now derived from _running.
Its test, named Health_is_always_Connected, asserted the defect and is replaced.

Every new guard was falsified by breaking the production code and watching it go red.
The two sub-code tests each assert two different Bad codes, since a single-code
assertion could pass against a hard-coded constant under the old projection.

Pre-existing failures, verified unchanged on a stashed tree and not chased: 4 in
Driver.AbLegacy.IntegrationTests (slc500/micrologix/plc5 fixtures not up) and 3 in
Host.IntegrationTests.

Closes #517
Closes #519

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-31 05:25:27 -04:00

385 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// R2-07 03/P1 — the HEADLINE proof: an over-the-wire subscription on an existing tag SURVIVES a
/// pure-add deploy (one new tag), instead of being killed by the full-rebuild teardown that pre-R2-07
/// every topology change forced. Boots the real <see cref="OtOpcUaSdkServer"/>, materialises tag A,
/// opens a real client subscription + monitored item on A, then drives a PURE-ADD apply adding tag B
/// (classifier ⇒ PureAdd ⇒ no rebuild ⇒ the idempotent Materialise passes create B and leave A's
/// NodeState untouched), and asserts:
/// <list type="bullet">
/// <item>A's monitored item still delivers a subsequent value write (the item is alive — no
/// <c>BadNodeIdUnknown</c>, no dead subscription);</item>
/// <item>B is browsable + readable (the add landed).</item>
/// </list>
/// Written against the master (pre-T2) behaviour this FAILS (the rebuild recreates A as a new NodeState
/// and the monitored item goes dead); it passes once the classifier routing (T2) + the publish-actor
/// announce (T4b) land.
/// <para><b>Run note:</b> this is a heavy in-process server+client integration test — run it in the
/// serial integration pass (it is NOT part of the lightweight unit sweep). The <see cref="OtOpcUaNodeManager"/>
/// namespace URI is <see cref="OtOpcUaNodeManager.DefaultNamespaceUri"/>; the client resolves the
/// folder-scoped variable NodeIds against it.</para>
/// </summary>
public sealed class SubscriptionSurvivalTests
{
private const string ServerUri = "urn:OtOpcUa.SubscriptionSurvival";
/// <summary>A subscription on tag A survives a pure-add deploy adding tag B; B becomes readable.</summary>
[Fact]
public async Task Subscription_on_existing_tag_survives_pure_add_of_another_tag()
{
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-subsurvive-{Guid.NewGuid():N}");
var port = AllocateFreePort();
var ct = TestContext.Current.CancellationToken;
try
{
var options = new OpcUaApplicationHostOptions
{
ApplicationName = ServerUri,
ApplicationUri = ServerUri,
OpcUaPort = port,
PublicHostname = "127.0.0.1",
PkiStoreRoot = pkiRoot,
EnabledSecurityProfiles = new List<OpcUaSecurityProfile> { OpcUaSecurityProfile.None },
AutoAcceptUntrustedClientCertificates = true,
};
var server = new OtOpcUaSdkServer();
await using var host = new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
await host.StartAsync(server, ct);
var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm);
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
// --- Materialise tag A (equipment eq-1, tag "A") and seed it a Good value. ---
var withA = CompositionWith(
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null));
applier.MaterialiseHierarchy(withA);
applier.MaterialiseEquipmentTags(withA);
var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
sink.WriteValue(nodeIdAString, 41, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
// --- Open a client session + subscription + monitored item on A. ---
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri);
ns.ShouldBeGreaterThan((ushort)0);
var nodeIdA = new NodeId(nodeIdAString, ns);
var received = new List<DataValue>();
var gate = new object();
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
session.AddSubscription(subscription);
await subscription.CreateAsync(ct);
var monitored = new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = nodeIdA,
AttributeId = Attributes.Value,
SamplingInterval = 50,
QueueSize = 10,
};
monitored.Notification += (item, _) =>
{
foreach (var v in item.DequeueValues())
lock (gate) received.Add(v);
};
subscription.AddItem(monitored);
await subscription.ApplyChangesAsync(ct);
// Baseline: the initial value (41) is delivered.
await WaitUntilAsync(() => { lock (gate) return received.Count >= 1; }, TimeSpan.FromSeconds(5));
// --- Drive a PURE-ADD apply adding tag B (previous={A}, next={A,B}). ---
var withAB = CompositionWith(
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null),
new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null));
var plan = AddressSpacePlanner.Compute(withA, withAB);
plan.AddedEquipmentTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no teardown
// Materialise passes (as the publish actor runs them) then announce — over the FULL composition.
applier.MaterialiseHierarchy(withAB);
applier.MaterialiseEquipmentTags(withAB);
applier.AnnounceAddedNodes(plan);
// --- A's monitored item must still be alive: a new write to A is delivered. ---
lock (gate) received.Clear();
sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
await WaitUntilAsync(() => { lock (gate) return received.Any(v => Equals(v.Value, 42)); }, TimeSpan.FromSeconds(5));
lock (gate)
{
received.ShouldContain(v => Equals(v.Value, 42));
received.ShouldNotContain(v => v.StatusCode == StatusCodes.BadNodeIdUnknown);
}
// --- B is browsable + readable (the add landed). ---
var nodeIdB = new NodeId(V3NodeIds.Uns("eq-1", "B"), ns);
sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
var bValue = await session.ReadValueAsync(nodeIdB, ct);
bValue.StatusCode.ShouldNotBe(StatusCodes.BadNodeIdUnknown);
bValue.Value.ShouldBe(7);
}
finally
{
if (Directory.Exists(pkiRoot)) Directory.Delete(pkiRoot, recursive: true);
}
}
/// <summary>R2-07 Phase 2 — a subscription on tag A survives a PURE-REMOVE deploy that removes tag B; B's
/// monitored item observes a final Bad and the node then disappears (a re-read returns BadNodeIdUnknown),
/// while A stays alive.</summary>
[Fact]
public async Task Subscription_on_surviving_tag_survives_pure_remove_of_another_tag()
{
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-subsurvive-rm-{Guid.NewGuid():N}");
var port = AllocateFreePort();
var ct = TestContext.Current.CancellationToken;
try
{
var options = new OpcUaApplicationHostOptions
{
ApplicationName = ServerUri,
ApplicationUri = ServerUri + ".Remove",
OpcUaPort = port,
PublicHostname = "127.0.0.1",
PkiStoreRoot = pkiRoot,
EnabledSecurityProfiles = new List<OpcUaSecurityProfile> { OpcUaSecurityProfile.None },
AutoAcceptUntrustedClientCertificates = true,
};
var server = new OtOpcUaSdkServer();
await using var host = new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
await host.StartAsync(server, ct);
var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm);
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
// Materialise A + B and seed both Good.
var withAB = CompositionWith(
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null),
new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null));
applier.MaterialiseHierarchy(withAB);
applier.MaterialiseEquipmentTags(withAB);
var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
var nodeIdBString = V3NodeIds.Uns("eq-1", "B");
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue(nodeIdBString, 20, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri);
var nodeIdA = new NodeId(nodeIdAString, ns);
var nodeIdB = new NodeId(nodeIdBString, ns);
var receivedA = new List<DataValue>();
var receivedB = new List<DataValue>();
var gate = new object();
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
session.AddSubscription(subscription);
await subscription.CreateAsync(ct);
var miA = new MonitoredItem(subscription.DefaultItem) { StartNodeId = nodeIdA, AttributeId = Attributes.Value, SamplingInterval = 50, QueueSize = 10 };
var miB = new MonitoredItem(subscription.DefaultItem) { StartNodeId = nodeIdB, AttributeId = Attributes.Value, SamplingInterval = 50, QueueSize = 10 };
miA.Notification += (item, _) => { foreach (var v in item.DequeueValues()) lock (gate) receivedA.Add(v); };
miB.Notification += (item, _) => { foreach (var v in item.DequeueValues()) lock (gate) receivedB.Add(v); };
subscription.AddItem(miA);
subscription.AddItem(miB);
await subscription.ApplyChangesAsync(ct);
await WaitUntilAsync(() => { lock (gate) return receivedA.Count >= 1 && receivedB.Count >= 1; }, TimeSpan.FromSeconds(5));
// Drive a PURE-REMOVE apply removing B (previous={A,B}, next={A}).
var withA = CompositionWith(
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null));
var plan = AddressSpacePlanner.Compute(withAB, withA);
plan.RemovedEquipmentTags.Count.ShouldBe(1);
lock (gate) { receivedA.Clear(); receivedB.Clear(); }
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse(); // PureRemove — no full rebuild
// B's monitored item observes a final Bad; the node is then gone from the address space.
await WaitUntilAsync(() => { lock (gate) return receivedB.Any(v => StatusCode.IsBad(v.StatusCode)); }, TimeSpan.FromSeconds(5));
// A stays alive: a fresh write to A is delivered.
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5));
// B is gone: a re-read surfaces BadNodeIdUnknown. The 1.5.378 node-cache read path
// throws ServiceResultException for an unknown node rather than returning a bad DataValue.
var bReadEx = await Should.ThrowAsync<ServiceResultException>(
async () => await session.ReadValueAsync(nodeIdB, ct));
bReadEx.StatusCode.ShouldBe(StatusCodes.BadNodeIdUnknown);
}
finally
{
if (Directory.Exists(pkiRoot)) Directory.Delete(pkiRoot, recursive: true);
}
}
/// <summary>R2-07 Phase 3 — a subscription on tag A survives a MIXED deploy (−B, +C in one apply): A
/// stays alive, B is gone (BadNodeIdUnknown), and C is browsable + readable.</summary>
[Fact]
public async Task Subscription_survives_mixed_add_remove_deploy()
{
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-subsurvive-mix-{Guid.NewGuid():N}");
var port = AllocateFreePort();
var ct = TestContext.Current.CancellationToken;
try
{
var options = new OpcUaApplicationHostOptions
{
ApplicationName = ServerUri,
ApplicationUri = ServerUri + ".Mixed",
OpcUaPort = port,
PublicHostname = "127.0.0.1",
PkiStoreRoot = pkiRoot,
EnabledSecurityProfiles = new List<OpcUaSecurityProfile> { OpcUaSecurityProfile.None },
AutoAcceptUntrustedClientCertificates = true,
};
var server = new OtOpcUaSdkServer();
await using var host = new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
await host.StartAsync(server, ct);
var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm);
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var withAB = CompositionWith(
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null),
new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null));
applier.MaterialiseHierarchy(withAB);
applier.MaterialiseEquipmentTags(withAB);
var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri);
var nodeIdA = new NodeId(nodeIdAString, ns);
var receivedA = new List<DataValue>();
var gate = new object();
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
session.AddSubscription(subscription);
await subscription.CreateAsync(ct);
var miA = new MonitoredItem(subscription.DefaultItem) { StartNodeId = nodeIdA, AttributeId = Attributes.Value, SamplingInterval = 50, QueueSize = 10 };
miA.Notification += (item, _) => { foreach (var v in item.DequeueValues()) lock (gate) receivedA.Add(v); };
subscription.AddItem(miA);
await subscription.ApplyChangesAsync(ct);
await WaitUntilAsync(() => { lock (gate) return receivedA.Count >= 1; }, TimeSpan.FromSeconds(5));
// MIXED apply: remove B, add C (previous={A,B}, next={A,C}).
var withAC = CompositionWith(
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null),
new EquipmentTagPlan("tag-c", "eq-1", "drv", FolderPath: "", Name: "C", DataType: "Int32", FullName: "3", Writable: false, Alarm: null));
var plan = AddressSpacePlanner.Compute(withAB, withAC);
plan.RemovedEquipmentTags.Count.ShouldBe(1);
plan.AddedEquipmentTags.Count.ShouldBe(1);
lock (gate) receivedA.Clear();
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse(); // AddRemoveMix — removes in place, no rebuild
// Publish-actor sequence: removes ran inside Apply; now materialise the adds + announce.
applier.MaterialiseEquipmentTags(withAC);
applier.AnnounceAddedNodes(plan);
// A stays alive.
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5));
// B is gone. (1.5.378 node-cache read throws for an unknown node — see above.)
var nodeIdB = new NodeId(V3NodeIds.Uns("eq-1", "B"), ns);
var bReadEx = await Should.ThrowAsync<ServiceResultException>(
async () => await session.ReadValueAsync(nodeIdB, ct));
bReadEx.StatusCode.ShouldBe(StatusCodes.BadNodeIdUnknown);
// C is browsable + readable.
var nodeIdC = new NodeId(V3NodeIds.Uns("eq-1", "C"), ns);
sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
var cRead = await session.ReadValueAsync(nodeIdC, ct);
cRead.StatusCode.ShouldNotBe((StatusCode)StatusCodes.BadNodeIdUnknown);
cRead.Value.ShouldBe(9);
}
finally
{
if (Directory.Exists(pkiRoot)) Directory.Delete(pkiRoot, recursive: true);
}
}
private static AddressSpaceComposition CompositionWith(params EquipmentTagPlan[] tags) =>
new(
UnsAreas: Array.Empty<UnsAreaProjection>(),
UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Equipment 1", "") },
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = tags,
};
private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
{
var appConfig = new ApplicationConfiguration
{
ApplicationName = "OtOpcUa.SubscriptionSurvivalClient",
ApplicationUri = $"urn:OtOpcUa.SubscriptionSurvivalClient.{Guid.NewGuid():N}",
ApplicationType = ApplicationType.Client,
// Directory cert stores rooted at a throwaway temp PKI dir — ValidateAsync rejects a
// bare SecurityConfiguration ("TrustedIssuerCertificates StorePath must be specified").
// The returned session outlives this method, so the dir is left for the OS temp sweep.
SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()),
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
};
await appConfig.ValidateAsync(ApplicationType.Client, ct);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var endpoint = await CoreClientUtils.SelectEndpointAsync(
appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct);
var endpointConfiguration = EndpointConfiguration.Create(appConfig);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
appConfig,
configuredEndpoint,
updateBeforeConnect: false,
checkDomain: false,
sessionName: "SubscriptionSurvivalTests",
sessionTimeout: 60_000,
identity: new UserIdentity(new AnonymousIdentityToken()),
preferredLocales: null,
ct: ct);
}
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (condition()) return;
await Task.Delay(50);
}
condition().ShouldBeTrue("condition not met within timeout");
}
private static int AllocateFreePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
}