Files
UA-.NETStandard/docs/Sessions.md
Marc Schier fa8d45f2cf Fix server/core validation, lifecycle and protocol regressions (#4477)
# Description

**Server/core audit fixes with regression tests. Latest review-fix
validation, merge validation and earlier acceptance results are
distinguished below.**

This addresses a 95-finding server/core report. Every finding was
reviewed with
the OPC UA specification knowledge base or, where the standard does not
prescribe a C# implementation, the corresponding ownership/configuration
contract. Incorrect or withdrawn subclaims remain qualified rather than
being
presented as additional fixes.

The original checkpoint covers session identity/certificate validation
and
revocation; certificate/key ownership and transactional updates; TCP
reconnect,
receive/admission and buffer lifetime; HTTP quotas; subscription
admission,
durability and pooled payloads; file-operation lifecycle; historian
aggregates;
fluent/state-machine behavior; and related utilities. It includes
targeted
regressions and directly related documentation.

The approved acceptance cleanup is separate in the commit history:

| Cleanup | Verified result |
| --- | --- |
| Invalid identity-policy and KeyCredential fixture inputs | Fixtures
explicitly advertise Username and use its generated policy ID.
Credential persistence uses valid clear mode inside the existing
encrypted admin context. Original permission, lockout and negative
encryption assertions remain. |
| Native GDS lifetime and concurrent connection failures | Retain
ApplicationInstance until client teardown. A deterministic native
reproducer proved the test-only channel cap of 10 was exhausted;
removing that override restores the existing bounded default of 1000. No
global serialization or production quota change. |
| Stress certificate ownership and close accounting | Balance two helper
references and the fake transport's server-certificate ownership.
Replace the invalid event-count bound with exactly-once physical
disposal, opens = closes, zero active entries and one logical entry
lifetime. |
| Participant deadlines | Use the injected TimeProvider with the
existing cross-framework WaitAsync overload. Tests pin one tick before,
exact expiry, terminal notification, recovery and timely completion. |
| Reproducibility and platform assertions | Preserve canonical WoT
bytes/hashes, explicit display-culture contracts and exact
numeric-vector assertions. Accept Apple's CryptographicException subtype
without weakening disposal balance. |
| Missing user-signature proof | Reject null/empty proofs before OS
crypto. Return BadUserSignatureInvalid per Part 4, 5.7.3.3. None-policy
and valid-signature controls remain. A CI-only test nullable-context
error was corrected without changing production behavior. |
| Durable sample restore and shared test storage | Dequeue waits for
actual batch residency instead of treating failed restore completion as
success, and restored files close before deletion. Five deterministic
regressions failed before the fix. The two fixtures that clear the
shared batch directory are isolated; all original 3,000-item assertions
remain unchanged. |

## Latest review follow-up

Commit `828856850219518970e8e2f580b8f94b3c1fc748` fixes the deferred
robotics
disposal reported in the latest review.
`RobotIntentNodeManager.DisposeAsync`
now joins a shared controller-host drain and then explicitly awaits base
node-manager disposal. A controller's bounded shutdown wait may defer
its
cleanup, but no longer lets the node-manager override finish early.
Concurrent
callers, including an asynchronous call after synchronous `Dispose`,
cannot
bypass the drain after the host list has been detached.

Synchronous disposal remains nonblocking for deferred work. Deferred
initialization failures are logged and retained, and base cleanup
failures
propagate to asynchronous callers. The existing controller-level bounded
disposal contract is unchanged; the node-manager contract is documented
in
`docs/Robotics.md`.

| Regression requirement | Evidence |
| --- | --- |
| Wait for deferred hosts; preserve synchronous compatibility and
concurrent joins | `NodeManagerAsyncDisposalWaitsForDeferredHostsAsync`:
four cases covering both entry-point orders and zero/positive host
shutdown waits. All four failed before the fix. |
| Wait through base cleanup and observe failures |
`NodeManagerDisposalWaitsForBaseCleanupAndReportsItsFailureAsync`: four
cases hold the actual base-resource semaphore, verify pending callers
after the host finishes, then check success or the same propagated
exception and exactly-once cleanup. |

The focused `IntentHostingCoverageTests` and
`IntentControllerHostTests.Dispose`
selection passed **30/30 on net10.0 and 30/30 on net48**, with no
failures or
skips. The final net10 solution build passed with zero errors and only
the four
previously documented CA2249 warnings in unchanged fuzz-test code. No
new
warnings, analyzer suppressions, dependencies, or weakened assertions
were added.
The review thread received a commit-specific reply and was explicitly
resolved.

This verification is scoped to the robotics feedback; the earlier full
Subscriptions-run limitation remains recorded in the merge section
below.


## Documentation review follow-up (2026-09-14)

Commit `f9a9686fcc5d3cfd19cbbc8c2d826e20f5c7e72f` addresses the six
follow-up
threads on documentation and implementation rationale. Each has an
in-thread
reply citing the commit and an explicit resolution.

Multiline XML documentation now covers the declarations introduced or
modified
throughout the entire PR, including internal contracts, samples, tests,
and
public members of related private helpers. A syntax-aware inventory
checked
**2,031 declarations across all 256 PR C# paths with zero documentation
gaps**.
A supplementary check also covers **75 previously unchanged public
members**
of private helpers involved in the changed code. Token comparison across
the
conditional-compilation branches proves this follow-up changes **no
executable
C# or test assertions**. Project-file changes are rationale comments
only.

The implementation questions are documented at their source:

| Question | Resolution |
| --- | --- |
| Certificate assembly friendship | Server Push configuration reuses the
internal explicit-subject CSR helper with the existing key; no public
crypto API was added. |
| Core.Types assembly friendship | Server builders use internal
transition callbacks/revision guards; Server.Tests checks the revision
deterministically without creating a public compatibility contract. |
| Always-single-reader notifications | One dequeuer preserves event
order; optional concurrency fans out within one event and joins before
the next event. |
| Asynchronous disposal completion | `DisposeAsync` already joins the
completion published after admitted operations and all cleanup attempts.
The compatibility `Dispose` remains nonblocking to avoid deadlocking an
admitted callback against its own drain. |

The requested detailed review disposition follows. It distinguishes
actual
fixes from confirmed policies and incorrect subclaims rather than
counting every
comment as a new runtime change.


### Detailed disposition of review 5191795284

The [SDK
review](https://github.com/OPCFoundation/UA-.NETStandard/pull/4477#pullrequestreview-5191795284)
was submitted against `1c4a9df6c`, before the subsequent merge and
feedback fixes.
Its **15 inline threads** have each received an action-specific reply
and an
explicit resolution. A fresh GraphQL query verified those replies and
resolution
states; the fixes in `b8a59990f` remain ancestors of the current branch.

| Review concern | Action and disposition | Concrete regression evidence
|
| --- | --- | --- |
| Injected certificate-store providers bypassed | **Fixed.**
Registration, configuration snapshots, and validation use the injected
providers. Explicit custom types remain authoritative; the built-in
Directory catch-all is deferred so native X509 paths and later custom
providers are not captured. Failed store opens dispose the created
store. | `InjectedProviderResolvesRegisteredAndConfiguredTrustStores`
covers default-provider ordering and both registration paths; provider
validation/open-failure cases and the unchanged
`TrustedCertificateInjectedViaIndependentX509StoreIsObservedAsync` cover
real use and independent-store visibility. |
| Unguarded timer-thread leadership callbacks | **Fixed.** Each
subscriber's exception is logged independently and cannot suppress
subsequent subscribers or fail lease operations. `IsLeader` reads the
deadline without invoking application callbacks. |
`ThrowingLeadershipSubscriberDoesNotFailAcquireOrDisposeAsync`,
`ExpiryNotifiesOtherSubscribersWhenOneThrowsAsync`, and
`IsLeaderDoesNotDispatchExpiryNotificationsAsync` were all demonstrated
failing before the fix. |
| Legacy pending-key provider accepts an unfinishable regenerated CSR |
**Fixed by explicit capability validation.** A provider implementing
only `IPendingCertificateKeyStore` receives `BadNotSupported` before key
generation or persistence when regeneration is requested. Existing-key
CSR/update remains supported. Regeneration requires matching-only
consumption; no unsafe destructive probing was introduced. |
`LegacyPendingProviderRejectsRegenerationBeforeCreatingOrSavingAKeyAsync`
checks no key or store mutation;
`LegacyPendingProviderCompletesExistingKeySigningAndUpdateAsync` proves
the supported flow completes. |
| Shutdown waits behind user deactivation | **Fixed.** Accepted
deactivations drain before the master's lifecycle gate is taken.
Configuration retains its binding until the shared drain completes,
including concurrent shutdown callers and direct master disposal. | Four
cases of `ShutdownDrainsDeactivationOutsideTheSessionClosingGateAsync`
exercise real configuration/binding/ServerInternalData/master reentry,
retained address space, exact session/subscription closure, and no
production cancellation shortcut. |
| Subclass cleanup runs under the operation-lifetime lock | **Fixed.**
Cleanup starts outside that lock. A pre-published completion joins all
disposers, and only the active asynchronous teardown context may use
guarded helpers. Captured contexts lose that privilege when the callback
exits. |
`SynchronousOwnedCleanupLetsSamplingWorkerReachClosedAdmissionAsync`,
`DisposeCoreCanUseGuardedMembersWithoutReopeningCallerAdmissionAsync`,
captured-context rejection, and exact cleanup-failure/ownership
assertions cover both synchronous and suspended cleanup. |
| Discovery requestId-zero chunk read after pool return | **Fixed by the
earlier merge.** Discovery validation precedes `SaveIntermediateChunk`;
no read occurs after that path can return the buffer. The ordering is
retained. | `DiscoveryChannelReadsTheFirstChunkBeforeItIsReturnedAsync`
uses a poisoning pool and the requestId-zero path. |
| T1.8 OPN cleanup misses post-decryption throws | **Fixed by the
earlier merge and pinned with new regressions.** Both sequence rejection
and certificate mismatch release the decrypted body and input ownership.
| `OpenSecureChannelRejectionAfterDecryptionReturnsEveryRentalAsync`
checks the exact audit status and pool conservation. Omitting either
corresponding release in an isolated snapshot made its case fail; no
mutation remains. |
| T1.2 mount-root protection depends on the provider | **Fixed at the
host boundary.** Separator-only aliases resolve to the protected root.
Traversal, empty/whitespace segments, backslashes, and drive/stream
qualifiers are rejected before arbitrary providers are invoked. Valid
nested paths and root destinations remain supported. | A deliberately
permissive provider is used by
`NonCanonicalObjectPathsCannotDeleteMoveOrCopyAsync` and invalid-target
cases; exact zero-call checks are paired with legitimate
nested-path/root-destination controls. |
| Inline trusted/issuer entries now face CRL checks | **Intentional
strict behavior, documented and tested.** A configured backing store
with strict unknown-status rejection requires current revocation
information. Explicit-only lists without a store retain their no-store
behavior. Actual revocation cannot be suppressed; a matching store
entry's policy takes precedence. |
`InlineIssuerWithStoreHonorsStrictUnknownRevocationPolicyAsync`,
`InlineIssuerRevocationIsNeverSuppressedAsync`, store-precedence cases,
and explicit-only controls preserve the missing-information versus
actual-revocation distinction in Part 4 6.1.3. |
| RSA minimum applied to unspecified ECC identifiers | **Fixed at the
application-certificate check.** The RSA minimum is compared only with
an actual RSA public key. An unspecified ECC identity is not replaced
simply because its key size is below an RSA threshold. |
`SilentCertificateCheckPreservesUnspecifiedEccIdentityAsync` checks DER,
thumbprint, store contents, and private-key retention; explicit RSA and
HTTPS minimum controls remain. |
| Failed directory refresh wedges corrective mutations | **Fixed.**
Recoverable automatic pre-refresh failures are logged and leave
reconciliation pending, but do not block corrective provider mutations.
Explicit refresh errors, capacity admission, caller cancellation, and
actual provider denials remain enforced. |
`CorrectiveDeleteRecoversAfterPersistentOverLimitRefreshFailureAsync`
converges an externally over-limit directory back to its exact allowed
contents. Capacity, cancellation/token, and six refresh-error controls
check negative paths. |
| Timed-transition rejection logs at 10 Hz | **Fixed.** Repeated
`(state, revision, status)` warnings are deduplicated without changing
the retry cadence. A different status or state entry permits another
warning. | `RepeatedTimedRejectionLogsOnceAndRecoversAsync` checks one
warning over eleven rejections and success on the next attempt.
Status-change and same/different-state reentry controls pin reset
behavior. |
| Bad unsubscribe strands retired-generation bookkeeping | **Fixed.**
Retired all-event bookkeeping completes even when unsubscribe reports
Bad; the first Bad is still returned to the caller. | Good and
BadCommunicationError cases of
`RetiredAllEventsDeletionCompletesBookkeepingAndReleasesGenerationAsync`
check event/snapshot removal, generation release, and exactly one
unsubscribe without finalization replay. |
| CSR subject parsing and fallback | **Fixed for existing and
regenerated keys.** Omitted subjects fall back to the actual stored
certificate. Regeneration resolves the public certificate's subject
before creating a key. Malformed or unresolvable subjects return
`BadInvalidArgument` before pending-key mutation. | Store-only fallback,
real-generator null/empty cases, and missing-subject/malformed-DN
controls verify the signed CSR, matching fresh pending key, unchanged
active identity, and no destructive side effects. |
| Committed RemoveUser change reported as failed | **Fixed for
RemoveUser and ModifyUser.** Accepted cleanup is still awaited and close
failures remain logged, but cannot replace the committed mutation
result. Precommit failures retain their original result and side
effects. |
`CommittedUserChangeKeepsResultAndPublishedUsersWhenCloseFailsAsync`
checks published Users, Good result, logging, and continued closes.
`FailedUserChangeKeepsPublishedUsersAndDoesNotCloseSessionsAsync` checks
BadNotFound with no publication or close. |
| Alleged pooled decoded-request aliasing | **No runtime redesign
needed; reviewer rebuttal confirmed.** The decoder copies the relevant
input data. Misleading ownership comments were corrected rather than
introducing a new dispatch model. |
`DecodedRequestRetainsValuesAfterInputBuffersAreReturnedAsync` checks
the exact decoded NodeId and ByteString after returned arrays are
poisoned. |

The current documentation-only follow-up rechecked **104 Core, 473
Server, and
4 Configuration cases** covering these concerns: **581 passed, zero
failed or
skipped**. The final net10 solution build also passed with **zero
warnings and
errors**. The complete earlier Core/Server/Configuration and net48
results remain
listed separately rather than being presented as rerun current-head
acceptance.

## Earlier review follow-up

Commit `b8a59990fe2e8e967a12ffd5d9158beed7831143` addresses all 19
review
threads. Each thread has an in-thread reply naming this commit and was
explicitly
resolved after verification; the final paginated query found 19 resolved
and
zero unresolved threads.

The follow-up fixes injected certificate-store selection (including
default
provider ordering and native X509 paths), actual-RSA minimum
enforcement, and
CSR capability/subject validation before key mutation. Node-manager
teardown
uses simpler scoped ownership and drains accepted user revocation
outside the
master gate; committed user results and retired-event cleanup remain
correct
when cleanup reports failure. File mutations can recover after failed
refresh
without bypassing capacity or cancellation, provider paths protect mount
roots,
and repeated timed rejections no longer flood logs. Leadership
subscriber
failures are isolated without callbacks from `IsLeader`.

Regressions also pin the already-merged OPN/discovery buffer fixes and
prove
decoded request values do not borrow returned input buffers. The strict
inline
issuer revocation policy is documented and tested, not weakened.
Existing tests
were changed only for the reviewed behavior: the incorrect
committed-user result
was corrected, and inline-issuer/root-alias controls were expanded.

### Review-fix validation

| Suite | Scope | Framework | Passed | Existing TRX NotExecuted |
| --- | --- | --- | ---: | ---: |
| Core | Complete project | net10.0 | 4,970 | 88 |
| Server | Complete project | net10.0 | 6,414 | 69 |
| Configuration | Complete project | net10.0 | 231 | 0 |
| Core | Certificate, lease and buffer compatibility slice | net48 | 153
| 0 |
| Server | Reviewed lifecycle, user, file, timer and CSR compatibility
slice | net48 | 692 | 0 |
| Configuration | Complete project | net48 | 231 | 0 |
| CSR provider regressions | Final portable-mock follow-up | net10.0 |
11 | 0 |

All runs exited successfully with normal concurrency and leak detection.
The Server console reports 5 skips; its TRX additionally records
existing
assumption/explicit cases, hence the 69-record count above. The
Framework build
caught two modern-only `ValueTask.CompletedTask` usages in new mock
setup; only
those expressions changed to the equivalent portable
`default(ValueTask)`.
The complete modern suites precede that test-only correction; all
production
files remained byte-identical, and the corrected fixture passed on both
targets.

The final net10 solution build succeeded with 0 errors and the same 4
CA2249
warnings in unchanged fuzz-test code. Diff checks and changed-line
whitespace
checks are clean; whole-file formatting still identifies 10 unchanged
baseline
lines, which were not reformatted outside the feedback scope. The
committed
35-file tree was hash-verified against the validation inputs.

This is the review-feedback verification scope, not a rerun or sign-off
of the
historical complete platform/native/soak acceptance campaign below.


## Latest upstream merge

Merge `9d9bf679f317164c5b2b90924cd3cdc27a86d451` integrates `master`
through `162fc5ebb` (#4486),
including the AddNodes child-name index, shared certificate-error
reporting,
CTT sample-data corrections, and the accompanying regressions and
documentation.

The CreateSession conflict preserves this branch's certificate
ownership,
required certificate, application-URI, and channel-certificate checks.
The
validation result now retains nested errors through `ThrowIfInvalid`,
allowing
the upstream shared reporting helper to mask trust, policy, and
revocation
failures consistently without losing audit detail.

The new indexed AddNodes regression initially attempted to reuse a
deterministic
NodeId after renaming its existing node. Renaming frees the BrowseName,
not that
NodeId. `IndexedBrowseNameReusePreservesExistingNodeIdentityAsync` now
proves
both contracts: the derived-ID collision leaves the original node
intact, then
a fresh explicitly requested ID can reuse the old name while the new
name
remains protected against duplicates. No production identity rule was
relaxed.
The incoming seeded random-index test uses the existing `UnsecureRandom`
wrapper
with the same seed and sequence; no new analyzer suppression was added.

| Local merge validation | Framework | Passed | Existing exclusions |
| --- | --- | ---: | ---: |
| Core, complete project | net10.0 | 5,081 | 88 |
| NodeState / BaseInstanceState | net10.0 | 579 | 0 |
| Server node management, certificate and session-security selection |
net10.0 | 112 | 0 |
| InformationModel node-management services | net10.0 | 29 | 1 |
| NodeState / BaseInstanceState | net48 | 578 | 0 |
| Core certificate validation, error reporting and TCP buffers | net48 |
111 | 0 |
| Server node management, certificate and session-security selection |
net48 | 112 | 0 |

All selected runs exited successfully at normal concurrency: **6,602
passing
test executions**. Modified test assemblies were explicitly rebuilt, and
the
combined name/identity regression was verified by name in both net10 and
net48
results. No skips or weakened assertions were introduced.

The final `dotnet build UA.slnx -c Release --no-restore -m:1` with
`CustomTestTarget=net10.0` succeeded with **zero warnings and zero
errors**.
The resolved source and adjusted tests pass whitespace verification, and
their
source hashes match the validation inputs.

This is merge-specific verification. Earlier review results and the
previously
reported incomplete full Subscriptions runs below remain scoped to their
own
commits; they were not reopened or relabeled as passing by this
operation.

## Previous upstream merge (2026-09-15 morning)

Merge `c4694e20ab089c555af233642042133ff3a03ac8` integrates `master`
through `484d6c887`, retaining
#4481's shared Like parser/GDS query fixes, #4488's RequestHandle
recovery in
ServiceFaults, and #4487's expired-session and durable-transfer
corrections.

Three conflict resolutions preserve both sides:

- Alias searches now use the shared `LikePattern` implementation,
including
upstream malformed-list validation. A timeout overload retains this
branch's
100 ms per-alias deadline and `BadTimeout` reporting without keeping a
second
wildcard translator. The shared matcher's original unlimited overload
remains
unchanged for existing GDS/ContentFilter callers. New regressions cover
bounded prefix/list scans, reuse after timeout, and the alias service
result.
- Expired activation closes outside the session-manager gate using
upstream's
once-only timeout claim, audit, and diagnostic accounting. This branch's
  original-channel and security-policy checks remain in place.
- TCP buffer tests retain this branch's ownership, poisoning-pool and
post-decryption checks alongside the upstream RequestHandle/fault
overload
  tests; neither set was discarded.

The upstream HTTPS binary behavior is intentionally retained: a decoder
`ServiceResultException` produces HTTP 200 with an OPC UA ServiceFault
carrying
the decoder status, response timestamp, and recoverable RequestHandle,
rather
than the previous HTTP 500 text response. Other exceptions retain their
existing HTTP error handling.

| Local validation | Framework | Passed | TRX NotExecuted |
| --- | --- | ---: | ---: |
| Core, complete project | net10.0 | 5,054 | 88 |
| Sessions, complete project | net10.0 | 780 | 610 |
| Server aliases, expiry/security and subscriptions | net10.0 | 288 | 0
|
| GDS database, paging and live directory calls | net10.0 | 263 | 0 |
| Shared Like patterns and bounded evaluation | net10.0 | 109 | 0 |
| Subscriptions transfer/durability/service selection | net10.0 | 23 | 1
|
| HTTPS WebApi, complete project | net10.0 | 346 | 0 |
| InformationModel aliases | net10.0 | 54 | 5 |
| Shared Like patterns and bounded evaluation | net48 | 109 | 0 |
| Core fault handles, TCP/HTTPS and filters | net48 | 268 | 0 |
| Server aliases, expiry/security and subscriptions | net48 | 288 | 0 |

These selected runs exited successfully with normal test concurrency:
**7,582 passing test executions**. Sessions' console reports 379 skips;
the
TRX also contains conditional/assumption exclusions, so the table uses
the
actual 610 NotExecuted records. No skips or weakened assertions were
added.
The new `EvaluationTimeoutBoundsWorkAndPreservesPatternReuse` and
`SharedPatternTimeoutRemainsAnAliasServiceError` tests passed on both
targets.

**Validation limitation:** two unfiltered Subscriptions runs did not
produce
a final result and were stopped, including a retry with a hang guard.
They
are not counted as passing. The isolated failover fixture passed 2/2,
and the
transfer/durability/service selection above passed. The stall's cause
was not
established; no unrelated code or test was changed to conceal it.

The final `dotnet build UA.slnx -c Release --no-restore -m:1` with
`CustomTestTarget=net10.0` succeeded with **0 warnings and 0 errors**.
Whitespace verification passed for all hand-reconciled files, and staged
source hashes match the validation inputs.

These are merge-specific checks, not a new full platform/native/soak
acceptance
sign-off. Earlier review and acceptance evidence below remains dated to
its
own commit.

## Previous upstream merge (2026-09-14)

Merge `c3e09553e4d1509502d4ba751960d58491bea2cf` integrates `master`
through `6313e38a0` (#4478),
including the CTT-driven Part 13 aggregate corrections and FindAlias
argument
validation. The upstream tests and CTT documentation are retained.

The single conflict in `AliasNameWildcardMatcher` is reconciled through
the
branch's shared regex compiler: upstream malformed-pattern rejection and
`IsValidPattern` coexist with OPC UA character escaping,
whole-string/newline
matching, the 100 ms timeout, and service-level error reporting. The
upstream
ReferenceTypeFilter checks and chronological aggregate behavior are
preserved.

Local merge verification:

| Suite / selection | Framework | Passed | Existing skips |
| --- | --- | ---: | ---: |
| Server aliases, aggregates and historian | net10.0 | 1,011 | 0 |
| Server aliases, aggregates and historian | net48 | 1,011 | 0 |
| InformationModel aliases | net10.0 | 54 | 5 |
| Features aliases | net10.0 | 11 | 0 |
| Client aliases | net10.0 | 28 | 0 |
| History aggregate/historical-access consumers | net10.0 | 265 | 0 |

All selected tests passed at normal concurrency. The final net10
solution
build passed with 0 errors and 0 warnings.
The resolved file's whitespace check and the staged diff check passed.
These
are merge-specific checks, not a new full platform/native/soak
acceptance claim.

## Previous upstream merges

Integrates `master` through `8e874d073`, including the Client, Types,
Core and
source-generator audit fixes in #4471, #4473, #4474 and #4475, plus the
event queue-size revision from #4480. The merge commits are `150ea0439`
(26 conflict resolutions) and `a0e17c07c` (the base update that arrived
during
validation).

The overlapping fixes are reconciled rather than replacing either
branch:

- **Rejected-certificate retention follows this branch, as explicitly
requested:**
zero means unlimited history; negative values disable new storage while
preserving existing backend trimming behavior (Directory clears prior
history;
shared key/value leaves it intact). The processor and manager policy are
not
  redefined, and documentation/tests describe the distinction.
- Preserve owned certificate snapshots and borrowed validation-core
lifetimes
  alongside upstream cache invalidation, store-first CRL checks and
  `BadCertificateRevocationUnknown` for unsupported revocation lookup.
- Combine exactly-once chunk ownership, receive-loop cancellation,
stale-epoch
rejection and detach-before-adoption with upstream
signature-before-padding
  verification and secret cleanup.
- Preserve pooled `HttpClient` per-request configuration together with
bounded
response reads and deadlines that remain active through body
consumption.
Regression cases exercise already-used clients and both timeout
precedence cases.
- Retain the binary-compatible channel-key constructor and normalize
omitted
profiles; preserve exact profile filtering and upstream binary-first
HTTPS/WSS
  endpoint ordering.
- Preserve monotonic lease expiry and stale-confirmation fencing while
adding
ordered, non-reentrant leadership notifications outside the state lock.

## Validation

Local validation of the conflict-resolution merge:

| Suite | Framework | Passed | Existing skips |
| --- | --- | ---: | ---: |
| Core | net10.0 | 4,940 | 88 |
| Client | net10.0 | 2,278 | 0 |
| Server | net10.0 | 6,088 | 5 |
| Types | net10.0 | 10,394 | 1 |
| SourceGeneration.Core | net10.0 | 3,940 | 8 |
| SourceGeneration | net10.0 | 185 | 0 |
| Core | net48 | 4,855 | 104 |

After the subsequent four-file #4480 update, the related Server slice
passed
811 tests with 5 existing skips, the real durable queue-size cases
passed 6/6,
and EventManager's 21 cases passed again after analyzer-only
expected-array
cleanup. The final `dotnet build UA.slnx -c Release` with
`CustomTestTarget=net10.0`
succeeded: 0 errors and 4 pre-existing CA2249 warnings in unchanged
fuzz-test
code. Whitespace verification passed for the resolved files and the
follow-up.

Merge-specific proof includes
`RequestTimeoutRemainsActiveWhileReadingTheOpenApiBodyAsync` on
already-used
shared clients with either the request or HttpClient timeout shorter,
the
existing ownership/expiry regressions from both branches, and both
branches'
retention tests adjusted only to the explicitly chosen zero/negative
contract.

Earlier acceptance at `1c4a9df6c` passed hosted Linux/macOS CI, CodeQL,
image
builds and the canonical coverage gate (87.15% line, 77.27% branch,
92.14% patch).
Both Framework project matrices, all three Windows native suites (183
cases),
and the unfiltered net10 stress project (230 cases, including full
60-minute
chaos and 30-minute memory soaks) also passed. Those are **pre-merge
results**,
not a claim that the full framework/platform/native/soak matrix has been
rerun
on this merge. The previous acceptance workflow remains separate from
this
review-feedback operation.

Security checks, meaningful assertions, leak detection, default native
concurrency, full soak durations and coverage thresholds remain intact.
The user-set ready-for-review state is unchanged. No architectural or
complete
merged-head acceptance sign-off is claimed.

## Related Issues

- Fixes #4476

## 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.
- [x] I have addressed **all** PR feedback received.
2026-09-16 04:42:09 +00:00

56 KiB

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.

Quick reference

Type Creates Reconnect Subscription engine Recommended for
Session itself (via constructor or Session.Create...Async) none — caller drives via Session.ReconnectAsync / SessionReconnectHandler ClassicSubscriptionEngine by default; pass ISubscriptionEngineFactory to opt in to V2 callers that own session lifecycle and existing reconnect code
DefaultSessionFactory raw Session none — caller drives reconnect configurable via SubscriptionEngineFactory init property drop-in ISessionFactory for the legacy flow
SessionReconnectHandler nothing (drives an existing session) yes, against a Session (only) inherited from the wrapped Session legacy callers that already own a Session
ManagedSession itself (via CreateAsync) yes — built-in ConnectionStateMachine + ReconnectPolicy DefaultSubscriptionEngine (V2) by default new code; long-lived clients that need reconnect / failover
ManagedSessionFactory ManagedSession inherited from ManagedSession inherited from ManagedSession drop-in ISessionFactory that yields managed sessions
ChannelManagerSessionFactory raw Session channel-level reconnect through IClientChannelManager classic engine raw sessions that share channels and coordinate transport reconnect centrally
ManagedSessionBuilder ManagedSession inherited from ManagedSession configurable via UseSubscriptionEngine fluent / DI scenarios
IClientChannelManager IManagedTransportChannel leases (shared) channel-level reconnect, coalesced and notified to participants n/a (channel-only) central transport-channel sharing & reconnect for sessions / discovery clients

Everything below explains how these pieces fit together.

1. Session — the OPC UA session primitive

Session (src/Opc.Ua.Client/Session/Session.cs) is the lowest-level client object that maps directly to a UA secure-channel + session pair on the server. It implements ISession and exposes the full surface of the OPC UA service set (Read, Write, Browse, Call, AddNodes, etc.) plus session-level concerns: keep-alive, namespace tables, the type tree, the node cache, and a publish pipeline.

A Session is bound to:

  • An ApplicationConfiguration (security policies, certificates, transport quotas, telemetry).
  • A ConfiguredEndpoint (URL, security mode, security policy URI).
  • An 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.

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:

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.

using var handler = new SessionReconnectHandler(
    telemetry,
    reconnectAbort: true,
    maxReconnectPeriod: 30000);

session.KeepAlive += (_, e) =>
{
    if (e.Status != null && ServiceResult.IsNotGood(e.Status))
    {
        handler.BeginReconnect(session, 1000, OnReconnectComplete);
    }
};

Supported session types

SessionReconnectHandler drives Session.ReconnectAsync and ISessionFactory.RecreateAsync directly, and replaces the channel on the underlying object. It therefore only supports the concrete Session class (or types derived from it). Passing any other ISession implementation — most notably ManagedSession — throws NotSupportedException, because those facades already run their own reconnect state machine and exposing two reconnect drivers on the same session would race.

// OK: raw Session
ISession raw = await new DefaultSessionFactory(telemetry).CreateAsync(...);
handler.BeginReconnect(raw, 1000, callback);

// Throws NotSupportedException
ISession managed = await new ManagedSessionFactory(telemetry).CreateAsync(...);
handler.BeginReconnect(managed, 1000, callback);

SessionReconnectHandler is retained as a supported entry point for callers that own raw Session instances. New code should prefer ManagedSession or — for raw Session usage — wire an IClientChannelManager (see section 4 below) so that reconnect is handled transparently by the channel manager.

3. ManagedSession — the connection-state-machine facade

ManagedSession (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.
  • 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.
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.

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:

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(...):

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 the failover endpoint.
  5. On success, transitions Reconnecting → Connected and releases the service gate, replaying any deferred calls.
  6. On exhaustion (MaxRetries reached), transitions to Closed and surfaces a ServiceResultException to outstanding callers.

When a ManagedSession is backed by IClientChannelManager, participant reactivation uses the manager's IChannelReconnectPolicy. The default ExponentialBackoffChannelReconnectPolicy bounds each participant callback with a ParticipantTimeout of 30 seconds.

Because all of this is driven internally, callers must not wrap a ManagedSession with SessionReconnectHandler; doing so throws NotSupportedException.

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.

// 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 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. 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) bypasses the gate via an AsyncLocal scope managed by the manager.

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.
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.

State model

IManagedTransportChannel.State follows a three-stage gate model:

State Service calls allowed Meaning
Disconnected no (BadSecureChannelClosed) Initial, or after graceful close.
TransportConnecting / TransportReconnecting no (queue with cancellation) Underlying transport is being opened.
TransportConnectedSessionReactivating only via the internal reactivation bypass Transport up; participants running OnReconnectAsync.
Ready yes Fully usable.
Faulted no Manager's retry policy exhausted.
Closed no Manager teardown.

Only Ready releases the public service-call gate. Discovery and session-service requests are not generally exempted; only the internal reactivation bypass crosses the gate during TransportConnectedSessionReactivating.

Participant model — IReconnectParticipant

Clients that want to react to channel reconnect implement IReconnectParticipant. Session implements it: when a channel manager is wired into a session, the manager invokes Session.OnReconnectAsync after each successful transport reconnect. The session runs the ActivateSession slice and returns one of:

Result Meaning
Reactivated Session is alive on the reconnected channel.
RequiresSessionRecreate Channel is OK; this participant's server-side session was lost (e.g. BadSessionIdInvalid) — the manager dispatches participant recreation out of band.
TransientFailure Transient channel-level failure; manager retries per policy.
FatalForParticipant Authentication / cert problem specific to this participant — detach only this participant.
FatalForChannel Fatal channel error; transition to Faulted.

When a participant returns RequiresSessionRecreate, the manager invokes IReconnectParticipant.RecreateAsync(ct) fire-and-forget and does not block the channel's transition to Ready on that work. Session implements the callback by recreating its server-side session in place. On target frameworks without default interface method support, participants opt in with IRecreateAwareReconnectParticipant.

Retry policy — IChannelReconnectPolicy

The default ExponentialBackoffChannelReconnectPolicy mirrors the historical SessionReconnectHandler backoff defaults: 500 ms → 30 s with unlimited attempts. It also sets ParticipantTimeout to 30 seconds, bounding each participant's OnReconnectAsync callback. A participant timeout is treated as TransientFailure; retry exhaustion still transitions the channel to Faulted. The IChannelReconnectPolicy default interface member remains Timeout.InfiniteTimeSpan for custom-policy backward compatibility, and older TFMs can opt in with IParticipantTimeoutPolicy. The policy is configurable on the ClientChannelManager constructor. 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 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:

services.AddOpcUa()
    .AddClient(opt =>
    {
        opt.Configuration = config;
        opt.Session.Endpoint = endpoint;
    });

To replace the DI default with custom HTTP resilience, configure the same named client and remove the default handler before adding your own:

services.AddHttpClient("Opc.Ua.Client")
    .RemoveAllResilienceHandlers()
    .AddStandardResilienceHandler(opts =>
    {
        opts.Retry.MaxRetryAttempts = 5;
        opts.AttemptTimeout.Timeout = TimeSpan.FromSeconds(30);
    });

For direct builder usage on .NET 8 and later, call ManagedSessionBuilder.WithHttpsResilience(...):

ManagedSession session = await new ManagedSessionBuilder(config, telemetry)
    .UseEndpoint(endpoint)
    .WithHttpsResilience(opts =>
    {
        opts.Retry.MaxRetryAttempts = 5;
        opts.AttemptTimeout.Timeout = TimeSpan.FromSeconds(30);
    })
    .ConnectAsync(ct);

HTTPS factory + OPC UA cert validation: secure-by-default fallback

HttpsTransportChannel.CreateHttpClient chooses between two HttpClient construction paths:

  • Direct path (CreateDirectHttpClient) — builds 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:

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.
  2. Outer state stays Connected while the manager handles it. ManagedSession subscribes to IManagedTransportChannel.StateChanged; while the manager is in TransportReconnecting / TransportConnectedSessionReactivating, ManagedSession suppresses the keep-alive-driven outer state-machine churn.
  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 and stop scheduling retries when the budget is exhausted.

ReconnectPolicyOptions.MaxTotalReconnectTime defaults to five minutes. Set it to the end-to-end reconnect window your application expects; channel-manager delays are automatically shrunk to fit inside that window instead of receiving a fresh budget on every outer attempt.

For example, with MaxTotalReconnectTime = TimeSpan.FromSeconds(30), a channel-manager reconnect that spends 27 seconds reopening transport and reactivating participants leaves about three seconds for any outer ManagedSession retry / failover decision. The channel manager clamps its next scheduled delay to the remaining time and transitions to Faulted once the shared budget is exhausted; the outer state machine then fails over or closes instead of starting another 30-second channel retry window. The result is one roughly 30-second reconnect cycle (plus any already in-flight attempt), not 30 seconds per layer or per outer attempt.

ManagedSession session = await new ManagedSessionBuilder(configuration, telemetry)
    .UseEndpoint(endpoint)
    .WithChannelManager(channelManager)
    .WithReconnectPolicy(p => p with
    {
        MaxTotalReconnectTime = TimeSpan.FromSeconds(30)
    })
    .ConnectAsync(ct);

For migration notes on the budget-aware APIs, see the migration guide.

Diagnostics surface contract — what tags and 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. The channel manager previously emitted its own Opc.Ua.ChannelManager EventSource (ETW / dotnet-trace); that provider is removed. The structured logs are the replacement — same category name, same event identities — routed through Microsoft.Extensions.Logging / OpenTelemetry logging instead of ETW.

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, 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.

DI registration

builder.AddClient(...) registers IClientChannelManager as a singleton resolved from OpcUaClientOptions.Configuration. The ManagedSessionBuilder produced by AddClient automatically wires the DI-resolved channel manager into every new ManagedSession. To override, call WithChannelManager(...) on the builder explicitly.

services.AddOpcUa()
    .AddClient(opt =>
    {
        opt.Configuration = config;
        opt.Session = new ManagedSessionOptions
        {
            Endpoint = endpoint,
            SessionName = "MyApp",
        };
    });

// Single channel manager + multiple sessions sharing channels per endpoint:
var sp = services.BuildServiceProvider();
var managedFactory = sp.GetRequiredService<Func<CancellationToken, Task<ManagedSession>>>();
ManagedSession s1 = await managedFactory(ct);
ManagedSession s2 = await managedFactory(ct); // shares s1's underlying channel

Migrating from AttachChannel / DetachChannel

IClientBase.AttachChannel(ITransportChannel) and DetachChannel() are marked [Obsolete] but remain functional. Migration path:

// Old: manually managed channel
ITransportChannel ch = await UaChannelBase.CreateUaBinaryChannelAsync(...);
var session = new Session(ch, config, endpoint);
await session.OpenAsync(...);

// New: channel manager owns the channel; sessions share it
var manager = new ClientChannelManager(config, telemetry);
Session session = await Session.CreateAsync(manager, config, endpoint, ...);

For reverse-connect:

ITransportWaitingConnection conn = await reverseConnectManager
    .WaitForConnectionAsync(serverUrl, serverUri, ct);
IManagedTransportChannel ch = await manager.GetAsync(participant, conn, ct);

Testing the channel manager

The channel manager is covered by a layered stress and chaos test suite in tests/Opc.Ua.Stress.Tests/:

  • L1 Contract — fast deterministic fake-based tests for coalescing, participant result aggregation, retry budgets, hung participants, recreate dispatch, lease lifecycle, gate + bypass, key equivalence, certificate rotation, and leak accuracy. These run in every PR.
  • L2 Integration — live in-process server tests for server outage recovery, live certificate rotation, participant timeout, session recreate dispatch, and failover lease swap. These run in every PR.
  • L3 ChaosTCP — TCP proxy chaos tests for transparent reconnect under load, subscription survival, accept-but-stall, and mixed drop / block-accept failures. These run nightly.
  • L4 Soak — long-running randomized soak, combinatorial matrix, and memory-stability runs. These are manual or nightly only.
# Contract + Integration (default PR CI):
dotnet test tests/Opc.Ua.Stress.Tests --filter "Category=Contract|Category=Integration"

# ChaosTCP (nightly):
dotnet test tests/Opc.Ua.Stress.Tests --filter "Category=ChaosTCP" --TestRunParameters.Parameter(Seed=<n>)

# Soak (manual):
dotnet test tests/Opc.Ua.Stress.Tests --filter "Category=Soak"

Every chaos test prints its seed at the start of the run. Failed chaos runs can be reproduced by passing the same seed back to the test host with --TestRunParameters.Parameter(Seed=<n>).

5. Subscription engines

The ISubscriptionEngine abstraction (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.

// V2 engine is the default for ManagedSession
ManagedSession session = await new ManagedSessionBuilder(configuration, telemetry)
    .UseEndpoint(endpoint)
    .ConnectAsync(ct);

var handler = new MyNotificationHandler(); // : ISubscriptionNotificationHandler
ISubscription subscription = session.AddSubscription(handler,
    new Opc.Ua.Client.Subscriptions.SubscriptionOptions
    {
        PublishingInterval = TimeSpan.FromMilliseconds(500),
        KeepAliveCount = 10,
        LifetimeCount = 100
    });

subscription.TryAddMonitoredItem(
    "ServerStatus_CurrentTime",
    VariableIds.Server_ServerStatus_CurrentTime,
    o => o with
    {
        SamplingInterval = TimeSpan.FromMilliseconds(250),
        QueueSize = 10
    },
    out IMonitoredItem _);

Unbounded monitored items (default)

The V2 ISubscription returned from Add(...) transparently splits monitored items across multiple server-side partition subscriptions when the server's per-subscription cap would be exceeded. Single- partition workloads pay zero overhead because the composite collection short-circuits to the primary partition. Pin items into the same partition with MonitoredItemOptions.Affinity so per- subscription features like SetTriggering keep working across the group; opt out of partitioning entirely with SubscriptionOptions.DisableUnboundedItemMode = true. Full developer guide: Subscriptions § Unbounded monitored items.

V2 notification pooling (opt-in)

The V2 subscription engine supports activator-level pooling of notification payload instances to reduce GC pressure on high-throughput publish loops. Pooling is opt-in and disabled by default. Enable it via ManagedSessionBuilder.WithPoolNotifications(), the ManagedSessionOptions.PoolNotifications init property, or by setting ISubscriptionManager.PoolNotifications = true directly on the V2 manager:

ManagedSession session = await new ManagedSessionBuilder(configuration, telemetry)
    .UseEndpoint(endpoint)
    .WithPoolNotifications()        // opt in
    .ConnectAsync(ct);

How it works:

  • Types that opt in to pooling implement IPooledEncodeable (a subinterface 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:

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).

For migration details see 2.0 migration guide — Node States and INodeCache.

7. Putting it all together

Pick the entry point that best matches your call site:

  • Already have a 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