File: Integration\ServerRetryHelper.cs
Web Access
Project: src\tests\Aspire.Dashboard.Tests\Aspire.Dashboard.Tests.csproj (Aspire.Dashboard.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Dashboard.Tests.Integration;
 
// Copied from https://github.com/dotnet/aspnetcore/blob/1b2e5286b089fa1cab90ba8692c2df7ca6f9c077/src/Servers/Kestrel/shared/test/ServerRetryHelper.cs
public static class ServerRetryHelper
{
    private const int RetryCount = 20;
 
    // Named mutex to prevent race conditions when multiple parallel tests (even across processes)
    // try to find and bind ports. Without this, GetAvailablePort can return the same port to
    // multiple tests before any of them bind.
    // Note: Using a session-local mutex (no "Global\" prefix) which is sufficient for tests
    // running in the same user session and avoids permission issues on some platforms.
    private const string PortAllocationMutexName = "AspireDashboardTestPortAllocation";
 
    /// <summary>
    /// Retry a func. Useful when a test needs an explicit port and you want to avoid port conflicts.
    /// </summary>
    public static Task BindPortWithRetry(Func<int, Task> retryFunc, ILogger logger)
    {
        return BindPortsWithRetry(ports => retryFunc(ports.Single()), logger, portCount: 1);
    }
 
    /// <summary>
    /// Retry a func. Useful when a test needs an explicit port and you want to avoid port conflicts.
    /// </summary>
    public static async Task BindPortsWithRetry(Func<List<int>, Task> retryFunc, ILogger logger, int portCount)
    {
        var retryCount = 0;
 
        // Add a random number to starting port to reduce chance of conflicts because of multiple tests using this retry.
        var nextPortAttempt = 30000 + Random.Shared.Next(10000);
 
        // Use a named mutex to prevent multiple parallel tests (even across processes) from
        // selecting the same port between the time we find it and the time the test binds to it.
        using var mutex = new Mutex(initiallyOwned: false, PortAllocationMutexName);
 
        while (true)
        {
            // Find a port that's available for TCP and UDP. Start with the given port search upwards from there.
            var ports = new List<int>(portCount);
 
            if (!mutex.WaitOne(TestConstants.DefaultTimeoutTimeSpan))
            {
                throw new TimeoutException($"Timed out waiting for port allocation mutex after {TestConstants.DefaultTimeoutTimeSpan}.");
            }
 
            try
            {
                while (ports.Count < portCount)
                {
                    var port = GetAvailablePort(ref nextPortAttempt, logger);
                    ports.Add(port);
                }
 
                // Should never happen, but sanity check to ensure we have unique ports.
                if (ports.Count != ports.Distinct().Count())
                {
                    throw new InvalidOperationException($"Generated ports list contains duplicate numbers: {string.Join(", ", ports)}");
                }
 
                // Call retryFunc inside the mutex so the ports are bound before we release.
                // This prevents another test from grabbing the same ports.
                await retryFunc(ports);
 
                // Success - exit the retry loop
                return;
            }
            catch (Exception ex)
            {
                retryCount++;
 
                if (retryCount >= RetryCount)
                {
                    throw;
                }
 
                logger.LogError(ex, "Error running test {retryCount}. Retrying.", retryCount);
            }
            finally
            {
                mutex.ReleaseMutex();
            }
        }
    }
 
    /// <summary>
    /// Finds an available port starting from nextPortAttempt, verifies it by binding, and updates nextPortAttempt.
    /// </summary>
    private static int GetAvailablePort(ref int nextPortAttempt, ILogger logger)
    {
        logger.LogInformation("Searching for free port starting at {nextPortAttempt}.", nextPortAttempt);
 
        var unavailableEndpoints = new List<IPEndPoint>();
 
        var properties = IPGlobalProperties.GetIPGlobalProperties();
 
        // Ignore active connections
        AddEndpoints(nextPortAttempt, unavailableEndpoints, properties.GetActiveTcpConnections().Select(c => c.LocalEndPoint));
 
        // Ignore active tcp listeners
        AddEndpoints(nextPortAttempt, unavailableEndpoints, properties.GetActiveTcpListeners());
 
        // Ignore active UDP listeners
        AddEndpoints(nextPortAttempt, unavailableEndpoints, properties.GetActiveUdpListeners());
 
        logger.LogInformation("Found {count} unavailable endpoints.", unavailableEndpoints.Count);
 
        while (nextPortAttempt < ushort.MaxValue)
        {
            var port = nextPortAttempt;
 
            // Always increase nextPortAttempt by a random amount to reduce the risk of port collisions.
            // Allocating consecutive ports (gap of 0) can lead to conflicts if the OS or other processes
            // allocate ports in the same range. The random gap further reduces the chance of collision.
            nextPortAttempt = port + Random.Shared.Next(10, 100);
 
            var match = unavailableEndpoints.FirstOrDefault(ep => ep.Port == port);
            if (match is not null)
            {
                logger.LogInformation("Port {port} in use. End point: {match}", port, match);
                continue;
            }
 
            // Port appears free, verify by actually binding to it.
            // This catches cases where IPGlobalProperties doesn't report the port as in use,
            // but it's actually unavailable (e.g., TIME_WAIT state on some platforms).
            if (TryBindPort(port, logger))
            {
                logger.LogInformation("Port {port} free and verified.", port);
                return port;
            }
            else
            {
                logger.LogInformation("Port {port} appeared free but failed to bind. Continuing search.", port);
            }
        }
 
        throw new InvalidOperationException($"Exhausted all available ports. Couldn't find a free port after {nextPortAttempt}.");
 
        static void AddEndpoints(int startingPort, List<IPEndPoint> endpoints, IEnumerable<IPEndPoint> activeEndpoints)
        {
            foreach (IPEndPoint endpoint in activeEndpoints)
            {
                if (endpoint.Port >= startingPort)
                {
                    endpoints.Add(endpoint);
                }
            }
        }
 
        static bool TryBindPort(int port, ILogger logger)
        {
            // Since we only bind without calling Listen() or Connect(), we never enter TIME_WAIT.
            // The port is released immediately on dispose.
            try
            {
                using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
                socket.Bind(new IPEndPoint(IPAddress.Loopback, port));
                // Successfully bound, port is available.
                return true;
            }
            catch (SocketException ex) when (ex.SocketErrorCode == SocketError.AddressAlreadyInUse)
            {
                logger.LogInformation("Port {port} bind check failed: address already in use.", port);
                return false;
            }
            catch (SocketException ex)
            {
                logger.LogWarning(ex, "Port {port} bind check failed with unexpected error.", port);
                return false;
            }
        }
    }
}