File: Utils\DotnetSdkVersionProvider.cs
Web Access
Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Globalization;
using System.IO.Hashing;
using System.Text;
using Aspire.Hosting.Dcp.Process;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Semver;
 
namespace Aspire.Hosting.Utils;
 
internal interface IDotnetSdkVersionProvider
{
    Task<SemVersion?> TryGetVersionAsync(string? workingDirectory, CancellationToken cancellationToken);
 
    Task<bool> SupportsMultiThreadedBuildAsync(
        string? workingDirectory,
        IReadOnlyDictionary<string, string> environmentVariables,
        CancellationToken cancellationToken);
 
    Task<bool> SupportsFileBasedMultiThreadedBuildAsync(
        string? workingDirectory,
        IReadOnlyDictionary<string, string> environmentVariables,
        CancellationToken cancellationToken);
}
 
internal sealed class DotnetSdkVersionProvider : IDotnetSdkVersionProvider
{
    private const string DefaultSdkContext = "<default>";
    private static readonly TimeSpan s_probeTimeout = TimeSpan.FromSeconds(5);
    private static readonly IReadOnlyDictionary<string, string> s_emptyEnvironment =
        new Dictionary<string, string>();
    private static readonly Dictionary<string, string> s_dotnetCliEnvironment = new()
    {
        ["DOTNET_NOLOGO"] = "true",
        ["DOTNET_GENERATE_ASPNET_CERTIFICATE"] = "false",
        ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "true",
        ["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "true",
        ["SuppressNETCoreSdkPreviewMessage"] = "true"
    };
 
    private readonly IProcessRunner _processRunner;
    private readonly ILogger<DotnetSdkVersionProvider> _logger;
    private readonly CancellationToken _applicationStopping;
    private readonly ConcurrentDictionary<string, Lazy<Task<SemVersion?>>> _versionsBySdkContext =
        new(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
 
    public DotnetSdkVersionProvider(
        IProcessRunner processRunner,
        IHostApplicationLifetime applicationLifetime,
        ILogger<DotnetSdkVersionProvider> logger)
        : this(processRunner, logger, applicationLifetime.ApplicationStopping)
    {
    }
 
    internal DotnetSdkVersionProvider(
        IProcessRunner processRunner,
        ILogger<DotnetSdkVersionProvider> logger,
        CancellationToken applicationStopping)
    {
        _processRunner = processRunner;
        _logger = logger;
        _applicationStopping = applicationStopping;
    }
 
    public async Task<SemVersion?> TryGetVersionAsync(
        string? workingDirectory,
        CancellationToken cancellationToken)
    {
        return await TryGetVersionAsync(
            workingDirectory,
            s_emptyEnvironment,
            cancellationToken).ConfigureAwait(false);
    }
 
    private async Task<SemVersion?> TryGetVersionAsync(
        string? workingDirectory,
        IReadOnlyDictionary<string, string> environmentVariables,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(environmentVariables);
 
        string normalizedWorkingDirectory;
        string sdkContext;
        Dictionary<string, string> probeEnvironment;
        try
        {
            normalizedWorkingDirectory = Path.GetFullPath(workingDirectory ?? Environment.CurrentDirectory);
            probeEnvironment = CreateProbeEnvironment(environmentVariables);
            sdkContext = GetSdkContext(normalizedWorkingDirectory, probeEnvironment);
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Failed to determine the .NET SDK selection context.");
            return null;
        }
 
        var versionTask = _versionsBySdkContext.GetOrAdd(
            sdkContext,
            _ => CreateVersionTask(sdkContext, normalizedWorkingDirectory, probeEnvironment));
 
        return await versionTask.Value.WaitAsync(cancellationToken).ConfigureAwait(false);
    }
 
    public async Task<bool> SupportsMultiThreadedBuildAsync(
        string? workingDirectory,
        IReadOnlyDictionary<string, string> environmentVariables,
        CancellationToken cancellationToken)
    {
        var version = await TryGetVersionAsync(
            workingDirectory,
            environmentVariables,
            cancellationToken).ConfigureAwait(false);
        return DotnetSdkUtils.SupportsMultiThreadedBuild(version);
    }
 
    public async Task<bool> SupportsFileBasedMultiThreadedBuildAsync(
        string? workingDirectory,
        IReadOnlyDictionary<string, string> environmentVariables,
        CancellationToken cancellationToken)
    {
        var version = await TryGetVersionAsync(
            workingDirectory,
            environmentVariables,
            cancellationToken).ConfigureAwait(false);
        return DotnetSdkUtils.SupportsFileBasedMultiThreadedBuild(version);
    }
 
    private Lazy<Task<SemVersion?>> CreateVersionTask(
        string sdkContext,
        string workingDirectory,
        IReadOnlyDictionary<string, string> probeEnvironment)
    {
        Lazy<Task<SemVersion?>>? versionTask = null;
        versionTask = new Lazy<Task<SemVersion?>>(
            async () =>
            {
                var version = await ProbeVersionAsync(
                    workingDirectory,
                    probeEnvironment).ConfigureAwait(false);
                if (version is null)
                {
                    // A later build may run after the SDK installation or global.json issue has been corrected.
                    _versionsBySdkContext.TryRemove(KeyValuePair.Create(sdkContext, versionTask!));
                }
 
                return version;
            },
            LazyThreadSafetyMode.ExecutionAndPublication);
        return versionTask;
    }
 
    private static string GetSdkContext(
        string workingDirectory,
        IReadOnlyDictionary<string, string> environmentVariables)
    {
        var globalJsonContext = DefaultSdkContext;
        if (DotnetSdkUtils.FindNearestGlobalJson(workingDirectory) is { } globalJsonPath)
        {
            // SDK selection depends on global.json contents, so include a cheap content fingerprint. This preserves
            // probe reuse while allowing rebuilds to observe SDK changes without restarting the AppHost.
            var hash = XxHash3.HashToUInt64(File.ReadAllBytes(globalJsonPath));
            globalJsonContext =
                $"{globalJsonPath}\0{hash.ToString("X16", CultureInfo.InvariantCulture)}";
        }
 
        return $"{globalJsonContext}\0{GetEnvironmentFingerprint(environmentVariables)}";
    }
 
    private static Dictionary<string, string> CreateProbeEnvironment(
        IReadOnlyDictionary<string, string> environmentVariables)
    {
        var comparer = OperatingSystem.IsWindows()
            ? StringComparer.OrdinalIgnoreCase
            : StringComparer.Ordinal;
        var probeEnvironment = new Dictionary<string, string>(
            environmentVariables.Count + s_dotnetCliEnvironment.Count,
            comparer);
 
        foreach (var (name, value) in environmentVariables)
        {
            probeEnvironment[name] = value;
        }
 
        foreach (var (name, value) in s_dotnetCliEnvironment)
        {
            probeEnvironment.TryAdd(name, value);
        }
 
        return probeEnvironment;
    }
 
    private static string GetEnvironmentFingerprint(
        IReadOnlyDictionary<string, string> environmentVariables)
    {
        var comparer = OperatingSystem.IsWindows()
            ? StringComparer.OrdinalIgnoreCase
            : StringComparer.Ordinal;
        var hash = new XxHash3();
 
        // Build environments can alter executable and SDK selection in ways that evolve with the .NET host.
        // Hash the complete override set instead of maintaining a brittle allowlist. The fingerprint is used
        // only as an in-memory cache key and is never logged.
        foreach (var (name, value) in environmentVariables
            .OrderBy(static variable => variable.Key, comparer)
            .ThenBy(static variable => variable.Key, StringComparer.Ordinal))
        {
            AppendHashValue(hash, name);
            AppendHashValue(hash, value);
        }
 
        return Convert.ToHexString(hash.GetCurrentHash());
 
        static void AppendHashValue(XxHash3 hash, string value)
        {
            var bytes = Encoding.UTF8.GetBytes(value);
            Span<byte> lengthBytes = stackalloc byte[sizeof(int)];
            BinaryPrimitives.WriteInt32LittleEndian(lengthBytes, bytes.Length);
            hash.Append(lengthBytes);
            hash.Append(bytes);
        }
    }
 
    private async Task<SemVersion?> ProbeVersionAsync(
        string workingDirectory,
        IReadOnlyDictionary<string, string> probeEnvironment)
    {
        try
        {
            var (resultTask, process) = _processRunner.Run(new ProcessSpec("dotnet")
            {
                WorkingDirectory = workingDirectory,
                ArgumentList = ["--version"],
                EnvironmentVariables = new Dictionary<string, string>(probeEnvironment),
                ResolveExecutablePath = true,
                RetainedOutputLineCount = 16,
                ThrowOnNonZeroReturnCode = false,
            });
 
            await using (process)
            {
                using var timeoutSource =
                    CancellationTokenSource.CreateLinkedTokenSource(_applicationStopping);
                timeoutSource.CancelAfter(s_probeTimeout);
 
                ProcessResult result;
                try
                {
                    result = await resultTask.WaitAsync(timeoutSource.Token).ConfigureAwait(false);
                }
                catch (OperationCanceledException) when (_applicationStopping.IsCancellationRequested)
                {
                    _logger.LogDebug("The .NET SDK version probe was canceled because the AppHost is stopping.");
                    return null;
                }
                catch (OperationCanceledException)
                {
                    _logger.LogDebug(
                        "The .NET SDK version probe in '{WorkingDirectory}' timed out after {TimeoutSeconds} seconds.",
                        workingDirectory,
                        s_probeTimeout.TotalSeconds);
                    return null;
                }
 
                if (result.ExitCode != 0)
                {
                    _logger.LogDebug(
                        "The .NET SDK version probe in '{WorkingDirectory}' exited with code {ExitCode}.",
                        workingDirectory,
                        result.ExitCode);
                    return null;
                }
 
                foreach (var line in result.ProcessOutput)
                {
                    if (SemVersion.TryParse(line.Trim(), SemVersionStyles.Strict, out var version))
                    {
                        return version;
                    }
                }
            }
 
            _logger.LogDebug(
                "The .NET SDK version probe in '{WorkingDirectory}' did not return a valid semantic version.",
                workingDirectory);
        }
        catch (Exception ex)
        {
            _logger.LogDebug(
                ex,
                "Failed to run the .NET SDK version probe in '{WorkingDirectory}'.",
                workingDirectory);
        }
 
        return null;
    }
}