| 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); } } }