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 };