File: Projects\AppHostInfoResolver.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Serialization;
using Aspire.Cli.Caching;
using Aspire.Cli.DotNet;
 
namespace Aspire.Cli.Projects;
 
internal interface IAppHostInfoResolver
{
    Task<AppHostProjectInfo> GetAppHostInfoAsync(FileInfo projectFile, CancellationToken cancellationToken);
}
 
internal sealed class AppHostInfoResolver(IDotNetCliRunner runner, IAppHostInfoDiskCache diskCache) : IAppHostInfoResolver
{
    private readonly ConcurrentDictionary<(string Path, DateTime LastWriteUtc), Task<AppHostProjectInfo>> _cache = new();
 
    public async Task<AppHostProjectInfo> GetAppHostInfoAsync(FileInfo projectFile, CancellationToken cancellationToken)
    {
        // Refresh so FileInfo reflects the on-disk mtime even when callers pass a long-lived instance.
        projectFile.Refresh();
        var key = (projectFile.FullName, projectFile.LastWriteTimeUtc);
        var task = GetOrAddSharedFetch(key, projectFile);
 
        try
        {
            // The MSBuild probe is shared by all callers for this project snapshot, so it cannot
            // use any one caller's token. Apply cancellation to each waiter instead; otherwise one
            // cancelled validation could cancel the shared probe for unrelated callers.
            var result = await task.WaitAsync(cancellationToken).ConfigureAwait(false);
            if (result.ExitCode != 0)
            {
                // Transient MSBuild failures should not poison long-lived CLI processes.
                _cache.TryRemove(key, out _);
            }
 
            return result;
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested && !task.IsCompleted)
        {
            throw;
        }
        catch
        {
            // A cancelled or faulted in-flight probe must not become the answer for later runs.
            _cache.TryRemove(key, out _);
            throw;
        }
    }
 
    private Task<AppHostProjectInfo> GetOrAddSharedFetch((string Path, DateTime LastWriteUtc) key, FileInfo projectFile)
    {
        while (true)
        {
            if (_cache.TryGetValue(key, out var existingTask))
            {
                return existingTask;
            }
 
            // ConcurrentDictionary.GetOrAdd can run value factories more than once under contention.
            // Add a TaskCompletionSource placeholder first, then run the MSBuild probe only if this
            // caller won the TryAdd race. Losers loop back and await the winner's task.
            var completion = new TaskCompletionSource<AppHostProjectInfo>(TaskCreationOptions.RunContinuationsAsynchronously);
            if (_cache.TryAdd(key, completion.Task))
            {
                _ = CompleteSharedFetchAsync(key, projectFile, completion);
                return completion.Task;
            }
        }
    }
 
    private async Task CompleteSharedFetchAsync((string Path, DateTime LastWriteUtc) key, FileInfo projectFile, TaskCompletionSource<AppHostProjectInfo> completion)
    {
        try
        {
            var result = await FetchAppHostInfoCoreAsync(projectFile, CancellationToken.None).ConfigureAwait(false);
            completion.TrySetResult(result);
        }
        catch (Exception ex)
        {
            // Match the retry behavior in GetAppHostInfoAsync: transient MSBuild/cache failures
            // should complete all current waiters, then allow the next caller to try again.
            completion.TrySetException(ex);
            _cache.TryRemove(key, out _);
        }
    }
 
