Files
Marc Schier 7d5f7a7076 Add shared stack APIs and fixes used by UaLens (#4464)
# Description

Extract shared stack, regression-test, sample and documentation changes
from draft #4462 into a separate prerequisite PR. The PR now contains
**19 shared paths**: the original eighteen extracted files plus a
focused user-management regression file added during review. It does not
include UaLens source, desktop dependencies, solution registrations or
application documentation.

## Changes

- Correct the V2 ConditionRefresh target and response validation. Bound
disposal-time remote subscription deletion with an independent
five-second TimeProvider-backed cancellation deadline, allowing local
cleanup after a server outage. This is a per-delete limit, not a global
application shutdown deadline.
- Preserve configured PubSub writer message/header settings, canonical
numeric JSON publisher matching, and flattened DataValue
quality/timestamps.
- Add asynchronous user-management client wrappers over the generated
standard proxy, and set file/directory method declaration IDs needed by
typed client workflows.
- Enable AI client NativeAOT compatibility and add focused AI client
regressions.
- Enable the ConsoleReferenceServer file-system node manager and
configure 1 ms publishing/sampling demonstrations. These sample defaults
do not promise hard real-time delivery.
- Document the subscription and PubSub behavior. Make the new
subscription/PubSub regression helpers portable to .NET Framework
without weakening their assertions.

## Review follow-up

Commit `812297ce1` aligns both public `ListUsersAsync` signatures with
`ValueTask<ArrayOf<UserManagementUser>>`. The implementation constructs
its result directly from an owned snapshot array, without an
intermediate list.

Five focused cases pass on **net10.0 and net48**:
`ListUsersReturnsArrayOfSnapshotsAsync` checks both interface and
concrete entry points and preserves snapshot order, values and flags;
`ListUsersReturnsEmptyArrayAsync` covers typed-empty and missing values;
`ListUsersPreservesBadReadStatusAsync` checks the exact server failure
status.

All **eight single-line XML summaries** were converted to multi-line
form. A complete scan of the fourteen changed C# files, including the
new test, finds zero remaining single-line summaries. Scoped
whitespace/line-ending verification passes.

The sole UaLens caller was adapted separately in draft #4462
(`4caf69f33`). A fresh combined dependency-graph build and all **924
UaLens net10.0 tests** pass with both changes. Each of the three review
threads received an action-taken reply and was resolved only after the
fixes were pushed and verified.

## Split boundary

The branch originated at `a45105375`; the subsequent master merge
`f209f3804` incorporates upstream `4633aa7a3`. The original shared
changes and review regression remain separate from the app. Draft #4462
retains `tools\Opc.Ua.Lens`, `tests\Opc.Ua.Lens.Tests`, and its six
solution/package/documentation integration files.

**Merge this prerequisite before the UaLens draft.** The draft consumes
the extracted APIs. Published history is preserved; neither PR has been
merged automatically.

## Initial split validation

The following results describe the initial split validation at
`fa7199afa`; current review-specific validation is recorded above.

| Scope | net10.0 | net48 |
|---|---:|---:|
| Client subscription/file-system tests | 168 passed | 168 passed |
| Server file-system/user-management tests | 244 passed | 244 passed |
| PubSub writer/reader regressions | 34 passed | 34 passed |
| ConsoleReferenceServer build | Passed | Passed |

The corrected portable regression helpers were rechecked on net10.0
after the net48 fixes. The combined UaLens application also passed its
924-case net10.0 suite before extraction.

**Focused NativeAOT: 16 passed, zero failed/skipped.** A session-local
TUnit runner linked the exact committed `AIClientAotTests.cs` and
referenced the real AI client project. It published without warnings or
suppressions and executed as a genuine Windows native binary with no CLR
header.

The canonical `Opc.Ua.Aot.Tests` project was blocked by pre-existing
CS8632 nullable-annotation errors in unchanged upstream
`SchemaAotTests.cs` (lines 82, 127, 129 and 132). Its existing nullable
configuration and all upstream tests were preserved; the focused result
is not a claim that the full AOT test project builds.

The net48 hosting selection uses a short NUnit WorkDirectory: the
existing fixture builds long PKI paths that otherwise exceed the
platform's path limit. The original full selection passed without test
exclusions, source changes or increased timeouts.

Scoped builds reported inherited warnings in unchanged upstream files:
CA1508 in `ReferenceBuilderExtensions.cs`, CA1861 in
`NodeSetImportIntegrationTests.cs`, and net48 CA1823 in
`DefaultNodeIdFactory.cs`. They were not suppressed or folded into this
split. Full-solution, coverage and CI/CodeQL gates remain unasserted.

## Related Issues

- Tracking/design review: #4461.
- Prerequisite extracted from draft #4462.

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

Tests and scoped results are documented above; measured coverage,
inherited-warning qualification, CLA status, full-solution results and
CI completion are not asserted by these checkboxes.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-09-09 19:22:06 +00:00

331 lines
16 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.CommandLine;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Gds.Server;
using Opc.Ua.Server;
namespace Quickstarts.ReferenceServer
{
/// <summary>
/// The program.
/// </summary>
public static class Program
{
public static Task<int> Main(string[] args)
{
Console.WriteLine("{0} OPC UA Reference Server", Utils.IsRunningOnMono() ? "Mono" : ".NET Core");
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
string applicationName = Utils.IsRunningOnMono() ? "MonoReferenceServer" : "ConsoleReferenceServer";
string configSectionName = Utils.IsRunningOnMono()
? "Quickstarts.MonoReferenceServer"
: "Quickstarts.ReferenceServer";
// command line options
var autoAcceptOption = new Option<bool>("--autoaccept", "-a") { Description = "auto accept certificates (for testing only)" };
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 = _ => -1
};
var shadowConfigOption = new Option<bool>("--shadowconfig", "-s") { Description = "create configuration in pki root" };
var samplingGroupsOption = new Option<bool>("--samplinggroups", "--sg")
{
Description = "use the sampling group mechanism in the Reference Node Manager"
};
var cttOption = new Option<bool>("--ctt") { Description = "CTT mode, use to preset alarms for CTT testing." };
var provisionOption = new Option<bool>("--provision")
{
Description = "start server in provisioning mode with limited namespace for certificate provisioning"
};
var reverseConnectOption = new Option<string>("--reverseconnect", "--rc")
{
Description = "Connect to the specified client endpoint for reverse connect. (e.g. --rc opc.tcp://localhost:65300)"
};
var rootCommand = new RootCommand(
Utils.IsRunningOnMono()
? $"Usage: mono {applicationName}.exe [OPTIONS]"
: $"Usage: dotnet {applicationName}.dll [OPTIONS]")
{
autoAcceptOption,
consoleOption,
logOption,
fileOption,
passwordOption,
renewOption,
timeoutOption,
shadowConfigOption,
samplingGroupsOption,
cttOption,
provisionOption,
reverseConnectOption
};
rootCommand.SetAction(async (parseResult, cancellationToken) =>
{
bool autoAccept = parseResult.GetValue(autoAcceptOption);
bool logConsole = parseResult.GetValue(consoleOption);
bool appLog = parseResult.GetValue(logOption);
bool fileLog = parseResult.GetValue(fileOption);
string? passwordStr = parseResult.GetValue(passwordOption);
char[]? password = passwordStr?.ToCharArray();
bool renewCertificate = parseResult.GetValue(renewOption);
int timeoutSec = parseResult.GetValue(timeoutOption);
int timeout = timeoutSec >= 0 ? timeoutSec * 1000 : -1;
bool shadowConfig = parseResult.GetValue(shadowConfigOption);
bool samplingGroups = parseResult.GetValue(samplingGroupsOption);
bool cttMode = parseResult.GetValue(cttOption);
bool provisioningMode = parseResult.GetValue(provisionOption);
string? reverseConnectUrlString = parseResult.GetValue(reverseConnectOption);
// Use CTT-specific config when CTT mode is enabled.
// Mono uses a separate server (MonoReferenceServer) with its own config, so CTT config is not applicable there.
if (cttMode && !Utils.IsRunningOnMono())
{
configSectionName = "Ctt.ReferenceServer";
}
using var telemetry = new ConsoleTelemetry();
ILogger logger = LoggerUtils.Null.Logger;
try
{
// log console output to logger
if (logConsole && appLog)
{
logger = telemetry.CreateLogger("Main");
}
var sw = Stopwatch.StartNew();
// create the UA server
var server = new UAServer<ReferenceServer>(
telemetry,
// The console reference server is the canonical
// host that exposes the full address space — turn
// FileSystem (Part 20) on by default so clients
// can browse Server.FileSystem without extra
// configuration. Unit-test fixtures construct
// ReferenceServer directly and keep the property
// at its default false.
t => new ReferenceServer(t) { EnableFileSystemNodeManager = true })
{
AutoAccept = autoAccept,
Password = password
};
// load the server configuration, validate certificates
Console.WriteLine($"Loading configuration from {configSectionName}.");
await server.LoadAsync(applicationName, configSectionName).ConfigureAwait(false);
// use the shadow config to map the config to an externally accessible location
if (shadowConfig)
{
Console.WriteLine("Using shadow configuration.");
// --shadowconfig requires TraceConfiguration.OutputFilePath and SourceFilePath to be set;
// null values would have thrown NullReferenceException historically — preserve that behavior.
string shadowPath = Directory
.GetParent(
Path.GetDirectoryName(
Utils.ReplaceSpecialFolderNames(server.Configuration.TraceConfiguration!.OutputFilePath))!)!
.FullName;
string shadowFilePath = Path.Combine(
shadowPath,
Path.GetFileName(server.Configuration.SourceFilePath)!
);
if (!File.Exists(shadowFilePath))
{
Console.WriteLine("Create a copy of the config in the shadow location.");
File.Copy(server.Configuration.SourceFilePath!, shadowFilePath, true);
}
Console.WriteLine($"Reloading configuration from shadow location {shadowFilePath}.");
await server
.LoadAsync(applicationName, Path.Combine(shadowPath, configSectionName))
.ConfigureAwait(false);
}
// setup the logging
telemetry.ConfigureLogging(
server.Configuration,
applicationName,
logConsole,
fileLog,
appLog,
LogLevel.Information);
// check or renew the certificate
Console.WriteLine("Check the certificate.");
await server.CheckCertificateAsync(renewCertificate).ConfigureAwait(false);
// Create and add the node managers
server.Create(Servers.Utils.NodeManagerFactories);
// Add async node managers
foreach (IAsyncNodeManagerFactory asyncFactory in Servers.Utils.AsyncNodeManagerFactories)
{
server.Server!.AddNodeManager(asyncFactory);
}
// Add GDS node manager if configured
GlobalDiscoveryServerConfiguration? gdsConfig = server.Configuration
.ParseExtension<GlobalDiscoveryServerConfiguration>();
if (gdsConfig != null)
{
Console.WriteLine("GDS configuration found. Adding GDS node manager.");
server.Server!.AddNodeManager(new GdsNodeManagerFactory(gdsConfig));
}
// enable provisioning mode if requested
if (provisioningMode)
{
Console.WriteLine("Enabling provisioning mode.");
Servers.Utils.EnableProvisioningMode(server.Server!);
// Auto-accept is required in provisioning mode
if (!autoAccept)
{
Console.WriteLine("Auto-accept enabled for provisioning mode.");
autoAccept = true;
server.AutoAccept = autoAccept;
}
}
// enable the sampling groups if requested
if (samplingGroups)
{
Servers.Utils.UseSamplingGroupsInReferenceNodeManager(server.Server!);
}
// start the server
Console.WriteLine("Start the server.");
await server.StartAsync(cancellationToken).ConfigureAwait(false);
// setup reverse connect if specified
if (!string.IsNullOrEmpty(reverseConnectUrlString))
{
try
{
Console.WriteLine($"Adding reverse connection to {reverseConnectUrlString}.");
var reverseConnectUrl = new Uri(reverseConnectUrlString);
server.Server!.AddReverseConnection(reverseConnectUrl);
}
catch (UriFormatException ex)
{
logger.InvalidReverseConnectUrl(ex, reverseConnectUrlString);
throw new ErrorExitException(
$"Invalid reverse connect URL: {reverseConnectUrlString}",
ExitCode.ErrorInvalidCommandLine);
}
}
// Apply custom settings for CTT testing
if (cttMode)
{
Console.WriteLine("Apply settings for CTT.");
// start Alarms and other settings for CTT test
await Servers.Utils.ApplyCTTModeAsync(Console.Out, server.Server!)
.ConfigureAwait(false);
}
Console.WriteLine($"Server started ({sw.ElapsedMilliseconds} ms). Press Ctrl-C to exit...");
// wait for timeout or Ctrl-C (cancellationToken is cancelled on Ctrl-C by System.CommandLine)
if (timeout >= 0)
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(timeout);
try
{
await Task.Delay(Timeout.Infinite, timeoutCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// expected — timeout or Ctrl-C
}
}
else
{
try
{
await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// expected — Ctrl-C
}
}
// stop server. May have to wait for clients to disconnect.
Console.WriteLine("Server stopped. Waiting for exit...");
await server.StopAsync(default).ConfigureAwait(false);
return (int)ExitCode.Ok;
}
catch (ErrorExitException eee)
{
Console.WriteLine($"The application exits with error: {eee.Message}");
return (int)eee.ExitCode;
}
});
args = ConsoleUtils.MergeEnvironmentArgs(args, "REFSERVER", rootCommand);
ParseResult parseResult = rootCommand.Parse(args);
return parseResult
.InvokeAsync(new InvocationConfiguration(), CancellationToken.None);
}
}
internal static partial class ProgramLog
{
[LoggerMessage(EventId = ConsoleReferenceServerEventIds.Program + 0, Level = LogLevel.Error,
Message = "Invalid reverse connect URL: {Url}")]
public static partial void InvalidReverseConnectUrl(this ILogger logger, UriFormatException ex, string url);
}
}