# Description
Fixes the 78 `Opc.Ua.Client` findings of a read-only bug audit of
`Opc.Ua.Client` / `Opc.Ua.Types` / `Opc.Ua.SourceGeneration` taken at
a05a36055, plus the regressions a subsequent review of those fixes
turned up. Every behavioural change was checked against the OPC UA
specification, and the citations are in the code comments.
## Session, reconnect and failover
- The reconnect `finally` re-acquired `m_reconnectLock` with the
caller's token, so a cancelled reconnect left the session stuck in
`Reconnecting` forever and every later reconnect failed. It now uses
`CancellationToken.None`.
- An in-place session recreate tore down subscriptions it should not
have: `transferred` is all-or-nothing, so a single failed transfer reset
and recreated the ones the server had just taken over, orphaning them on
the new session. `TransferSubscriptions` now reports the
per-subscription outcome and only the subscriptions the server did not
take over are recreated. A token-reuse failover keeps the same server
session, so nothing is transferred or reset there at all.
- In-place recreate only tears down the channel it created itself; a
caller-supplied channel or a channel-manager lease is left to its owner.
- `Snapshot(out SessionState)` went through the `SessionOptions` record
copy constructor, which drops every member declared on `SessionState`,
so the paired `Snapshot`/`Restore` API could not round-trip.
- The user-token policy override restored the policy URI on failure but
had already disposed the ECC ephemeral key the previous identity needs,
so the identity the rollback claims to preserve could no longer be
reactivated. The key is now parked and restored.
- `SetTriggering` batching sliced the remove list past its end when a
call had more adds than removes (Part 5 §6.3.11 bounds the *sum* of both
arrays, so an individual list may be shorter than the limit).
- The certificate-less `None` attempt dropped the request header, and
with it the ECDH ephemeral key that travels in `additionalHeader` (Part
4 §7.1/§7.15).
- `Session.Equals` compared a `ConfiguredEndpoint` against an
`EndpointDescription`, so a session did not equal a session on the same
endpoint.
## Managed session, pool and connection state machine
- `ManagedSession.Dispose()` blocked on the connection state machine's
worker, which raises `ConnectionStateChanged` synchronously — disposing
from that handler deadlocked permanently. The machine now recognises its
own flow and completes the teardown as the worker's continuation.
- `ManagedSessionFactory` dropped `updateBeforeConnect`, `connection`
and `reverseConnectManager` on all four `CreateAsync` overloads.
- The session pool ran its shared connect on no token at all, so once
the only caller gave up nothing could abort it and
`RemoveAsync`/`DisposeAsync` waited out the whole reconnect budget. Each
entry now owns the token that aborts its connect, and removal closes
*and* disposes the session.
- An explicit `engineFactory` was silently ignored for
`DefaultSessionFactory` subclasses, which quietly downgraded the session
to the classic subscription engine.
## Subscriptions
- Fixed a lock-order inversion between `m_subscriptionLock` and the
partition locks in `CompleteAsync` and `DisposeAsync`.
- `OwnsSubscriptionId` stopped at the first registered subscription
carrying the id — but in the very case it guards, the asker still holds
its own retired id, so the answer depended on `HashSet` enumeration
order and the delete could still tear down a sibling that had been
handed the recycled id.
- `MessageProcessor`'s republish gap walk was unbounded; it is now
bounded by the server's retransmission queue (Part 4 §5.14.5).
- A publish acknowledgement is sent even when a notification handler
throws — the ack means "the server may delete the message" (Part 4
§5.14.5.2), not "I processed it".
- Loaded monitored items did not raise the process-wide client-handle
counter, so a fresh process minted handles that collided with the loaded
ones; a transfer-restored item was also stamped `BadOperationAbandoned`
and left at the default monitoring mode.
- `AutoSetQueueSize` was not idempotent, so the queue size ratcheted up
on every subscription state change.
- The snapshot dropped the partition and recovery options entirely, and
clamped the sampling interval and the secondary-partition idle timeout
to zero — turning the Part 4 §7.21 "use the publishing interval"
sentinel and "never delete an idle partition" into something else.
- The state stream is positional OPC UA binary and the reader cannot
detect an absent field, so appending the partition options would have
mis-decoded every previously saved file. The stream now carries a format
version and the first format is decoded through a frozen copy of its
shape.
## Browse, node cache and the remaining clients
- Managed browse wrote a retry pass's status into whatever node happened
to sit at that offset in the caller's list, and retried
continuation-point errors without any bound. It now keeps the caller's
slot and gives up on passes that make no progress — a fixed pass cap
would cut short a large browse against a conforming server with a small
continuation-point quota.
- Continuation points are released when a consumer stops enumerating
early, as Part 4 §5.9.3.2 requires.
- The node cache reordered its batched results relative to the inputs,
which mispairs them for every consumer that matches positionally; its
supertype walks could also spin forever on a server whose `HasSubtype`
chain loops back on itself.
- A browse error was cached as "this node has no references"; it is now
surfaced to the direct caller, while the bulk and hierarchy walks treat
the node as having no references for that one operation without caching
it, so a single un-browsable node cannot fail a whole batch.
- `WebApiClient` mutated a caller-supplied (typically
`IHttpClientFactory`-pooled) `HttpClient`, which throws once that client
has sent its first request; those instances are configured per request
instead.
- `PathCache` was not thread-safe.
## Review follow-ups
The last two commits are the result of reviewing the audit fixes
themselves. Worth calling out: the `Task.WaitAsync(CancellationToken)`
polyfill in `Opc.Ua.Types` never checked `IsCompleted`, so an
already-finished task plus an already-cancelled token reported
cancellation on every pre-net8.0 target where the BCL overload returns
the result. It is now aligned with the BCL and pinned by a test.
## Related Issues
- No tracking issue; this PR is the result of a read-only audit of the
client stack.
## Checklist
- [x] 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.
### Verification
35 regression tests were added under
`tests/Opc.Ua.Client.Tests/AuditRegressions/` (category
`AuditRegression`), one per fixed defect where the defect is observable
at unit level; most were mutation-checked by reintroducing the bug and
confirming the test fails.
Locally on **net10.0**: `Opc.Ua.Client.Tests` 2252/2252,
`Opc.Ua.Client.ComplexTypes.Tests` 2773/2773,
`Opc.Ua.Bindings.Https.WebApi.Tests` 346/346, and all 23 library and
test projects that reference `Opc.Ua.Client` build clean.
`Opc.Ua.Client` also builds clean for **netstandard2.1**, the target
that actually compiles the `WaitAsync` polyfill. The full **UA.slnx**
solution and the .NET Framework targets have not been run locally — left
to CI.
One pre-existing failure in `Opc.Ua.Types.Tests` is unrelated to this
branch:
`WotSpecFixtureManifestTests.EveryEmbeddedExampleMatchesItsRecordedHash`
(embedded WoT fixture hashes) fails identically with these changes
reverted.
### Notes for reviewers
- Two review findings were deliberately left unfixed and are documented
in the code: `ManagedSession.CreateAsync` has to write the engine choice
onto a caller-supplied `DefaultSessionFactory` subclass (the only
alternative is changing the `ISessionFactory.Create` contract), and the
connection state machine's "am I on my own worker" flag is an
`AsyncLocal`, so it also flows into work a `StateChanged` handler
spawns. The previous behaviour in that second case was a hard deadlock.
- `Session.Dispose()`'s single `GetAwaiter().GetResult()` bridge to
`CloseAsync` is left as is: `Dispose(bool)` must be synchronous and
`DisposeAsync` already exists.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
OPC Foundation UA .NET Standard Reference Client
Introduction
The console reference client can be configured using several console parameters. Some of these parameters are explained in more detail below.
To see all available parameters call console reference client with the parameter -h.
Supported transport profiles
The client picks the wire transport automatically from the serverUrl
scheme. The OPC UA spec aliases the URL scheme to the
TransportProfileUri
on the negotiated endpoint:
| URL scheme | TransportProfileUri | OPC UA Part 6 reference |
|---|---|---|
opc.tcp:// |
uatcp-uasc-uabinary |
§7.4.2 |
opc.https:// (or https://) with binary body |
https-uabinary |
§7.4.4 |
opc.https:// (or https://) with application/opcua+uajson body |
https-uajson |
§7.4.5 |
opc.wss:// (or wss://) with opcua+uacp sub-protocol |
wss-uasc-uabinary |
§7.5.2 |
opc.wss:// (or wss://) with opcua+uajson sub-protocol |
wss-uajson |
§7.5.2 |
Examples:
# UA-TCP (default)
dotnet ConsoleReferenceClient.dll opc.tcp://localhost:62541/Quickstarts/ReferenceServer
# WSS+uacp (binary UA SecureChannel over TLS WebSocket)
dotnet ConsoleReferenceClient.dll opc.wss://localhost:62543/Quickstarts/ReferenceServer
# HTTPS (binary)
dotnet ConsoleReferenceClient.dll opc.https://localhost:62543/Quickstarts/ReferenceServer
The reference server (ConsoleReferenceServer) advertises the
opc.tcp://, opc.https:// and opc.wss:// endpoints by default; see
docs/Transports.md for the full server
configuration story (TLS certificates, mutual TLS, JSON sub-protocol
restrictions, etc.).
Historical Access
Use --historian against the reference server to run the Part 11 sample
workflow:
dotnet ConsoleReferenceClient.dll --historian --autoaccept --nosecurity \
opc.tcp://localhost:62541/Quickstarts/ReferenceServer
The workflow uses ISession.Historian() to discover server and per-node
configuration, follow paged raw and modified reads, read at-time and
representative aggregate families, perform point and range deletes, and
round-trip annotation Insert/Replace/Update/Remove batches. It also exercises
generic KeyValuePair StructuredHistoryData through raw, modified, at-time,
Insert, Replace, Update, and Remove operations, plus historical event
insert/replace/update/delete. The workflow triggers and verifies automatic
server-event capture, demonstrates server-generated EventIds, and releases
continuation points automatically when an await foreach loop completes or
exits early.
Reverse Connect
The OPC UA reverse connect feature allows an OPC UA server to initiate the connection to a client, rather than the traditional model where clients connect to servers. This is particularly useful in scenarios where the server is behind a firewall or NAT, making it difficult for clients to directly connect to it.
How to use Reverse Connect
To enable reverse connect mode, specify the client endpoint URL using the --rc or --reverseconnect parameter:
dotnet ConsoleReferenceClient.dll --rc=opc.tcp://localhost:65300 opc.tcp://localhost:62541/Quickstarts/ReferenceServer
The client will start a reverse connect listener on the specified endpoint (e.g., opc.tcp://localhost:65300) and wait for the server to connect to it.
Example: Client and Server with Reverse Connect
-
Start the client with reverse connect listener on port 65300:
dotnet ConsoleReferenceClient.dll --rc=opc.tcp://localhost:65300 opc.tcp://localhost:62541/Quickstarts/ReferenceServer -
In a separate terminal, start the server with reverse connect to the client:
dotnet ConsoleReferenceServer.dll --rc=opc.tcp://localhost:65300 -a
The server will establish a reverse connection to the client endpoint, and the client will use this connection to communicate with the server.
How to specify User Identity
Username & Password
Specify as console parameters:
-un YourUsername
-up YourPassword
Certificate
Place your user certificate in the TrustedUserCertificatesStore (the path can be found in the client configuration XML). Make sure to include an accessible private key with the certificate.
Specify console parameters:
-uc Thumbprint (of the user certificate to select)
-ucp Password (of the user certificates private key (optional))