    private async Task<AppHostProjectInfo> FetchAppHostInfoCoreAsync(FileInfo projectFile, CancellationToken cancellationToken)
    {
        // First, see if a previous CLI invocation already cached the answer on disk. The
        // cache key includes mtimes of the tracked inputs that affect this metadata query, so a
        // hit means those inputs have not changed since that previous evaluation. This is not a
        // build-output cache; normal build/no-build semantics still decide whether .cs changes are
        // compiled before the AppHost runs.
        var diskEntry = await diskCache.TryGetAsync(projectFile, cancellationToken).ConfigureAwait(false);
        if (diskEntry is not null)
        {
            return new AppHostProjectInfo(
                ExitCode: diskEntry.ExitCode,
                IsAspireHost: diskEntry.IsAspireHost,
                AspireHostingVersion: diskEntry.AspireHostingVersion,
                IsUsingCliBundle: diskEntry.IsUsingCliBundle,
                UserSecretsId: diskEntry.UserSecretsId,
                RunCommand: diskEntry.RunCommand,
                TargetPath: diskEntry.TargetPath,
                RunWorkingDirectory: diskEntry.RunWorkingDirectory,
                RunArguments: diskEntry.RunArguments,
                TargetFramework: diskEntry.TargetFramework,
                TargetFrameworks: diskEntry.TargetFrameworks);
        }
 
        // Capture the input fingerprint before evaluating MSBuild. If any tracked input changes
        // while MSBuild is running, SetAsync will skip writing this now-stale result.
        var expectedCacheKey = diskCache.GetCacheKey(projectFile);
 
        // Mirror the property/item shape used by DotNetCliRunner.GetAppHostInformationAsync and
        // additionally request AspireUseCliBundle, UserSecretsId, and run metadata so the CLI bundle
        // handoff, --isolated user-secrets clone, and post-build AppHost launch path do not require
        // their own MSBuild evaluations.
        // Adding extra -getProperty names is an evaluation-only cost.
        //
        // The Run* properties (RunCommand, RunArguments, RunWorkingDirectory) are only
        // populated for the direct-launch contract after ComputeRunArguments has run, so
        // request that target here. This matches the property values `dotnet run` would
        // resolve before launching the AppHost. TargetFrameworks is also cached so
        // DotNetAppHostProject can detect multi-targeted AppHosts and fall back to
        // `dotnet run`, which already handles target framework selection.
        //
        // In the .NET SDK, ComputeRunArguments is intentionally a placeholder target with no
        // DependsOnTargets; it exists so projects can override RunCommand/RunArguments after
        // evaluation. Requesting it preserves that extension point without causing the default
        // probe to build the AppHost. See:
        // https://github.com/dotnet/sdk/blob/main/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.Sdk.targets
        var (exitCode, jsonDocument) = await runner.GetProjectItemsAndPropertiesAsync(
            projectFile,
            items: ["PackageReference", "AspireProjectOrPackageReference", "PackageVersion"],
            properties: ["IsAspireHost", "AspireHostingSDKVersion", "AspireUseCliBundle", "UserSecretsId", "RunCommand", "TargetPath", "RunWorkingDirectory", "RunArguments", "TargetFramework", "TargetFrameworks"],
            targets: ["ComputeRunArguments"],
            new ProcessInvocationOptions(),
            cancellationToken).ConfigureAwait(false);
 
        if (exitCode != 0 || jsonDocument is null)
        {
            // Do not persist failure responses to memory or disk — a transient MSBuild error should
            // not sit in a cache and short-circuit later successful evaluations.
            // DotNetCliRunner already logged the failing MSBuild stdout/stderr before returning
            // null here; keep the non-zero exit code so callers can surface the normal
            // "not buildable AppHost" project-resolution behavior instead of treating this as a
            // negative cache entry.
            return new AppHostProjectInfo(ExitCode: exitCode, IsAspireHost: false, AspireHostingVersion: null, IsUsingCliBundle: false, UserSecretsId: null, RunCommand: null, TargetPath: null, RunWorkingDirectory: null, RunArguments: null, TargetFramework: null, TargetFrameworks: null);
        }
 
        AppHostProjectInfo info;
        using (jsonDocument)
        {
            var msbuildOutput = JsonSerializer.Deserialize(
                jsonDocument.RootElement,
                JsonSourceGenerationContext.Default.AppHostProjectInspectionOutput);
 
            info = ParseAppHostInfo(msbuildOutput, exitCode);
        }
 
        // Persist successful evaluations so the next CLI process can short-circuit MSBuild
        // entirely when the inputs are unchanged. Failures inside the cache write are
        // swallowed; cache misses never break a run.
        await diskCache.SetAsync(projectFile, expectedCacheKey, new AppHostInfoCacheEntry
        {
            ExitCode = info.ExitCode,
            IsAspireHost = info.IsAspireHost,
            AspireHostingVersion = info.AspireHostingVersion,
            IsUsingCliBundle = info.IsUsingCliBundle,
            UserSecretsId = info.UserSecretsId,
            RunCommand = info.RunCommand,
            TargetPath = info.TargetPath,
            RunWorkingDirectory = info.RunWorkingDirectory,
            RunArguments = info.RunArguments,
            TargetFramework = info.TargetFramework,
            TargetFrameworks = info.TargetFrameworks,
        }, cancellationToken).ConfigureAwait(false);
 
        return info;
    }
 
