diff --git a/src/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.ValueReadAuthorization.cs b/src/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.ValueReadAuthorization.cs index d727304fee..04297aa427 100644 --- a/src/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.ValueReadAuthorization.cs +++ b/src/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.ValueReadAuthorization.cs @@ -399,6 +399,30 @@ namespace Opc.Ua.Server /// /// The monitored item. public static OperationContext GetSubscriberContext(IMonitoredItem item) + { + return GetSubscriberEntry(item).Context; + } + + /// + /// Gets the copy of a node manager's system context for the shared subscriber context of + /// a monitored item. + /// + /// + /// The copy is kept with the shared context for every node manager, so it stays the same + /// object across notifications however many node managers the session's items span; the + /// item's cached verdicts are keyed by it. The returned context is shared: callers must + /// not dispose or retain it. + /// + /// The monitored item. + /// The node manager's system context. + public static ServerSystemContext GetSubscriberSystemContext( + IMonitoredItem item, + ServerSystemContext template) + { + return GetSubscriberEntry(item).GetSystemContext(template); + } + + private static SubscriberContext GetSubscriberEntry(IMonitoredItem item) { ISession? session = item.Session; if (session == null) @@ -406,16 +430,19 @@ namespace Opc.Ua.Server IUserIdentity? identity = item.EffectiveIdentity; SubscriberContext? itemContext = s_itemContexts.TryGetValue(item, out SubscriberContextSlot? itemSlot) ? Volatile.Read(ref itemSlot.Current) : null; - if (itemContext != null && ReferenceEquals(itemContext.Identity, identity)) + // A context created while the item had a session is bound to that session. + if (itemContext != null && + itemContext.Context.Session is null && + ReferenceEquals(itemContext.Identity, identity)) { - return itemContext.Context; + return itemContext; } // CA2000: the shared context is owned by the item's entry and never disposed by callers. #pragma warning disable CA2000 - var created = new SubscriberContext(identity, null, 0, null, new OperationContext(item)); + var created = new SubscriberContext(identity, null, 0, null, null, new OperationContext(item)); #pragma warning restore CA2000 Volatile.Write(ref s_itemContexts.GetValue(item, static _ => new SubscriberContextSlot()).Current, created); - return created.Context; + return created; } // read without the session's lock; a context captured under it is reused only when @@ -425,39 +452,75 @@ namespace Opc.Ua.Server long generation = session is Session ? 0 : session.CaptureIdentityRefreshSnapshot().Generation; IUserIdentity effectiveIdentity = session.EffectiveIdentity; EndpointDescription? endpoint = GetEndpoint(session); + string? secureChannelId = session.SecureChannelId; SubscriberContextSlot slot = s_sessionContexts.GetValue(session, static _ => new SubscriberContextSlot()); SubscriberContext? current = Volatile.Read(ref slot.Current); if (current != null && ReferenceEquals(current.Identity, effectiveIdentity) && current.Generation == generation && ReferenceEquals(current.Endpoint, endpoint) && + string.Equals(current.SecureChannelId, secureChannelId, StringComparison.Ordinal) && (session is not Session || ReferenceEquals(current.Authorization, authorization))) { - return current.Context; + return current; } OperationContext context = CreateContext(session); // the constructor captured identity and epoch together; key the entry on what it captured. current = new SubscriberContext( - context.UserIdentity, context.NotificationAuthorization, generation, endpoint, context); + context.UserIdentity, + context.NotificationAuthorization, + generation, + endpoint, + secureChannelId, + context); Volatile.Write(ref slot.Current, current); - return context; + return current; } /// - /// A shared subscriber context and the inputs it was created from. + /// A shared subscriber context, the inputs it was created from and its system context + /// copies, one per node manager. /// - private sealed class SubscriberContext( - IUserIdentity? identity, - NotificationAuthorization? authorization, - long generation, - EndpointDescription? endpoint, - OperationContext context) + private sealed class SubscriberContext { - public IUserIdentity? Identity { get; } = identity; - public NotificationAuthorization? Authorization { get; } = authorization; - public long Generation { get; } = generation; - public EndpointDescription? Endpoint { get; } = endpoint; - public OperationContext Context { get; } = context; + public SubscriberContext( + IUserIdentity? identity, + NotificationAuthorization? authorization, + long generation, + EndpointDescription? endpoint, + string? secureChannelId, + OperationContext context) + { + Identity = identity; + Authorization = authorization; + Generation = generation; + Endpoint = endpoint; + SecureChannelId = secureChannelId; + Context = context; + m_createCopy = template => template.Copy(context); + } + + public IUserIdentity? Identity { get; } + public NotificationAuthorization? Authorization { get; } + public long Generation { get; } + public EndpointDescription? Endpoint { get; } + public string? SecureChannelId { get; } + public OperationContext Context { get; } + + /// + /// Gets the copy of the node manager's system context for this context. Unlike the + /// bounded per-request cache of , the + /// table keeps one copy for every node manager. + /// + public ServerSystemContext GetSystemContext(ServerSystemContext template) + { + return m_copies.TryGetValue(template, out ServerSystemContext? copy) + ? copy + : m_copies.GetValue(template, m_createCopy); + } + + private readonly ConditionalWeakTable m_copies = new(); + private readonly ConditionalWeakTable.CreateValueCallback m_createCopy; } /// diff --git a/src/Opc.Ua.Server/NodeManager/MasterNodeManager.cs b/src/Opc.Ua.Server/NodeManager/MasterNodeManager.cs index 945672c3f6..a834ff2dba 100644 --- a/src/Opc.Ua.Server/NodeManager/MasterNodeManager.cs +++ b/src/Opc.Ua.Server/NodeManager/MasterNodeManager.cs @@ -1207,10 +1207,16 @@ namespace Opc.Ua.Server RetiredGenerationNotifications? notifications = m_retiredGenerationNotifications.FirstOrDefault(candidate => ReferenceEquals(candidate.NodeManager, nodeManager)); - NotificationDispatchState? dispatchState = - notifications?.DispatchState ?? - m_notificationDispatchStates.FirstOrDefault(candidate => - candidate.References(nodeManager)); + // The table holds the state that leases admitted without the lock count on, also + // while the state is not in the tracked list. + NotificationDispatchState? dispatchState = notifications?.DispatchState; + if (dispatchState is null && + m_notificationDispatchStateTable.TryGetValue( + nodeManager, + out NotificationDispatchState? tracked)) + { + dispatchState = tracked; + } if (dispatchState is not null) { dispatchState.Enabled = enabled; diff --git a/src/Opc.Ua.Server/NodeManager/MonitoredItem/MonitoredNode.cs b/src/Opc.Ua.Server/NodeManager/MonitoredItem/MonitoredNode.cs index 089e1293a5..eda25bfed0 100644 --- a/src/Opc.Ua.Server/NodeManager/MonitoredItem/MonitoredNode.cs +++ b/src/Opc.Ua.Server/NodeManager/MonitoredItem/MonitoredNode.cs @@ -1869,10 +1869,7 @@ namespace Opc.Ua.Server ServerSystemContext template, IDataChangeMonitoredItem2 monitoredItem) { - // CA2000: the shared context is owned by the session's entry and never disposed by callers. -#pragma warning disable CA2000 - return MonitoredReadAuthorization.GetSubscriberContext(monitoredItem).GetSystemContext(template); -#pragma warning restore CA2000 + return MonitoredReadAuthorization.GetSubscriberSystemContext(monitoredItem, template); } /// diff --git a/tests/Opc.Ua.Server.Tests/NodeManager/SubscriberContextTests.cs b/tests/Opc.Ua.Server.Tests/NodeManager/SubscriberContextTests.cs index b882555adf..367810e077 100644 --- a/tests/Opc.Ua.Server.Tests/NodeManager/SubscriberContextTests.cs +++ b/tests/Opc.Ua.Server.Tests/NodeManager/SubscriberContextTests.cs @@ -70,6 +70,7 @@ namespace Opc.Ua.Server.Tests.NodeManager [TestCase(0)] [TestCase(1)] [TestCase(2)] + [TestCase(3)] public void ChangedSubscriberInputReplacesContext(int change) { var subscriber = new Subscriber(); @@ -84,6 +85,9 @@ namespace Opc.Ua.Server.Tests.NodeManager case 1: subscriber.Generation++; break; + case 2: + subscriber.SecureChannelId = "channel-2"; + break; default: subscriber.Endpoint = new EndpointDescription { SecurityMode = MessageSecurityMode.Sign }; break; @@ -93,9 +97,72 @@ namespace Opc.Ua.Server.Tests.NodeManager Assert.That(after, Is.Not.SameAs(before)); Assert.That(after.UserIdentity, Is.SameAs(subscriber.Identity)); Assert.That(after.ChannelContext?.EndpointDescription, Is.SameAs(subscriber.Endpoint)); + Assert.That(after.ChannelContext?.SecureChannelId, Is.EqualTo(subscriber.SecureChannelId)); Assert.That(MonitoredReadAuthorization.GetSubscriberContext(item), Is.SameAs(after)); } + /// + /// The system context copy of every node manager stays the same object across calls, also + /// when a session's items span more node managers than a request's copy cache keeps. + /// + [Test] + public void SystemContextCopyIsStableForManyNodeManagers() + { + var subscriber = new Subscriber(); + IMonitoredItem item = subscriber.CreateItem(); + Mock server = DeterministicServerMock.Create(out MonitoredItemQueueFactory queues); + using (queues) + { + var templates = new ServerSystemContext[20]; + var copies = new ServerSystemContext[templates.Length]; + for (int ii = 0; ii < templates.Length; ii++) + { + templates[ii] = new ServerSystemContext(server.Object); + copies[ii] = MonitoredReadAuthorization.GetSubscriberSystemContext(item, templates[ii]); + } + + for (int ii = 0; ii < templates.Length; ii++) + { + ServerSystemContext again = MonitoredReadAuthorization.GetSubscriberSystemContext(item, templates[ii]); + Assert.That(again, Is.SameAs(copies[ii])); + Assert.That(again.OperationContext, Is.SameAs(MonitoredReadAuthorization.GetSubscriberContext(item))); + } + } + } + + /// + /// A sessionless item never reuses a context that was created while it had a session. + /// + [Test] + public void SessionlessItemDoesNotReuseSessionBoundContext() + { + var subscriber = new Subscriber(); + ISession? owner = subscriber.Session.Object; + var item = new Mock(); + item.SetupGet(value => value.EffectiveIdentity).Returns(subscriber.Identity); + + // the session detaches between the sessionless check and the context's creation + bool first = true; + item.SetupGet(value => value.Session).Returns(() => + { + if (first) + { + first = false; + return null!; + } + return owner!; + }); + OperationContext bound = MonitoredReadAuthorization.GetSubscriberContext(item.Object); + Assert.That(bound.Session, Is.SameAs(subscriber.Session.Object)); + + owner = null; + OperationContext sessionless = MonitoredReadAuthorization.GetSubscriberContext(item.Object); + + Assert.That(sessionless, Is.Not.SameAs(bound)); + Assert.That(sessionless.Session, Is.Null); + Assert.That(MonitoredReadAuthorization.GetSubscriberContext(item.Object), Is.SameAs(sessionless)); + } + /// /// A modeled custom session whose identity, identity refresh generation and endpoint can change. /// @@ -108,6 +175,7 @@ namespace Opc.Ua.Server.Tests.NodeManager Session.SetupGet(value => value.EndpointDescription).Returns(() => Endpoint); Session.Setup(value => value.CaptureIdentityRefreshSnapshot()) .Returns(() => new IdentityRefreshSnapshot(Identity, Generation)); + Session.SetupGet(value => value.SecureChannelId).Returns(() => SecureChannelId); } public Mock Session { get; } = new(); @@ -116,6 +184,8 @@ namespace Opc.Ua.Server.Tests.NodeManager public long Generation { get; set; } + public string SecureChannelId { get; set; } = "channel-1"; + public EndpointDescription Endpoint { get; set; } = new() { SecurityMode = MessageSecurityMode.SignAndEncrypt };