Files
UA-.NETStandard/samples/Reference/ConsoleReferenceClient/Program.cs
T
romanett ea93c86da2 Fix the Opc.Ua.Client defects reported by the client stack audit (#4471)
# 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>
2026-09-14 08:22:28 +02:00

890 lines
43 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.CommandLine;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Client.ComplexTypes;
using Opc.Ua.Configuration;
using Opc.Ua.Identity;
using Opc.Ua.Security.Certificates;
namespace Quickstarts.ConsoleReferenceClient
{
/// <summary>
/// The program.
/// </summary>
public static class Program
{
/// <summary>
/// Main entry point.
/// </summary>
/// <exception cref="ErrorExitException"></exception>
public static Task<int> Main(string[] args)
{
Console.WriteLine("OPC UA Console Reference Client");
Console.WriteLine(
"OPC UA library: {0} @ {1} -- {2}",
Utils.GetAssemblyBuildNumber(),
Utils.GetAssemblyTimestamp().ToString("G", CultureInfo.InvariantCulture),
Utils.GetAssemblySoftwareVersion()
);
// The application name and config file names
const string applicationName = "ConsoleReferenceClient";
const string configSectionName = "Quickstarts.ReferenceClient";
// command line options
var autoAcceptOption = new Option<bool>("--autoaccept", "-a")
{
Description = "auto accept certificates (for testing only)"
};
var noSecurityOption = new Option<bool>("--nosecurity", "--nsec")
{
Description = "select endpoint with security NONE, least secure if unavailable"
};
var usernameOption = new Option<string>("--username", "--un")
{
Description = "the name of the user identity for the connection"
};
var userPasswordOption = new Option<string>("--userpassword", "--up")
{
Description = "the password of the user identity for the connection"
};
var userCertificateOption = new Option<string>("--usercertificate", "--uc")
{
Description = "the thumbprint of the user certificate for the user identity"
};
var userCertificatePasswordOption = new Option<string>("--usercertificatepassword", "--ucp")
{
Description = "the password of the user certificate for the user identity"
};
var consoleOption = new Option<bool>("--console", "-c") { Description = "log to console" };
var logOption = new Option<bool>("--log", "-l") { Description = "log app output" };
var fileOption = new Option<bool>("--file", "-f") { Description = "log to file" };
var passwordOption = new Option<string>("--password", "-p")
{
Description = "optional password for private key"
};
var renewOption = new Option<bool>("--renew", "-r")
{
Description = "renew application certificate"
};
var timeoutOption = new Option<int>("--timeout", "-t")
{
Description = "timeout in seconds to exit application",
DefaultValueFactory = _ => Timeout.Infinite
};
var logFileOption = new Option<string>("--logfile")
{
Description = "custom file name for log output"
};
var loadTypesOption = new Option<bool>("--loadtypes", "--lt")
{
Description = "Load custom types"
};
var managedBrowseAllOption = new Option<bool>("--managedbrowseall", "-m")
{
Description = "Browse all references using the MangedBrowseAsync method"
};
var browseAllOption = new Option<bool>("--browseall", "-b")
{
Description = "Browse all references"
};
var exportOption = new Option<bool>("--export", "-e")
{
Description = "Export all fetched nodes into Nodeset2 xml per default"
};
var exportNamespaceOption = new Option<string[]>("--namespace", "--ns")
{
Description =
"Restrict --export to nodes whose namespace URI is in the supplied list. " +
"Repeat the option to add more, e.g. --namespace http://opcfoundation.org/UA/Boiler/ " +
"--namespace urn:other. When omitted, all non-OPC-UA-base namespaces are exported."
};
var fetchAllOption = new Option<bool>("--fetchall", "--fa")
{
Description = "Fetch all nodes"
};
var jsonOption = new Option<bool>("--json", "-j")
{
Description = "Output all Values as JSON"
};
var verboseOption = new Option<bool>("--verbose", "-v") { Description = "Verbose output" };
var subscribeOption = new Option<bool>("--subscribe", "-s") { Description = "Subscribe" };
var testallEndpointsOption = new Option<bool>("--testall", "--ea") { Description = "Test All Endpoints" };
var reverseConnectOption = new Option<string>("--reverseconnect", "--rc")
{
Description = "Connect using the reverse connect endpoint. (e.g. --rc opc.tcp://localhost:65300)"
};
var foreverOption = new Option<bool>("--forever")
{
Description = "run inner connect/disconnect loop forever"
};
var leakChannelsOption = new Option<bool>("--leakchannels")
{
Description = "Leave a channel leak open when disconnecting a session."
};
var durableSubscriptionOption = new Option<bool>("--durablesubscription", "--ds")
{
Description = "SetDurableSubscription example"
};
var historianOption = new Option<bool>("--historian")
{
Description = "Run the complete OPC UA Historical Access client workflow"
};
var serverUrlArgument = new Argument<string>("serverUrl")
{
Description = "The OPC UA server URL to connect to",
DefaultValueFactory = _ => "opc.tcp://localhost:62541/Quickstarts/ReferenceServer"
};
var rootCommand = new RootCommand($"Usage: dotnet {applicationName}.dll [OPTIONS] [serverUrl]")
{
autoAcceptOption,
noSecurityOption,
usernameOption,
userPasswordOption,
userCertificateOption,
userCertificatePasswordOption,
consoleOption,
logOption,
fileOption,
passwordOption,
renewOption,
timeoutOption,
logFileOption,
loadTypesOption,
managedBrowseAllOption,
browseAllOption,
exportOption,
exportNamespaceOption,
fetchAllOption,
jsonOption,
verboseOption,
subscribeOption,
testallEndpointsOption,
reverseConnectOption,
foreverOption,
leakChannelsOption,
durableSubscriptionOption,
historianOption,
serverUrlArgument
};
rootCommand.SetAction(async (parseResult, cancellationToken) =>
{
bool autoAccept = parseResult.GetValue(autoAcceptOption);
bool noSecurity = parseResult.GetValue(noSecurityOption);
string? username = parseResult.GetValue(usernameOption);
byte[]? userpassword =
parseResult.GetValue(userPasswordOption) is string upStr ?
Encoding.UTF8.GetBytes(upStr) : null;
string? userCertificateThumbprint =
parseResult.GetValue(userCertificateOption);
byte[]? userCertificatePassword =
parseResult.GetValue(userCertificatePasswordOption) is string ucpStr ?
Encoding.UTF8.GetBytes(ucpStr) : null;
bool logConsole = parseResult.GetValue(consoleOption);
bool appLog = parseResult.GetValue(logOption);
bool fileLog = parseResult.GetValue(fileOption);
byte[]? pfxPassword =
parseResult.GetValue(passwordOption) is string pfxStr ?
Encoding.UTF8.GetBytes(pfxStr) : null;
bool renewCertificate = parseResult.GetValue(renewOption);
int timeout = parseResult.GetValue(timeoutOption);
if (timeout > 0)
{
timeout *= 1000;
}
string? logFile = parseResult.GetValue(logFileOption);
bool loadTypes = parseResult.GetValue(loadTypesOption);
bool managedbrowseall =
parseResult.GetValue(managedBrowseAllOption);
bool browseall = parseResult.GetValue(browseAllOption);
bool exportNodes = parseResult.GetValue(exportOption);
string[]? exportNamespacesRaw = parseResult.GetValue(exportNamespaceOption);
HashSet<string>? exportNamespaces = exportNamespacesRaw is { Length: > 0 }
? new HashSet<string>(exportNamespacesRaw, StringComparer.OrdinalIgnoreCase)
: null;
bool fetchall = parseResult.GetValue(fetchAllOption);
bool jsonvalues = parseResult.GetValue(jsonOption);
bool verbose = parseResult.GetValue(verboseOption);
bool subscribe = parseResult.GetValue(subscribeOption);
string? reverseConnectUrlString =
parseResult.GetValue(reverseConnectOption);
bool forever = parseResult.GetValue(foreverOption);
bool leakChannels = parseResult.GetValue(leakChannelsOption);
bool enableDurableSubscriptions =
parseResult.GetValue(durableSubscriptionOption);
bool runHistorian = parseResult.GetValue(historianOption);
// serverUrlArgument has DefaultValueFactory so GetValue returns non-null at runtime.
var serverUrl = new Uri(parseResult.GetValue(serverUrlArgument)!);
bool testallEndpoints = parseResult.GetValue(testallEndpointsOption);
ReverseConnectManager? reverseConnectManager = null;
using var telemetry = new ConsoleTelemetry();
ILogger logger = LoggerUtils.Null.Logger;
try
{
// log console output to logger
if (logConsole && appLog)
{
logger = telemetry.CreateLogger("Main");
}
// Define the UA Client application
ApplicationInstance.MessageDlg = new ApplicationMessageDlg();
// Lib param is annotated non-null but the impl tolerates null at runtime.
var passwordProvider = new CertificatePasswordProvider(pfxPassword!);
var application = new ApplicationInstance(telemetry)
{
ApplicationName = applicationName,
ApplicationType = ApplicationType.Client,
ConfigSectionName = configSectionName,
CertificatePasswordProvider = passwordProvider
};
// load the application configuration.
ApplicationConfiguration config = await application
.LoadApplicationConfigurationAsync(silent: false, ct: cancellationToken)
.ConfigureAwait(false);
// After LoadApplicationConfigurationAsync the property is set; cache the
// non-null reference so we don't repeat the null-forgiving operator.
ApplicationConfiguration appConfig = application.ApplicationConfiguration!;
// override logfile
if (logFile != null)
{
string? logFilePath = config.TraceConfiguration!.OutputFilePath;
string filename = Path.GetFileNameWithoutExtension(logFilePath)!;
config.TraceConfiguration.OutputFilePath = logFilePath!.Replace(
filename,
logFile,
StringComparison.Ordinal
);
config.TraceConfiguration.DeleteOnLoad = true;
}
// setup the logging
telemetry.ConfigureLogging(
config,
applicationName,
logConsole,
fileLog,
appLog,
LogLevel.Information);
// delete old certificate
if (renewCertificate)
{
await application.DeleteApplicationInstanceCertificateAsync(ct: cancellationToken)
.ConfigureAwait(false);
}
// check the application certificate.
bool haveAppCertificate = await application
.CheckApplicationInstanceCertificatesAsync(false, ct: cancellationToken)
.ConfigureAwait(false);
if (!haveAppCertificate)
{
throw new ErrorExitException(
"Application instance certificate invalid!",
ExitCode.ErrorCertificate
);
}
if (reverseConnectUrlString != null)
{
// start the reverse connection manager
Console.WriteLine($"Create reverse connection endpoint at {reverseConnectUrlString}.");
reverseConnectManager = new ReverseConnectManager(telemetry);
reverseConnectManager.AddEndpoint(new Uri(reverseConnectUrlString));
await reverseConnectManager.StartServiceAsync(config, cancellationToken)
.ConfigureAwait(false);
}
// wait for timeout or Ctrl-C
var quitCTS = new CancellationTokenSource();
CancellationToken ct = quitCTS.Token;
ManualResetEvent quitEvent = ConsoleUtils.CtrlCHandler(quitCTS);
// handle connect all endpoints test.
if (testallEndpoints)
{
var tester = new ConnectTester(telemetry, quitEvent);
await using (tester.ConfigureAwait(false))
{
await tester.RunAsync(ct).ConfigureAwait(false);
}
return;
}
var identityProviders = new List<IClientIdentityProvider>();
// set user identity of type username/pw
if (!string.IsNullOrEmpty(username))
{
if (userpassword == null)
{
Console.WriteLine($"No password provided for user {username}, using empty password.");
}
var passwordStore = new InMemorySecretStore();
var passwordId = new SecretIdentifier(
"console-reference-client-password",
passwordStore.StoreType);
await passwordStore
.SetAsync(passwordId, userpassword ?? [], ct)
.ConfigureAwait(false);
identityProviders.Add(new UserNamePasswordIdentityProvider(
username!,
new SecretRegistry(passwordStore),
passwordId));
Console.WriteLine($"Connect with user identity for user {username}");
}
// set user identity of type certificate
if (!string.IsNullOrEmpty(userCertificateThumbprint))
{
CertificateIdentifier? userCertificateIdentifier =
await FindUserCertificateIdentifierAsync(
userCertificateThumbprint!,
appConfig.SecurityConfiguration
.TrustedUserCertificates!,
telemetry,
ct).ConfigureAwait(true);
if (userCertificateIdentifier != null)
{
identityProviders.Insert(0, new X509ClientIdentityProvider(
userCertificateIdentifier,
new CertificatePasswordProvider(userCertificatePassword!),
application.ApplicationConfiguration!.CertificateManager!.CertificateProvider));
Console.WriteLine($"Connect with user certificate with Thumbprint {userCertificateThumbprint}");
}
else
{
Console.WriteLine($"Failed to load user certificate with Thumbprint {userCertificateThumbprint}");
}
}
identityProviders.Add(new AnonymousIdentityProvider());
var identityProvider = new CompositeClientIdentityProvider(identityProviders);
// connect to a server until application stops
bool quit = false;
DateTime start = DateTime.UtcNow;
int waitTime = int.MaxValue;
do
{
if (timeout > 0)
{
waitTime = timeout - (int)DateTime.UtcNow.Subtract(start).TotalMilliseconds;
if (waitTime <= 0)
{
if (!forever)
{
break;
}
waitTime = 0;
}
if (forever)
{
start = DateTime.UtcNow;
}
}
// create the UA Client object and connect to configured server.
using var uaClient = new UAClient(
appConfig,
reverseConnectManager,
telemetry,
null
)
{
AutoAccept = autoAccept,
SessionLifeTime = 60_000,
IdentityProvider = identityProvider
};
if (enableDurableSubscriptions)
{
uaClient.ReconnectPeriodExponentialBackoff = 60000;
}
bool connected = await uaClient
.ConnectAsync(serverUrl.ToString(), !noSecurity, ct)
.ConfigureAwait(false);
if (connected)
{
Console.WriteLine("Connected! Ctrl-C to quit.");
// enable subscription transfer
uaClient.ReconnectPeriod = 1000;
uaClient.ReconnectPeriodExponentialBackoff = 10000;
uaClient.Session.MinPublishRequestCount = 3;
uaClient.Session.TransferSubscriptionsOnReconnect = true;
var samples = new ClientSamples(
telemetry,
null,
quitEvent,
verbose);
if (loadTypes)
{
using var complexTypeSystem = ComplexTypeSystem.Create(uaClient.Session, telemetry);
await samples.LoadTypeSystemAsync(complexTypeSystem, ct).ConfigureAwait(false);
}
if (runHistorian)
{
await HistorianClientSample.RunAsync(
uaClient.Session,
ct).ConfigureAwait(false);
quit = true;
}
else if (browseall || fetchall || jsonvalues || managedbrowseall)
{
List<NodeId>? variableIds = null;
List<NodeId>? variableIdsManagedBrowse = null;
ArrayOf<ReferenceDescription> referenceDescriptions = default;
ArrayOf<ReferenceDescription> referenceDescriptionsFromManagedBrowse = default;
if (browseall)
{
Console.WriteLine("Browse the full address space.");
referenceDescriptions = await samples
.BrowseFullAddressSpaceAsync(uaClient, ObjectIds.RootFolder, ct: ct)
.ConfigureAwait(false);
variableIds =
[
.. referenceDescriptions
.Filter(r =>
r.NodeClass == NodeClass.Variable &&
r.TypeDefinition.NamespaceIndex != 0
)
.ConvertAll(r => ExpandedNodeId.ToNodeId(
r.NodeId,
uaClient.Session.NamespaceUris))
];
}
if (managedbrowseall)
{
Console.WriteLine("ManagedBrowse the full address space.");
referenceDescriptionsFromManagedBrowse = await samples
.ManagedBrowseFullAddressSpaceAsync(
uaClient,
ObjectIds.RootFolder,
ct: ct)
.ConfigureAwait(false);
variableIdsManagedBrowse =
[
.. referenceDescriptionsFromManagedBrowse
.Filter(r =>
r.NodeClass == NodeClass.Variable &&
r.TypeDefinition.NamespaceIndex != 0
)
.ConvertAll(r => ExpandedNodeId.ToNodeId(
r.NodeId,
uaClient.Session.NamespaceUris))
];
}
// treat managedBrowseall result like browseall results if the latter is missing
if (!browseall && managedbrowseall)
{
referenceDescriptions = referenceDescriptionsFromManagedBrowse;
browseall = managedbrowseall;
}
IList<INode>? allNodes = null;
if (fetchall)
{
allNodes = await samples
.FetchAllNodesNodeCacheAsync(
uaClient,
ObjectIds.RootFolder,
true,
true,
false,
ct: ct)
.ConfigureAwait(false);
variableIds =
[
.. allNodes
.Where(r =>
r.NodeClass == NodeClass.Variable &&
r is VariableNode v &&
v.DataType.NamespaceIndex != 0
)
.Select(r => ExpandedNodeId.ToNodeId(
r.NodeId,
uaClient.Session.NamespaceUris))
];
if (exportNodes)
{
await samples.ExportNodesToNodeSet2PerNamespaceAsync(
uaClient.Session,
allNodes,
Environment.CurrentDirectory,
exportNamespaces,
cancellationToken)
.ConfigureAwait(false);
}
}
if (jsonvalues && variableIds != null)
{
(
ArrayOf<DataValue> allValues,
ArrayOf<ServiceResult> results
) = await samples
.ReadAllValuesAsync(uaClient, variableIds.ToArrayOf(), ct)
.ConfigureAwait(false);
}
if (subscribe && (browseall || fetchall))
{
// subscribe to 1000 random variables
const int maxVariables = 1000;
var variables = new List<Node>();
if (fetchall)
{
variables.AddRange(
allNodes!
.Where(r =>
r.NodeClass == NodeClass.Variable &&
r.NodeId.NamespaceIndex > 1
)
.Cast<VariableNode>()
.OrderBy(o => UnsecureRandom.Shared.Next())
.Take(maxVariables)
);
}
else if (browseall)
{
var variableReferences = referenceDescriptions
.ToList()
.Where(r => r.NodeClass == NodeClass.Variable &&
r.NodeId.NamespaceIndex > 1)
.Select(r => r.NodeId)
.OrderBy(o => UnsecureRandom.Shared.Next())
.Take(maxVariables)
.ToList();
variables.AddRange(
(await uaClient.Session.NodeCache.FindAsync(variableReferences, ct)
.ConfigureAwait(false))
.ToList()
.Cast<Node>()
);
}
await samples
.SubscribeAllValuesAsync(
uaClient,
variableIds: [.. variables],
samplingInterval: 100,
publishingInterval: 1000,
queueSize: 10,
lifetimeCount: 60,
keepAliveCount: 2,
ct: ct
)
.ConfigureAwait(false);
// Wait for DataChange notifications from MonitoredItems
Console.WriteLine($"Subscribed to {maxVariables} variables. Press Ctrl-C to exit.");
// free unused memory
uaClient.Session.NodeCache.Clear();
waitTime = timeout -
(int)DateTime.UtcNow.Subtract(start).TotalMilliseconds;
DateTime endTime =
waitTime > 0
? DateTime.UtcNow.Add(TimeSpan.FromMilliseconds(waitTime))
: DateTime.MaxValue;
List<Node>.Enumerator variableIterator = variables.GetEnumerator();
while (!quit && endTime > DateTime.UtcNow)
{
if (variableIterator.MoveNext())
{
try
{
DataValue value = await uaClient
.Session.ReadValueAsync(
variableIterator.Current.NodeId, ct)
.ConfigureAwait(false);
Console.WriteLine($"Value of {variableIterator.Current.NodeId} is {value}");
}
catch (Exception ex)
{
Console.WriteLine($"Error reading value of {variableIterator.Current.NodeId}: {ex.Message}");
}
}
else
{
variableIterator = variables.GetEnumerator();
}
quit = quitEvent.WaitOne(500);
}
}
else
{
quit = true;
}
}
else
{
int quitTimeout = 65_000;
if (enableDurableSubscriptions)
{
quitTimeout = 150_000;
uaClient.ReconnectPeriod = 500_000;
}
NodeId sessionNodeId = uaClient.Session.SessionId;
// Run tests for available methods on reference server.
await samples.ReadNodesAsync(
uaClient.Session,
ct).ConfigureAwait(false);
await samples.WriteNodesAsync(
uaClient.Session,
ct).ConfigureAwait(false);
await samples.BrowseAsync(
uaClient.Session,
ct).ConfigureAwait(false);
await samples.CallMethodAsync(
uaClient.Session,
ct).ConfigureAwait(false);
await samples.EnableEventsAsync(
uaClient.Session,
(uint)quitTimeout,
ct).ConfigureAwait(false);
await samples.SubscribeToDataChangesAsync(
uaClient.Session,
60_000,
enableDurableSubscriptions,
ct).ConfigureAwait(false);
Console.WriteLine("Waiting...");
// Wait for some DataChange notifications from MonitoredItems
int waitCounters = 0;
const int checkForWaitTime = 1000;
const int closeSessionTime = checkForWaitTime * 15;
const int restartSessionTime = checkForWaitTime * 45;
const bool stopNotQuit = false;
int stopCount = 0;
while (!quit && !stopNotQuit && waitCounters < quitTimeout)
{
quit = quitEvent.WaitOne(checkForWaitTime);
waitCounters += checkForWaitTime;
if (enableDurableSubscriptions)
{
if (waitCounters == closeSessionTime &&
uaClient.Session.SubscriptionCount == 1)
{
Console.WriteLine($"Closing Session (CurrentTime: {DateTime.Now:T})");
await uaClient.Session.CloseAsync(closeChannel: false, ct: ct)
.ConfigureAwait(false);
}
if (waitCounters == restartSessionTime)
{
Console.WriteLine($"Restarting Session (CurrentTime: {DateTime.Now:T})");
await uaClient
.DurableSubscriptionTransferAsync(
serverUrl.ToString(),
useSecurity: !noSecurity,
ct
)
.ConfigureAwait(true);
}
if (waitCounters is > closeSessionTime and < restartSessionTime)
{
Console.WriteLine(
"No Communication Interval " +
stopCount.ToString(CultureInfo.InvariantCulture)
);
stopCount++;
}
}
}
}
Console.WriteLine("Client disconnected.");
await uaClient.DisconnectAsync(leakChannels, ct).ConfigureAwait(false);
}
else
{
Console.WriteLine("Could not connect to server! Retry in 10 seconds or Ctrl-C to quit.");
quit = quitEvent.WaitOne(Math.Min(10_000, waitTime));
}
} while (!quit);
Console.WriteLine("Client stopped.");
}
catch (Exception ex)
{
Console.Error.WriteLine(ex.Message);
throw;
}
finally
{
if (reverseConnectManager != null)
{
await reverseConnectManager.DisposeAsync().ConfigureAwait(false);
}
}
});
args = ConsoleUtils.MergeEnvironmentArgs(args, "REFCLIENT", rootCommand);
ParseResult cmdParseResult = rootCommand.Parse(args);
return cmdParseResult
.InvokeAsync(new InvocationConfiguration(), CancellationToken.None);
}
/// <summary>
/// returns a CertificateIdentifier of the Certificate with the specified thumbprint
/// if it is found in the trustedUserCertificates TrustList
/// </summary>
/// <param name="thumbprint">the thumbprint of the certificate to select</param>
/// <param name="trustedUserCertificates">the trustlist of the user certificates</param>
/// <param name="ct">the cancellation token</param>
/// <returns>Certificate Identifier</returns>
private static async Task<CertificateIdentifier?> FindUserCertificateIdentifierAsync(
string thumbprint,
CertificateTrustList trustedUserCertificates,
ITelemetryContext telemetry,
CancellationToken ct = default)
{
CertificateIdentifier? userCertificateIdentifier = null;
using CertificateCollection certificates =
await trustedUserCertificates.GetCertificatesAsync(telemetry, ct).ConfigureAwait(false);
// get user certificate with matching thumbprint
using CertificateCollection userCertificatesWithMatchingThumbprint =
certificates.Find(X509FindType.FindByThumbprint, thumbprint, false);
// create Certificate Identifier
if (userCertificatesWithMatchingThumbprint.Count == 1)
{
Certificate userCert = userCertificatesWithMatchingThumbprint[0];
userCertificateIdentifier = new CertificateIdentifier
{
Thumbprint = userCert.Thumbprint,
SubjectName = userCert.Subject,
StorePath = trustedUserCertificates.StorePath,
StoreType = trustedUserCertificates.StoreType
};
}
return userCertificateIdentifier;
}
/// <summary>
/// A dialog which asks for user input.
/// </summary>
public class ApplicationMessageDlg : IApplicationMessageDlg
{
private readonly TextWriter m_output;
private string m_message = string.Empty;
private bool m_ask;
/// <summary>
/// Initializes a message dialog that writes to the supplied writer or standard output.
/// </summary>
public ApplicationMessageDlg(TextWriter? output = null)
{
m_output = output ?? Console.Out;
}
/// <summary>
/// Sets the message and whether displaying it should request confirmation.
/// </summary>
public override void Message(string text, bool ask)
{
m_message = text;
m_ask = ask;
}
/// <summary>
/// Displays the message and returns the confirmation response, defaulting to acceptance.
/// </summary>
public override async Task<bool> ShowAsync()
{
if (m_ask)
{
var message = new StringBuilder(m_message);
message.Append(" (y/n, default y): ");
m_output.Write(message.ToString());
try
{
ConsoleKeyInfo result = Console.ReadKey();
m_output.WriteLine();
return await Task.FromResult(result.KeyChar is 'y' or 'Y' or '\r')
.ConfigureAwait(false);
}
catch
{
// intentionally fall through
}
}
else
{
m_output.WriteLine(m_message);
}
return await Task.FromResult(true).ConfigureAwait(false);
}
}
}
}