    private static AppHostProjectInfo ParseAppHostInfo(AppHostProjectInspectionOutput? msbuildOutput, int exitCode)
    {
        var properties = msbuildOutput?.Properties;
        if (properties is null)
        {
            return new AppHostProjectInfo(ExitCode: exitCode, IsAspireHost: false, AspireHostingVersion: null, IsUsingCliBundle: false, UserSecretsId: null, RunCommand: null, TargetPath: null, RunWorkingDirectory: null, RunArguments: null, TargetFramework: null, TargetFrameworks: null);
        }
 
        var isUsingCliBundle = string.Equals(properties.AspireUseCliBundle, "true", StringComparison.OrdinalIgnoreCase);
 
        var userSecretsId = string.IsNullOrWhiteSpace(properties.UserSecretsId) ? null : properties.UserSecretsId;
        var runCommand = string.IsNullOrWhiteSpace(properties.RunCommand) ? null : properties.RunCommand;
        var targetPath = string.IsNullOrWhiteSpace(properties.TargetPath) ? null : properties.TargetPath;
        var runWorkingDirectory = string.IsNullOrWhiteSpace(properties.RunWorkingDirectory) ? null : properties.RunWorkingDirectory;
        var runArguments = string.IsNullOrWhiteSpace(properties.RunArguments) ? null : properties.RunArguments;
        var targetFramework = string.IsNullOrWhiteSpace(properties.TargetFramework) ? null : properties.TargetFramework;
        var targetFrameworks = string.IsNullOrWhiteSpace(properties.TargetFrameworks) ? null : properties.TargetFrameworks;
 
        var isAspireHost = string.Equals(properties.IsAspireHost, "true", StringComparison.Ordinal);
 
        if (!isAspireHost)
        {
            return new AppHostProjectInfo(ExitCode: exitCode, IsAspireHost: false, AspireHostingVersion: null, IsUsingCliBundle: isUsingCliBundle, UserSecretsId: userSecretsId, RunCommand: runCommand, TargetPath: targetPath, RunWorkingDirectory: runWorkingDirectory, RunArguments: runArguments, TargetFramework: targetFramework, TargetFrameworks: targetFrameworks);
        }
 
        // Try to get Aspire.Hosting version from PackageReference items first, then fall back
        // to AspireProjectOrPackageReference (for SDK-provided refs) and PackageVersion (CPM),
        // then finally to the SDK version. Mirrors DotNetCliRunner.GetAppHostInformationAsync.
        string? aspireHostingVersion = null;
 
        var items = msbuildOutput?.Items;
        if (items is not null)
        {
            aspireHostingVersion = GetPackageVersionFromItems(items.PackageReference, "Aspire.Hosting")
                ?? GetPackageVersionFromItems(items.PackageReference, "Aspire.Hosting.AppHost");
 
            aspireHostingVersion ??= GetPackageVersionFromItems(items.AspireProjectOrPackageReference, "Aspire.Hosting")
                ?? GetPackageVersionFromItems(items.AspireProjectOrPackageReference, "Aspire.Hosting.AppHost");
 
            aspireHostingVersion ??= GetPackageVersionFromItems(items.PackageVersion, "Aspire.Hosting")
                ?? GetPackageVersionFromItems(items.PackageVersion, "Aspire.Hosting.AppHost");
        }
 
        aspireHostingVersion ??= properties.AspireHostingSDKVersion;
 
        return new AppHostProjectInfo(ExitCode: exitCode, IsAspireHost: true, AspireHostingVersion: aspireHostingVersion, IsUsingCliBundle: isUsingCliBundle, UserSecretsId: userSecretsId, RunCommand: runCommand, TargetPath: targetPath, RunWorkingDirectory: runWorkingDirectory, RunArguments: runArguments, TargetFramework: targetFramework, TargetFrameworks: targetFrameworks);
    }
 
