Sessions, Reconnection, and Subscription Engines
July 7, 2026 · View on GitHub
This document describes how the OPC UA client stack composes the building
blocks that turn a configured endpoint into a running, fault-tolerant OPC
UA session: the raw Session and its reconnect helper, the
ManagedSession facade, the pluggable subscription engines, and how to
choose between them.
Quick reference
| Type | Creates | Reconnect | Subscription engine | Recommended for |
|---|---|---|---|---|
Session | itself (via constructor or Session.Create...Async) | none — caller drives via Session.ReconnectAsync / SessionReconnectHandler | ClassicSubscriptionEngine by default; pass ISubscriptionEngineFactory to opt in to V2 | callers that own session lifecycle and existing reconnect code |
DefaultSessionFactory | raw Session | none — caller drives reconnect | configurable via SubscriptionEngineFactory init property | drop-in ISessionFactory for the legacy flow |
SessionReconnectHandler | nothing (drives an existing session) | yes, against a Session (only) | inherited from the wrapped Session | legacy callers that already own a Session |
ManagedSession | itself (via CreateAsync) | yes — built-in ConnectionStateMachine + ReconnectPolicy | DefaultSubscriptionEngine (V2) by default | new code; long-lived clients that need reconnect / failover |
ManagedSessionFactory | ManagedSession | inherited from ManagedSession | inherited from ManagedSession | drop-in ISessionFactory that yields managed sessions |
ChannelManagerSessionFactory | raw Session | channel-level reconnect through IClientChannelManager | classic engine | raw sessions that share channels and coordinate transport reconnect centrally |
ManagedSessionBuilder | ManagedSession | inherited from ManagedSession | configurable via UseSubscriptionEngine | fluent / DI scenarios |
IClientChannelManager | IManagedTransportChannel leases (shared) | channel-level reconnect, coalesced and notified to participants | n/a (channel-only) | central transport-channel sharing & reconnect for sessions / discovery clients |
Everything below explains how these pieces fit together.
1. Session — the OPC UA session primitive
Session (Libraries/Opc.Ua.Client/Session/Session.cs) is the lowest-level
client object that maps directly to a UA secure-channel + session pair on
the server. It implements ISession and exposes the full surface of the
OPC UA service set (Read, Write, Browse, Call, AddNodes, etc.) plus
session-level concerns: keep-alive, namespace tables, the type tree, the
node cache, and a publish pipeline.
A Session is bound to:
- An
ApplicationConfiguration(security policies, certificates, transport quotas, telemetry). - A
ConfiguredEndpoint(URL, security mode, security policy URI). - An
ITransportChannelcreated against that endpoint.
Lifecycle transitions on a Session are explicit and synchronous-looking
from the caller's perspective:
Session.ReconnectAsync()re-uses the existing channel where possible and re-establishes the activated session.ISessionFactory.RecreateAsync(...)builds a freshSessionthat inherits the previous session's identity, subscriptions, and node cache, swapping in a new channel.
Session does not run a background reconnect state machine. If the
keep-alive callback raises a bad status, the caller decides whether to
reconnect, recreate, or abandon the session.
Operation limits and MaxArrayLength
Session.FetchOperationLimitsAsync() reads the server's OperationLimits
(MaxNodesPerRead, MaxNodesPerBrowse, MaxMonitoredItemsPerCall, ...) along
with the MaxArrayLength server capability. Each operation limit governs the
length of the operations array of its corresponding request, and the server
also rejects any request whose array is longer than MaxArrayLength. The client
therefore caps every operation limit to the effective MaxArrayLength, which
is min(server MaxArrayLength, client TransportQuotas.MaxArrayLength) where 0
means no limit (a 0 side does not constrain). Each server-reported operation
limit is then adjusted against that effective MaxArrayLength:
- if the server reports
0(unlimited), the operation limit becomes the effectiveMaxArrayLength; - if the server reports a value larger than the effective
MaxArrayLength, the operation limit becomes the effectiveMaxArrayLength; - otherwise the operation limit is honored.
If there is no effective MaxArrayLength (both the server capability and the
client transport quota are 0), the operation limits are left unchanged.
SessionClientBatched then batches Read/Write/Browse/… calls using these capped
limits, so a single request never exceeds what the server will accept.
Plugging in a subscription engine
Session accepts an optional ISubscriptionEngineFactory at
construction. The factory creates an ISubscriptionEngine that owns the
publish loop and the live subscription set. Two implementations ship in
the box:
ClassicSubscriptionEngineFactory.Instance(default when no factory is supplied) — the historical fire-and-forget publish engine that drivesSubscriptionandMonitoredItemdirectly fromSession.DefaultSubscriptionEngineFactory.Instance— wraps the new options-basedSubscriptionManager(the V2 subscription pipeline) inside anISubscriptionEngineadapter so even a rawSessioncan expose theISubscriptionManagerAPI.
The engine is selected once, at construction. It is then reused across reconnect / recreate cycles. See section 4 for the engine architecture.
DefaultSessionFactory
DefaultSessionFactory (Libraries/Opc.Ua.Client/Session/DefaultSessionFactory.cs)
is the drop-in ISessionFactory that builds raw Session instances. It
exposes a single tunable knob beyond ITelemetryContext:
var factory = new DefaultSessionFactory(telemetry)
{
SubscriptionEngineFactory = DefaultSubscriptionEngineFactory.Instance, // optional; null => classic
ReturnDiagnostics = DiagnosticsMasks.SymbolicId,
};
ISession session = await factory.CreateAsync(...);
When SubscriptionEngineFactory is null, Session falls back to
ClassicSubscriptionEngineFactory — preserving the historical default.
2. SessionReconnectHandler — legacy reconnect driver
SessionReconnectHandler (Libraries/Opc.Ua.Client/Session/SessionReconnectHandler.cs)
is the original reconnect helper that ships with Session. It implements
the timer-driven retry loop, picks between
Session.ReconnectAsync and ISessionFactory.RecreateAsync, handles
endpoint refreshes when the server certificate changes, and cooperates
with ReverseConnectManager.
using var handler = new SessionReconnectHandler(
telemetry,
reconnectAbort: true,
maxReconnectPeriod: 30000);
session.KeepAlive += (_, e) =>
{
if (e.Status != null && ServiceResult.IsNotGood(e.Status))
{
handler.BeginReconnect(session, 1000, OnReconnectComplete);
}
};
Supported session types
SessionReconnectHandler drives Session.ReconnectAsync and
ISessionFactory.RecreateAsync directly, and replaces the channel on the
underlying object. It therefore only supports the concrete Session
class (or types derived from it). Passing any other ISession
implementation — most notably ManagedSession — throws
NotSupportedException, because those facades already run their own
reconnect state machine and exposing two reconnect drivers on the same
session would race.
// OK: raw Session
ISession raw = await new DefaultSessionFactory(telemetry).CreateAsync(...);
handler.BeginReconnect(raw, 1000, callback);
// Throws NotSupportedException
ISession managed = await new ManagedSessionFactory(telemetry).CreateAsync(...);
handler.BeginReconnect(managed, 1000, callback);
SessionReconnectHandler is retained as a supported entry point for callers that
own raw Session instances. New code should prefer ManagedSession or — for raw
Session usage — wire an IClientChannelManager (see
section 4
below) so that reconnect is handled transparently by the channel manager.
3. ManagedSession — the connection-state-machine facade
ManagedSession (Libraries/Opc.Ua.Client/Session/ManagedSession.cs) is
an ISession facade that wraps a raw Session and adds:
- A connection state machine (
ConnectionStateMachine) that owns transitions betweenDisconnected,Connecting,Connected,Reconnecting,Closing,Closed. - A reconnect policy (
ReconnectPolicy/ReconnectPolicyOptions) with pluggable backoff strategies (Exponential,Linear,Constant), jitter, max-retry caps, bounded delays, and a sharedMaxTotalReconnectTimebudget. - A server-redundancy handler (
IServerRedundancyHandler, default:DefaultServerRedundancyHandler) that reads the server'sServerRedundancyobject and can fail over to a backup endpoint. - A service gate (
m_serviceLock) that pauses caller-issued service calls (Read/Write/Browse/Call/...) for the duration of a reconnect or failover, so consumers see one transparent retry rather than a torn state. - A default V2 subscription engine so the new options-based
subscription API (
ISubscriptionManager) is available viaISession.TryGetSubscriptionManager(out var manager)out of the box.
ManagedSession session = await ManagedSession.CreateAsync(
configuration,
endpoint,
sessionFactory: new DefaultSessionFactory(telemetry),
reconnectPolicy: new ReconnectPolicy(new ReconnectPolicyOptions
{
Strategy = BackoffStrategy.Exponential,
InitialDelay = TimeSpan.FromSeconds(1),
MaxDelay = TimeSpan.FromSeconds(30),
MaxRetries = 0, // unlimited
JitterFactor = 0.1,
MaxTotalReconnectTime = TimeSpan.FromMinutes(5)
}),
redundancyHandler: new DefaultServerRedundancyHandler(),
telemetry: telemetry);
ManagedSessionFactory
ManagedSessionFactory (Libraries/Opc.Ua.Client/Session/ManagedSessionFactory.cs)
is the ISessionFactory that produces ManagedSession (cast as
ISession to satisfy the interface). Internally it composes a
DefaultSessionFactory to build the raw inner Session, then wraps it.
The factory is the drop-in replacement for code that currently calls
DefaultSessionFactory.CreateAsync(...) and wants managed reconnect for
free:
ISessionFactory factory = new ManagedSessionFactory(telemetry);
ISession session = await factory.CreateAsync(...); // actually a ManagedSession
Naming note.
ManagedSessionFactorycreatesManagedSessioninstances.DefaultSessionFactorycreates rawSessioninstances. The two factories are siblings underISessionFactory;ManagedSessionFactoryuses an innerDefaultSessionFactoryonly as an implementation detail for building the underlying transport+session pair.
ManagedSessionBuilder
ManagedSessionBuilder (Libraries/Opc.Ua.Client/ClientBuilder/ManagedSessionBuilder.cs)
is the fluent / DI-friendly entry point. Build() returns an immutable
ManagedSessionOptions snapshot; ConnectAsync() wraps Build() plus
ManagedSession.CreateAsync(...):
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()
.UseSubscriptionEngine(DefaultSubscriptionEngineFactory.Instance) // optional; V2 by default
.ConnectAsync(ct);
The builder is also the integration point for
OpcUaClientBuilderExtensions.AddClient — DI consumers
inject a Func<CancellationToken, Task<ManagedSession>> that lazily
connects on first use and caches the resulting session.
Reconnect semantics on ManagedSession
When the underlying session's keep-alive fires a bad status, the
connection state machine transitions Connected → Reconnecting and:
- Pauses the service gate so all in-flight and incoming service calls wait.
- Drives the configured
ReconnectPolicyto compute the next delay (with jitter), bounded byInitialDelay/MaxDelay, capped byMaxRetries(where0means unlimited), and constrained by the sharedMaxTotalReconnectTimeretry budget. - Calls
Session.ReconnectAsync(channel reuse path) and on failureISessionFactory.RecreateAsync(channel rebuild path), updating the inner session reference atomically. - If the redundancy handler is enabled and the active server is
unreachable, it consults
IServerRedundancyHandler.GetNextEndpointAsyncand recreates the session against the failover endpoint. - On success, transitions
Reconnecting → Connectedand releases the service gate, replaying any deferred calls. - On exhaustion (
MaxRetriesreached), transitions toClosedand surfaces aServiceResultExceptionto outstanding callers.
When a ManagedSession is backed by IClientChannelManager, participant reactivation uses the
manager's IChannelReconnectPolicy. The default ExponentialBackoffChannelReconnectPolicy
bounds each participant callback with a ParticipantTimeout of 30 seconds.
Because all of this is driven internally, callers must not wrap a
ManagedSession with SessionReconnectHandler; doing so throws
NotSupportedException.
Server retry-after backpressure
When a server sheds load it can tell the client how long to wait before
retrying — independently of ReturnDiagnostics — and the reconnect loop
honors that hint as a lower bound on its next delay. Two carriers are
emitted on by default:
ResponseHeader.additionalHeader(all UA transports): aBadServerTooBusyServiceFaultcarries a machine-readableRetryAfterMsvalue (a whole-millisecondInt64in the standardAdditionalParametersType), attached by the server inEndpointBase.CreateFault(viaRetryAfterHeader/ServerBusyException) and read by the client inClientBase.ValidateResponse— the choke point the source-generated clients call for every service. It is delivered regardless ofReturnDiagnostics.- HTTP
Retry-After(HTTPS transport): theAddHttpsRateLimitergate sets the header on its HTTP 429, and the client'sHttpsTransportChannelmaps a 429/503 carryingRetry-AftertoBadServerTooBusywith the hint.
In addition, the UA-TCP client honors a RetryAfterMs=N token in a
transient server-busy Error (ERR) message reason as a lower bound on
channel-reconnect backoff. The reference server sheds a connection
cheaply by dropping the accepted socket rather than emitting an ERR, so
this interoperates with any server that does send one. The reference
server also computes a load-based Server.ServiceLevel (255 at low load,
scaling toward a floor as sessions approach MaxSessionCount, with
hysteresis) as a proactive capacity signal a client can read or
subscribe to.
All reactive hints reach IReconnectPolicy.TryGetNextDelay, surviving
the diagnostics gate and client exception re-wrapping; the legacy
RetryAfterMs=N AdditionalInfo token remains a best-effort
compatibility hint. See Rate Limiting and Admission
Control for the server limiters that produce these
signals.
4. IClientChannelManager — centralised channel sharing and reconnect
IClientChannelManager
(Stack/Opc.Ua.Core/Stack/Client/IClientChannelManager.cs) is the new
central registry of client-side transport channels introduced by issue
#3288. It
sits below Session / ManagedSession and is concerned with the
underlying ITransportChannel, not with sessions or subscriptions. It
adds three behaviours that the previous one-channel-per-client model
could not support:
- Channel sharing — multiple sessions (or discovery / registration
clients) whose
ConfiguredEndpointand (optional) reverse-connect handle match share one underlying secure channel. The shared channel stays open until the last lease is released. - Coalesced reconnect — concurrent reconnect triggers (e.g. from
multiple sessions on the same channel observing a keep-alive
failure) collapse into a single reconnect cycle. Each attached
participant is notified once via
IReconnectParticipant.OnReconnectAsyncin parallel. - Transparent gating — service calls through an
IManagedTransportChannelblock until the channel isReady(gate released after both transport open AND participant reactivation complete). Internal reactivation traffic (e.g.ActivateSessionissued by a participant insideOnReconnectAsync) bypasses the gate via anAsyncLocalscope managed by the manager.
Session factory choices
Code that accepts an ISessionFactory can choose the session and
reconnect model without changing the consuming client:
DefaultSessionFactorycreates a rawSession. It does not run a reconnect state machine; the caller owns reconnect / recreate.ManagedSessionFactorycreatesManagedSession, which wraps the raw session in the built-in reconnect state machine.ChannelManagerSessionFactorycreates a rawSessionwhose transport channel comes from anIClientChannelManager. Multiple compatible sessions share channels and channel-level reconnect is coordinated centrally by the manager.
IClientChannelManager channelManager = new ClientChannelManager(config, telemetry);
ISessionFactory factory = new ChannelManagerSessionFactory(
channelManager,
telemetry,
DiagnosticsMasks.SymbolicId);
ISession session = await factory.CreateAsync(
config,
endpoint,
updateBeforeConnect: true,
sessionName: "SharedChannelSession",
sessionTimeout: 60_000,
identity: null,
preferredLocales: default,
ct);
Use ChannelManagerSessionFactory when the desired surface is still a
raw Session, but the transport should be shared with other sessions or
channel-only clients such as discovery and registration clients.
Channel identity (ManagedChannelKey)
Two participants share a channel only when their ManagedChannelKey
values are equal. The key is composed of:
- Endpoint URL.
- Security policy URI and message security mode.
- Server-certificate SHA-1 thumbprint (matches X.509 thumbprint conventions; used only as a stable hash, not for security).
- A stable hash of the
EndpointConfigurationvalue properties (timeouts, encoding, message-size limits). - Client instance-certificate thumbprint.
- Reverse-connect identity (the
ITransportWaitingConnectioninstance used to acquire the channel;nullfor forward connections).
Forward and reverse channels to the same server are never shared:
forward keys carry null for the reverse identity while reverse keys
carry the waiting-connection instance.
State model
IManagedTransportChannel.State follows a three-stage gate model:
| State | Service calls allowed | Meaning |
|---|---|---|
Disconnected | no (BadSecureChannelClosed) | Initial, or after graceful close. |
TransportConnecting / TransportReconnecting | no (queue with cancellation) | Underlying transport is being opened. |
TransportConnectedSessionReactivating | only via the internal reactivation bypass | Transport up; participants running OnReconnectAsync. |
Ready | yes | Fully usable. |
Faulted | no | Manager's retry policy exhausted. |
Closed | no | Manager teardown. |
Only Ready releases the public service-call gate. Discovery and
session-service requests are not generally exempted; only the
internal reactivation bypass crosses the gate during
TransportConnectedSessionReactivating.
Participant model — IReconnectParticipant
Clients that want to react to channel reconnect implement
IReconnectParticipant. Session implements it: when a channel
manager is wired into a session, the manager invokes
Session.OnReconnectAsync after each successful transport reconnect.
The session runs the ActivateSession slice and returns one of:
| Result | Meaning |
|---|---|
Reactivated | Session is alive on the reconnected channel. |
RequiresSessionRecreate | Channel is OK; this participant's server-side session was lost (e.g. BadSessionIdInvalid) — the manager dispatches participant recreation out of band. |
TransientFailure | Transient channel-level failure; manager retries per policy. |
FatalForParticipant | Authentication / cert problem specific to this participant — detach only this participant. |
FatalForChannel | Fatal channel error; transition to Faulted. |
When a participant returns RequiresSessionRecreate, the manager invokes
IReconnectParticipant.RecreateAsync(ct) fire-and-forget and does not block the channel's
transition to Ready on that work. Session implements the callback by recreating its
server-side session in place. On target frameworks without default interface method support,
participants opt in with IRecreateAwareReconnectParticipant.
Retry policy — IChannelReconnectPolicy
The default ExponentialBackoffChannelReconnectPolicy mirrors the
historical SessionReconnectHandler backoff defaults:
500 ms → 30 s with unlimited attempts. It also sets ParticipantTimeout
to 30 seconds, bounding each participant's OnReconnectAsync callback. A
participant timeout is treated as TransientFailure; retry exhaustion still
transitions the channel to Faulted. The IChannelReconnectPolicy default
interface member remains Timeout.InfiniteTimeSpan for custom-policy
backward compatibility, and older TFMs can opt in with IParticipantTimeoutPolicy.
The policy is configurable on the ClientChannelManager constructor.
HTTPS resilience vs channel-mgr reconnect
On .NET 8 and later, HTTPS endpoints have two independent resilience layers:
- HTTP resilience pipeline — the named
HttpClientcreated throughIOpcUaHttpClientFactoryusesAddStandardResilienceHandlerfrom theMicrosoft.Extensions.Http.Resiliencepackage. The standard handler applies request-level rate limiting, a total request timeout, retries, circuit breaking, and per-attempt timeouts. Current package defaults are: 3 retries with exponential backoff and jitter, a 30 s total request timeout, a 10 s attempt timeout, a circuit breaker that opens after a 10% transient-failure ratio over a 30 s sample with at least 100 requests, and a 1,000-request concurrency limit with no queue. - Channel manager reconnect —
IChannelReconnectPolicyruns per managed channel after the request has escaped the HTTP layer. Its defaultExponentialBackoffChannelReconnectPolicyis500 ms → 30 swith unlimited attempts.
Use the HTTP pipeline for transient failures of one HTTPS call: the call is retried or timed out, then the channel remains usable. Use the channel manager for failures that require the full OPC UA channel to be torn down and opened again, such as a server reboot, a closed TLS connection, or a TLS rehandshake requirement.
With the DI defaults, an HTTPS request fails through the HTTP pipeline
first. If all HTTP retries are exhausted (or the timeout / circuit
breaker rejects the call), the resulting HttpRequestException, timeout,
or equivalent transport exception bubbles up to the OPC UA channel. The
channel manager then treats that as a channel-level failure and starts
its reconnect policy.
Avoid making both layers aggressive. The channel-manager MaxAttempts
should be sized assuming the HTTP layer already retried the individual
request. For HTTPS deployments, prefer a bounded channel policy such as
MaxAttempts <= 5, or use a shared IRetryBudget when the HTTP layer
participates in one so a single end-to-end retry budget caps both layers.
DI registration wires the standard handler automatically:
services.AddOpcUa()
.AddClient(opt =>
{
opt.Configuration = config;
opt.Session.Endpoint = endpoint;
});
To replace the DI default with custom HTTP resilience, configure the same named client and remove the default handler before adding your own:
services.AddHttpClient("Opc.Ua.Client")
.RemoveAllResilienceHandlers()
.AddStandardResilienceHandler(opts =>
{
opts.Retry.MaxRetryAttempts = 5;
opts.AttemptTimeout.Timeout = TimeSpan.FromSeconds(30);
});
For direct builder usage on .NET 8 and later, call
ManagedSessionBuilder.WithHttpsResilience(...):
ManagedSession session = await new ManagedSessionBuilder(config, telemetry)
.UseEndpoint(endpoint)
.WithHttpsResilience(opts =>
{
opts.Retry.MaxRetryAttempts = 5;
opts.AttemptTimeout.Timeout = TimeSpan.FromSeconds(30);
})
.ConnectAsync(ct);
HTTPS factory + OPC UA cert validation: secure-by-default fallback
HttpsTransportChannel.CreateHttpClient chooses between two HttpClient
construction paths:
- Direct path (
CreateDirectHttpClient) — builds anHttpClientHandlerconfigured with:ServerCertificateCustomValidationCallbackwired to the OPC UAICertificateValidatorEx/CertificateValidator(validates against the OPC UA trust list / issuer chain).- The OPC UA application instance certificate attached as a client certificate (mutual TLS using the OPC UA application identity).
AllowAutoRedirect = falseso an attacker-controlled redirect cannot silently move the OPC UA call to a different host.MaxConnectionsPerServerandMaxRequestContentBufferSizequotas enforced at the HTTP layer.
- Factory path — calls
IOpcUaHttpClientFactory.CreateClient(...)and uses the returned namedHttpClient(its primary handler is owned byIHttpClientFactoryand CANNOT be reconfigured byHttpsTransportChannelafter the fact).
When an OPC UA CertificateValidator is configured for a channel — the
normal case for any non-SecurityPolicies.None HTTPS profile —
HttpsTransportChannel.CanUseHttpClientFactory() returns false and
the direct path is always taken. This is the secure default: it
guarantees that the OPC UA trust list, OPC UA mTLS, and the redirect
lock are in effect on every OPC UA HTTPS connection. If a non-Shared
factory was supplied but bypassed for security reasons,
CreateHttpClient emits a one-time LogWarning so operators see that
the named HttpClient pipeline (Polly resilience handler, etc.) is NOT
applied to this OPC UA HTTPS channel.
To keep BOTH Polly resilience AND OPC UA cert validation on the same
channel, register the named Opc.Ua.Client HttpClient with a
configured primary handler that wires OPC UA validation in yourself.
The handler must be re-buildable per channel from the configuration
because the OPC UA validator and client certificate are channel-bound,
not process-bound:
services.AddHttpClient(OpcUaHttpClientDefaults.ClientName)
.ConfigurePrimaryHttpMessageHandler(sp =>
{
// Resolve the per-application validator and client cert from
// your DI container, then build a handler the same way
// CreateDirectHttpClient does.
ApplicationConfiguration config = sp.GetRequiredService<ApplicationConfiguration>();
ICertificateValidatorEx validator = config.CertificateValidator;
return new HttpClientHandler
{
ClientCertificateOptions = ClientCertificateOption.Manual,
AllowAutoRedirect = false,
ServerCertificateCustomValidationCallback = (_, cert, chain, _) =>
{
// … delegate to `validator.ValidateAsync(...)` synchronously.
},
// … and attach the OPC UA client instance certificate.
};
})
.AddStandardResilienceHandler();
Without that explicit primary-handler configuration, OPC UA HTTPS channels deliberately ignore the named-client pipeline and use the direct path — preserving OPC UA security at the cost of Polly resilience on those channels.
Shared retry budget with ManagedSession
When ManagedSession is constructed with WithChannelManager(...),
the manager owns channel-level retry while
ManagedSession.ConnectionStateMachine + IReconnectPolicy owns
session-level retry on top. The two are sequenced, not concurrent, and
share a single IRetryBudget for each outer reconnect cycle:
- Channel-level retry runs first. A keep-alive failure on the
inner
SessiontriggersIClientChannelManager.ReconnectAsync(channel, ct). The manager coalesces concurrent triggers from multiple sessions on the same channel, retries the transport open according to itsIChannelReconnectPolicy, then notifies attached sessions viaOnReconnectAsync. - Outer state stays
Connectedwhile the manager handles it.ManagedSessionsubscribes toIManagedTransportChannel.StateChanged; while the manager is inTransportReconnecting/TransportConnectedSessionReactivating,ManagedSessionsuppresses the keep-alive-driven outer state-machine churn. - Outer retry shares its deadline with channel retry. When the
channel transitions to
Faulted,ManagedSession.StateChangedraisesConnectionState.Reconnecting. The outerIReconnectPolicyschedulesSession.ReconnectAsync(ct)and passes the same retry budget intoIClientChannelManager.ReconnectAsync(channel, budget, ct). Both layers cap their delays to the remaining time and stop scheduling retries when the budget is exhausted.
ReconnectPolicyOptions.MaxTotalReconnectTime defaults to five
minutes. Set it to the end-to-end reconnect window your application
expects; channel-manager delays are automatically shrunk to fit inside
that window instead of receiving a fresh budget on every outer attempt.
For example, with MaxTotalReconnectTime = TimeSpan.FromSeconds(30),
a channel-manager reconnect that spends 27 seconds reopening transport
and reactivating participants leaves about three seconds for any outer
ManagedSession retry / failover decision. The channel manager clamps
its next scheduled delay to the remaining time and transitions to
Faulted once the shared budget is exhausted; the outer state machine
then fails over or closes instead of starting another 30-second channel
retry window. The result is one roughly 30-second reconnect cycle (plus
any already in-flight attempt), not 30 seconds per layer or per outer
attempt.
ManagedSession session = await new ManagedSessionBuilder(configuration, telemetry)
.UseEndpoint(endpoint)
.WithChannelManager(channelManager)
.WithReconnectPolicy(p => p with
{
MaxTotalReconnectTime = TimeSpan.FromSeconds(30)
})
.ConnectAsync(ct);
For migration notes on the budget-aware APIs, see the migration guide.
Diagnostics surface contract — what tags and EventSource fields carry
The channel manager emits diagnostics through three independent
channels: System.Diagnostics.Activity tags (distributed tracing),
the Opc.Ua.ChannelManager EventSource (ETW / dotnet-trace), and
System.Diagnostics.Metrics instruments. Tag and field values surfaced
through any of these are restricted to OPC UA-protocol-level fields
only:
StatusCode(numeric / symbolic), e.g.BadSecureChannelClosedServiceResult.SymbolicIdServiceResult.LocalizedText.Text— the operator-facing message
Notably, the following are never sent to Activity tags or to
EventSource events:
- The full
ServiceResult.ToString()serialization ServiceResult.AdditionalInfo— typically carries inner-exception messages, file paths, internal IDsException.StackTrace,Exception.Message, or any other inner .NET exception detailInnerrecursion intoServiceResult.InnerResult
This keeps internal client/server diagnostics out of distributed
tracing backends where operators with telemetry-read access (but no
production-debug access) should not be able to read internal failure
detail. Full failure context — including stack traces and
AdditionalInfo — flows only through the local ILogger.LogDebug
path on the manager, where it stays under the host's log-access
controls.
The metric tag set is also bounded for routine operation:
| Instrument | Tag keys |
|---|---|
opc.ua.channel.open / opc.ua.channel.close | endpoint, reverse (+ reason on close) |
opc.ua.channel.active / opc.ua.channel.refcount / opc.ua.channel.participants | endpoint |
opc.ua.channel.reconnect.attempts / opc.ua.channel.reconnect.duration | endpoint, outcome |
opc.ua.channel.gate.wait | endpoint |
opc.ua.channel.participant.timeout.count / opc.ua.channel.participant.recreate.count | endpoint, participant (+ success on recreate) |
outcome is one of success, transient-failure, policy-exhausted,
fatal-channel. reason is one of lease-released,
manager-disposed, faulted. endpoint cardinality is bounded by the
number of distinct OPC UA endpoint URLs the application connects to.
The
participanttag carries the kind prefix of the participant identifier (e.g."Session","Client"), not the per-instance suffix. This keeps cardinality bounded by the small set of participant kinds rather than growing with every session / reconnect-storm participant ever created. The full per-instanceIReconnectParticipant.Idis preserved on Activity tags and EventSource events so individual sessions remain correlatable in distributed traces. Custom participants that don't use the "kind---instance" naming convention contribute their full id to the tag, so prefer the prefix-then-suffix shape for new participant types.
DI registration
builder.AddClient(...) registers IClientChannelManager as a
singleton resolved from OpcUaClientOptions.Configuration. The
ManagedSessionBuilder produced by AddClient automatically wires
the DI-resolved channel manager into every new ManagedSession. To
override, call WithChannelManager(...) on the builder explicitly.
services.AddOpcUa()
.AddClient(opt =>
{
opt.Configuration = config;
opt.Session = new ManagedSessionOptions
{
Endpoint = endpoint,
SessionName = "MyApp",
};
});
// Single channel manager + multiple sessions sharing channels per endpoint:
var sp = services.BuildServiceProvider();
var managedFactory = sp.GetRequiredService<Func<CancellationToken, Task<ManagedSession>>>();
ManagedSession s1 = await managedFactory(ct);
ManagedSession s2 = await managedFactory(ct); // shares s1's underlying channel
Migrating from AttachChannel / DetachChannel
IClientBase.AttachChannel(ITransportChannel) and DetachChannel()
are marked [Obsolete] but remain functional. Migration path:
// Old: manually managed channel
ITransportChannel ch = await UaChannelBase.CreateUaBinaryChannelAsync(...);
var session = new Session(ch, config, endpoint);
await session.OpenAsync(...);
// New: channel manager owns the channel; sessions share it
var manager = new ClientChannelManager(config, telemetry);
Session session = await Session.CreateAsync(manager, config, endpoint, ...);
For reverse-connect:
ITransportWaitingConnection conn = await reverseConnectManager
.WaitForConnectionAsync(serverUrl, serverUri, ct);
IManagedTransportChannel ch = await manager.GetAsync(participant, conn, ct);
Testing the channel manager
The channel manager is covered by a layered stress and chaos test suite in
Tests/Opc.Ua.Stress.Tests/:
- L1 Contract — fast deterministic fake-based tests for coalescing, participant result aggregation, retry budgets, hung participants, recreate dispatch, lease lifecycle, gate + bypass, key equivalence, certificate rotation, and leak accuracy. These run in every PR.
- L2 Integration — live in-process server tests for server outage recovery, live certificate rotation, participant timeout, session recreate dispatch, and failover lease swap. These run in every PR.
- L3 ChaosTCP — TCP proxy chaos tests for transparent reconnect under load, subscription survival, accept-but-stall, and mixed drop / block-accept failures. These run nightly.
- L4 Soak — long-running randomized soak, combinatorial matrix, and memory-stability runs. These are manual or nightly only.
# Contract + Integration (default PR CI):
dotnet test Tests/Opc.Ua.Stress.Tests --filter "Category=Contract|Category=Integration"
# ChaosTCP (nightly):
dotnet test Tests/Opc.Ua.Stress.Tests --filter "Category=ChaosTCP" --TestRunParameters.Parameter(Seed=<n>)
# Soak (manual):
dotnet test Tests/Opc.Ua.Stress.Tests --filter "Category=Soak"
Every chaos test prints its seed at the start of the run. Failed chaos runs can be reproduced by passing the same
seed back to the test host with --TestRunParameters.Parameter(Seed=<n>).
5. Subscription engines
The ISubscriptionEngine abstraction
(Libraries/Opc.Ua.Client/Session/ISubscriptionEngine.cs) decouples the
publish pipeline from Session. Each Session owns exactly one engine
chosen at construction; the engine manages the publish workers, ack
queues, keep-alive timers, and dispatch to subscriptions.
ClassicSubscriptionEngine
The historical engine. Owns:
- The
SubscriptionandMonitoredItemclasses inLibraries/Opc.Ua.Client/Subscription/Classic. - A fire-and-forget publish loop driven from
Session.BeginPublish/OnPublishComplete. - The
Subscriptionscollection onISessionandManagedSession.
This is the default when no SubscriptionEngineFactory is supplied to
Session directly. It is also what ManagedSession exposes via the
classic Subscriptions property regardless of which engine is active —
an internal SubscriptionBridge forwards classic subscriptions onto the
V2 engine when one is in use, so existing classic-API code keeps
working.
DefaultSubscriptionEngine (V2)
The new engine wraps the options-based SubscriptionManager
(Libraries/Opc.Ua.Client/Subscription/SubscriptionManager.cs) inside
the ISubscriptionEngine contract. It owns:
- The
Subscriptions.Subscription/Subscriptions.MonitoredItems.MonitoredItemrecords consumed viaIOptionsMonitor<T>. - A worker-pool publish loop with adaptive worker count.
- The full subscription-service-set lifecycle:
CreateSubscription,ModifySubscription,SetPublishingMode,Republish, plusCreateMonitoredItems,ModifyMonitoredItems,DeleteMonitoredItems,SetMonitoringMode,Call,SetTriggering— all routed back through theSession's service-set client methods. - An
ISubscriptionNotificationHandlercallback contract forOnDataChangeNotificationAsync/OnEventDataNotificationAsync/OnKeepAliveNotificationAsync.
DefaultSubscriptionEngine.SubscriptionManager exposes the
ISubscriptionManager API; on any ISession it is surfaced through
TryGetSubscriptionManager(out var manager), which returns true and the
manager for V2-engine sessions and false for classic-engine sessions.
// V2 engine is the default for ManagedSession
ManagedSession session = await new ManagedSessionBuilder(configuration, telemetry)
.UseEndpoint(endpoint)
.ConnectAsync(ct);
var handler = new MyNotificationHandler(); // : ISubscriptionNotificationHandler
ISubscription subscription = session.AddSubscription(handler,
new Opc.Ua.Client.Subscriptions.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 _);
Unbounded monitored items (default)
The V2 ISubscription returned from Add(...) transparently splits
monitored items across multiple server-side partition subscriptions
when the server's per-subscription cap would be exceeded. Single-
partition workloads pay zero overhead because the composite
collection short-circuits to the primary partition. Pin items into
the same partition with MonitoredItemOptions.Affinity so per-
subscription features like SetTriggering keep working across the
group; opt out of partitioning entirely with
SubscriptionOptions.DisableUnboundedItemMode = true. Full
developer guide: Subscriptions § Unbounded monitored items.
V2 notification pooling (opt-in)
The V2 subscription engine supports activator-level pooling of
notification payload instances to reduce GC pressure on
high-throughput publish loops. Pooling is opt-in and disabled by
default. Enable it via ManagedSessionBuilder.WithPoolNotifications(),
the ManagedSessionOptions.PoolNotifications init property, or by
setting ISubscriptionManager.PoolNotifications = true directly on
the V2 manager:
ManagedSession session = await new ManagedSessionBuilder(configuration, telemetry)
.UseEndpoint(endpoint)
.WithPoolNotifications() // opt in
.ConnectAsync(ct);
How it works:
- Types that opt in to pooling implement
IPooledEncodeable(a subinterface ofIEncodeable) and provide aReuse()method that resets fields and returns the instance to its activator's pool. The activator derives fromPooledEncodeableType<T>instead ofEncodeableType<T>and owns a bounded, lock-free pool. - When
PoolNotificationsistrue, the V2 subscription dispatcher walks eachDataChangeNotification/EventNotificationListin afinallyblock after the handler await completes, callingReuse()on every payload item that implementsIPooledEncodeable. Types that do not implement the interface are skipped silently — the walk is universally safe. - The walk runs on the subscription's single-reader
ProcessMessageAsyncloop, so it never races the channel decode that produced the payload.
Handler contract — retain by copy
When PoolNotifications is enabled, handlers must not retain a
reference to a DataChangeNotification / EventNotificationList /
MonitoredItemNotification / EventFieldList 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 before returning:
public ValueTask OnDataChangeNotificationAsync(
ISubscription subscription, uint sequenceNumber,
DateTime publishTime,
ReadOnlyMemory<DataValueChange> notification,
PublishState publishStateMask, IReadOnlyList<string> stringTable)
{
foreach (DataValueChange change in notification.Span)
{
// OK: DataValueChange is a struct, captured by value.
// Value (DataValue) and DiagnosticInfo are not themselves
// pooled in this design, so storing them past the call is
// safe.
m_history.Add(new MyHistoryEntry(
change.MonitoredItem?.NodeId,
change.Value, // safe to retain
change.DiagnosticInfo));
}
return default;
}
The DataValueChange and EventNotification projection structs
are designed not to surface a reference to a pooled instance —
they project the inner DataValue / ArrayOf<Variant> directly,
which are not themselves pooled. Handlers can safely copy
DataValueChange / EventNotification by value and continue to
use them after the await.
What is not safe under pooled mode:
- Retaining the outer
DataChangeNotification/EventNotificationListreference past the await. - Retaining individual
MonitoredItemNotification/EventFieldListreferences past the await. - Calling
Reuse()on a retained reference after the framework has already done so —Reuse()is idempotent against accidental double-call (it uses a per-instance sentinel), but the pool may have already re-rented the instance, in which case the lateReuse()steals it from its current consumer.
The dispatcher is the only framework-initiated Reuse() caller.
Handler code that follows the retain-by-copy rule never reaches
the unsafe pattern.
Server-side request/response pooling
On the server side, decoded IServiceRequest and IServiceResponse
objects are automatically returned to their activator pools after the
service handler completes and the response is encoded to the wire.
This is unconditional — no opt-in flag is required — because the
server framework owns the full lifecycle of both objects:
- The request is decoded by the channel, consumed by the service handler, and never exposed to application code by reference.
- The response is constructed by the handler, encoded by the channel's
WriteSymmetricMessage/BinaryEncoder.EncodeMessagepath, and then has no further consumers.
The reuse calls are placed in finally blocks in
TcpTransportListener.OnRequestReceivedAsync (UA-TCP transport) and
EndpointBase.InvokeServiceAsync (HTTPS transport), ensuring both
objects are released regardless of success or failure.
Server-side node managers and service handlers do not need any code changes to benefit from this pooling — it is transparent at the channel/transport layer.
Choosing an engine
| Scenario | Engine |
|---|---|
Existing code using session.AddSubscription(new Subscription(...)) and the classic event-driven API | ClassicSubscriptionEngine (the default for raw Session) |
| New code, options-based subscriptions, DI / Microsoft.Extensions.Options friendly | DefaultSubscriptionEngine (the default for ManagedSession) |
| Migrating a managed session but you still need a classic subscription you cannot rewrite | ManagedSession with UseSubscriptionEngine(ClassicSubscriptionEngineFactory.Instance); TryGetSubscriptionManager then returns false and you keep using Subscriptions |
The two engines can co-exist on a ManagedSession:
ManagedSession.Subscriptions (classic API) keeps working alongside
the V2 subscription manager obtained via
ISession.TryGetSubscriptionManager(out var manager) when the V2 engine is
active. Internally, classic subscriptions are bridged onto the V2
publish pipeline.
6. The INodeCache surface
Session.NodeCache (and ManagedSession.NodeCache) returns
INodeCache, the unified client-side cache contract. As of 2.0 it is
the single contract — the previous ILruNodeCache parallel interface
has been merged into it and removed.
The interface deliberately exposes two complementary lookup families:
Find*/Fetch*— take anExpandedNodeId, return a nullable result, may fetch from the server.Find*consults the cache first;Fetch*always re-reads from the server and updates the cache.Get*— take a localNodeId, return a non-nullable result (throws when the node cannot be resolved). LRU-style direct hit; cheaper for in-process callers that already hold a localNodeIdand a known namespace index.
Both families coexist on INodeCache because the lifecycle and error
semantics differ. All async methods return ValueTask /
ValueTask<T>; only void Clear() is synchronous (pure local-state
mutation).
For migration details see 2.0 migration guide — Node States and INodeCache.
7. Putting it all together
Pick the entry point that best matches your call site:
- Already have a
Sessionand an existing reconnect helper. KeepSession+SessionReconnectHandler. They are first-class APIs in 2.0 and not deprecated. You may opt into the V2 subscription engine by passingSubscriptionEngineFactory = DefaultSubscriptionEngineFactory.InstancetoDefaultSessionFactory. - New service / ASP.NET Core / generic-host application. Use
services.AddOpcUa().AddClient(...)and resolveFunc<CancellationToken, Task<ManagedSession>>from DI. - New code without DI. Use
ManagedSessionBuilder.ConnectAsync(...). - Drop-in replacement for
DefaultSessionFactorythat wants managed reconnect. Switch toManagedSessionFactory— the public surface (ISessionFactory) is identical. - Need to share a single underlying
Sessionacross two reconnect drivers, or wrap aManagedSessionwithSessionReconnectHandler. Don't. The reconnect drivers race, andSessionReconnectHandlerwill throwNotSupportedExceptionto enforce this.
See also
- 2.0 migration guide — ManagedSession and Automatic Reconnection
- 2.0 migration guide — Sessions, GDS Client, and Subscriptions — V2 subscription engine, fluent builder, and DI integration are covered alongside ManagedSession.
- TransferSubscription — server-driven session-handoff support.
- Diagnostics — telemetry plumbed through
ITelemetryContexton every factory and session type. - Reverse Connect — works with both
SessionandManagedSession. - Diagnostics — Packet capture, dissection, and replay —
Opc.Ua.Core.Diagnosticscomposes withIClientChannelManagervia the globalTransportBindings.Channelsregistry; channel sharing, transparent reconnect, and faulted-entry swap all flow through to one continuous capture session perManagedChannelKey.