mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-17 05:11:13 +08:00
9b034f8291
# Description Nullable reference types are now enabled once, in `common.props`, and every project in the repository builds warning-clean with them. **Configuration** - `<Nullable>enable</Nullable>` lives in `common.props`. All per-project `<Nullable>` settings are removed: 178 project files (enable, annotations, and the `disable` in the three Aot test projects). - 419 per-file `#nullable enable`/`restore` directives and the 4 `#nullable disable` directives are removed. - Kept on purpose: - The 5 `<auto-generated>` schema files (`UANodeSet.cs`, `UA Type Dictionary.cs`, `OPCBinarySchema.cs`, `SecuredApplication.cs`, `PInvokeHelper.cs`) keep `#nullable enable`. The compiler ignores the project setting in generated code, so the directive is required there. - `tests/Opc.Ua.Interop.LegacyPeer` keeps `disable`. It builds against the 1.5.378 packages and does not import the repository props. - `docs/DeveloperGuide.md` and `.github/copilot-instructions.md` now describe the central setting. **Migration** (the 43 unset and 13 annotations-only projects, roughly 10k diagnostics) - **Production code: annotated at the declarations.** Covers `tools/Opc.Ua.SourceGeneration.Core`, `src/Opc.Ua.Lds.Server` and the LDS sample. - Members, parameters and returns are nullable where null actually flows. - Null-check helpers carry `[NotNullWhen]` (`IsNull`, `IsValidXmlName`, `IsMethodTypeDesign`). - `CoreUtils.Format`, `Utils.Format`, `ITemplateWriter.WriteLine` take `params object?[]`. `Template.AddReplacement` takes `string?` (rendering already skips null replacements). - `IRegisteredServerStore.RegisterAsync` returns `Task<RegistrationEntry?>`: it returns null when a registration is removed. - Dereferences backed by an invariant (for example, the validated model) use `!`, so runtime behaviour is unchanged. - **Behavioural changes in production code** (small, deliberate): - `NodeSetToModelDesign` throws `InvalidOperationException` when the file is not a NodeSet. Before, it threw a NullReferenceException on the next line. - `ModelDesignValidator` starts its lookup tables empty instead of null. A redundant `if (m_nodes == null) return;` guard is gone. - **Test code: minimal annotations.** - `?` on values that can be null, `!` where the test asserts non-null. - `= null!` for fixture members assigned in SetUp. - `TryGetStructure<T>(out T? x)` where the inferred `T?` broke the `IEncodeable` constraint. - A few redundant `== null` checks on structs (`NodeId`, `ArrayOf<T>`) folded into the adjacent `IsNull`/`Count` checks. **Notes for reviewers** - The diff is large (~1,500 files) but almost entirely mechanical: `?`, `!` and directive removal. The non-annotation changes are listed above. - Known follow-up, not done here: `IModelDesign.Namespaces/TargetNamespace/NamespaceUris` stay non-nullable even though the validator returns null before it loads a dictionary. Making them nullable would ripple through every generator call site. ## Related Issues - Fixes #4439 ## 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. - [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. The "no new warnings" box covers net10.0 and net48: full non-incremental builds of `UA.slnx`, `tools/SourceGeneration.slnx`, `tools/MigrationAnalyzer.slnx` and the Aot test projects finished with 0 warnings and 0 errors under the repository's warnings-as-errors settings. net9.0 and net8.0 also had no nullable diagnostics, but they were not rebuilt after the final fixes. Tests have not been run locally yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
793 lines
33 KiB
C#
793 lines
33 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.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
|
||
{
|
||
/// <summary>
|
||
/// Server fixture for testing.
|
||
/// </summary>
|
||
/// <typeparam name="T">A server class T used for testing.</typeparam>
|
||
// 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<T>
|
||
#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; }
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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;
|
||
|
||
/// <summary>
|
||
/// When <c>true</c> (default), HTTPS / WSS endpoints require client
|
||
/// TLS authentication and the listener emits the configured
|
||
/// secure security policy. When <c>false</c>, mutual TLS is off and
|
||
/// the listener emits a <see cref="MessageSecurityMode.None"/>
|
||
/// endpoint - required for the JSON sub-protocols
|
||
/// (HTTPS-JSON, WSS-JSON) which do not use UA Secure Conversation.
|
||
/// </summary>
|
||
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!;
|
||
|
||
/// <summary>
|
||
/// Optional <see cref="Bindings.ITransportBindingRegistry"/>
|
||
/// assigned to the server immediately after construction (before
|
||
/// <c>StartAsync</c>). 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.
|
||
/// </summary>
|
||
public Bindings.ITransportBindingRegistry TransportBindingRegistry { get; set; } = null!;
|
||
|
||
public ServerFixture(
|
||
Func<ITelemetryContext, T> factory,
|
||
bool useTracing,
|
||
bool disableActivityLogging)
|
||
: this(factory)
|
||
{
|
||
UseTracing = useTracing;
|
||
if (UseTracing)
|
||
{
|
||
StartActivityListenerInternal(disableActivityLogging);
|
||
}
|
||
}
|
||
|
||
public ServerFixture(Func<ITelemetryContext, T> factory)
|
||
{
|
||
m_factory = factory;
|
||
m_telemetry = NUnitTelemetryContext.Create(true);
|
||
m_logger = m_telemetry.CreateLogger<ServerFixture<T>>();
|
||
}
|
||
|
||
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<CertificateIdentifier> 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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Start server fixture on random or fixed port.
|
||
/// </summary>
|
||
public Task<T> StartAsync(int port = 0)
|
||
{
|
||
return StartAsync(null!, port);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Start server fixture on random or fixed port with dedicated PKI.
|
||
/// </summary>
|
||
public async Task<T> 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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Create the configuration and start the server.
|
||
/// </summary>
|
||
/// <exception cref="InvalidOperationException"></exception>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Configures Activity Listener and registers with Activity Source.
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Stop the server.
|
||
/// </summary>
|
||
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();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gets whether a start failure was a taken port, anywhere in its chain.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The listener that failed decides the shape: TCP raises a
|
||
/// <see cref="ServiceResultException"/>, while Kestrel wraps a
|
||
/// <see cref="SocketException"/> in an <c>AddressInUseException</c> inside an
|
||
/// <see cref="IOException"/>. Only the socket error at the bottom is common to
|
||
/// both, so that is what this looks for.
|
||
/// </remarks>
|
||
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);
|
||
|
||
/// <summary>
|
||
/// Set by the first <see cref="RunWithTeardownWatchdogAsync"/> or
|
||
/// <see cref="RunSyncWithTeardownWatchdog"/> invocation that
|
||
/// exceeds the watchdog. Subsequent fixtures' <see cref="StopAsync"/>
|
||
/// calls short-circuit to a no-op so we do not stack N x watchdog
|
||
/// timeouts during a known-bad teardown chain.
|
||
/// </summary>
|
||
private static volatile bool s_skipRemainingTeardowns;
|
||
|
||
/// <summary>
|
||
/// Runs <paramref name="taskFactory"/> bounded by
|
||
/// <see cref="s_teardownTimeout"/>. If the task does not complete
|
||
/// in time, logs a warning, sets <see cref="s_skipRemainingTeardowns"/>
|
||
/// 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
|
||
/// <c>Task.WaitAsync(TimeSpan)</c> which is .NET 6+; the test
|
||
/// framework targets net48 and netstandard2.0 among others.
|
||
/// </summary>
|
||
private async Task RunWithTeardownWatchdogAsync(
|
||
Func<Task> 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);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Runs a synchronous <paramref name="action"/> on a background
|
||
/// thread bounded by <see cref="s_teardownTimeout"/>. Used for
|
||
/// synchronous <c>Dispose()</c> calls that can block on socket
|
||
/// shutdown or thread joins; the async watchdog above only
|
||
/// bounds <see cref="Task"/>-returning calls and cannot interrupt
|
||
/// a sync method that ignores cancellation.
|
||
/// </summary>
|
||
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<ITelemetryContext, T> m_factory;
|
||
private readonly ITelemetryContext m_telemetry;
|
||
private readonly ILogger m_logger;
|
||
}
|
||
}
|