Files
Marc Schier 1f6700c30b Fail closed when reconnect cannot preserve endpoint security (#4809)
# Description

It was reported that endpoint refresh during reconnect
(`UpdateBeforeConnect` / reconnect endpoint update) could silently
change a connection's security configuration (e.g. from `SignAndEncrypt`
to a different mode/policy) without the application knowing.

Investigation showed the hard downgrade paths had already been fixed,
but the legacy `SessionReconnectHandler` still had a composite-score
fallback that could select a *different* security mode/policy when the
original combination was no longer offered by the (unauthenticated)
discovery reply. A stronger mode could offset a weaker or deprecated
policy in that score. Managed failover/alternate-network endpoints also
had no explicit mode/policy guard.

Changes:
- **`SessionReconnectHandler`**: removed `SelectFallbackEndpoint` and
the alternate-policy fallback. If the exact mode/policy is unavailable,
reconnect fails (logged as an error) instead of changing security.
- **`ManagedSession`**: added a mode/policy mismatch check; mismatched
alternate-network endpoints, reactivation targets and
failover/recreation targets are rejected with `BadSecurityModeRejected`
/ `BadSecurityPolicyRejected` before a channel is opened.
- Endpoint refresh for **certificate rotation is retained**; new server
certificates are still subject to normal trust validation.
- **Docs** (`docs/Sessions.md`): documents exact mode/policy
preservation during recovery and that `UpdateBeforeConnect` does not
authorize security negotiation. Changing policy requires explicit
reconfiguration and a new connection.
- **Tests**: replaced fallback-selection tests with a fail-closed legacy
reconnect test; added managed alternate-endpoint policy mismatch and
failover mode mismatch tests.

Validation:
- `dotnet test tests/Opc.Ua.Client.Tests/Opc.Ua.Client.Tests.csproj -f
net10.0 --filter
"FullyQualifiedName~SessionReconnectHandlerTests|FullyQualifiedName~ManagedSessionTests"`
— passed (48/48)
- `dotnet test tests/Opc.Ua.Core.Tests/Opc.Ua.Core.Tests.csproj -f
net10.0 --filter "FullyQualifiedName~ConfiguredEndpointTests"` — passed
(46/46)
- Full UA.slnx suite and net48 tests: not run locally.

## Related Issues

- Reported security concern: reconnect endpoint refresh could change the
session's security configuration without notifying the application.

## 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.
- [x] 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.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-15 20:33:35 +02:00

1476 lines
76 KiB
Markdown

