Files
UA-.NETStandard/tests/Opc.Ua.Server.TestFramework/ServerFixture.cs
a10f9e252d Generic runtime continuation, batch lifecycle and readiness (#4759)
# Description

This pull request extracts and integrates generic server runtime and
continuation-management capabilities for OPC UA NodeManagers.

- **NodeManager lifecycle and routing:** add batch registration and
reload, staged publication and readiness notifications, captured routing
snapshots and reservations, and safe retirement. Wire the lifecycle
through host interfaces, async/sync adapters, `MasterNodeManager`, and
`StandardServer` startup and shutdown.
- **Browse and History continuations:** retain continuation ownership
and manager dependencies across requests and reloads; add atomic claims,
session caching, and cleanup when requests fail or generations retire.
Add historian resume state, tokens, dependency capabilities, and shared
continuation-store support.
- **MonitoredItem lifecycle:** support detaching and reattaching items
across node-manager generations, including event subscriptions shared
across root notifiers and correct defaults for detached-item
modification.
- **Runtime NodeSet references:** track reference additions and
deletions through supported API paths and preserve the tracked
references during reload.
- **Published views and materialization:** add immutable `TypeTable` and
`EncodeableFactory` snapshots with publication-owner lifetime
management, and provide a live node-materialization query for the
existing WoT projection-view consumer.
- **CI trace support and documentation:** add scripts and tests for
staging NUnit internal traces, and document NodeManager lifecycle,
session continuation ownership, and historical-access behavior.

## Related Issues

- Extracts the generic runtime portion of #4511.
- #4757 depends on this pull request; this pull request does not depend
on #4757.

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [ ] 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.
- [ ] 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.

---------

Co-authored-by: perf <perf@local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-19 16:08:37 +02:00

796 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;
}
if (retryStartServer)
{
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;
}
}