/* ======================================================================== * 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.Diagnostics; using System.IO; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Opc.Ua.Configuration; using Opc.Ua.Tests; using Quickstarts.ReferenceServer; namespace Opc.Ua.Server.TestFramework { /// /// Server fixture for testing. /// /// A server class T used for testing. // CA1001: test fixture; lifecycle is handled via async StopAsync // (Application.DisposeAsync at line 445), not the IDisposable pattern. #pragma warning disable CA1001 public class ServerFixture #pragma warning restore CA1001 where T : ServerBase { public IApplicationInstance Application { get; private set; } = null!; public ApplicationConfiguration Config { get; private set; } = null!; public T Server { get; private set; } = null!; public bool AutoAccept { get; set; } public bool OperationLimits { get; set; } // Keep room for the default 100 Sessions, one replacement channel and two protected startup floors. public int MaxChannelCount { get; set; } = 103; public int ReverseConnectTimeout { get; set; } public bool AllNodeManagers { get; set; } /// /// Whether the fixture's server refuses to mint a NodeId it already /// gave a different browse path. On by default, so a test run /// exercises the check whatever configuration the stack was built in. /// public bool DetectNodeIdCollisions { get; set; } = true; public int TraceMasks { get; set; } = Utils.TraceMasks.Error | Utils.TraceMasks.StackTrace | Utils.TraceMasks.Security | Utils.TraceMasks.Information; public bool SecurityNone { get; set; } public string UriScheme { get; set; } = Utils.UriSchemeOpcTcp; /// /// When true (default), HTTPS / WSS endpoints require client /// TLS authentication and the listener emits the configured /// secure security policy. When false, mutual TLS is off and /// the listener emits a /// endpoint - required for the JSON sub-protocols /// (HTTPS-JSON, WSS-JSON) which do not use UA Secure Conversation. /// public bool HttpsMutualTls { get; set; } = true; public int Port { get; private set; } public bool UseTracing { get; } public bool DurableSubscriptionsEnabled { get; set; } public bool UseSamplingGroupsInReferenceNodeManager { get; set; } public bool ProvisioningMode { get; set; } public ActivityListener ActivityListener { get; private set; } = null!; /// /// Optional /// assigned to the server immediately after construction (before /// StartAsync). Used by integration tests that swap a /// listener / channel factory for a binding under test - e.g. the /// Kestrel-TCP listener fixture - without touching the /// process-wide static state. /// public Bindings.ITransportBindingRegistry TransportBindingRegistry { get; set; } = null!; public ServerFixture( Func factory, bool useTracing, bool disableActivityLogging) : this(factory) { UseTracing = useTracing; if (UseTracing) { StartActivityListenerInternal(disableActivityLogging); } } public ServerFixture(Func factory) { m_factory = factory; m_telemetry = NUnitTelemetryContext.Create(true); m_logger = m_telemetry.CreateLogger>(); } public async Task LoadConfigurationAsync(string? pkiRoot = null) { Application = new ApplicationInstance(m_telemetry) { ApplicationName = typeof(T).Name, ApplicationType = ApplicationType.Server }; // create the application configuration. Use temp path for cert stores. pkiRoot ??= Path.GetTempPath() + Path.GetRandomFileName(); string endpointUrl = $"{UriScheme}://localhost:0/" + typeof(T).Name; IApplicationConfigurationBuilderServerSelected serverConfig = Application .Build( "urn:localhost:UA:" + typeof(T).Name, "uri:opcfoundation.org:" + typeof(T).Name) .SetMaxByteStringLength(4 * 1024 * 1024) .SetMaxArrayLength(1024 * 1024) .SetMaxMessageSize(16 * 1024 * 1024) .SetChannelLifetime(30000) .AsServer([endpointUrl]); if (SecurityNone) { serverConfig.AddUnsecurePolicyNone(); } if (Utils.IsUriHttpsScheme(endpointUrl) || Utils.IsUriWssScheme(endpointUrl)) { serverConfig.AddPolicy( MessageSecurityMode.SignAndEncrypt, SecurityPolicies.Basic256Sha256); } else if (endpointUrl.StartsWith(Utils.UriSchemeOpcTcp, StringComparison.Ordinal)) { // add deprecated policies for opc.tcp tests serverConfig .AddSignPolicies() .AddSignAndEncryptPolicies() .AddEccSignPolicies() .AddEccSignAndEncryptPolicies(); AddPolicyIfSupported( serverConfig, MessageSecurityMode.Sign, SecurityPolicies.Basic128Rsa15); AddPolicyIfSupported( serverConfig, MessageSecurityMode.SignAndEncrypt, SecurityPolicies.Basic128Rsa15); AddPolicyIfSupported( serverConfig, MessageSecurityMode.Sign, SecurityPolicies.Basic256); AddPolicyIfSupported( serverConfig, MessageSecurityMode.SignAndEncrypt, SecurityPolicies.Basic256); AddPolicyIfSupported( serverConfig, MessageSecurityMode.Sign, SecurityPolicies.RSA_DH_AesGcm); AddPolicyIfSupported( serverConfig, MessageSecurityMode.SignAndEncrypt, SecurityPolicies.RSA_DH_AesGcm); AddPolicyIfSupported( serverConfig, MessageSecurityMode.Sign, SecurityPolicies.RSA_DH_ChaChaPoly); AddPolicyIfSupported( serverConfig, MessageSecurityMode.SignAndEncrypt, SecurityPolicies.RSA_DH_ChaChaPoly); AddPolicyIfSupported( serverConfig, MessageSecurityMode.Sign, SecurityPolicies.ECC_nistP256_AesGcm); AddPolicyIfSupported( serverConfig, MessageSecurityMode.SignAndEncrypt, SecurityPolicies.ECC_nistP256_AesGcm); AddPolicyIfSupported( serverConfig, MessageSecurityMode.Sign, SecurityPolicies.ECC_nistP256_ChaChaPoly); AddPolicyIfSupported( serverConfig, MessageSecurityMode.SignAndEncrypt, SecurityPolicies.ECC_nistP256_ChaChaPoly); AddPolicyIfSupported( serverConfig, MessageSecurityMode.Sign, SecurityPolicies.ECC_nistP384_AesGcm); AddPolicyIfSupported( serverConfig, MessageSecurityMode.SignAndEncrypt, SecurityPolicies.ECC_nistP384_AesGcm); AddPolicyIfSupported( serverConfig, MessageSecurityMode.Sign, SecurityPolicies.ECC_nistP384_ChaChaPoly); AddPolicyIfSupported( serverConfig, MessageSecurityMode.SignAndEncrypt, SecurityPolicies.ECC_nistP384_ChaChaPoly); AddPolicyIfSupported( serverConfig, MessageSecurityMode.Sign, SecurityPolicies.ECC_brainpoolP256r1_AesGcm); AddPolicyIfSupported( serverConfig, MessageSecurityMode.SignAndEncrypt, SecurityPolicies.ECC_brainpoolP256r1_AesGcm); AddPolicyIfSupported( serverConfig, MessageSecurityMode.Sign, SecurityPolicies.ECC_brainpoolP256r1_ChaChaPoly); AddPolicyIfSupported( serverConfig, MessageSecurityMode.SignAndEncrypt, SecurityPolicies.ECC_brainpoolP256r1_ChaChaPoly); AddPolicyIfSupported( serverConfig, MessageSecurityMode.Sign, SecurityPolicies.ECC_brainpoolP384r1_AesGcm); AddPolicyIfSupported( serverConfig, MessageSecurityMode.SignAndEncrypt, SecurityPolicies.ECC_brainpoolP384r1_AesGcm); AddPolicyIfSupported( serverConfig, MessageSecurityMode.Sign, SecurityPolicies.ECC_brainpoolP384r1_ChaChaPoly); AddPolicyIfSupported( serverConfig, MessageSecurityMode.SignAndEncrypt, SecurityPolicies.ECC_brainpoolP384r1_ChaChaPoly); } if (OperationLimits) { serverConfig.SetOperationLimits( new OperationLimits { MaxNodesPerBrowse = 2500, MaxNodesPerRead = 1000, MaxNodesPerWrite = 1000, MaxNodesPerMethodCall = 1000, MaxMonitoredItemsPerCall = 1000, MaxNodesPerHistoryReadData = 1000, MaxNodesPerHistoryReadEvents = 1000, MaxNodesPerHistoryUpdateData = 1000, MaxNodesPerHistoryUpdateEvents = 1000, MaxNodesPerNodeManagement = 1000, MaxNodesPerRegisterNodes = 1000, MaxNodesPerTranslateBrowsePathsToNodeIds = 1000 }); } serverConfig .SetMaxChannelCount(MaxChannelCount) .SetMaxMessageQueueSize(20) .SetDiagnosticsEnabled(true) .SetAuditingEnabled(true) .SetHttpsMutualTls(HttpsMutualTls) // Tests always close the session explicitly before stopping the server, // so no real client needs the shutdown-delay grace period. Setting to 0 // eliminates the Thread.Sleep(1000)×ShutdownDelay blocking call in // StandardServer.ShutDownDelay(), which otherwise races with the // ServerFixture teardown watchdog (both default to 5 s) and causes a // post-test process hang on macOS where Thread.Sleep drifts slightly // above 1 s per iteration. .SetShutdownDelay(0); if (ReverseConnectTimeout != 0) { serverConfig.SetReverseConnect( new ReverseConnectServerConfiguration { ConnectInterval = ReverseConnectTimeout / 4, ConnectTimeout = ReverseConnectTimeout, RejectTimeout = ReverseConnectTimeout / 4 }); } ArrayOf applicationCerts = ApplicationConfigurationBuilder.CreateDefaultApplicationCertificates( "CN=" + typeof(T).Name + ", C=US, S=Arizona, O=OPC Foundation, DC=localhost", CertificateStoreType.Directory, pkiRoot); if (DurableSubscriptionsEnabled) { serverConfig .SetDurableSubscriptionsEnabled(true) .SetMaxDurableEventQueueSize(10000) .SetMaxDurableNotificationQueueSize(1000) .SetMaxDurableSubscriptionLifetime(3600); } Config = await serverConfig .AddSecurityConfiguration(applicationCerts, pkiRoot) .SetAutoAcceptUntrustedCertificates(AutoAccept) .CreateAsync() .ConfigureAwait(false); } /// /// Start server fixture on random or fixed port. /// public Task StartAsync(int port = 0) { return StartAsync(null!, port); } /// /// Start server fixture on random or fixed port with dedicated PKI. /// public async Task StartAsync(string pkiRoot, int port = 0) { int testPort = port; int serverStartRetries = 1; if (Application == null) { await LoadConfigurationAsync(pkiRoot).ConfigureAwait(false); } if (port <= 0) { testPort = ServerFixtureUtils.GetNextFreeIPPort(); serverStartRetries = 25; } bool retryStartServer; do { retryStartServer = false; try { await InternalStartServerAsync(testPort).ConfigureAwait(false); } catch (Exception ex) when (serverStartRetries > 0 && ServerFixtureUtils.IsPortUnavailable(ex)) { serverStartRetries--; testPort = UnsecureRandom.Shared.Next( ServerFixtureUtils.MinTestPort, ServerFixtureUtils.MaxTestPort); retryStartServer = true; } catch (Exception ex) when (serverStartRetries > 0 && IsAddressAlreadyInUse(ex)) { // The TCP listener reports a taken port as BadNoCommunication, // which the clause above retries. The HTTPS listener does not: it // lets Kestrel's IOException out verbatim, so without this the // retry designed for exactly this case never runs and the whole // fixture fails on a port race it was built to survive. serverStartRetries--; testPort = UnsecureRandom.Shared.Next( ServerFixtureUtils.MinTestPort, ServerFixtureUtils.MaxTestPort); retryStartServer = true; } await Task.Delay(UnsecureRandom.Shared.Next(100, 1000)).ConfigureAwait(false); } while (retryStartServer); return Server; } /// /// Create the configuration and start the server. /// /// private async Task InternalStartServerAsync(int port) { Config.ServerConfiguration!.BaseAddresses = [$"{UriScheme}://localhost:{port}/{typeof(T).Name}"]; // check the application certificate. bool haveAppCertificate = await Application .CheckApplicationInstanceCertificatesAsync(true, CertificateFactory.DefaultLifeTime) .ConfigureAwait(false); if (!haveAppCertificate) { throw new InvalidOperationException("Application instance certificate invalid!"); } // start the server. T server = m_factory(m_telemetry); server.TransportBindings = TransportBindingRegistry ?? TestTransportBindings.WithAllSchemes(); if (server is StandardServer nodeIdCollisionServer) { // On for every test server whatever configuration the stack // was built in. Off is the release default, so leaving it // alone would mean a release test run never exercises the // check and a collision would surface as a silently replaced // node instead of a failing test. nodeIdCollisionServer.DetectNodeIdCollisions = DetectNodeIdCollisions; } if (AllNodeManagers && server is StandardServer standardServer) { Quickstarts.Servers.Utils.AddDefaultNodeManagers(standardServer); } if (UseSamplingGroupsInReferenceNodeManager && server is ReferenceServer referenceServer) { Quickstarts.Servers.Utils.UseSamplingGroupsInReferenceNodeManager(referenceServer); } if (ProvisioningMode && server is ReferenceServer provisioningReferenceServer) { Quickstarts.Servers.Utils.EnableProvisioningMode(provisioningReferenceServer); } m_startupCts = new CancellationTokenSource(); await Application.StartAsync(server, m_startupCts.Token).ConfigureAwait(false); Server = server; Port = port; } /// /// Configures Activity Listener and registers with Activity Source. /// public void StartActivityListenerInternal(bool disableActivityLogging = false) { // Source construction invokes ShouldListenTo synchronously, so resolve before registration. ActivitySource activitySource = m_telemetry.GetActivitySource(); string expectedName = activitySource.Name; if (disableActivityLogging) { // Create an instance of ActivityListener without logging ActivityListener = new ActivityListener { ShouldListenTo = source => source.Name == expectedName, Sample = (ref _) => ActivitySamplingResult.AllDataAndRecorded, ActivityStarted = _ => { }, ActivityStopped = _ => { } }; } else { // Create an instance of ActivityListener and configure its properties with logging ActivityListener = new ActivityListener { ShouldListenTo = source => source.Name == expectedName, Sample = (ref _) => ActivitySamplingResult.AllDataAndRecorded, ActivityStarted = activity => { if (m_logger.IsEnabled(LogLevel.Information)) { m_logger.LogInformation( "Server Started: {OperationName,-15} - TraceId: {TraceId,-32} SpanId: {SpanId,-16} ParentId: {ParentId,-32}", activity.OperationName, activity.TraceId, activity.SpanId, activity.ParentId); } }, ActivityStopped = activity => { if (m_logger.IsEnabled(LogLevel.Information)) { m_logger.LogInformation( "Server Stopped: {OperationName,-15} - TraceId: {TraceId,-32} SpanId: {SpanId,-16} ParentId: {ParentId,-32} Duration: {Duration}", activity.OperationName, activity.TraceId, activity.SpanId, activity.ParentId, activity.Duration); } } }; } ActivitySource.AddActivityListener(ActivityListener); } /// /// Stop the server. /// public async Task StopAsync() { // Cancel any in-progress startup (e.g., address space creation) m_startupCts?.Dispose(); m_startupCts = null!; // Watchdog around server / application teardown. Without it, a // stuck Server.StopAsync() or Application.DisposeAsync() blocks // the per-fixture OneTimeTearDown indefinitely, and the entire // dotnet test host hangs until the --blame-hang-timeout fires // (observed in macOS net10.0 CI runs of Opc.Ua.History.Tests: // all 466+ tests pass, then a 10-minute teardown hang followed // by a hang dump and exit code 1). Mirrors the bounded-thread // pattern in LeakDetectionHelpers.TryRunFinalizerSweep so a // process-killing hang becomes an observable per-fixture // warning while subsequent fixtures' teardowns and the dotnet // host shutdown can still complete. // // The per-call timeout is kept tight (5s) because each test // project has up to 27 fixtures; a 30s budget per call would // exceed the 10-minute dotnet test hang timeout once even a // handful of fixtures hit the watchdog in series. A graceful // shutdown in the healthy case completes in well under 5s. // The same timeout is propagated as a CancellationToken so the // server can short-circuit instead of being abandoned (the // server StopAsync overload threads the token through // RegisterWithDiscoveryServerAsync, the shutdown semaphore, // SubscriptionManager.ShutdownAsync and // NodeManager.ShutdownAsync). // // GLOBAL FAST-PATH: once any fixture's teardown exceeds the // watchdog in this test process, set a static "give up // teardown" flag and short-circuit subsequent fixtures' // teardowns to a no-op. This converts the worst case from // (27 fixtures x N watchdog operations x 5s) into a single // ~10s teardown stall: only the first hung fixture pays the // watchdog cost; the rest skip directly to the next test // host shutdown step. We accept the resource leak as the // test process is about to exit. if (s_skipRemainingTeardowns) { m_logger.LogWarning( "ServerFixture.StopAsync: a prior fixture teardown timed out; " + "skipping the remaining server / application disposal in this process to " + "avoid pinning the dotnet test host past --blame-hang-timeout. " + "References will be released to the runtime for finalization."); DisposeCertificateManagers(); Server = null!; Application = null!; Config = null!; ActivityListener?.Dispose(); ActivityListener = null!; return; } if (Server != null) { using var serverStopCts = new CancellationTokenSource(s_teardownTimeout); await RunWithTeardownWatchdogAsync( () => Server.StopAsync(serverStopCts.Token).AsTask(), nameof(Server) + "." + nameof(Server.StopAsync)).ConfigureAwait(false); RunSyncWithTeardownWatchdog( () => Server.Dispose(), nameof(Server) + "." + nameof(Server.Dispose)); DisposeCertificateManagers(); Server = null!; } if (Application != null) { try { await RunWithTeardownWatchdogAsync( () => Application.DisposeAsync().AsTask(), nameof(Application) + "." + nameof(Application.DisposeAsync)).ConfigureAwait(false); } finally { DisposeCertificateManagers(); Application = null!; } } Config = null!; ActivityListener?.Dispose(); ActivityListener = null!; await Task.Delay(100).ConfigureAwait(false); } private void DisposeCertificateManagers() { var applicationManager = Application?.ApplicationConfiguration?.CertificateManager as IDisposable; applicationManager?.Dispose(); var configManager = Config?.CertificateManager as IDisposable; if (configManager != null && !ReferenceEquals(configManager, applicationManager)) { configManager.Dispose(); } if (Server?.CertificateManager is IDisposable serverManager && !ReferenceEquals(serverManager, applicationManager) && !ReferenceEquals(serverManager, configManager)) { serverManager.Dispose(); } } /// /// Gets whether a start failure was a taken port, anywhere in its chain. /// /// /// The listener that failed decides the shape: TCP raises a /// , while Kestrel wraps a /// in an AddressInUseException inside an /// . Only the socket error at the bottom is common to /// both, so that is what this looks for. /// private static bool IsAddressAlreadyInUse(Exception exception) { for (Exception current = exception; current is not null; current = current.InnerException!) { if (current is SocketException socket && socket.SocketErrorCode == SocketError.AddressAlreadyInUse) { return true; } if (current is AggregateException aggregate) { foreach (Exception inner in aggregate.InnerExceptions) { if (IsAddressAlreadyInUse(inner)) { return true; } } } } return false; } private static readonly TimeSpan s_teardownTimeout = TimeSpan.FromSeconds(5); /// /// Set by the first or /// invocation that /// exceeds the watchdog. Subsequent fixtures' /// calls short-circuit to a no-op so we do not stack N x watchdog /// timeouts during a known-bad teardown chain. /// private static volatile bool s_skipRemainingTeardowns; /// /// Runs bounded by /// . If the task does not complete /// in time, logs a warning, sets /// so subsequent fixtures short-circuit, and returns; the /// underlying task is abandoned (it is a background continuation /// in the runtime thread pool, so it does not block process /// exit). Cross-TFM compatible: avoids /// Task.WaitAsync(TimeSpan) which is .NET 6+; the test /// framework targets net48 and netstandard2.0 among others. /// private async Task RunWithTeardownWatchdogAsync( Func taskFactory, string operationName) { Task work; try { work = taskFactory(); } catch (Exception ex) { m_logger.LogWarning( ex, "ServerFixture.StopAsync: {Operation} threw synchronously.", operationName); return; } Task winner = await Task .WhenAny(work, Task.Delay(s_teardownTimeout)) .ConfigureAwait(false); if (winner != work) { s_skipRemainingTeardowns = true; m_logger.LogWarning( "ServerFixture.StopAsync: {Operation} exceeded the {TimeoutSeconds}s teardown watchdog. " + "Continuing with the rest of the fixture teardown; subsequent fixtures will short-circuit.", operationName, s_teardownTimeout.TotalSeconds); return; } try { await work.ConfigureAwait(false); } catch (Exception ex) { m_logger.LogWarning( ex, "ServerFixture.StopAsync: {Operation} faulted.", operationName); } } /// /// Runs a synchronous on a background /// thread bounded by . Used for /// synchronous Dispose() calls that can block on socket /// shutdown or thread joins; the async watchdog above only /// bounds -returning calls and cannot interrupt /// a sync method that ignores cancellation. /// private void RunSyncWithTeardownWatchdog(Action action, string operationName) { using var done = new ManualResetEventSlim(false); Exception? captured = null; var worker = new Thread(() => { try { action(); } catch (Exception ex) { captured = ex; } finally { done.Set(); } }) { IsBackground = true, Name = "ServerFixture.SyncTeardown." + operationName }; worker.Start(); if (!done.Wait(s_teardownTimeout)) { s_skipRemainingTeardowns = true; m_logger.LogWarning( "ServerFixture.StopAsync: {Operation} exceeded the {TimeoutSeconds}s teardown watchdog (sync). " + "Continuing with the rest of the fixture teardown; subsequent fixtures will short-circuit.", operationName, s_teardownTimeout.TotalSeconds); return; } if (captured != null) { m_logger.LogWarning( captured, "ServerFixture.StopAsync: {Operation} threw.", operationName); } } private static void AddPolicyIfSupported( IApplicationConfigurationBuilderServerSelected serverConfig, MessageSecurityMode securityMode, string securityPolicyUri) { if (SecurityPolicies.Default.GetInfo(securityPolicyUri) != null) { serverConfig.AddPolicy(securityMode, securityPolicyUri); } } private CancellationTokenSource m_startupCts = null!; private readonly Func m_factory; private readonly ITelemetryContext m_telemetry; private readonly ILogger m_logger; } }