File: Helpers\LaunchSettingsPortHelpers.cs
Web Access
Project: src\tests\Aspire.Cli.EndToEnd.Tests\Aspire.Cli.EndToEnd.Tests.csproj (Aspire.Cli.EndToEnd.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.Globalization;
using System.Text.RegularExpressions;
 
namespace Aspire.Cli.EndToEnd.Tests.Helpers;
 
/// <summary>
/// The Aspire project templates pick every launch profile port (dashboard, OTLP, resource service and
/// project endpoints) at random from a fixed 300-port range, and only avoid ports that are bound while
/// the template runs. Two projects generated before either is started therefore share a port a few
/// percent of the time, and the second AppHost then fails with "address already in use".
/// </summary>
internal static partial class LaunchSettingsPortHelpers
{
    private const int MaxPort = 65535;
 
    /// <summary>
    /// Rewrites the launch settings ports of <paramref name="project"/> that are also used by any of
    /// <paramref name="otherProjects"/>, so the projects can run side by side. Each colliding port is
    /// replaced by the next higher port not used by any of the projects; other ports are left unchanged.
    /// </summary>
    /// <returns>The original-to-new mapping of the rewritten ports.</returns>
    internal static IReadOnlyDictionary<int, int> MakePortsDistinct(DirectoryInfo project, params DirectoryInfo[] otherProjects)
    {
        var otherPorts = otherProjects.SelectMany(GetLaunchSettingsFiles).SelectMany(f => GetPorts(File.ReadAllText(f))).ToHashSet();
        var projectFiles = GetLaunchSettingsFiles(project).ToArray();
        var projectPorts = projectFiles.SelectMany(f => GetPorts(File.ReadAllText(f))).ToHashSet();
 
        var used = new HashSet<int>(otherPorts);
        used.UnionWith(projectPorts);
 
        var mapping = new Dictionary<int, int>();
        foreach (var port in projectPorts.Where(otherPorts.Contains).Order())
        {
            var candidate = port + 1;
            while (!used.Add(candidate))
            {
                candidate++;
            }
 
            if (candidate > MaxPort)
            {
                throw new InvalidOperationException($"No free replacement port found for {port}.");
            }
 
            mapping[port] = candidate;
        }
 
        if (mapping.Count > 0)
        {
            foreach (var file in projectFiles)
            {
                var content = File.ReadAllText(file);
                var updated = PortRegex().Replace(content, m =>
                    mapping.TryGetValue(int.Parse(m.Value, CultureInfo.InvariantCulture), out var newPort)
                        ? newPort.ToString(CultureInfo.InvariantCulture)
                        : m.Value);
                if (!string.Equals(content, updated, StringComparison.Ordinal))
                {
                    File.WriteAllText(file, updated);
                }
            }
        }
 
        return mapping;
    }
 
    internal static IEnumerable<int> GetPorts(string launchSettingsContent) =>
        PortRegex().Matches(launchSettingsContent).Select(m => int.Parse(m.Value, CultureInfo.InvariantCulture));
 
    internal static IEnumerable<string> GetLaunchSettingsFiles(DirectoryInfo project) =>
        Directory.EnumerateFiles(project.FullName, "launchSettings.json", SearchOption.AllDirectories).Order(StringComparer.Ordinal);
 
    // Matches the port of a URL such as "https://localhost:17179" or "http://app.dev.localhost:15203".
    [GeneratedRegex(@"(?<=://[^/\s"";]+:)\d+")]
    private static partial Regex PortRegex();
}