    private static string? GetPackageVersionFromItems(IReadOnlyList<AppHostProjectInspectionItem>? items, string packageId)
    {
        if (items is null)
        {
            return null;
        }
 
        foreach (var item in items)
        {
            if (string.Equals(item.Identity, packageId, StringComparison.Ordinal))
            {
                return item.Version;
            }
        }
 
        return null;
    }
}
 
internal sealed record AppHostProjectInfo(
    int ExitCode,
    bool IsAspireHost,
    string? AspireHostingVersion,
    bool IsUsingCliBundle,
    string? UserSecretsId,
    string? RunCommand,
    string? TargetPath,
    string? RunWorkingDirectory,
    string? RunArguments,
    string? TargetFramework,
    string? TargetFrameworks);
 
internal sealed record AppHostProjectInspectionOutput
{
    [JsonPropertyName("Properties")]
    public AppHostProjectInspectionProperties? Properties { get; init; }
 
    [JsonPropertyName("Items")]
    public AppHostProjectInspectionItems? Items { get; init; }
}
 
internal sealed record AppHostProjectInspectionProperties
{
    [JsonPropertyName("IsAspireHost")]
    public string? IsAspireHost { get; init; }
 
    [JsonPropertyName("AspireHostingSDKVersion")]
    public string? AspireHostingSDKVersion { get; init; }
 
    [JsonPropertyName("AspireUseCliBundle")]
    public string? AspireUseCliBundle { get; init; }
 
    [JsonPropertyName("UserSecretsId")]
    public string? UserSecretsId { get; init; }
 
    [JsonPropertyName("RunCommand")]
    public string? RunCommand { get; init; }
 
    [JsonPropertyName("TargetPath")]
    public string? TargetPath { get; init; }
 
    [JsonPropertyName("RunWorkingDirectory")]
    public string? RunWorkingDirectory { get; init; }
 
    [JsonPropertyName("RunArguments")]
    public string? RunArguments { get; init; }
 
    [JsonPropertyName("TargetFramework")]
    public string? TargetFramework { get; init; }
 
    [JsonPropertyName("TargetFrameworks")]
    public string? TargetFrameworks { get; init; }
}
 
internal sealed record AppHostProjectInspectionItems
{
    [JsonPropertyName("PackageReference")]
    public IReadOnlyList<AppHostProjectInspectionItem>? PackageReference { get; init; }
 
    [JsonPropertyName("AspireProjectOrPackageReference")]
    public IReadOnlyList<AppHostProjectInspectionItem>? AspireProjectOrPackageReference { get; init; }
 
    [JsonPropertyName("PackageVersion")]
    public IReadOnlyList<AppHostProjectInspectionItem>? PackageVersion { get; init; }
}
 
internal sealed record AppHostProjectInspectionItem
{
    [JsonPropertyName("Identity")]
    public string? Identity { get; init; }
 
    [JsonPropertyName("Version")]
    public string? Version { get; init; }
}