mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-17 05:11:13 +08:00
1f6700c30b
# 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>
1476 lines
76 KiB
Markdown
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`.
|