Server: keep subscriber system contexts stable and bind shared contexts to their channel

Review fixes for the shared subscriber context:

- The per-node-manager system context of a shared subscriber context now comes
  from a table on the context instead of OperationContext.GetSystemContext,
  whose per-request cache keeps nine node managers and then restarts. With a
  session whose items span more node managers, every notification got a new
  copy, so the item's permission cache (keyed by the copy) missed every time.
- The shared session context is also keyed by the session's SecureChannelId,
  so a session reactivated on another channel with the same identity, epoch
  and endpoint description does not keep the old channel.
- A sessionless item no longer reuses a context that was created while the
  item still had a session.
- SetRetiredGenerationNotifications finds the node manager's state in the
  state table, which leases admitted without the lock count on, rather than
  only in the tracked list that those leases never join.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Roman Ettlinger
2026-10-26 06:37:57 +01:00
co-authored by Claude Opus 5.5
parent e1c7664a96
commit db09cb737c
4 changed files with 163 additions and 27 deletions
@@ -399,6 +399,30 @@ namespace Opc.Ua.Server
/// </remarks>
/// <param name="item">The monitored item.</param>
public static OperationContext GetSubscriberContext(IMonitoredItem item)
{
return GetSubscriberEntry(item).Context;
}
/// <summary>
/// Gets the copy of a node manager's system context for the shared subscriber context of
/// a monitored item.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="item">The monitored item.</param>
/// <param name="template">The node manager's system context.</param>
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;
}
/// <summary>
/// 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.
/// </summary>
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; }
/// <summary>
/// Gets the copy of the node manager's system context for this context. Unlike the
/// bounded per-request cache of <see cref="OperationContext.GetSystemContext"/>, the
/// table keeps one copy for every node manager.
/// </summary>
public ServerSystemContext GetSystemContext(ServerSystemContext template)
{
return m_copies.TryGetValue(template, out ServerSystemContext? copy)
? copy
: m_copies.GetValue(template, m_createCopy);
}
private readonly ConditionalWeakTable<ServerSystemContext, ServerSystemContext> m_copies = new();
private readonly ConditionalWeakTable<ServerSystemContext, ServerSystemContext>.CreateValueCallback m_createCopy;
}
/// <summary>
@@ -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;
@@ -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);
}
/// <summary>
@@ -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));
}
/// <summary>
/// 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.
/// </summary>
[Test]
public void SystemContextCopyIsStableForManyNodeManagers()
{
var subscriber = new Subscriber();
IMonitoredItem item = subscriber.CreateItem();
Mock<IServerInternal> 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)));
}
}
}
/// <summary>
/// A sessionless item never reuses a context that was created while it had a session.
/// </summary>
[Test]
public void SessionlessItemDoesNotReuseSessionBoundContext()
{
var subscriber = new Subscriber();
ISession? owner = subscriber.Session.Object;
var item = new Mock<IMonitoredItem>();
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));
}
/// <summary>
/// A modeled custom session whose identity, identity refresh generation and endpoint can change.
/// </summary>
@@ -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<ISession> 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 };