Files
romanett 9b034f8291 Enable nullable reference types centrally; migrate all unset and annotations-only projects (#4700)
# 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>
2026-10-08 08:48:13 +02:00

793 lines
33 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ========================================================================
* 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;
}
}