# Description Fixes defects found in a spec-grounded audit of the server **Subscription / MonitoredItem** implementation (`src/Opc.Ua.Server/Subscription/**`, `NodeManager/MonitoredItem/**`, `EventManager`, monitored-item service dispatch and filter validation). Each behaviour change was checked against OPC 10000-4 (§5.13, §5.14, §7.21, §7.22), Part 5 (subscription diagnostics) and Part 9 (ConditionRefresh). Every fix has a regression test. The audit found 56 defects (1 Critical, 3 High, 20 Medium, 32 Low). A follow-up max-effort code review of the fix branch found 27 more issues, mostly introduced by the first round of fixes. All but one are fixed here. ### Most important fixes - **Critical — remote hang:** an `AggregateFilter` with `processingInterval = NaN` was not revised, and `AggregateCalculator` looped forever while holding the node-manager lock, so one CreateMonitoredItems request blocked the node manager. Non-finite, ≤0 and sub-tick intervals are now revised (Part 4 §5.13.2.1), and the calculator rejects them as a backstop. - **Monitored-item limits:** new `ServerConfiguration.MaxMonitoredItemCount` / `MaxMonitoredItemsPerSubscription`; Bad_TooManyMonitoredItems is returned when a limit is hit, and ServerCapabilities now report the limits. Both default to `0` (no limit), so existing behaviour is unchanged. - **Sampling groups:** a client could get one dedicated thread per distinct sampling interval above 1 h; intervals above the last rate group are now rounded, and the loop is async. An item could also end up in two sampling groups and keep being sampled after it was deleted. - **Subscription state machine** (Part 4 §5.14.1.2): - the lifetime counter resets while a Publish is queued and counts otherwise; - the lifetime and keep-alive stay within `MaxSubscriptionLifetime`; - LatePublishRequestCount is counted once per entry into LATE; - messages held while publishing is disabled are no longer sent, and are not offered by Transfer/Republish either; - ConditionRefresh returns Bad_RefreshInProgress for any refresh already queued or running on the subscription. - **Triggering** (Part 4 §5.13.1.6): - linked items are triggered by an item still draining a backlog, by the first value after re-enable, and by an item created Disabled; - links are persisted for durable subscriptions; - Disabled/Sampling items follow the publish rules in the shared pipeline, so custom `IMonitoredItem` implementations follow them too. - **Queues** (Part 4 §5.13.1.5, §7.21): - no duplicate value after a queue-size increase; - EventQueueOverflow events are placed where the events were lost, including on queue shrink; - the duplicate-event check scans the newest entries; - EventQueueOverflowCount and MonitoringQueueOverflowCount are maintained correctly; - changing select clauses rebuilds queued events in the new layout, and is safe on durable queues and on failure. - **Filters:** - per-clause EventFilterResult instead of rejecting the whole item (Part 4 §7.22.3); - corrected status codes: Bad_DeadbandFilterInvalid, Bad_MonitoredItemFilterInvalid, Bad_EventFilterInvalid, Bad_MonitoringModeInvalid, Bad_TimestampsToReturnInvalid; - aggregate filter in subscriptions: processing interval at least 2× the revised sampling interval, revised StartTime on the requested grid, ServerTimestamp = end of interval (Part 4 §7.22.4). - **Lifecycle and races:** - expired subscriptions are always cleaned up, even when the session closes during cleanup; - Create/Transfer racing CloseSession returns Bad_SessionClosed; - session diagnostics counts no longer drift; - a large but valid Publish TimeoutHint no longer fails the request; - a ready subscription is served by queued Publish requests instead of going Late; - restored persisted state is validated. - **Access:** a value change notification is checked against each subscriber's UserAccessLevel, not the writer's. - **Redundancy / durable store:** - the first-unsent sequence number is mirrored; - sequence numbers are no longer reused after a second failover; - triggering links are stored (definition format 6, written only when links exist, so older replicas can still read link-less subscriptions). ### Behaviour changes reviewers should know - With the default config, publishing intervals above `MaxSubscriptionLifetime / 3` (1,200,000 ms) are revised down, so lifetime ≥ 3×keep-alive fits the configured maximum. - The revised keep-alive is capped at a third of the max lifetime. - Idle subscriptions expire after exactly MaxLifetimeCount cycles without a queued Publish. - A partly invalid EventFilter select list is accepted with per-clause errors. The public `EventFilter.Validate` keeps Status Good if at least one clause is valid; callers that cannot return per-clause results must also check the new `EventFilter.Result.HasSelectClauseErrors`. There is a migration note in `docs/migrate/2.0.x/sessions-subscriptions.md`. - Aggregate processing intervals may be revised higher (≥ 2× sampling interval); aggregate ServerTimestamp is now the interval end. - Sampling intervals above 3,600,000 ms are rounded to multiples of 900,000 ms (default rate groups). ### Public API Additive only: - `ServerConfiguration.MaxMonitoredItemCount` and `MaxMonitoredItemsPerSubscription`; - `EventFilter.Result.HasSelectClauseErrors`; - `IStoredSubscriptionTriggering`, `ISubscriptionRetransmissionSendStateStore`, `SubscriptionRetransmissionState.FirstUnsentSequenceNumber` (optional store capabilities, detected with `as`); - new `EventQueueHandler` constructor overloads. ### Known open items (not in this PR) - **Part 4 §5.13.2.1:** CreateMonitoredItems should create an item even when the user lacks Read permission and report the error in Publish. This is not done: the create-time RolePermissions check is the only role check on the initial value, so following the spec would leak values until every node manager re-checks per sample. - **Sampling loop:** it runs on thread-pool threads; node managers with blocking synchronous reads should not use sampling groups heavily. The alternative, a dedicated thread per group, was the thread-exhaustion issue fixed here. - **Restarts:** a pending QueueSize-1 value and a pending overflow flag are not kept across a restart; that needs persistence interface extensions. - **Limit defaults:** decide whether the new limits should get finite defaults (currently 0 = unlimited). ## Related Issues - No tracking issue; the findings come from an internal audit of the server subscription code. ## Checklist - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [ ] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. ### Local verification (net10.0, all green) | Suite | Passed | Failed | |---|---|---| | Opc.Ua.Server.Tests | 7963 | 0 | | Opc.Ua.Subscriptions.Tests | 643 | 0 | | Opc.Ua.Subscriptions.Classic.Tests | 57 | 0 | | Opc.Ua.Subscriptions.Durable.Tests | 36 | 0 | | Opc.Ua.Redundancy.Server.Tests | 847 | 0 | | Opc.Ua.Core.Tests (filter tests) | 283 | 0 | net48 build of `Opc.Ua.Server.Tests`: 0 errors. net48 tests and the full UA.slnx were not run locally. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
41 KiB
Sessions, GDS Client, and Subscriptions
When to read this: Read this for the move from caller-driven
Sessionreconnect toManagedSession, the V2 subscription engine, the GDS-clientTask->ValueTaskAPI modernisation, removed obsolete GDS APIs, durable subscriptions, removedReverseConnectClientCollection, and transport abstraction changes.
Contents
- GDS Client API modernization
- ManagedSession and Automatic Reconnection
- Server Session Activation and Subscription Transfer
- Subscriptions and Transports
- Durable subscriptions and reshaped Subscription tree
- Request completion is owned by
OperationContext Opc.Ua.Server.ISession:IsClosingandInvalidateContinuationPointsOpc.Ua.Server.ISubscription: the publish pipeline is server-internalEventFilter.Validateaccepts partially valid select clauses- PubSub
- Reverse connect
IMessageSocketabstraction removed- Transport binding registry —
TransportBindingsstatic API removed
GDS Client API modernization
The Opc.Ua.Gds.Client package has undergone a significant cleanup. Two breaking changes affect almost every consumer of the GDS / LDS / Server-Push client APIs.
Task → ValueTask on GDS client interfaces
Breaking Change: All asynchronous methods on IGlobalDiscoveryServerClient, ILocalDiscoveryServerClient, and IServerPushConfigurationClient (and their concrete implementations) now return ValueTask / ValueTask<T> instead of Task / Task<T>.
Rationale: Many GDS operations complete synchronously when a session is already established. Returning ValueTask avoids the per-call Task allocation on those fast paths and keeps the surface consistent with the rest of the modernized client stack.
Impact: Pure await callers require no change — await works identically on Task and ValueTask. However, two patterns require a small adjustment.
| Pattern | Old (Task) |
New (ValueTask) |
|---|---|---|
await on the return value |
works | works (no change) |
Block synchronously via .Result / .Wait() |
works | use .AsTask().Result / .AsTask().Wait() |
Combine results with Task.WhenAll / Task.WhenAny |
works | call .AsTask() first |
| Await the same return value more than once | works | not supported — call .AsTask() first |
Important
: A
ValueTaskmay be awaited only once and the underlying value source must not be observed after the operation has completed. If you need to await a result more than once, fan it out across multiple consumers, or pass it to anything other than a singleawait, materialize it via.AsTask()first.
// Before
Task<NodeId> registration = gds.RegisterApplicationAsync(application, ct);
NodeId id = await registration;
await Task.WhenAll(registration, otherTask); // worked
// After
ValueTask<NodeId> registration = gds.RegisterApplicationAsync(application, ct);
NodeId id = await registration; // unchanged
// Multi-await / Task.WhenAll: materialize first
Task<NodeId> asTask = gds.RegisterApplicationAsync(application, ct).AsTask();
await Task.WhenAll(asTask, otherTask);
Removal of obsolete GDS APIs
Breaking Change: All [Obsolete] synchronous wrappers, APM (Begin*/End*) methods, and other deprecated members have been removed from the GDS client surface.
Affected APIs (non-exhaustive):
- All synchronous wrappers on
GlobalDiscoveryServerClient(~25 methods such asFindApplication,RegisterApplication,StartNewKeyPairRequest, …) — use the corresponding*Asyncoverload returningValueTask/ValueTask<T>. - All synchronous wrappers on
ServerPushConfigurationClient(~14 methods such asUpdateCertificate,ReadTrustList,ApplyChanges, …) — use the*Asyncoverload. - APM (
Begin*/End*) overloads onLocalDiscoveryServerClient(e.g.BeginFindServers/EndFindServers) — use the*Asyncoverload. - The capability identifier constants are now source-generated as
Opc.Ua.ServerCapability(singular, e.g.ServerCapability.GDS,ServerCapability.LDS,ServerCapability.DA). The[Obsolete] public const stringshims previously exposed on the value-typeServerCapabilityclass (nowServerCapabilityInfoinOpc.Ua.Gds.Client) have been removed. The runtimeServerCapabilities.csvparsing path (which never actually loaded — the resource was not embedded) has been replaced by the generated dictionaryServerCapability.All. The instance enumerable previously namedServerCapabilityCatalogis nowOpc.Ua.Gds.Client.ServerCapabilitiesand itsFindreturnsServerCapabilityInfo. RegisteredApplicationis now asealed record; the obsolete extension methods that wrapped its property access have been removed — use the record properties directly.CertificateWrapperis nowsealedand no longer implementsIEncodeable; remove any code that treated it as an encodeable.
Migration:
The ServerCapability identifiers are source-generated from tools/Opc.Ua.SourceGeneration.Core/Design/ServerCapabilities.csv; each capability emits a public const string field. The instance type carrying Id / Description is ServerCapabilityInfo, and the registry exposing IEnumerable<ServerCapabilityInfo> plus Find(string?) : ServerCapabilityInfo? is the static ServerCapabilities class in Opc.Ua.Gds.Client.
// Before
var apps = gds.FindApplication(uri); // sync wrapper
var caps = ServerCapability.GlobalDiscoveryServer; // obsolete shim
// After
var apps = await gds.FindApplicationAsync(uri, ct);
string id = ServerCapability.GDS; // const string "GDS"
ServerCapabilityInfo? info = ServerCapabilities.Find(id); // null if not registered
If you currently rely on a [Obsolete] member, switch to the Async equivalent and apply the ValueTask migration notes above. If a particular API has no direct replacement, the migration is described inline in the XML doc comment of the replacement member.
ManagedSession and Automatic Reconnection
Version 2.0 introduces ManagedSession, a wrapper around Session that automatically handles connection lifecycle including reconnection and server redundancy failover.
Key Changes:
ManagedSessionFactoryis a new factory that createsManagedSessioninstances which handle reconnection and failover automatically. Use this when you want managed-session behavior.DefaultSessionFactoryis unchanged — it continues to create rawSessioninstances. Existing code that constructsDefaultSessionFactorydirectly keeps the same behavior in 2.0.SessionReconnectHandleris retained as a supported legacy entry point for callers that already manage rawSessioninstances. The type itself is not removed. Its parameterless legacy constructor remains marked[Obsolete("Use SessionReconnectHandler(ITelemetryContext, bool, int) instead.")]in 2.0 (the same attribute was already present in 1.5.378); pass anITelemetryContextto the new ctor when adopting it. It now also requires the wrappedISessionto be aSession(or a derived type) — passing aManagedSession(or any otherISessionfacade) throwsNotSupportedException, since those facades drive their own reconnect / failover state machine. New code should still preferManagedSessionFactory/ManagedSession.CreateAsync.
For a deeper architectural picture of how Session, ManagedSession, SessionReconnectHandler, and the subscription engines fit together, see Sessions, Reconnection, and Subscription Engines.
Migration:
If you use DefaultSessionFactory:
No code changes are required — DefaultSessionFactory still returns raw Session. To opt into automatic reconnection and redundancy failover, switch to ManagedSessionFactory:
// Still supported in 2.0 — DefaultSessionFactory creates raw Session:
var defaultFactory = new DefaultSessionFactory(telemetry);
ISession rawSession = await defaultFactory.CreateAsync(...);
// Opt in to managed reconnect/failover — ManagedSessionFactory creates ManagedSession:
var managedFactory = new ManagedSessionFactory(telemetry);
ISession managedSession = await managedFactory.CreateAsync(...);
Both factories implement ISessionFactory. ManagedSessionFactory internally uses a DefaultSessionFactory to create the raw Session and then wraps it in a ManagedSession; the public surface is unchanged.
If you implement ISessionFactory yourself:
The interface gained two members. SubscriptionEngineFactory returns the engine your sessions use (null for the classic engine), and WithSubscriptionEngine(engineFactory, timeProvider) returns a factory that is identical except for that engine and, when given, the time provider. ManagedSession calls it when your factory has no engine configured, so return a copy rather than modifying the instance. DefaultSessionFactory.SubscriptionEngineFactory is init-only again; use WithSubscriptionEngine to change it after construction. A Moq mock of ISessionFactory passed to ManagedSession must set up either member, since a loose mock returns null from both.
If you use SessionReconnectHandler:
SessionReconnectHandler continues to work in 2.0 against Session instances. The pattern below is unchanged, but the legacy parameterless ctor remains [Obsolete] - prefer the (ITelemetryContext, bool, int) overload:
ISession session = await new DefaultSessionFactory(telemetry).CreateAsync(...);
using var reconnectHandler = new SessionReconnectHandler(telemetry);
session.KeepAlive += (s, e) =>
{
if (e.Status != null && ServiceResult.IsNotGood(e.Status))
{
reconnectHandler.BeginReconnect(session, 1000, OnReconnectComplete);
}
};
SessionReconnectHandler.BeginReconnect only supports the legacy Session class (or types derived from it). Passing a ManagedSession throws NotSupportedException. If you have already migrated to ManagedSession, do not wrap it with a SessionReconnectHandler — ManagedSession already runs its own reconnect state machine. Use the StateChanged event to observe transitions:
ISession session = await ManagedSession.CreateAsync(
configuration, endpoint,
reconnectPolicy: new ReconnectPolicy
{
Strategy = BackoffStrategy.Exponential,
InitialDelay = TimeSpan.FromSeconds(1),
MaxDelay = TimeSpan.FromSeconds(30)
});
// Reconnection is automatic — no manual handler needed
((ManagedSession)session).StateMachine.StateChanged += (s, e) =>
{
Console.WriteLine($"Session state: {e.NewState}");
};
Or, equivalently, via the factory:
var factory = new ManagedSessionFactory(telemetry);
ISession session = await factory.CreateAsync(...);
Configuring Reconnection Policy
Two related types ship side-by-side and are not interchangeable. ReconnectPolicyOptions is a public sealed record with init-only properties - the DTO consumed by dependency injection / ManagedSessionOptions. ReconnectPolicy is a public class (implementing IReconnectPolicy) - the runtime policy passed to ManagedSession.CreateAsync and SessionReconnectHandler. Construct the runtime policy from the options snapshot with new ReconnectPolicy(options); ManagedSessionBuilder.ConnectAsync performs this conversion internally.
var policy = new ReconnectPolicy
{
Strategy = BackoffStrategy.Exponential, // or Linear, Constant
InitialDelay = TimeSpan.FromSeconds(1),
MaxDelay = TimeSpan.FromSeconds(30),
MaxRetries = 0, // 0 = unlimited
JitterFactor = 0.1 // ±10% jitter
};
Server Redundancy
ManagedSession automatically reads server redundancy information and can failover to backup servers:
var session = await ManagedSession.CreateAsync(
configuration, endpoint,
redundancyHandler: new DefaultServerRedundancyHandler());
Service Call Behavior During Reconnect
When the session is reconnecting, service calls (Read, Write, Browse, etc.) automatically wait until the session is reconnected. This is transparent to the caller — no special handling needed. If reconnection fails permanently, calls will throw ServiceResultException.
Fluent Builder, V2 Subscriptions, and Dependency Injection
Version 2.0 introduces a fluent builder for ManagedSession, exposes the new options-based subscription API on the managed session, and adds Microsoft.Extensions.DependencyInjection integration for Azure / ASP.NET Core / generic-host scenarios.
Fluent builder:
ManagedSession session = await new ManagedSessionBuilder(configuration, telemetry)
.UseEndpoint(endpoint)
.WithSessionName("MyClient")
.WithSessionTimeout(TimeSpan.FromSeconds(60))
.WithReconnectPolicy(p => p with
{
Strategy = BackoffStrategy.Exponential,
InitialDelay = TimeSpan.FromSeconds(1),
MaxDelay = TimeSpan.FromSeconds(30)
})
.WithServerRedundancy()
.ConnectAsync(ct);
Build() returns an immutable ManagedSessionOptions snapshot; ConnectAsync() wraps Build() and ManagedSession.CreateAsync(...) so most callers can use the builder directly.
New subscription API on ISession:
The V2 options-based subscription manager (ISubscriptionManager) is exposed on ISession through bool TryGetSubscriptionManager(out ISubscriptionManager? manager) — it returns true and the manager for V2-engine sessions (the default for ManagedSession) and false for classic-engine sessions. The classic Subscriptions property remains available alongside it. Use UseSubscriptionEngine(ClassicSubscriptionEngineFactory.Instance) on the builder if you need the legacy classic engine instead.
Source-breaking (2.0 preview): the earlier throwing
ManagedSession.SubscriptionManagerproperty has been removed in favor ofISession.TryGetSubscriptionManager. Replacevar manager = session.SubscriptionManager;(which threwInvalidOperationExceptionon classic-engine sessions) with:
if (session.TryGetSubscriptionManager(out ISubscriptionManager? manager)) { // use manager (V2 engine) }
The
session.AddSubscription(...)fluent extensions are unchanged.
using Opc.Ua.Client;
using Opc.Ua.Client.Subscriptions;
var handler = new MyNotificationHandler(); // : ISubscriptionNotificationHandler
ISubscription subscription = session.AddSubscription(handler,
new SubscriptionOptions
{
PublishingInterval = TimeSpan.FromMilliseconds(500),
KeepAliveCount = 10,
LifetimeCount = 100
});
subscription.TryAddMonitoredItem(
"ServerStatus_CurrentTime",
VariableIds.Server_ServerStatus_CurrentTime,
o => o with
{
SamplingInterval = TimeSpan.FromMilliseconds(250),
QueueSize = 10
},
out IMonitoredItem _);
The SubscriptionOptions and MonitoredItemOptions records used by this API live in Opc.Ua.Client.Subscriptions and Opc.Ua.Client.Subscriptions.MonitoredItems. They are distinct from the classic types of the same names in the Opc.Ua.Client namespace; use namespace aliases (or fully-qualified names) when both are visible in the same file. Both records ship in the same assembly (Opc.Ua.Client.dll), so a using-alias is sufficient - extern alias is not required:
using ClassicSubscriptionOptions = Opc.Ua.Client.SubscriptionOptions;
using V2SubscriptionOptions = Opc.Ua.Client.Subscriptions.SubscriptionOptions;
The classic ManagedSession.Subscriptions collection (V1 Subscription objects) remains supported. Mixing classic subscriptions with the V2 manager on the same session is allowed for the time being, but this will change in future releases; classic subscriptions still receive notifications via the internal SubscriptionBridge when the V2 engine is active.
Opt-in V2 notification pooling (WithPoolNotifications):
The V2 subscription engine supports activator-level pooling of decoded
notification payload instances (DataChangeNotification,
MonitoredItemNotification, EventNotificationList, EventFieldList) to
reduce GC pressure on high-throughput publish loops. Pooling is opt-in
and disabled by default. Enable it on the builder, in
ManagedSessionOptions, or directly on the V2 manager:
ManagedSession session = await new ManagedSessionBuilder(configuration, telemetry)
.UseEndpoint(endpoint)
.WithPoolNotifications() // opt in
.ConnectAsync(ct);
When pooling is enabled, the V2 dispatcher walks each decoded notification
after the handler await completes and calls
IPooledEncodeable.Reuse() on every payload item, returning instances to
their static activator pools. The recorded benchmarks show ~315× fewer
allocations per MonitoredItemNotification and a corresponding drop in
gen-0 GC pressure
(see docs/Benchmarks.md → Pooled encodeable).
Handler contract change (only when WithPoolNotifications is enabled):
Handlers must not retain references to notification objects past the
await of the dispatch call. The pool may re-rent those instances to the
next publish immediately after Reuse() runs. Handlers that need to keep
values must copy them out of the dispatched struct before returning.
The DataValueChange / EventNotification projection structs are
designed not to surface pooled instances directly — copy-by-value of the
struct itself is safe and is the recommended pattern. See
docs/Sessions.md for full
detail and a code example.
// UNSAFE - captures a pooled instance across await
handler.OnDataChange = async (notif, ct) =>
{
log.Add(notif); // notif may be re-rented on the next publish
await Task.Yield();
};
// SAFE - value-copy the projection struct before suspending
handler.OnDataChange = async (notif, ct) =>
{
var snapshot = notif;
log.Add(snapshot);
await Task.Yield();
};
This affects only the V2 engine; the classic subscription engine is
unaffected. There is no breaking change to IEncodeable,
IDecoder, IServiceMessageContext, or
ISubscriptionNotificationHandler — pooling is opt-in via the new
IPooledEncodeable sub-interface, which only the source-generated
publish-payload types implement today.
Dependency Injection:
services.AddOpcUa().AddClient(...) registers a ManagedSession factory delegate that lazily connects on first use:
using Microsoft.Extensions.DependencyInjection;
using Opc.Ua.Client;
services.AddOpcUa().AddClient(opt =>
{
opt.Configuration = applicationConfiguration;
opt.Session = new ManagedSessionOptions
{
Endpoint = endpoint,
ReconnectPolicy = new ReconnectPolicyOptions
{
Strategy = BackoffStrategy.Exponential
}
};
});
// Resolve and connect on first use:
var sessionFactory = serviceProvider
.GetRequiredService<Func<CancellationToken, Task<ManagedSession>>>();
ManagedSession session = await sessionFactory(ct);
The factory caches the connected session — subsequent awaits return the same instance. The registered delegate type is Func<CancellationToken, Task<ManagedSession>> (the OPC UA client APIs use Task here, not ValueTask), so resolving it from dependency injection and await-ing the result returns the connected ManagedSession. The dependency injection registration also exposes ITelemetryContext, ISessionFactory (a DefaultSessionFactory configured with the V2 engine), ManagedSessionFactory, and the top-level OpcUaClientOptions.
This iteration uses single-instance options (no named/keyed registrations); the underlying V2 manager consumes options via IOptionsMonitor<T> unfiltered. For one-off use, the AddSubscription/TryAddMonitoredItem extensions adapt plain options snapshots into the required IOptionsMonitor<T> automatically. Named-options dependency injection is deferred to a future iteration.
Server Session Activation and Subscription Transfer
ISession.ValidateBeforeActivateAsync(...) is now the direct activation-validation contract. Custom server ISession implementations must validate the application signature and user identity token asynchronously and return the validated token handler and matching UserTokenPolicy. The synchronous ValidateBeforeActivate(...) member remains available but is obsolete; migrate callers and implementations to the asynchronous member.
ISubscription now exposes IsTransferIdentityCompatible(ISession targetSession). Custom server subscription implementations must compare the authenticated owner of the target Session with the identity that owns the subscription and return false when the transfer would cross ClientUserIds.
Identity continuity across a SecureChannel transfer or a Subscription transfer is compared using an encoded continuity key rather than the human-readable ClientUserId that OPC 10000-5 reports through SessionSecurityDiagnostics. The key includes the UserTokenType and delimits the issuer from the subject, so a UserNameIdentityToken whose user name equals an X.509 certificate subject — or two issued tokens whose issuer and subject concatenate to the same string — are no longer treated as the same owner. Transfers that previously succeeded only because of such a collision are now rejected with Bad_IdentityChangeNotSupported (activation) or Bad_UserAccessDenied (subscription transfer). The reported ClientUserIdOfSession diagnostic value is unchanged.
SessionManager.OnSessionActivatedAsync takes an additional long activationSequence parameter. The value increases with every successful activation of a Session and is stamped while the per-Session activation gate is still held. Because the callback itself runs after that gate is released, implementations that persist activation state outside the gate must use the sequence to discard writes that a newer concurrent activation has already superseded.
Subscriptions and Transports
Durable subscriptions and reshaped Subscription tree
Source-breaking. Durable subscription support reshapes the subscription tree on both the client and the server. On the client side, the new public surface in src/Opc.Ua.Client/Subscription/ includes ISubscription, ISubscriptionManager, SubscriptionOptions, and MonitoredItemOptions - these are the V2 options-based shapes; the classic Opc.Ua.Client.Subscription continues to ship alongside them. On the server side, the new public surface in src/Opc.Ua.Server/Subscription/... includes DataChangeMonitoredItemQueue, EventMonitoredItemQueue, IDataChangeMonitoredItemQueue, IMonitoredItemQueueFactory, ISubscriptionStore, IStoredSubscription, StoredSubscription, and StoredMonitoredItem.
Consumers adopting the new shape may need to add a using Opc.Ua.Client.Subscriptions; import alongside the existing using Opc.Ua.Client;. Because the V2 records share their type names with the classic records, namespace aliases are required when both are visible in the same file - see Fluent Builder, V2 Subscriptions, and Dependency Injection for the canonical alias snippet.
The server-side ISubscriptionStore definition-persistence methods are asynchronous: StoreSubscriptions is now ValueTask<bool> StoreSubscriptionsAsync(IEnumerable<IStoredSubscription>, CancellationToken), RestoreSubscriptions is now ValueTask<RestoreSubscriptionResult> RestoreSubscriptionsAsync(CancellationToken), and OnSubscriptionRestoreComplete is now ValueTask OnSubscriptionRestoreCompleteAsync(Dictionary<uint, ArrayOf<uint>>, CancellationToken). This lets subscription definitions be persisted to an async network backend without a sync-over-async wrapper. Custom ISubscriptionStore implementations must adopt the async signatures; a synchronous body can simply return new ValueTask<T>(result).
ISubscriptionStore also adds asynchronous per-monitored-item queue-restore hooks - ValueTask<IDataChangeMonitoredItemQueue?> RestoreDataChangeMonitoredItemQueueAsync(uint, CancellationToken) and ValueTask<IEventMonitoredItemQueue?> RestoreEventMonitoredItemQueueAsync(uint, CancellationToken) - so a networked store can re-hydrate a monitored-item queue without blocking. The master node manager pre-fetches these queues asynchronously at the start of subscription restore and hands the pre-hydrated queue to the (still synchronous) MonitoredItem constructor via new runtime-only IStoredMonitoredItem.RestoredDataChangeQueue/RestoredEventQueue properties. Custom ISubscriptionStore implementations must add the two async members; a store that keeps queues locally can simply delegate to the existing synchronous RestoreDataChangeMonitoredItemQueue/RestoreEventMonitoredItemQueue, which remain as the fallback used when no pre-hydrated queue is supplied. Custom IStoredMonitoredItem implementations must add the two new properties (the built-in StoredMonitoredItem already does). The high-availability SharedKeyValueMonitoredItemQueueFactory uses these hooks to restore mirrored queue contents on failover (see High Availability).
Request completion is owned by OperationContext
Source-breaking for custom servers. OperationContext is now IDisposable and owns the scope
that tracks the request while it executes. Service handlers obtain it with
using OperationContext context = await ValidateRequestAsync(...), and disposing it reports the
request as completed. RequestManager.RequestCompleted(OperationContext) is [Obsolete]: a context
that is disposed completes its own request, and completing it twice is a no-op. Code that creates an
OperationContext directly must dispose it; leaving one undisposed keeps the request registered and
blocks NodeManager lifecycle operations until they time out.
StandardServer.ValidateRequestAsync is no longer virtual. A subclass that rejected a request
after calling base left it registered forever, because the caller never received the context and
nothing disposed it. Override protected virtual ValueTask OnRequestValidatedAsync(OperationContext)
instead; throwing from it rejects the request and completes it.
Opc.Ua.Server.ISession: IsClosing and InvalidateContinuationPoints
Source-breaking for custom implementations. This is the server-side Opc.Ua.Server.ISession,
implemented by the server's Session — not the client-side Opc.Ua.Client.ISession discussed above.
It gains bool IsClosing and void InvalidateContinuationPoints(IAsyncNodeManager).
IsClosing reports that the Session is being torn down, so work that would create new state for it
is rejected instead of started. It is one way: a Session that started closing never serves new work
again, even if the close itself fails. InvalidateContinuationPoints drops the saved Browse and
history continuation points that would otherwise keep a retired NodeManager reachable.
Custom implementations must add both. Returning false from IsClosing and doing nothing in
InvalidateContinuationPoints preserves the previous behaviour; the built-in server Session
already implements them.
Opc.Ua.Server.ISubscription: the publish pipeline is server-internal
Source-breaking for custom implementations and for callers. This is the server-side
Opc.Ua.Server.ISubscription — not the client-side Opc.Ua.Client.Subscriptions.ISubscription
introduced by the V2 subscription shape above. Twelve members leave the interface
(analyzer UA0030):
ItemReadyToPublishandItemNotificationsAvailableare deleted. Their implementation bodies had been commented out since 1.5.x, so every call was a no-op — delete the calls and the implementations.- The parameterless obsolete
SessionClosed()is deleted; session teardown goes throughISubscriptionManager.SessionClosingAsync. TransferSessionAsyncis deleted; the server transfers subscriptions through its internal claim/prepare/commit protocol, reached via theTransferSubscriptionsservice (ISubscriptionManager.TransferSubscriptionsAsync).PublishTimerExpired,Acknowledge,PublishTimeout,SubscriptionTransferred,AvailableSequenceNumbersForRetransmission,QueueOverflowHandler,SessionClosed(ISession)andPublishmove to an internal contract implemented only by the built-inSubscription. They mutate the publishing state machine and are called only bySubscriptionManagerand the (now likewise internal)SessionPublishQueue; on the public interface, any holder of anISubscriptioncould call them and corrupt publishing.
Custom subscription implementations must derive from Subscription. A
SubscriptionManager.CreateSubscription override that returns an ISubscription implementation
not derived from Subscription previously worked partially (the pipeline drove it through the
public members, but transfer already required the concrete type); it now fails at creation with
Bad_InternalError. Derive from Subscription instead — the interface members that remain
(ResendData, GetMonitoredItems, the monitored-item operations, Modify,
SetPublishingMode, Republish, IsTransferIdentityCompatible and the durable members)
keep their meaning, and the pipeline members are explicit implementations the runtime drives
for you. Implementations that
had added SessionClosed(ISession) per the earlier 2.0 guidance delete it together with the
rest of the pipeline members.
SessionPublishQueue is internal for the same reason (SentMessageQueue already was): the
queue is the other half of the publish protocol. Code that constructed or drove one should use
the Publish service path via ISubscriptionManager.PublishAsync.
For the public operations that remain, obtain a subscription with
ISubscriptionManager.TryGetSubscription before calling ResendData,
GetMonitoredItems, or the monitored-item operations. Acknowledgements belong
in the Publish request, not in direct calls into the subscription's internal
publishing state machine.
EventFilter.Validate accepts partially valid select clauses
Behaviour change. EventFilter.Validate(IFilterContext) no longer sets Result.Status to
Bad_EventFilterInvalid when only some select clauses are invalid. Following Part 4 §7.22.3, the
server creates (or modifies) an event monitored item as long as at least one select clause is
valid: each rejected clause is reported in EventFilterResult.SelectClauseResults and its event
field is returned as null. Result.Status stays Bad when there is no select clause, no valid
select clause, or an invalid where clause.
The new EventFilter.Result.HasSelectClauseErrors reports whether any select clause was rejected.
Code that validates an EventFilter itself and cannot return per-clause results (a custom
HistoryRead or HistoryUpdate of events, an event store, a custom node manager) must check it
alongside the status to keep the 1.5.x behaviour:
EventFilter.Result result = filter.Validate(filterContext);
if (ServiceResult.IsBad(result.Status) || result.HasSelectClauseErrors)
{
return StatusCodes.BadEventFilterInvalid;
}
The built-in CustomNodeManager2, AsyncCustomNodeManager and the historian event update
validator already do this, so HistoryRead and HistoryUpdate of events still reject such filters.
PubSub
PubSub has source-breaking package and API changes. See the PubSub migration guide for removed application/connection types, builder replacements, JSON encoding changes, and wire-compatibility limits. Dependency changes are maintained in the package guide.
Reverse connect
ReverseConnectManager, ReverseConnectProperty, and ReverseConnectServer retain the same public shape in 2.0. The ReverseConnectClientCollection wrapper is removed, which is source-breaking for callers using that collection. See Configuration collection types removed.
IMessageSocket abstraction removed
The runtime transport boundary moved from IMessageSocket to the new public IUaSCByteTransport (Opc.Ua.Bindings). This let us share one UASC pipeline across raw TCP and WebSocket connections and let JSON profiles bypass UASC entirely. As part of this change the entire IMessageSocket family was removed from the public API surface — this is a breaking change versus 1.5.378.
| Removed type | Replacement |
|---|---|
IMessageSocket, IMessageSocketAsyncEventArgs, IMessageSink, IMessageSocketChannel |
IUaSCByteTransport (chunk-level send / receive); typical consumers use ITransportChannel instead |
IMessageSocketFactory |
IUaSCByteTransportFactory |
MessageSocketExtensions (e.g. BeginConnect) |
IUaSCByteTransport.ConnectAsync |
TcpMessageSocket, TcpMessageSocketFactory, TcpMessageSocketAsyncEventArgs |
TcpByteTransport (sealed; TcpByteTransportFactory for client-side construction). The public TcpTransportChannel / TcpTransportChannelFactory shapes are unchanged. |
UaSCUaBinaryTransportChannel.Socket (IMessageSocket?) |
UaSCUaBinaryTransportChannel.Transport (IUaSCByteTransport?) |
UaSCUaBinaryClientChannel(..., IMessageSocketFactory, ...) ctor |
UaSCUaBinaryClientChannel(..., IUaSCByteTransportFactory, ...) ctor |
ITcpChannelListener.ReconnectToExistingChannel(IMessageSocket, ...) |
ITcpChannelListener.ReconnectToExistingChannel(IUaSCByteTransport, ...) |
If you previously implemented a custom IMessageSocket (rare in practice — almost no consumer subclasses TcpMessageSocket): the recommended migration path is to implement IUaSCByteTransport directly. See docs/Transports.md § "Implementing a custom byte transport" for the contract, an implementation checklist, and a worked example (the public InProcessTransport reference implementation that consumes only the public surface). The new abstraction is chunk-oriented (one Send / Receive per UASC MessageChunk) and exposes only ValueTask-based async; it is intentionally narrower than the old SAEA-based IMessageSocket and most legacy implementations collapse to ~150 lines.
Transport binding registry — TransportBindings static API removed
Source-breaking. The process-wide TransportBindings static class and the Utils.DefaultBindings reflection-based assembly auto-load helper have been removed. The new ITransportBindingRegistry interface (in Opc.Ua.Bindings) is the only public registry surface; it resolves out of the host's IServiceProvider so two hosts (e.g. parallel test fixtures or multi-tenant applications) can install different factories without racing on shared global state.
| Removed | Replacement |
|---|---|
TransportBindings.Channels (static) |
ITransportBindingRegistry (resolved from DI or constructed via DefaultTransportBindingRegistry.WithDefaultTcp()) |
TransportBindings.Listeners (static) |
Same ITransportBindingRegistry — listeners and channels share one keyed registry per scheme |
TransportBindings.Channels.SetBinding(...) |
ITransportBindingRegistry.RegisterChannelFactory(...) or the DI extension AddCustomTransport<,>() |
TransportBindings.Listeners.SetBinding(...) |
ITransportBindingRegistry.RegisterListenerFactory(...) or the DI extension AddKestrelOpcTcpTransport() |
TransportBindings.Channels.GetBinding(scheme, telemetry) |
ITransportBindingRegistry.GetChannelFactory(scheme) |
TransportBindings.Listeners.GetBinding(scheme, telemetry) |
ITransportBindingRegistry.GetListenerFactory(scheme) |
TransportBindings.AddBindings(assembly) (reflection) |
Explicit DI registration via AddOpcTcpTransport() / AddHttpsTransport() / AddWssTransport() / AddKestrelOpcTcpTransport() / AddCustomTransport<,>() |
Utils.DefaultBindings dictionary |
Removed; no replacement needed |
ITransportBindings<T> interface |
Removed (folded into ITransportBindingRegistry per-facet methods) |
TransportBindingsBase (reflection helper) |
Removed |
Microsoft.Extensions.DependencyInjection consumers (recommended path):
// Before (1.5.378):
// TransportBindings.Listeners.SetBinding(new KestrelTcpTransportListenerFactory());
// After (2.0):
services
.AddOpcUa()
.AddOpcTcpTransport() // raw-socket opc.tcp default
.AddHttpsTransport() // HTTPS + HTTPS-JSON
.AddWssTransport() // WSS + WSS-JSON
.AddKestrelOpcTcpTransport(); // override opc.tcp with Kestrel (last-writer-wins)
AddHttpsTransport(),AddWssTransport(), andAddKestrelOpcTcpTransport()all ship in theOPCFoundation.NetStandard.Opc.Ua.Bindings.Httpspackage. Reference that package directly; Server and GDS packages do not pull the optional binding transitively. The Kestrel-hostedopc.tcp://listener (AddKestrelOpcTcpTransport()) is available onnet8.0+ only; on the .NET Framework targets keep the default raw-socketopc.tcplistener that ships inOpc.Ua.Core.
Every Add*Transport() extension installs an ITransportBindingConfigurator instance into the IServiceCollection. The DefaultTransportBindingRegistry singleton runs every registered configurator in registration order at first resolution time, so the last registration for a given URI scheme wins — exactly the same semantics SetBinding had, but scoped per IServiceProvider.
Non-DI consumers:
// Before (1.5.378):
// TransportBindings.Listeners.SetBinding(new KestrelTcpTransportListenerFactory());
// var server = new MyServer();
// server.Start(config); // pre-2.0 sync path
// After (2.0):
DefaultTransportBindingRegistry registry = DefaultTransportBindingRegistry.WithDefaultTcp();
registry.RegisterListenerFactory(new KestrelTcpTransportListenerFactory());
var server = new MyServer(telemetry);
server.TransportBindings = registry; // public setter; rejected after StartAsync
await server.StartAsync(config, ct);
ServerBase exposes a new constructor overload (ServerBase(ITelemetryContext, ITransportBindingRegistry?)) and a publicly-settable TransportBindings property (with a started-server guard). Non-DI callers that pass nothing get a DefaultTransportBindingRegistry.WithDefaultTcp() on first use — exactly the raw-socket TCP listener the 1.5.378 stack defaulted to.
Custom transport authors:
// Before (1.5.378):
// TransportBindings.Listeners.SetBinding(new MyCustomListenerFactory());
// TransportBindings.Channels.SetBinding(new MyCustomChannelFactory());
// After (2.0):
services
.AddOpcUa()
.AddCustomTransport<MyCustomListenerFactory, MyCustomChannelFactory>();
The DI extension resolves both factory types out of the container (so they may have constructor-injected dependencies), and both are registered into the registry under the UriScheme exposed by MyCustomListenerFactory.
See also
- Related: certificates.md, identity.md, node-states.md.
- 2.0 migration index — analyzer quick-start + symptom → sub-doc table.
- Migration Guide — landing page across versions.