File: DotnetProjectRunPropertiesResolver.cs
Web Access
Project: src\src\Aspire.Hosting.Dotnet\Aspire.Hosting.Dotnet.csproj (Aspire.Hosting.Dotnet)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.ComponentModel;
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Dotnet;
 
internal sealed record DotnetProjectRunProperties(
    string Command,
    string Arguments,
    string? WorkingDirectory);
 
internal delegate Task<DotnetProjectRunProperties> DotnetProjectRunPropertiesResolverCallback(
    string projectPath,
    string? buildConfiguration,
    IReadOnlyDictionary<string, string> buildEnvironment,
    string workingDirectory,
    ILogger logger,
    CancellationToken cancellationToken);
 
internal static class DotnetProjectRunPropertiesResolver
{
    public static async Task<DotnetProjectRunProperties> ResolveAsync(
        string projectPath,
        string? buildConfiguration,
        IReadOnlyDictionary<string, string> buildEnvironment,
        string workingDirectory,
        ILogger logger,
        CancellationToken cancellationToken)
    {
        using var responseFile = await DotnetProjectBuildEnvironment.CreateResponseFileAsync(
            buildEnvironment,
            logger,
            cancellationToken).ConfigureAwait(false);
        var resultDirectory = Directory.CreateTempSubdirectory("aspire-msbuild-result-");
        var resultOutputPath = Path.Combine(resultDirectory.FullName, "run-properties.json");
 
        try
        {
            var startInfo = new ProcessStartInfo("dotnet")
            {
                WorkingDirectory = workingDirectory,
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false,
            };
            startInfo.ArgumentList.Add("msbuild");
            startInfo.ArgumentList.Add(projectPath);
            startInfo.ArgumentList.Add("-nologo");
            startInfo.ArgumentList.Add("-target:ComputeRunArguments");
            startInfo.ArgumentList.Add("-getProperty:RunCommand,RunArguments,RunWorkingDirectory");
            startInfo.ArgumentList.Add($"-getResultOutputFile:{resultOutputPath}");
            startInfo.ArgumentList.Add("-v:q");
            if (!string.IsNullOrEmpty(buildConfiguration))
            {
                startInfo.ArgumentList.Add($"-property:Configuration={buildConfiguration}");
            }
 
            foreach (var (name, value) in buildEnvironment)
            {
                startInfo.Environment[name] = value;
            }
            if (responseFile is not null)
            {
                startInfo.ArgumentList.Add(responseFile.Argument);
            }
            startInfo.ArgumentList.Add("-property:GenerateFullPaths=true");
 
            using var process = new Process { StartInfo = startInfo };
            try
            {
                process.Start();
            }
            catch (Exception ex) when (ex is Win32Exception or InvalidOperationException)
            {
                throw new DistributedApplicationException(
                    $"Failed to start dotnet to resolve the run command for project '{projectPath}'.",
                    ex);
            }
 
            // Read both streams concurrently to avoid deadlock when a pipe buffer fills.
            var standardOutputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
            var standardErrorTask = process.StandardError.ReadToEndAsync(cancellationToken);
            try
            {
                await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
            }
            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
            {
                try
                {
                    if (!process.HasExited)
                    {
                        process.Kill(entireProcessTree: true);
                    }
                }
                catch (Exception ex) when (ex is InvalidOperationException or Win32Exception)
                {
                    // The process can exit between HasExited and Kill, and termination itself can fail. Neither cleanup
                    // outcome should replace the cancellation that caused this path.
                }
 
                await ((Task)Task.WhenAll(standardOutputTask, standardErrorTask))
                    .ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
                throw;
            }
 
            var standardOutput = await standardOutputTask.ConfigureAwait(false);
            var standardError = await standardErrorTask.ConfigureAwait(false);
            if (process.ExitCode != 0)
            {
                logger.LogDebug(
                    "dotnet msbuild failed while resolving the run command for project {ProjectPath}. Standard output: {StandardOutput} Standard error: {StandardError}",
                    projectPath,
                    standardOutput,
                    standardError);
                throw new DistributedApplicationException(
                    $"dotnet msbuild failed with exit code {process.ExitCode} while resolving the run command for project '{projectPath}'.");
            }
 
            try
            {
                // Multiple -getProperty values produce:
                //   { "Properties": { "RunCommand": "...", "RunArguments": "...", "RunWorkingDirectory": "..." } }
                // Keep the machine-readable result separate so SDK diagnostics on stdout cannot corrupt it.
                var resultOutput = await File.ReadAllTextAsync(resultOutputPath, cancellationToken).ConfigureAwait(false);
                using var document = JsonDocument.Parse(resultOutput);
                var properties = document.RootElement.GetProperty("Properties");
                var command = properties.GetProperty("RunCommand").GetString();
                var arguments = properties.GetProperty("RunArguments").GetString() ?? string.Empty;
                var runWorkingDirectory = properties.GetProperty("RunWorkingDirectory").GetString();
                var normalizedCommand = command?.Trim().Trim('"');
                var normalizedWorkingDirectory = string.IsNullOrEmpty(runWorkingDirectory)
                    ? null
                    : Path.GetFullPath(runWorkingDirectory, Path.GetDirectoryName(Path.GetFullPath(projectPath))!);
                if (string.IsNullOrWhiteSpace(normalizedCommand))
                {
                    throw new DistributedApplicationException(
                        $"dotnet msbuild returned an empty run command for project '{projectPath}'.");
                }
 
                return new(normalizedCommand, arguments, normalizedWorkingDirectory);
            }
            catch (Exception ex) when (ex is ArgumentException or JsonException or InvalidOperationException or KeyNotFoundException or IOException or UnauthorizedAccessException)
            {
                throw new DistributedApplicationException(
                    $"dotnet msbuild returned an invalid run-command response for project '{projectPath}'.",
                    ex);
            }
        }
        finally
        {
            DotnetProjectBuildEnvironment.TryDeleteDirectory(resultDirectory, logger);
        }
    }
}