using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Client; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// /// Review 01 L2, over the wire against the real SDK: a rebuild (or a surgical remove + re-add) that /// recreates a NodeId must not leave the SDK's per-NodeId MonitoredNode2 — and its component-cache /// entry — pointing at the DEAD NodeState. Before the fix, ANY new item on that NodeId, from any /// session, bound to the dead node while an older item on it still existed: created Good, fed its /// stale initial value, then never again. /// /// Each test keeps the OLD item alive while creating the NEW one — that is exactly the trap — and /// proves the positive path (the new item receives a value written AFTER its creation) before asserting /// the old item's BadNodeIdUnknown. Deleting the old item afterwards must succeed too. /// /// public sealed class StaleMonitoredNodeWireTests { private const string ServerUri = "urn:OtOpcUa.StaleMonitoredNode"; private const string Device = "Plant/Modbus/dev1"; private const string Tag = "Plant/Modbus/dev1/Speed"; /// Rebuild + re-create the same NodeId: the old item is told, a new item on the same NodeId receives values. [Fact] public async Task A_new_item_after_a_rebuild_binds_to_the_recreated_node() { await RunAsync("rebuild", nm => { nm.RebuildAddressSpace(); nm.CompleteAddressSpaceApply(); Materialise(nm); }); } /// Surgical remove + re-add of the same NodeId: same contract as the rebuild. [Fact] public async Task A_new_item_after_a_surgical_remove_and_readd_binds_to_the_recreated_node() { await RunAsync("surgical", nm => { nm.RemoveVariableNode(Tag, AddressSpaceRealm.Raw).ShouldBeTrue(); nm.EnsureVariable(Tag, Device, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Raw); }); } /// /// The same trap from a SECOND session: a UaExpert operator connecting after a deploy while ScadaBridge's /// original item is still alive. /// [Fact] public async Task A_second_session_binds_to_the_recreated_node_while_the_first_sessions_item_lives() { var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-stale-2s-{Guid.NewGuid():N}"); var ct = TestContext.Current.CancellationToken; try { var (server, host, port) = await BootServerAsync(pkiRoot, ServerUri + ".TwoSessions", ct); await using var _ = host; var nm = server.NodeManager!; Materialise(nm); nm.WriteValue(Tag, 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); using var sessionA = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); var nodeId = new NodeId(Tag, (ushort)sessionA.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri)); var (subA, oldItem, oldValues, gate) = await SubscribeAsync(sessionA, nodeId, ct); await WaitUntilAsync(() => { lock (gate) return oldValues.Count >= 1; }, TimeSpan.FromSeconds(5)); nm.RebuildAddressSpace(); nm.CompleteAddressSpaceApply(); Materialise(nm); nm.WriteValue(Tag, 2, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); // Session B arrives AFTER the rebuild, while A's item is still in A's subscription. using var sessionB = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); var (subB, newItem, newValues, gateB) = await SubscribeAsync(sessionB, nodeId, ct); await WaitUntilAsync(() => { lock (gateB) return newValues.Count >= 1; }, TimeSpan.FromSeconds(5)); nm.WriteValue(Tag, 3, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); await WaitUntilAsync(() => { lock (gateB) return newValues.Any(v => Equals(v.Value, 3)); }, TimeSpan.FromSeconds(5), "the second session's item must receive a value written after its creation"); lock (gate) oldValues.ShouldContain(v => v.StatusCode == StatusCodes.BadNodeIdUnknown, "the first session's item was told its node went away"); lock (gateB) newValues.ShouldNotContain(v => v.StatusCode == StatusCodes.BadNodeIdUnknown); subA.RemoveItem(oldItem); await subA.ApplyChangesAsync(ct); subB.RemoveItem(newItem); await subB.ApplyChangesAsync(ct); } finally { SafeDelete(pkiRoot); } } private static async Task RunAsync(string name, Action recreate) { var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-stale-{name}-{Guid.NewGuid():N}"); var ct = TestContext.Current.CancellationToken; try { var (server, host, port) = await BootServerAsync(pkiRoot, $"{ServerUri}.{name}", ct); await using var _ = host; var nm = server.NodeManager!; Materialise(nm); nm.WriteValue(Tag, 41, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); var nodeId = new NodeId(Tag, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri)); var (subscription, oldItem, oldValues, gate) = await SubscribeAsync(session, nodeId, ct); await WaitUntilAsync(() => { lock (gate) return oldValues.Any(v => Equals(v.Value, 41)); }, TimeSpan.FromSeconds(5)); nm.IsNodeMonitored(nodeId).ShouldBeTrue("non-vacuity: the SDK really holds a fan-out for the node"); // The node goes away and comes back under the same NodeId; a fresh value lands on the NEW node. recreate(nm); nm.WriteValue(Tag, 42, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); // A NEW item on the same NodeId, in the same subscription, with the old one still present. var newValues = new List(); var newItem = new MonitoredItem(subscription.DefaultItem) { StartNodeId = nodeId, AttributeId = Attributes.Value, SamplingInterval = 50, QueueSize = 10, }; newItem.Notification += (item, _) => { foreach (var v in item.DequeueValues()) lock (gate) newValues.Add(v); }; subscription.AddItem(newItem); await subscription.ApplyChangesAsync(ct); newItem.Status.Error.ShouldBeNull(); // Positive control: the initial value is the NEW node's, and a value written afterwards arrives. await WaitUntilAsync(() => { lock (gate) return newValues.Any(v => Equals(v.Value, 42)); }, TimeSpan.FromSeconds(5), "the new item's initial read must come from the re-created node"); nm.WriteValue(Tag, 43, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); await WaitUntilAsync(() => { lock (gate) return newValues.Any(v => Equals(v.Value, 43)); }, TimeSpan.FromSeconds(5), "the new item must receive a value written after its creation"); lock (gate) newValues.ShouldNotContain(v => v.StatusCode == StatusCodes.BadNodeIdUnknown); // Review fix I3: the cache entry the new item holds is ITS reference alone — the old item's was released // by the removal. CacheRefCount(nm, nodeId).ShouldBe(1, "one live item, one component-cache reference"); // The old item was told, and the dead node's fan-out is gone. await WaitUntilAsync(() => { lock (gate) return oldValues.Any(v => v.StatusCode == StatusCodes.BadNodeIdUnknown); }, TimeSpan.FromSeconds(5), "the item on the removed node must be told BadNodeIdUnknown"); nm.IsNodeMonitored(nodeId).ShouldBeTrue("the NEW item's fan-out exists for the re-created node"); // The old item's delete completes — its node is gone, not its right to be deleted. subscription.RemoveItem(oldItem); await subscription.ApplyChangesAsync(ct); (oldItem.Status.Error is null || ServiceResult.IsGood(oldItem.Status.Error)).ShouldBeTrue( $"deleting the orphaned item must succeed, got {oldItem.Status.Error}"); // The SDK releases a deleted item's cache reference on a Good delete; the old item's was released by the // removal already, so a second release would evict the NEW item's live entry (review fix I3). CacheRefCount(nm, nodeId).ShouldBe(1, "deleting the orphaned item must not take the new item's reference"); // And the new item is unaffected by that delete. nm.WriteValue(Tag, 44, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); await WaitUntilAsync(() => { lock (gate) return newValues.Any(v => Equals(v.Value, 44)); }, TimeSpan.FromSeconds(5)); // Positive control: deleting the live item gives its reference back. subscription.RemoveItem(newItem); await subscription.ApplyChangesAsync(ct); CacheRefCount(nm, nodeId).ShouldBe(0); } finally { SafeDelete(pkiRoot); } } /// The SDK's component-cache reference count for (private SDK state, read by /// reflection: CustomNodeManager2.m_componentCache → CacheEntry.RefCount); 0 when no entry. private static int CacheRefCount(OtOpcUaNodeManager nm, NodeId nodeId) { const System.Reflection.BindingFlags Any = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic; var field = typeof(Opc.Ua.Server.CustomNodeManager2).GetField("m_componentCache", Any) .ShouldNotBeNull("the SDK's component cache field (SDK internals moved?)"); lock (nm.Lock) { if (field.GetValue(nm) is not System.Collections.IDictionary cache || !cache.Contains(nodeId)) return 0; var entry = cache[nodeId]!; var member = (object?)entry.GetType().GetField("RefCount", Any) ?? entry.GetType().GetProperty("RefCount", Any); return member switch { System.Reflection.FieldInfo f => (int)f.GetValue(entry)!, System.Reflection.PropertyInfo p => (int)p.GetValue(entry)!, _ => throw new InvalidOperationException("CacheEntry.RefCount not found"), }; } } private static void Materialise(OtOpcUaNodeManager nm) { nm.EnsureFolder(Device, null, "dev1", AddressSpaceRealm.Raw); nm.EnsureVariable(Tag, Device, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Raw); } private static async Task<(Subscription Subscription, MonitoredItem Item, List Values, object Gate)> SubscribeAsync( ISession session, NodeId nodeId, CancellationToken ct) { var values = new List(); var gate = new object(); var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 }; session.AddSubscription(subscription); await subscription.CreateAsync(ct); var item = new MonitoredItem(subscription.DefaultItem) { StartNodeId = nodeId, AttributeId = Attributes.Value, SamplingInterval = 50, QueueSize = 10, }; item.Notification += (i, _) => { foreach (var v in i.DequeueValues()) lock (gate) values.Add(v); }; subscription.AddItem(item); await subscription.ApplyChangesAsync(ct); return (subscription, item, values, gate); } private static async Task<(OtOpcUaSdkServer Server, OpcUaApplicationHost Host, int Port)> BootServerAsync( string pkiRoot, string appUri, CancellationToken ct) { var boundPort = 0; var started = await OpcUaHostStart.StartAsync( port => { boundPort = port; return new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = appUri, ApplicationUri = appUri, OpcUaPort = port, PublicHostname = "127.0.0.1", PkiStoreRoot = pkiRoot, EnabledSecurityProfiles = new List { OpcUaSecurityProfile.None }, AutoAcceptUntrustedClientCertificates = true, }, NullLogger.Instance); }, () => new OtOpcUaSdkServer(), ct); return (started.Server, started.Host, boundPort); } private static async Task OpenSessionAsync(string endpointUrl, CancellationToken ct) { var appConfig = new ApplicationConfiguration { ApplicationName = "OtOpcUa.StaleMonitoredNodeClient", ApplicationUri = $"urn:OtOpcUa.StaleMonitoredNodeClient.{Guid.NewGuid():N}", ApplicationType = ApplicationType.Client, 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 configuredEndpoint = new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig)); return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync( appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false, sessionName: "StaleMonitoredNodeWireTests", sessionTimeout: 60_000, identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct); } private static async Task WaitUntilAsync(Func condition, TimeSpan timeout, string? because = null) { var deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { if (condition()) return; await Task.Delay(50); } condition().ShouldBeTrue(because ?? "condition not met within timeout"); } private static void SafeDelete(string dir) { if (!Directory.Exists(dir)) return; try { Directory.Delete(dir, recursive: true); } catch { /* best-effort */ } } }