# Sessions, Reconnection, and Subscription Engines
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.
> **New to the client API?** Run the client in
> [Getting started](GettingStarted.md) first. For new applications, read
> [`ManagedSession`](#3-managedsession--the-connection-state-machine-facade)
> and [Putting it all together](#7-putting-it-all-together). Sections 1 and 2
> describe the raw `Session` and `SessionReconnectHandler` for applications
> that manage the connection lifecycle themselves.
## 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 |
| `SessionRequestContext` | borrowed session view | rejects stale dispatch instead of reconnecting | not a subscription/reconnect facade | generation-sensitive remote handles, cursors and target classifications |
| `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.
## Contents
- [Quick reference](#quick-reference)
- [1. `Session` — the OPC UA session primitive](#1-session--the-opc-ua-session-primitive)
- [Operation limits and `MaxArrayLength`](#operation-limits-and-maxarraylength)
- [Plugging in a subscription engine](#plugging-in-a-subscription-engine)
- [`DefaultSessionFactory`](#defaultsessionfactory)
- [2. `SessionReconnectHandler` — legacy reconnect driver](#2-sessionreconnecthandler--legacy-reconnect-driver)
- [Supported session types](#supported-session-types)
- [3. `ManagedSession` — the connection-state-machine facade](#3-managedsession--the-connection-state-machine-facade)
- [Failure of the initial connect](#failure-of-the-initial-connect)
- [Recovery after an established connection is exhausted](#recovery-after-an-established-connection-is-exhausted)
- [`ManagedSessionFactory`](#managedsessionfactory)
- [`ManagedSessionBuilder`](#managedsessionbuilder)
- [Reconnect semantics on `ManagedSession`](#reconnect-semantics-on-managedsession)
- [Generation-bound client requests](#generation-bound-client-requests)
- [Closing a `ManagedSession`](#closing-a-managedsession)
- [Server retry-after backpressure](#server-retry-after-backpressure)
- [4. `IClientChannelManager` — centralised channel sharing and reconnect](#4-iclientchannelmanager--centralised-channel-sharing-and-reconnect)
- [Session factory choices](#session-factory-choices)
- [Channel identity (`ManagedChannelKey`)](#channel-identity-managedchannelkey)
- [State model](#state-model)
- [Participant model — `IReconnectParticipant`](#participant-model--ireconnectparticipant)
- [Retry policy — `IChannelReconnectPolicy`](#retry-policy--ichannelreconnectpolicy)
- [HTTPS resilience vs channel-mgr reconnect](#https-resilience-vs-channel-mgr-reconnect)
- [HTTPS factory + OPC UA cert validation: secure-by-default fallback](#https-factory--opc-ua-cert-validation-secure-by-default-fallback)
- [Shared retry budget with `ManagedSession`](#shared-retry-budget-with-managedsession)
- [Diagnostics surface contract — what tags and structured log fields carry](#diagnostics-surface-contract--what-tags-and-structured-log-fields-carry)
- [DI registration](#di-registration)
- [Migrating from `AttachChannel` / `DetachChannel`](#migrating-from-attachchannel--detachchannel)
- [Testing the channel manager](#testing-the-channel-manager)
- [5. Subscription engines](#5-subscription-engines)
- [`ClassicSubscriptionEngine`](#classicsubscriptionengine)
- [`DefaultSubscriptionEngine` (V2)](#defaultsubscriptionengine-v2)
- [Unbounded monitored items (default)](#unbounded-monitored-items-default)
- [V2 notification pooling (opt-in)](#v2-notification-pooling-opt-in)
- [Server-side request/response pooling](#server-side-requestresponse-pooling)
- [Choosing an engine](#choosing-an-engine)
- [6. The `INodeCache` surface](#6-the-inodecache-surface)
- [7. Putting it all together](#7-putting-it-all-together)
- [Server session lifecycle](#server-session-lifecycle)
- [See also](#see-also)
## 1. `Session` — the OPC UA session primitive
`Session` (`src/Opc.Ua.Client/Session/Session.cs`) is the lowest-level client
object. It maps directly to a UA SecureChannel and Session on the server.
`Session` implements `ISession` and exposes OPC UA services such as Read,
Write, Browse, Call, and AddNodes. It also manages keep-alive, namespace
tables, the type tree, the node cache, and the publish pipeline.
A `Session` is bound to:
- An `ApplicationConfiguration` (security policies, certificates, transport
quotas, telemetry).
- A `ConfiguredEndpoint` (URL, security mode, security policy URI).
- An `ITransportChannel` created against that endpoint.
On secured endpoints, `CreateSession` validates the client certificate
chain independently of the supplied `ApplicationUri`, then requires that
URI to match the certificate. UA-TCP and UA Secure Conversation over
WebSockets also require the session certificate's leaf to match the
channel certificate; supplying the same leaf with an issuer chain is
allowed. HTTPS may use a distinct, separately validated TLS certificate,
as specified by Part 4 §6.1.8. A `SecurityPolicyUri` of `None` ignores the
optional client application certificate.
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 fresh `Session` that
inherits the previous session's identity, subscriptions, and node cache,
swapping in a new channel.
In-place recreation refreshes an existing discovery snapshot over the installed
channel, including a reverse connection or a scoped channel-manager recovery
view. It does not open an outbound discovery connection or consume a reverse
connection again. Expected discovery availability failures are logged and fall
back to the stored snapshot. The `CreateSession` endpoint list, selected user-token
policy, server certificate and signature still undergo normal validation;
cancellation and discovery security failures are not treated as availability
failures.
`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 effective
`MaxArrayLength`;
- if the server reports a value larger than the effective `MaxArrayLength`, the
operation limit becomes the effective `MaxArrayLength`;
- 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 drives `Subscription` and `MonitoredItem` directly from
`Session`.
- **`DefaultSubscriptionEngineFactory.Instance`** — wraps the new
options-based `SubscriptionManager` (the V2 subscription pipeline)
inside an `ISubscriptionEngine` adapter so even a raw `Session` can
expose the `ISubscriptionManager` API.
The engine is selected once, at construction. It is then reused across
reconnect / recreate cycles. See section 4 for the engine architecture.
### `DefaultSessionFactory`
`DefaultSessionFactory` (`src/Opc.Ua.Client/Session/DefaultSessionFactory.cs`)
is the drop-in `ISessionFactory` that builds raw `Session` instances. It
exposes a single tunable knob beyond `ITelemetryContext`:
```csharp
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` (`src/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`. Refresh updates endpoint metadata, but reconnect
requires the same message-security mode and security policy as the configured
endpoint. If discovery no longer offers that exact combination, reconnect fails
instead of selecting another security configuration. Network redundancy skips
incompatible alternate endpoints and continues searching the configured list
for a matching endpoint.
```csharp
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.
```csharp
// 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](#4-iclientchannelmanager--centralised-channel-sharing-and-reconnect)
below) so that reconnect is handled transparently by the channel manager.
## 3. `ManagedSession` — the connection-state-machine facade
`ManagedSession` (`src/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 between `Disconnected`, `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 shared
`MaxTotalReconnectTime` budget.
- A **server-redundancy handler** (`IServerRedundancyHandler`,
default: `DefaultServerRedundancyHandler`) that reads the server's
`ServerRedundancy` object and can fail over to a backup endpoint.
Refresh is best effort and bounded at connect, reconnect, and failover by
`ManagedSessionOptions.ServerRedundancy` (`ServerRedundancyOptions`), which
bounds the metadata read (`RefreshTimeout`) and each peer endpoint lookup
(`PeerDiscoveryTimeout`) at two seconds each by default. Raise them on a
high-latency link, or set `Timeout.InfiniteTimeSpan` when the caller already
bounds the operation. A failed or unresponsive refresh retains the previous
snapshot, so an unavailable primary cannot prevent selecting a cached
backup. A provider that ignores cancellation is still observed, and no
overlapping refresh is started while it remains in flight.
On timeout or caller cancellation, the handler cancels the resolver scope before
disposing it, even if cancellation delivery to that scope is delayed. Late
results cannot populate the endpoint cache.
The default handler can resolve cached peer URIs even when the primary is
unavailable, and invalidates a peer's cached endpoint after failed failover.
Certificate-validation reconnect failures refresh endpoint discovery before
recreating the session; normal certificate trust validation still applies.
Refresh preserves the active endpoint's message-security mode and
security-policy URI. A new server certificate can therefore be accepted only
through the existing certificate-validation rules, not by changing the
channel's security configuration.
- 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 via
`ISession.TryGetSubscriptionManager(out var manager)` out of the box.
```csharp
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);
```
### Failure of the initial connect
`CreateAsync` returns only once the session is actually connected. When the
first connect attempt fails, the state machine treats it like any other
connection loss and retries it under the configured reconnect policy — a
reconnect that finds no inner session runs a full connect rather than
reactivating one. If the policy, the `MaxTotalReconnectTime` budget and the
redundancy failover are all exhausted, `CreateAsync` disposes the half-built
session and throws a `ServiceResultException` carrying the error of the last
failed attempt (for example `BadCertificateUntrusted`), not a generic
`BadNotConnected`. It never returns a `ManagedSession` without a live inner
session.
Callers that want to give up sooner than the policy does should cap the policy
(`MaxRetries`, `MaxTotalReconnectTime`) or pass a cancellation token, which
surfaces as an `OperationCanceledException`.
### Recovery after an established connection is exhausted
An established session keeps its identity and subscriptions when a reconnect
cycle and failover are exhausted. It reports `Disconnected` and the last error,
then starts a fresh bounded cycle after the policy's maximum backoff (at least
one second). Closing or disposing the session cancels this recovery timer.
This does not restart failed initial connections: `CreateAsync` still fails as
described above.
`ReconnectAsync` explicitly re-arms the state machine instead of waiting at the
ordinary service gate. An explicitly supplied channel or reverse connection is
used by the next attempt; concurrent ordinary reconnect requests join the active
cycle. Certificate reload and identity/locale updates also use the exclusive
control path, so callers can repair credentials before requesting recovery:
```csharp
await session.ReloadInstanceCertificateAsync(ct);
await session.ReconnectAsync(connection: null, channel: null, ct);
```
Cancellation stops a caller's wait; the managed state machine still owns ongoing
automatic recovery until the session is closed.
### `ManagedSessionFactory`
`ManagedSessionFactory` (`src/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:
```csharp
ISessionFactory factory = new ManagedSessionFactory(telemetry);
ISession session = await factory.CreateAsync(...); // actually a ManagedSession
```
> **Naming note.** `ManagedSessionFactory` creates `ManagedSession`
> instances. `DefaultSessionFactory` creates raw `Session` instances. The
> two factories are siblings under `ISessionFactory`; `ManagedSessionFactory`
> uses an inner `DefaultSessionFactory` only as an implementation detail
> for building the underlying transport+session pair.
### `ManagedSessionBuilder`
`ManagedSessionBuilder` (`src/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(...)`:
```csharp
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:
1. Pauses the service gate so all in-flight and incoming service calls
wait.
2. Drives the configured `ReconnectPolicy` to compute the next delay
(with jitter), bounded by `InitialDelay` / `MaxDelay`, capped by
`MaxRetries` (where `0` means unlimited), and constrained by the
shared `MaxTotalReconnectTime` retry budget.
3. Calls `Session.ReconnectAsync` (channel reuse path) and on failure
`ISessionFactory.RecreateAsync` (channel rebuild path), updating the
inner session reference atomically.
4. If the redundancy handler is enabled and the active server is
unreachable, it consults
`IServerRedundancyHandler.GetNextEndpointAsync` and recreates the
session against a failover endpoint with the same message-security mode
and security-policy URI. Targets with a different mode or policy are
skipped before opening their channels; recovery continues to the next
configured endpoint.
5. On success, transitions `Reconnecting → Connected` and releases the
service gate, replaying any deferred calls.
6. If the retry budget and failover are exhausted after an established
connection, reports `Disconnected` and the last error, then schedules a new
bounded cycle after the policy's maximum backoff (at least one second).
Closing or disposing the session stops recovery.
A failed initial connection has a different lifetime: `CreateAsync` disposes the
half-built session and throws when its policy is exhausted. See
[initial-connect failure](#failure-of-the-initial-connect) and
[established-session recovery](#recovery-after-an-established-connection-is-exhausted).
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.
`ManagedSession` also supplies a finite limit for the entire channel recovery cycle,
including in-flight requests and subscription restoration. The default is
`max(3 * KeepAliveInterval, revised SessionTimeout, OperationTimeout)`, sampled
from the inner session when the cycle starts. See
[shared retry budgets](#shared-retry-budget-with-managedsession) for overrides and handoff behaviour.
Because all of this is driven internally, callers must **not** wrap a
`ManagedSession` with `SessionReconnectHandler`; doing so throws
`NotSupportedException`.
Reverse-connected managed sessions require a `ReverseConnectManager` to obtain
fresh connections during recovery. A caller-supplied `ITransportWaitingConnection`
is consumed by the initial connection attempt only. Supplying that connection
without a manager supports a single connection, not automatic recovery: later
attempts fail with `BadSecureChannelClosed` through the configured reconnect
policy. They never fall back to opening an outbound connection.
`ConfiguredEndpoint.UpdateBeforeConnect` controls whether endpoint discovery
refreshes the configured endpoint before connecting. It does not authorize
automatic security-policy changes and does not disable discovery needed during
recovery. Reconnect and failover preserve the selected security mode and policy;
if the server or failover target no longer offers them, the connection attempt
fails visibly. To use a different policy, explicitly reconfigure and establish a
new connection rather than relying on automatic reconnect to negotiate one.
### Generation-bound client requests
Use `SessionRequestContext` when several calls must use the **same server
session**, such as continuing a history read, writing an open file, or acting on
a target you have already checked. A `ManagedSession` can reconnect between
those calls. Without a context, an old file handle or continuation point could
be sent to the replacement session.
Capture a context from your existing `ManagedSession` and pass `context.Session`
to the clients or generated proxies used by the whole operation:
```csharp
using Opc.Ua.Client;
await using SessionRequestContext context =
await SessionRequestContext.CaptureAsync(managedSession, ct);
ISession requestSession = context.Session;
// Use requestSession for target lookup and every subsequent call in this operation.
```
The context borrows the connection; it does not connect or reconnect for you.
Keep it alive until resources opened through it have been cleaned up. Disposing
it releases its local resources, not the owning session.
If recovery starts or the captured session becomes stale, later calls fail with
`BadSessionIdInvalid` instead of following the replacement. `Invalidated` lets you
stop work that depends on that session. Start a new context and obtain fresh
handles before starting another operation.
The context does not make a sequence atomic or retry failed calls. Late
cancellation does not replace a returned mutation result; a lost response can
mean the mutation committed, so inspect the server before retrying. Changes to the
server's address space or permissions still need your application's normal
handling.
Capture requires the stack's `ManagedSession` and a supported transport;
unsupported custom sessions or managed transports return `BadNotSupported`.
For ordinary independent calls, continue using the managed session directly.
See [generation-bound file transfers](FileSystemClient.md#generation-bound-transfers),
[historical reads](HistoricalAccess.md#bounded-pages-and-session-lifetime), and
[role snapshots](RoleBasedUserManagement.md#bounded-role-snapshots) for the
corresponding client APIs.
### Closing a `ManagedSession`
`CloseAsync` requests the close on the connection state machine, which
cancels the connect, reconnect or failover attempt in flight before it
closes the inner session. Without that cancellation a close issued while
the session is reconnecting would have to wait out the current attempt,
which runs to the endpoint's `OperationTimeout` against a peer that
accepts the connection but never answers.
```csharp
// Bounded close: returns BadTimeout when the close does not settle in
// time, and closes the transport channel as well.
StatusCode status = await session.CloseAsync(
timeout: 10_000,
closeChannel: true,
ct);
```
Both overloads close through the state machine and report the status of
the inner `CloseSession` call; `CloseAsync(ct)` is equivalent to
`CloseAsync(0, closeChannel: true, ct)`, where a `timeout` of `0` leaves
the wait unbounded (other than by `ct`). Passing `closeChannel: false`
tears down the session but leaves the transport channel open and owned by
the caller, so it can be reused for another session.
### 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): a
`BadServerTooBusy` `ServiceFault` carries a machine-readable
`RetryAfterMs` value (a whole-millisecond `Int64` in the standard
`AdditionalParametersType`), attached by the server in
`EndpointBase.CreateFault` (via `RetryAfterHeader` /
`ServerBusyException`) and read by the client in
`ClientBase.ValidateResponse` — the choke point the source-generated
clients call for every service. It is delivered regardless of
`ReturnDiagnostics`.
- **HTTP `Retry-After`** (HTTPS transport): the `AddHttpsRateLimiter`
gate sets the header on its HTTP 429, and the client's
`HttpsTransportChannel` maps a 429/503 carrying `Retry-After` to
`BadServerTooBusy` with 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](RateLimiting.md) for the server limiters that produce these
signals.
## 4. `IClientChannelManager` — centralised channel sharing and reconnect
`IClientChannelManager`
(`src/Opc.Ua.Core/Stack/Client/IClientChannelManager.cs`) is the new
central registry of client-side transport channels introduced by issue
[#3288](https://github.com/OPCFoundation/UA-.NETStandard/issues/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:
1. **Channel sharing** — multiple sessions (or discovery / registration
clients) whose `ConfiguredEndpoint` and (optional) reverse-connect
handle match share *one* underlying secure channel. The shared
channel stays open until the last lease is released.
2. **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.OnReconnectAsync`
in parallel.
3. **Transparent gating** — service calls through an
`IManagedTransportChannel` block until the channel is `Ready` (gate
released after both transport open AND participant reactivation
complete). Internal reactivation traffic (e.g. `ActivateSession`
issued by a participant inside `OnReconnectAsync`) uses a non-owning,
generation-bound recovery view. There is no ambient bypass: ordinary
calls through the owning lease still wait for `Ready`.
### Session factory choices
Code that accepts an `ISessionFactory` can choose the session and
reconnect model without changing the consuming client:
- **`DefaultSessionFactory`** creates a raw `Session`. It does not run a
reconnect state machine; the caller owns reconnect / recreate.
- **`ManagedSessionFactory`** creates `ManagedSession`, which wraps the
raw session in the built-in reconnect state machine.
- **`ChannelManagerSessionFactory`** creates a raw `Session` whose
transport channel comes from an `IClientChannelManager`. Multiple
compatible sessions share channels and channel-level reconnect is
coordinated centrally by the manager.
```csharp
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 `EndpointConfiguration` value properties
(timeouts, encoding, message-size limits).
- Client instance-certificate thumbprint.
- Reverse-connect identity (the `ITransportWaitingConnection` instance
used to acquire the channel; `null` for 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.
That identity remains stable after the manager consumes the reverse connection.
A supplied connection is single-use: concurrent acquisition cannot consume it
twice, and a recovery attempt cannot reopen an already-consumed socket.
### 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 awaits participant recreation before admitting ordinary requests. |
| `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)` and awaits it before transitioning to
`Ready`. On target frameworks without default interface method support,
participants opt in with `IRecreateAwareReconnectParticipant`. The non-owning
managed-channel view passed to the legacy `OnReconnectAsync` remains usable for
that immediately following recreation callback. It expires when this recovery
finishes, times out or is cancelled; a later generation cannot reuse it.
Participants must keep the original owning lease for ordinary requests rather
than retaining the recovery view for application callbacks or background work.
Closing or disposing a recovery view never releases that lease.
`Session` implements `IChannelRecoveryParticipant`, which receives the unchanged
owning lease and a separate recovery send channel. Each callback has its own
scoped view. Callback-dependent subscription restoration runs through
`CompleteRecoveryAsync` after the channel admits ordinary requests, and the
reconnect caller still awaits that restoration.
Recovery remains active for every session sharing a channel until all admitted
participants finish restoration. A participant that finishes early continues
to suppress transport keep-alive failures while a sibling is restoring.
Explicit recreation and certificate reload acquire recovery ownership before
pausing and draining Publish, so a competing reconnect cannot overlap that work.
Recreation retains that ownership through cancellation cleanup and the final
Publish drain. Only the owning recovery can release its publishing pause, and
the session admits another recovery after that cleanup finishes.
An explicit `ReconnectAsync` with a supplied channel is rejected while an
existing channel recovery still owns the session. A supplied managed channel
becomes the session's managed binding when it is installed, including when
activation subsequently fails. `ManagedSession`
rebinds its channel events on both success and failure. Recovery markers belong
to their owning channel, and completion from an older recovery cannot clear a
new owner's marker. A retired channel's state cannot suppress keepalive recovery
or trigger recovery of its replacement.
Automatic V2 subscription updates remain paused while subscription restoration
is pending, including a handoff after deadline expiry. Recovery cancels active
update passes and restores subscriptions explicitly before admitting their
automatic retries. See [publishing during session recovery](Subscriptions.md#publishing-during-session-recovery).
Successful network-path or token-reuse reactivation restarts keepalive
monitoring and classic Publish replenishment even when no new session or
subscription is created. A failed or cancelled reactivation does not restart
these workers.
`IReconnectParticipant.CreateReconnectBudget` supplies a budget for each new
shared recovery cycle, or returns null to impose no participant-specific limit.
The manager samples active participants outside its entry lock. The earliest
participant or caller deadline wins. `Session` supplies a budget only when
configured by its `ManagedSession` owner. Raw sessions and discovery clients
impose no automatic limit, although a managed session sharing their channel can
constrain the shared cycle.
The budget callback must return promptly without starting recovery. An exception
while creating or evaluating a participant budget terminates the shared recovery
with `BadSecureChannelClosed`, preserving the original error in diagnostics. The
manager sends final participant notifications, releases recovery ownership, and
publishes `Faulted`; it does not ignore the failed budget or start an unbounded
replacement cycle. Cancellation of the recovery itself retains its cancellation
semantics rather than being reported as a budget failure.
`IChannelRecoveryParticipant` implementations, including `Session`, must finish
their local recovery cleanup when cancelled. The manager waits for these
callbacks to finish before allowing the outer policy to recover the session.
Other callbacks that continue after cancellation lose their recovery send
capability. Their eventual faults are observed, and a recovery send stops
waiting when its scope expires even if the transport ignores cancellation.
Once the cancelled recovery callbacks have finished, the manager sends a final
`OnReconnectAsync` notification with `reconnectAttempt = -1`. The session clears
its recovery-in-progress flag before awaiting further cleanup. The manager can
then stop waiting for that notification without leaving the session marked as
recovering. The notification gets a fresh shutdown-linked token, not the expired
recovery-cycle token, and is awaited to completion or `ParticipantTimeout`
(five seconds when that timeout is infinite). The manager then releases the
channel recovery cycle and publishes `Faulted`,
allowing the outer policy to run:
```mermaid
sequenceDiagram
participant Manager as Channel manager
participant Session as Inner Session
participant Owner as ManagedSession
Manager->>Session: Cancel recovery callbacks
Session-->>Manager: Recovery callbacks finish local cleanup
Manager->>Session: OnReconnectAsync with attempt -1
Note right of Session: Clear recovery state before awaiting cleanup
Session-->>Manager: Return final-notification task
Note over Manager,Session: Remaining notification cleanup has a bounded wait
Manager->>Manager: Release recovery-cycle ownership
Manager-->>Owner: StateChanged(Faulted)
Owner->>Owner: Run outer reconnect or failover policy
```
### 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 reactivation and recreation callbacks. 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.
Participant deadlines use the manager's injected `TimeProvider`, including on
.NET Framework, so they follow the same clock as reconnect backoff and retry budgets.
### HTTPS resilience vs channel-mgr reconnect
On .NET 8 and later, HTTPS endpoints have two independent resilience layers:
- **HTTP resilience pipeline** — the named `HttpClient` created through
[`IOpcUaHttpClientFactory`](../src/Opc.Ua.Core/Stack/Https/OpcUaHttpClientFactory.cs)
uses `AddStandardResilienceHandler` from the
`Microsoft.Extensions.Http.Resilience` package. 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** — `IChannelReconnectPolicy` runs per
managed channel after the request has escaped the HTTP layer. Its
default `ExponentialBackoffChannelReconnectPolicy` is
`500 ms → 30 s` with 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:
```csharp
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:
```csharp
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(...)`](../src/Opc.Ua.Client/Fluent/ManagedSessionBuilder.cs):
```csharp
ManagedSession session = await new ManagedSessionBuilder(config, telemetry)
.UseEndpoint(endpoint)
.WithHttpsResilience(opts =>
{
opts.Retry.MaxRetryAttempts = 5;
opts.AttemptTimeout.Timeout = TimeSpan.FromSeconds(30);
})
.ConnectAsync(ct);
```
The returned session owns the private HTTP provider and channel manager created
by this builder path. Failed construction releases them; session disposal
releases them after channel use ends. An injected channel manager remains owned
by its caller.
### HTTPS factory + OPC UA cert validation: secure-by-default fallback
`HttpsTransportChannel.CreateHttpClient` chooses between two `HttpClient`
construction paths:
- **Direct path** (`CreateDirectHttpClient`) — builds an
`HttpClientHandler` configured with:
- `ServerCertificateCustomValidationCallback` wired to the OPC UA
`ICertificateValidatorEx` / `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 = false` so an attacker-controlled redirect cannot
silently move the OPC UA call to a different host.
- `MaxConnectionsPerServer` and `MaxRequestContentBufferSize` quotas
enforced at the HTTP layer.
- **Factory path** — calls `IOpcUaHttpClientFactory.CreateClient(...)`
and uses the returned named `HttpClient` (its primary handler is owned
by `IHttpClientFactory` and CANNOT be reconfigured by
`HttpsTransportChannel` after 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:
```csharp
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:
1. **Channel-level retry runs first.** A keep-alive failure on the
inner `Session` triggers `IClientChannelManager.ReconnectAsync(channel, ct)`.
The manager coalesces concurrent triggers from multiple sessions on
the same channel, retries the transport open according to its
`IChannelReconnectPolicy`, then notifies attached sessions via
`OnReconnectAsync`. This initial channel-owned cycle has its own finite
`ManagedSessionOptions.ChannelReconnectTimeout`.
2. **Outer state stays `Connected` while the manager handles it.**
`ManagedSession` observes `IManagedTransportChannel.StateChanged`.
While the manager reconnects the transport, reactivates the session, or
restores subscriptions through provisional `Ready`, a failed keep-alive
does not start a second reconnect through the outer state machine.
Instead, `ManagedSession` leaves its outer state at `Connected` and logs
the failure at Information level with the elapsed channel recovery time.
This is keep-alive failure suppression. It does not stop keep-alive probes
or restart the channel recovery deadline.
3. **Outer retry shares its deadline with channel retry.** When the
channel transitions to `Faulted`, `ManagedSession.StateChanged`
raises `ConnectionState.Reconnecting`. The outer `IReconnectPolicy`
schedules `Session.ReconnectAsync(ct)` and passes the same retry
budget into `IClientChannelManager.ReconnectAsync(channel, budget, ct)`.
Both layers cap their delays to the remaining time. The channel manager
enforces the tighter of that remaining outer budget and its participant
deadlines throughout transport open, reactivation, recreation and restoration.
`ChannelReconnectTimeout` defaults to `null`, selecting
`max(3 * KeepAliveInterval, revised SessionTimeout, OperationTimeout)` in
milliseconds. Only positive finite settings contribute. The requested session
timeout is used until a usable revised timeout is available. Automatic values are
clamped to the supported timer range. Changing keep-alive settings affects the
next cycle, not the active one. An explicit override must be positive and no
larger than `uint.MaxValue - 1` milliseconds. `Timeout.InfiniteTimeSpan` is the
explicit opt-out. Zero and other negative values are rejected.
The deadline uses the manager's injected `TimeProvider`. Partial progress,
per-callback timeout retries and certificate changes do not renew the window.
A tighter coalesced caller shortens an in-flight operation. A looser caller
cannot extend it. Provisional `Ready`, which admits
subscription-restoration requests, does not finish the cycle.
On expiry the manager cancels recovery scopes, waits for built-in session
cleanup, retires cycle ownership, and publishes `Faulted`. A Warning records
the effective duration, elapsed time and phase. The existing outer state machine
can then retry, fail over or close without another keep-alive tick. Late
transport results cannot restore the old entry to `Ready`. Late-opened transports
are closed. Session recreation still drains cancelled Publish attempts completely
before replacing session/subscription state. The deadline never bypasses that drain.
`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. This
outer window starts when the outer state machine takes recovery ownership.
It is separate from the initial channel-only window described above.
For example, with `MaxTotalReconnectTime = TimeSpan.FromSeconds(30)`,
the outer recovery cycle receives one 30-second budget. If a channel reconnect
uses 27 seconds reopening the transport and reactivating sessions, only three
seconds remain for the rest of that outer cycle. Retrying or changing recovery
phases does not grant another 30 seconds.
The same remaining outer budget covers deferred subscription restoration, even
after channel-manager reconnect has returned. Expiry cancels that restoration
and waits for its local unwind before another recovery owner can proceed.
When the 30 seconds expire, the manager cancels any in-flight channel recovery
and waits for the session's local recovery cleanup to finish. It then publishes
`Faulted`. The outer policy evaluates failover or closing with the same exhausted
budget, rather than starting a new channel retry window. Local cleanup can finish
after the deadline, but it does not permit another recovery attempt under that
expired budget.
```csharp
ManagedSession session = await new ManagedSessionBuilder(configuration, telemetry)
.UseEndpoint(endpoint)
.WithChannelManager(channelManager)
.WithChannelReconnectTimeout(TimeSpan.FromSeconds(15))
.WithReconnectPolicy(p => p with
{
MaxTotalReconnectTime = TimeSpan.FromSeconds(30)
})
.ConnectAsync(ct);
```
The same setting is available through DI's `OpcUaClientOptions.Session` or direct
construction with `ManagedSession.CreateAsync(options, configuration, sessionFactory,
channelManager: channelManager, ct: ct)`, where `options` is a
`ManagedSessionOptions` snapshot. Existing factory overloads select the automatic
bound. Use `.WithChannelReconnectTimeout(Timeout.InfiniteTimeSpan)` to opt out of
the participant-imposed bound. Explicit caller budgets still apply.
For migration notes on the budget-aware APIs, see
[the session migration guide](migrate/2.0.x/sessions-subscriptions.md#managedsession-and-automatic-reconnection).
### Diagnostics surface contract — what tags and structured log fields carry
The channel manager emits diagnostics through three independent channels: `System.Diagnostics.Activity` tags (distributed tracing), structured `ILogger` logs under the `Opc.Ua.ChannelManager` category, and `System.Diagnostics.Metrics` instruments. There is no `EventSource` (ETW / `dotnet-trace`) provider; the structured logs carry the same category name and event identities, routed through `Microsoft.Extensions.Logging` / OpenTelemetry logging.
Each event kept its original `EventId` and `EventName` on the `[LoggerMessage]` replacement, so tooling matching on the numeric id or name keeps working:
| EventId | EventName | Level | Message template |
|---|---|---|---|
| 1 | `ChannelOpened` | Information | `Channel opened. Endpoint={Endpoint}, Reverse={Reverse}, Refcount={Refcount}, Participants={ParticipantCount}` |
| 2 | `ChannelClosed` | Information | `Channel closed. Endpoint={Endpoint}, Reason={Reason}, Refcount={Refcount}, Participants={ParticipantCount}` |
| 3 | `StateChanged` | Information | `State changed. Endpoint={Endpoint}, Previous={PreviousState}, New={NewState}, Attempt={ReconnectAttempt}, Status={StatusCode}, ErrorMessage={ErrorMessage}` |
| 4 | `ReconnectStarted` | Information | `Reconnect started. Endpoint={Endpoint}, AttemptCount={AttemptCount}` |
| 5 | `ReconnectCompleted` | Information | `Reconnect completed. Endpoint={Endpoint}, AttemptCount={AttemptCount}, Outcome={Outcome}` |
| 6 | `ReconnectFailed` | Warning | `Reconnect failed. Endpoint={Endpoint}, Attempt={Attempt}, Outcome={Outcome}, Status={StatusCode}, ErrorMessage={ErrorMessage}` |
| 7 | `ParticipantAttached` | Information | `Participant attached. Endpoint={Endpoint}, Participant={ParticipantId}, Refcount={Refcount}, Participants={ParticipantCount}` |
| 8 | `ParticipantDetached` | Information | `Participant detached. Endpoint={Endpoint}, Participant={ParticipantId}, Refcount={Refcount}, Participants={ParticipantCount}` |
Tag and structured-field values surfaced through Activity tags or these logs are restricted to **OPC UA-protocol-level fields only**:
- `StatusCode` (numeric / symbolic), e.g. `BadSecureChannelClosed`
- `ServiceResult.SymbolicId`
- `ServiceResult.LocalizedText.Text` — the operator-facing message
Notably, the following are **never** sent to Activity tags or to the `Opc.Ua.ChannelManager` structured logs above:
- The full `ServiceResult.ToString()` serialization
- `ServiceResult.AdditionalInfo` — typically carries inner-exception messages, file paths, internal IDs
- `Exception.StackTrace`, `Exception.Message`, or any other inner .NET exception detail
- `Inner` recursion into `ServiceResult.InnerResult`
This keeps internal client/server diagnostics out of distributed tracing and logging 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 a separate, local `ILogger.LogDebug` call on the manager, where it stays under the host's log-access controls; it is never folded into the `Opc.Ua.ChannelManager`-category events listed above.
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`, `deadline-expired`, `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 `participant` tag 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-instance `IReconnectParticipant.Id` is preserved on Activity tags and on the `Opc.Ua.ChannelManager` structured logs above 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.
For the removal of the old `EventSource` providers (including this one) and migration guidance for `EventListener` / `dotnet-trace` consumers, see [migrate/2.0.x/telemetry.md](migrate/2.0.x/telemetry.md#etw-eventsource-provider-removal).
### 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.
```csharp
services.AddOpcUa()
.AddClient(opt =>
{
opt.Configuration = config;
opt.Session = new ManagedSessionOptions
{
Endpoint = endpoint,
SessionName = "MyApp",
};
});
// Each call creates a distinct, caller-owned session using the shared channel manager:
await using var sp = services.BuildServiceProvider();
var managedFactory = sp.GetRequiredService<IManagedSessionFactory>();
await using ManagedSession s1 = await managedFactory.ConnectAsync(endpoint, ct);
await using ManagedSession s2 = await managedFactory.ConnectAsync(endpoint, ct);
```
The `Func<CancellationToken, Task<ManagedSession>>` registration is different: it
caches one connected session. Repeated awaits return that same instance; use it
for a single fixed endpoint, not to create independently owned sessions.
### Migrating from `AttachChannel` / `DetachChannel`
`IClientBase.AttachChannel(ITransportChannel)` and `DetachChannel()`
are marked `[Obsolete]` but remain functional. Migration path:
```csharp
// 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:
```csharp
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/`](../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.
```bash
# 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
(`src/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 `Subscription` and `MonitoredItem` classes in
`src/Opc.Ua.Client/Subscription/Classic`.
- A fire-and-forget publish loop driven from
`Session.BeginPublish` / `OnPublishComplete`.
- The `Subscriptions` collection on `ISession` and `ManagedSession`.
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`
(`src/Opc.Ua.Client/Subscription/SubscriptionManager.cs`) inside
the `ISubscriptionEngine` contract. It owns:
- The `Subscriptions.Subscription` / `Subscriptions.MonitoredItems.MonitoredItem`
records consumed via `IOptionsMonitor<T>`.
- A worker-pool publish loop with adaptive worker count.
- The full subscription-service-set lifecycle: `CreateSubscription`,
`ModifySubscription`, `SetPublishingMode`, `Republish`, plus
`CreateMonitoredItems`, `ModifyMonitoredItems`, `DeleteMonitoredItems`,
`SetMonitoringMode`, `Call`, `SetTriggering` — all routed back through
the `Session`'s service-set client methods.
- An `ISubscriptionNotificationHandler` callback contract for
`OnDataChangeNotificationAsync` / `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.
```csharp
// 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](Subscriptions.md#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:
```csharp
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 of `IEncodeable`) and provide a `Reuse()` method
that resets fields and returns the instance to its activator's
pool. The activator derives from `PooledEncodeableType<T>` instead
of `EncodeableType<T>` and owns a bounded, lock-free pool.
- When `PoolNotifications` is `true`, the V2 subscription
dispatcher walks each `DataChangeNotification` /
`EventNotificationList` in a `finally` block after the handler
await completes, calling `Reuse()` on every payload item that
implements `IPooledEncodeable`. Types that do not implement the
interface are skipped silently — the walk is universally safe.
- The walk runs on the subscription's single-reader
`ProcessMessageAsync` loop, 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:
```csharp
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` /
`EventNotificationList` reference past the await.
- Retaining individual `MonitoredItemNotification` /
`EventFieldList` references 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 late
`Reuse()` 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.EncodeMessage` path, 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 an `ExpandedNodeId`, 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 local `NodeId`, 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 local
`NodeId` and 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).
Concurrent atomic node, value and reference lookups share one fetch for a cache
key. Cancelling one caller cancels only that caller's wait; another caller can
still complete, and the successful result remains cached.
For migration details see
[2.0 migration guide — Node States and INodeCache](migrate/2.0.x/node-states.md#inodecache-changes).
## 7. Putting it all together
Pick the entry point that best matches your call site:
- **Already have a `Session` and an existing reconnect helper.** Keep
`Session` + `SessionReconnectHandler`. They are first-class APIs in
2.0 and not deprecated. You may opt into the V2 subscription engine
by passing
`SubscriptionEngineFactory = DefaultSubscriptionEngineFactory.Instance`
to `DefaultSessionFactory`.
- **New service / ASP.NET Core / generic-host application.** Use
`services.AddOpcUa().AddClient(...)` and resolve
`Func<CancellationToken, Task<ManagedSession>>` from DI.
- **New code without DI.** Use `ManagedSessionBuilder.ConnectAsync(...)`.
- **Drop-in replacement for `DefaultSessionFactory` that wants managed
reconnect.** Switch to `ManagedSessionFactory` — the public surface
(`ISessionFactory`) is identical.
- **Need to share a single underlying `Session` across two reconnect
drivers, or wrap a `ManagedSession` with `SessionReconnectHandler`.**
Don't. The reconnect drivers race, and `SessionReconnectHandler` will
throw `NotSupportedException` to enforce this.
## Server session lifecycle
Channel and security-policy association failures are rejected before
authentication-failure accounting. Requests from another channel cannot
lock out the application owning an unactivated session; genuine invalid
signatures and identity credentials still count toward that application's
lockout threshold.
Certificate user tokens require proof of possession when their token policy
requires signing, even on a `None` SecureChannel. A missing, empty or incorrect
user-token signature is rejected with `BadUserSignatureInvalid`, as specified
by OPC UA Part 4, 5.7.3.3. Only a policy without signing accepts an absent signature.
Closed sessions do not accept browse continuation points. A pending
continuation remains caller-owned until its mirror write and local
admission succeed. If the session closes during persistence, the late
mirror entry is removed and `BadSessionClosed` is returned without
transferring or disposing the caller's resource twice.
Replacing or resetting a session's ephemeral user-token key retires the
previous key. A key already borrowed by credential decryption remains
valid until the final decrypt operation returns or fails, then its
cryptographic resources are released.
Reloading configured reverse connections removes a snapshot of the
configured entries while preserving dynamically added connections,
including on .NET Framework.
Discovery registration runs as tracked, cancellable work. Server stop
cancels and drains an in-flight registration before releasing server
resources. Consumed or stopped timer callbacks cannot schedule another
attempt or replace a newer timer; active registrations retain their
configured success interval and bounded failure backoff.
## See also
- [2.0 migration guide — ManagedSession and Automatic Reconnection](migrate/2.0.x/sessions-subscriptions.md#managedsession-and-automatic-reconnection)
- [2.0 migration guide — Sessions, GDS Client, and Subscriptions](migrate/2.0.x/sessions-subscriptions.md) — V2 subscription engine, fluent builder, and DI integration are covered alongside ManagedSession.
- [TransferSubscription](TransferSubscription.md) — server-driven session-handoff support.
- [Diagnostics](Diagnostics.md#1-telemetry-context-itelemetrycontext) — telemetry plumbed through `ITelemetryContext` on every factory and session type.
- [Reverse Connect](ReverseConnect.md) — works with both `Session` and `ManagedSession`.
- [Diagnostics — Packet capture, dissection, and replay](Diagnostics.md#4-packet-capture-dissection-and-replay) — `Opc.Ua.Core.Diagnostics` composes with `IClientChannelManager` via the global `TransportBindings.Channels` registry; channel sharing, transparent reconnect, and faulted-entry swap all flow through to one continuous capture session per `ManagedChannelKey`.