Files
UA-.NETStandard/tests/Opc.Ua.Server.TestFramework/ServerFixture.cs
f9892020a3 Add configurable server resource isolation (#4541)
# Description

Add configurable server resource isolation on top of the merged #4522
foundation.

- Coordinate connection, handshake, retained-message,
session-establishment, queue, execution, request-cost and parked-request
limits through one server-scoped provider.
- Keep configured startup, continuity/reconnect, control and
trusted-owner reservations inside existing totals. Shared capacity
remains usable up to explicit owner ceilings.
- Use authoritative Session membership for reassembly continuity
capacity without granting unrelated resource or rate privileges.
Preserve explicit owner mappings and bounded tracking.
- Cache immutable classification evidence while retaining current
Session and classifier checks.
- Support weighted request ordering or FIFO independently of resource
accounting. Installed providers enforce decoded-request limits in both
modes.
- Preserve fail-fast reassembly, valid Abort handling, fixed deadlines,
active-work protection, reverse-connect handoff and bounded OpenAPI
processing.
- Support custom-handler parking through `IRequestParkingPolicy`,
`WithRequestParking(...)`, and `RequestLifetime.ParkSink`. Parking
releases execution capacity only, keeping retained-data and parked
charges until completion. Built-in Publish and the global opt-out remain
unchanged.
- Provide direct, fluent and DI configuration, public incoming-request
parking support, effective startup limit logs and user-facing
documentation.

Balanced remains the default. SharedOnly without an explicit provider
retains shared behavior for intentionally incompatible/unlimited
configurations. Reservations are finite and are not one full-message
allowance per Session.

## Related Issues

Builds on merged #4522. Fixes #4554.

## Review and CI corrections

All three previously missed findings in [review
5326734718](https://github.com/OPCFoundation/UA-.NETStandard/pull/4541#pullrequestreview-5326734718)
are addressed in `9e25e4ecc`:

1. A custom FIFO provider receives RequestQueue, RequestQueueBytes,
RequestExecution and ParkedRequest accounting. A blocked head cannot be
overtaken.
2. `IResourceIsolationReassemblyProvider` replaces the sessionless
threshold independently of `UseFairScheduling`. The aggregate byte
ceiling remains enforced.
3. Runtime `ReservedReassemblyBytes` includes all configured protected
stage floors, including ControlReserved.

The review's two already-resolved changes remain intact.

The CI collection race was fixed at its source in `e1a994a4b`:
`ArrayOf.ToArrayOf` treats a separately read collection count as an
initial capacity hint and grows/trims for the actual enumeration.
Deterministic tests cover growth, shrinkage and stale-zero counts.

The subsequent recovery-test race was fixed in `05753ce77`: fake time
advances only after a gated keepalive response has been processed and
reset its timer, not merely after request arrival. Recovery-order and
timeout assertions remain intact, with additional response-state
assertions. Instrumentation reproduced the original failure twice; both
fixed variants passed 400 executions per framework.

CodeQL alert 388 was verified and dismissed as a false positive. Startup
logs contain aggregate numeric capacities, not credentials or caller
identities.

## Validation

| Scope | Result |
| --- | --- |
| Latest automated checks at `05753ce7775a58801b4ba9d8f6d7781b709d0b89`
| 167 successful, one package-publication check skipped, no failures |
| Remaining external prerequisite | `license/cla` pending: Contributor
License Agreement is not signed yet |
| FIFO/weighted queues, custom parking and server-channel selection |
175 passed on each net10.0/net48 |
| Isolation, configuration and parking-hosting selection | 259 passed
net10.0; 257 passed net48 |
| ArrayOf conversion tests | 73 passed on each net10.0/net48 |
| Recovery-audit and keepalive fixtures | 40 passed on each
net10.0/net48 |
| NativeAOT smoke from preceding updates | Published and executed
successfully |

No tests, assertions, coverage thresholds, or required checks were
weakened. The remaining CLA prerequisite must be completed through [CLA
Assistant](https://cla-assistant.io/OPCFoundation/UA-.NETStandard?pullRequest=4541).

This is not CTT certification or an unconditional network-DDoS,
whole-process-memory or fleet-wide guarantee.

## 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.
- [x] 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>
2026-09-27 21:11:52 +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; }
public ApplicationConfiguration Config { get; private set; }
public T Server { get; private set; }
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; }
/// <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; }
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;
private readonly Func<ITelemetryContext, T> m_factory;
private readonly ITelemetryContext m_telemetry;
private readonly ILogger m_logger;
}
}