Files
Copilot d525bc3f28 Bound reconnect cleanup and organize samples by domain (#4276)
# Description

This PR fixes bounded ManagedSession reconnect cleanup and reorganizes
the sample applications into domain-focused folders.

## Reconnect cleanup

- Bounds reconnect integration cleanup with independent 30-second close
and dispose limits.
- Sends `CloseSession` through the awaited transport request path during
`Session.DisposeAsync`.
- Preserves explicit `Session.CloseAsync` behavior that waits for or
cancels outstanding publish requests.
- Covers the disposal request path with a regression test.

## Sample organization

- `samples/OpenUsd`: `GeneratorServer`, `SiteCompositionServer`
- `samples/Redundancy`: `RedundantClient`, `RedundantServer`,
`RedundantPubSub`
- `samples/Isa95`: `MinimalIsa95Server`
- `samples/MinimalApi`: `MinimalBoilerServer`, `MinimalCalcServer`,
`MinimalClient`
- `samples/DI`: `PumpDeviceIntegrationServer`
- `samples/Lds`: `ConsoleLdsServer`
- `samples/Reference`: `ConsoleReferenceClient`,
`ConsoleReferenceServer`
- `samples/PubSub`: `ConsoleReferencePubSubClient`

`UA.slnx` now exposes the same requested domain groups. Project
references, tests, runtime sample probes, CI workflows, Dockerfiles,
Compose files, helper scripts, and documentation were updated to the new
paths. `samples/Robotics/MinimalRobotServer` remains under `Robotics`.

## Validation

- `dotnet build UA.slnx`
- 3 Redundancy short-haul sample tests
- 70 DI sample tests
- 914 OpenUSD tests
- 1 WotCon model-materialization test
- 137 AOT tests
- `docker compose config` for all 6 moved Redundancy Compose files

## Related Issues

- Fixes #4273

## 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.
- [x] I built the complete `UA.slnx` solution locally.
- [ ] I ran all tests locally against at least .NET Framework and .NET
10.
- [ ] I fixed all failing and flaky tests in the CI pipelines and all
CodeQL warnings.
- [x] I have addressed all PR feedback received.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com>
Co-authored-by: Marc Schier <mschier@microsoft.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
Copilot-Session: a2c2838f-74ae-4997-a1d4-50574d9e0636
2026-08-21 04:35:47 +00:00

615 lines
25 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.IO;
#if NET8_0_OR_GREATER
using System.Linq;
#endif
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.Configuration;
using Opc.Ua.Identity;
using Opc.Ua.Security.Certificates;
using Quickstarts.ConsoleReferenceClient;
namespace Quickstarts
{
/// <summary>
/// Wraps connect testing functionality
/// </summary>
public sealed class ConnectTester : IAsyncDisposable
{
public ConnectTester(
ITelemetryContext telemetry,
ManualResetEvent? quitEvent = null)
{
m_quitEvent = quitEvent;
m_telemetry = telemetry;
m_logger = telemetry.CreateLogger<ConnectTester>();
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
m_reconnectHandler?.Dispose();
ISession? session = m_wrapper?.Session;
if (session != null)
{
await session.DisposeAsync().ConfigureAwait(false);
}
GC.SuppressFinalize(this);
}
/// <summary>
/// Run the tests until cancelled or quit
/// </summary>
/// <param name="ct"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
public async Task RunAsync(CancellationToken ct)
{
try
{
m_reconnectHandler = new SessionReconnectHandler(
m_telemetry,
true,
kReconnectPeriodExponentialBackoff);
m_logger.OpcUaSecurityTestClient();
// The application name and config file names
const string applicationName = "ConsoleReferenceClient";
const string configSectionName = "Quickstarts.ReferenceClient";
// Define the UA Client application
var passwordProvider = new CertificatePasswordProvider([]);
var application = new ApplicationInstance(m_telemetry)
{
ApplicationName = applicationName,
ApplicationType = ApplicationType.Client,
ConfigSectionName = configSectionName,
CertificatePasswordProvider = passwordProvider
};
await using (application.ConfigureAwait(false))
{
// load the application configuration.
m_configuration = await application
.LoadApplicationConfigurationAsync(silent: false, ct: ct)
.ConfigureAwait(false);
m_configuration.CertificateManager.AcceptError = AcceptCertificate;
// check the application certificate.
bool haveAppCertificate = await application
.CheckApplicationInstanceCertificatesAsync(false, ct: ct)
.ConfigureAwait(false);
if (!haveAppCertificate)
{
throw new InvalidOperationException("Application instance certificate invalid!");
}
}
m_logger.ConnectingTo(kServerUrl);
ArrayOf<EndpointDescription> endpoints = await GetEndpointsAsync(
m_configuration,
kServerUrl,
ct).ConfigureAwait(false);
var endpointConfiguration = EndpointConfiguration.Create(m_configuration);
var sessionFactory = new DefaultSessionFactory(m_telemetry);
IClientIdentityProvider userNameProvider = await CreateUserNameProviderAsync(ct)
.ConfigureAwait(false);
foreach (EndpointDescription ii in endpoints.ToArray()!)
{
string securityPolicyUri = ii.SecurityPolicyUri!;
string userCertificateFile = GetUserCertificateFile(securityPolicyUri);
X509Certificate2 x509 = X509CertificateLoader.LoadPkcs12FromFile(
Path.Combine("..\\..\\pki\\trustedUser\\private",
userCertificateFile),
"password");
string thumbprint = x509.Thumbprint!;
IClientIdentityProvider certificateProvider = await LoadUserCertificateProviderAsync(
thumbprint,
"password",
ct).ConfigureAwait(false);
var identityProviders = new List<IClientIdentityProvider>
{
new AnonymousIdentityProvider()
};
if (!string.IsNullOrEmpty(kUserName))
{
identityProviders.Add(userNameProvider);
}
if (kSupportsX509)
{
identityProviders.Add(certificateProvider);
}
foreach (IClientIdentityProvider identityProvider in identityProviders)
{
IUserIdentity identity = await CreateUserIdentityAsync(
identityProvider,
ii,
ct).ConfigureAwait(false);
try
{
if (m_logger.IsEnabled(LogLevel.Warning))
{
m_logger.SeparatorLine(new string('=', 80));
}
if (m_logger.IsEnabled(LogLevel.Warning))
{
m_logger.SecurityPolicy(
SecurityPolicies.Default.GetDisplayName(securityPolicyUri),
ii.SecurityMode);
}
m_logger.Identity(
identity.DisplayName,
identity.TokenType);
SessionWrapper wrapper = m_wrapper = await RunTestAsync(
endpointConfiguration,
sessionFactory,
ii,
identity,
ct).ConfigureAwait(false);
m_logger.WaitingForSecureChannelRenew();
await wrapper.Session.UpdateSessionAsync(identity, default, ct).ConfigureAwait(false);
for (int count = 0; count < 1; count++)
{
ReadResponse result = await wrapper.Session.ReadAsync(
null,
0,
TimestampsToReturn.Neither,
new List<ReadValueId>
{
new() {
NodeId = VariableIds.Server_ServerStatus_CurrentTime,
AttributeId = Attributes.Value
}
},
ct).ConfigureAwait(false);
m_logger.CurrentTime(result.Results[0].WrappedValue.GetDateTime());
await Task.Delay(5000, ct).ConfigureAwait(false);
}
await wrapper.Session.UpdateSessionAsync(identity, default, ct).ConfigureAwait(false);
await wrapper.Session.CloseAsync(true, ct: ct).ConfigureAwait(false);
}
catch (Exception e)
{
Console.WriteLine("Exception: {0}", e.Message);
Console.WriteLine("StackTrace: {0}", e.StackTrace);
if (m_logger.IsEnabled(LogLevel.Warning))
{
m_logger.SecurityPolicy(
SecurityPolicies.Default.GetDisplayName(securityPolicyUri),
ii.SecurityMode);
}
m_logger.Identity(
identity.DisplayName,
identity.TokenType);
if (m_logger.IsEnabled(LogLevel.Warning))
{
m_logger.SeparatorLine(new string('=', 80));
}
}
if (m_logger.IsEnabled(LogLevel.Warning))
{
m_logger.TestComplete(
SecurityPolicies.Default.GetDisplayName(securityPolicyUri),
ii.SecurityMode);
}
if (m_logger.IsEnabled(LogLevel.Warning))
{
m_logger.SeparatorLine(new string('=', 80));
}
}
}
Console.WriteLine("Ctrl-C to stop.");
m_quitEvent!.WaitOne();
}
catch (Exception e)
{
m_logger.ExceptionMessage(e.Message);
m_logger.StackTrace(e.StackTrace);
}
}
internal async Task<SessionWrapper> RunTestAsync(
EndpointConfiguration endpointConfiguration,
DefaultSessionFactory sessionFactory,
EndpointDescription endpointDescription,
IUserIdentity identity,
CancellationToken ct)
{
var endpoint = new ConfiguredEndpoint(
null,
endpointDescription,
endpointConfiguration);
// Create the session
ISession isession = await sessionFactory
.CreateAsync(
m_configuration,
endpoint,
false,
false,
m_configuration.ApplicationName!,
600000,
endpointDescription.SecurityMode != MessageSecurityMode.None
? identity
: await CreateUserIdentityAsync(
new AnonymousIdentityProvider(),
endpointDescription,
ct).ConfigureAwait(false),
default,
ct
)
.ConfigureAwait(false);
bool ownsSession = true;
try
{
SessionWrapper wrapper = m_wrapper = new SessionWrapper { Session = isession };
ownsSession = false;
// Assign the created session
if (!wrapper.Session.Connected)
{
throw new InvalidOperationException("Could not connect to server at " + kServerUrl);
}
wrapper.Session.KeepAliveInterval = 10000;
wrapper.Session.KeepAlive += Session_KeepAlive;
var samples = new ClientSamples(m_telemetry, null, m_quitEvent);
ArrayOf<ReferenceDescription> nodes = await samples.BrowseFullAddressSpaceAsync(
wrapper,
ObjectIds.ObjectsFolder,
null,
ct).ConfigureAwait(false);
return wrapper;
}
finally
{
if (ownsSession)
{
await isession.DisposeAsync().ConfigureAwait(false);
}
}
}
private static async ValueTask<UserNamePasswordIdentityProvider> CreateUserNameProviderAsync(
CancellationToken ct)
{
var passwordStore = new InMemorySecretStore();
var passwordId = new SecretIdentifier(
"connect-tester-password",
passwordStore.StoreType);
await passwordStore
.SetAsync(passwordId, new UTF8Encoding(false).GetBytes(kPassword), ct)
.ConfigureAwait(false);
return new UserNamePasswordIdentityProvider(
kUserName,
new SecretRegistry(passwordStore),
passwordId);
}
private ValueTask<IUserIdentity> CreateUserIdentityAsync(
IClientIdentityProvider provider,
EndpointDescription endpointDescription,
CancellationToken ct)
{
return provider.AcquireIdentityAsync(
endpointDescription,
m_configuration.CreateMessageContext(),
ct);
}
private async Task<IClientIdentityProvider> LoadUserCertificateProviderAsync(
string thumbprint,
string password,
CancellationToken ct)
{
CertificateTrustList store = m_configuration.SecurityConfiguration.TrustedUserCertificates!;
#if NET8_0_OR_GREATER
// get user certificate with matching thumbprint
using CertificateCollection certificates =
await store.GetCertificatesAsync(m_telemetry, ct).ConfigureAwait(false);
using Certificate hit = certificates
.Find(X509FindType.FindByThumbprint, thumbprint, false)
.FirstOrDefault()!;
// create Certificate Identifier
var cid = new CertificateIdentifier
{
Thumbprint = hit.Thumbprint,
SubjectName = hit.Subject,
StorePath = store.StorePath,
StoreType = store.StoreType
};
return new X509ClientIdentityProvider(
cid,
new CertificatePasswordProvider(new UTF8Encoding(false).GetBytes(password)),
m_configuration.CertificateManager.CertificateProvider);
#else
throw new NotSupportedException("User certificate identity requires net8.0 or greater.");
#endif
}
private static async ValueTask<ArrayOf<EndpointDescription>> GetEndpointsAsync(
ApplicationConfiguration application,
string discoveryUrl,
CancellationToken ct = default)
{
var endpointConfiguration = EndpointConfiguration.Create(application);
using DiscoveryClient client = await DiscoveryClient.CreateAsync(
application,
new Uri(discoveryUrl),
endpointConfiguration,
ct: ct).ConfigureAwait(false);
return await client.GetEndpointsAsync(default, ct).ConfigureAwait(false);
}
private bool AcceptCertificate(Certificate certificate, ServiceResult error)
{
// ****
// Implement a custom logic to decide if the certificate should be
// accepted. Return true to accept, false to reject.
// ***
m_logger.ServiceResult(error);
bool certificateAccepted = error.StatusCode == StatusCodes.BadCertificateUntrusted;
if (certificateAccepted)
{
m_logger.UntrustedCertificateAccepted(certificate.Subject);
}
else
{
m_logger.UntrustedCertificateRejected(certificate.Subject);
}
return certificateAccepted;
}
/// <summary>
/// Handles a keep alive event from a session and triggers a reconnect if necessary.
/// </summary>
private void Session_KeepAlive(ISession session, KeepAliveEventArgs e)
{
try
{
// check for events from discarded sessions.
if (m_wrapper == null || !m_wrapper.Session.Equals(session))
{
return;
}
// start reconnect sequence on communication error.
if (ServiceResult.IsBad(e.Status))
{
SessionReconnectHandler.ReconnectState state = m_reconnectHandler!
.BeginReconnect(
m_wrapper.Session,
null,
kReconnectPeriod,
Client_ReconnectComplete
);
if (state == SessionReconnectHandler.ReconnectState.Triggered)
{
m_logger.KeepAliveReconnectStatusWithPeriod(
e.Status,
state,
kReconnectPeriod);
}
else
{
m_logger.KeepAliveReconnectStatus(
e.Status,
state);
}
// cancel sending a new keep alive request, because reconnect is triggered.
e.CancelKeepAlive = true;
}
}
catch (Exception exception)
{
m_logger.ErrorInOnKeepAlive(exception);
}
}
private void Client_ReconnectComplete(object? sender, EventArgs e)
{
// ignore callbacks from discarded objects.
if (!ReferenceEquals(sender, m_reconnectHandler))
{
return;
}
lock (m_lock)
{
// if session recovered, Session property is null
if (m_reconnectHandler!.Session != null)
{
// ensure only a new instance is disposed
// after reactivate, the same session instance may be returned
if (!ReferenceEquals(m_wrapper!.Session, m_reconnectHandler.Session))
{
m_logger.ReconnectedToNewSession(m_reconnectHandler.Session.SessionId);
ISession session = m_wrapper.Session;
m_wrapper = new SessionWrapper { Session = m_reconnectHandler.Session };
session?.Dispose();
}
else
{
m_logger.ReactivatedSession(m_reconnectHandler.Session.SessionId);
}
}
else
{
m_logger.ReconnectKeepAliveRecovered();
}
}
}
private static string GetUserCertificateFile(string securityPolicyUri)
{
// GetInfo returns null only for null/empty URI; caller passes a non-empty value.
SecurityPolicyInfo securityPolicy = SecurityPolicies.Default.GetInfo(securityPolicyUri)!;
switch (securityPolicy.CertificateKeyAlgorithm)
{
case CertificateKeyAlgorithm.BrainpoolP256r1:
return "iama.tester.brainpoolP256r1.pfx";
case CertificateKeyAlgorithm.BrainpoolP384r1:
return "iama.tester.brainpoolP384r1.pfx";
case CertificateKeyAlgorithm.NistP256:
return "iama.tester.nistP256.pfx";
case CertificateKeyAlgorithm.NistP384:
return "iama.tester.nistP384.pfx";
default:
return "iama.tester.rsa.pfx";
}
}
internal sealed class SessionWrapper : IUAClient
{
public required ISession Session { get; init; }
}
private readonly Lock m_lock = new();
private SessionReconnectHandler? m_reconnectHandler;
private ApplicationConfiguration m_configuration = null!;
private SessionWrapper? m_wrapper;
private readonly ILogger m_logger;
private readonly ITelemetryContext m_telemetry;
private readonly ManualResetEvent? m_quitEvent;
private const string kServerUrl = "opc.tcp://localhost:62541";
private const string kUserName = "sysadmin";
private const string kPassword = "demo";
private const bool kSupportsX509 = true;
private const int kReconnectPeriod = 1000;
private const int kReconnectPeriodExponentialBackoff = 15000;
}
internal static partial class ConnectTesterLog
{
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 0, Level = LogLevel.Information,
Message = "OPC UA Security Test Client")]
public static partial void OpcUaSecurityTestClient(this ILogger logger);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 1, Level = LogLevel.Information,
Message = "Connecting to... {ServerUrl}")]
public static partial void ConnectingTo(this ILogger logger, string serverUrl);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 2, Level = LogLevel.Warning,
Message = "{Line}")]
public static partial void SeparatorLine(this ILogger logger, string line);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 3, Level = LogLevel.Warning,
Message = "SECURITY-POLICY={SecurityPolicyUri} {SecurityMode}")]
public static partial void SecurityPolicy(
this ILogger logger,
string? securityPolicyUri,
MessageSecurityMode securityMode);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 4, Level = LogLevel.Warning,
Message = "IDENTITY={DisplayName} {TokenType}")]
public static partial void Identity(
this ILogger logger,
string displayName,
UserTokenType tokenType);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 5, Level = LogLevel.Warning,
Message = "Waiting for SecureChannel renew")]
public static partial void WaitingForSecureChannelRenew(this ILogger logger);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 6, Level = LogLevel.Warning,
Message = "CurrentTime: {CurrentTime}")]
public static partial void CurrentTime(this ILogger logger, DateTimeUtc currentTime);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 7, Level = LogLevel.Warning,
Message = "TEST COMPLETE: {SecurityPolicyUri} {SecurityMode}")]
public static partial void TestComplete(
this ILogger logger,
string? securityPolicyUri,
MessageSecurityMode securityMode);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 8, Level = LogLevel.Error,
Message = "Exception: {Message}")]
public static partial void ExceptionMessage(this ILogger logger, string message);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 9, Level = LogLevel.Trace,
Message = "StackTrace: {StackTrace}")]
public static partial void StackTrace(this ILogger logger, string? stackTrace);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 10, Level = LogLevel.Information,
Message = "{ServiceResult}")]
public static partial void ServiceResult(this ILogger logger, ServiceResult serviceResult);
}
}