File: RunAspireCliCommand.cs
Web Access
Project: src\src\Aspire.Hosting.Tasks\Aspire.Hosting.Tasks.csproj (Aspire.Hosting.Tasks)
// 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.Globalization;
using System.Text;
using Microsoft.Build.Framework;
 
namespace Aspire.Hosting.Tasks;
 
/// <summary>
/// Runs an Aspire CLI command with structured arguments and bounded execution.
/// </summary>
public sealed class RunAspireCliCommand : Microsoft.Build.Utilities.Task
{
    private const string ArgumentValueMetadataName = "Value";
    private const string CommandShimPathEnvironmentVariable = "__ASPIRE_MSBUILD_COMMAND_PATH";
    private const string CommandShimArgumentEnvironmentVariablePrefix = "__ASPIRE_MSBUILD_COMMAND_ARGUMENT_";
    private const int ProcessTerminationTimeoutMilliseconds = 5_000;
 
    /// <summary>
    /// Gets or sets the executable or command shim to run.
    /// </summary>
    [Required]
    public string FileName { get; set; } = null!;
 
    /// <summary>
    /// Gets or sets the ordered command arguments.
    /// </summary>
    /// <remarks>
    /// An item can carry its argument in <c>Value</c> metadata when its item identity is used only
    /// to preserve ordering or avoid MSBuild item-list splitting.
    /// </remarks>
    public ITaskItem[] Arguments { get; set; } = [];
 
    /// <summary>
    /// Gets or sets the maximum command duration in milliseconds.
    /// </summary>
    public int TimeoutMilliseconds { get; set; } = 120_000;
 
    /// <summary>
    /// Gets the command exit code, or <c>-1</c> when the command did not exit normally.
    /// </summary>
    [Output]
    public int ExitCode { get; set; } = -1;
 
    /// <summary>
    /// Gets a value indicating whether the command exceeded <see cref="TimeoutMilliseconds"/>.
    /// </summary>
    [Output]
    public bool TimedOut { get; set; }
 
    /// <summary>
    /// Gets the process launch or timeout failure description.
    /// </summary>
    [Output]
    public string? FailureMessage { get; set; }
 
    /// <summary>
    /// Overrides process termination for tests that must keep a process alive after termination is requested.
    /// </summary>
    internal Func<Process, bool>? TestTerminateProcess { get; set; }
 
    public override bool Execute()
    {
        if (string.IsNullOrWhiteSpace(FileName))
        {
            Log.LogError("An executable path is required.");
            return false;
        }
 
        if (TimeoutMilliseconds <= 0)
        {
            Log.LogError("The command timeout must be greater than zero.");
            return false;
        }
 
        var arguments = Arguments.Select(GetArgumentValue).ToArray();
        using var process = new Process
        {
            StartInfo = CreateStartInfo(arguments)
        };
 
        try
        {
            if (!process.Start())
            {
                FailureMessage = $"The command '{FileName}' could not be started.";
                return true;
            }
        }
        catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or NotSupportedException)
        {
            FailureMessage = $"The command '{FileName}' could not be started: {ex.Message}";
            return true;
        }
 
        // Read both streams concurrently to avoid deadlock when a pipe buffer fills.
        var standardOutputTask = process.StandardOutput.ReadToEndAsync();
        var standardErrorTask = process.StandardError.ReadToEndAsync();
 
        if (!process.WaitForExit(TimeoutMilliseconds))
        {
            TimedOut = true;
            var processExited = false;
            if (TestTerminateProcess?.Invoke(process) ?? TerminateProcess(process))
            {
                processExited = process.WaitForExit(ProcessTerminationTimeoutMilliseconds);
                if (!processExited)
                {
                    FailureMessage ??= $"The command '{FileName}' timed out after {TimeoutMilliseconds} milliseconds and did not exit within {ProcessTerminationTimeoutMilliseconds} milliseconds after termination was requested.";
                }
            }
 
            if (processExited)
            {
                WaitForProcessOutput(standardOutputTask, standardErrorTask);
            }
 
            // WaitForExit reports only the root process's exit after Kill(entireProcessTree: true).
            // A surviving descendant can retain inherited pipe handles, so timeout cleanup must
            // never wait indefinitely for the redirected readers to reach EOF.
            // See https://learn.microsoft.com/dotnet/api/system.diagnostics.process.kill#remarks.
            LogProcessOutputIfCompleted(standardOutputTask);
            LogProcessOutputIfCompleted(standardErrorTask);
            FailureMessage ??= $"The command timed out after {TimeoutMilliseconds} milliseconds.";
            return true;
        }
 
        var standardOutput = standardOutputTask.GetAwaiter().GetResult();
        var standardError = standardErrorTask.GetAwaiter().GetResult();
        LogProcessOutput(standardOutput);
        LogProcessOutput(standardError);
 
        ExitCode = process.ExitCode;
        return true;
    }
 
    private ProcessStartInfo CreateStartInfo(IReadOnlyList<string> arguments)
    {
        var startInfo = IsWindowsCommandShim(FileName)
            ? new ProcessStartInfo(GetCommandProcessorPath(), BuildCommandProcessorArguments(arguments.Count))
            : new ProcessStartInfo(FileName);
 
        startInfo.CreateNoWindow = true;
        startInfo.RedirectStandardOutput = true;
        startInfo.RedirectStandardError = true;
        startInfo.UseShellExecute = false;
 
        if (IsWindowsCommandShim(FileName))
        {
            SetEnvironmentVariable(startInfo, CommandShimPathEnvironmentVariable, FileName);
            for (var index = 0; index < arguments.Count; index++)
            {
                SetEnvironmentVariable(startInfo, $"{CommandShimArgumentEnvironmentVariablePrefix}{index}", arguments[index]);
            }
        }
        else
        {
#if NETFRAMEWORK
            startInfo.Arguments = string.Join(" ", arguments.Select(QuoteWindowsArgument));
#else
            foreach (var argument in arguments)
            {
                startInfo.ArgumentList.Add(argument);
            }
#endif
        }
 
        return startInfo;
    }
 
    private bool TerminateProcess(Process process)
    {
#if NETFRAMEWORK
        return TerminateProcessTree(process);
#else
        try
        {
            process.Kill(entireProcessTree: true);
            return true;
        }
        catch (InvalidOperationException)
        {
            // The process exited between the timeout and termination attempt.
            return true;
        }
        catch (Win32Exception ex)
        {
            FailureMessage = $"The timed-out command '{FileName}' could not be terminated: {ex.Message}";
            return false;
        }
#endif
    }
 
#if NETFRAMEWORK
    private bool TerminateProcessTree(Process process)
    {
        int processId;
        try
        {
            processId = process.Id;
        }
        catch (InvalidOperationException)
        {
            // The process exited between the timeout and termination attempt.
            return true;
        }
 
        var taskKillPath = Path.Combine(Environment.SystemDirectory, "taskkill.exe");
        using var taskKill = new Process
        {
            StartInfo = new ProcessStartInfo(
                taskKillPath,
                $"/PID {processId.ToString(CultureInfo.InvariantCulture)} /T /F")
            {
                CreateNoWindow = true,
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false
            }
        };
 
        try
        {
            if (!taskKill.Start())
            {
                FailureMessage = $"The process-tree terminator '{taskKillPath}' could not be started.";
                return false;
            }
 
            // taskkill is part of Windows and /T terminates the specified process and its descendants.
            // Resolve it from the system directory so a restricted or test-modified PATH cannot redirect it.
            // See https://learn.microsoft.com/windows-server/administration/windows-commands/taskkill.
            var standardOutputTask = taskKill.StandardOutput.ReadToEndAsync();
            var standardErrorTask = taskKill.StandardError.ReadToEndAsync();
 
            if (!taskKill.WaitForExit(ProcessTerminationTimeoutMilliseconds))
            {
                try
                {
                    taskKill.Kill();
                }
                catch (InvalidOperationException)
                {
                    // The helper exited between the timeout and termination attempt.
                }
 
                if (taskKill.HasExited || taskKill.WaitForExit(ProcessTerminationTimeoutMilliseconds))
                {
                    _ = standardOutputTask.GetAwaiter().GetResult();
                    _ = standardErrorTask.GetAwaiter().GetResult();
                }
 
                FailureMessage = $"The process-tree terminator '{taskKillPath}' did not exit within {ProcessTerminationTimeoutMilliseconds} milliseconds.";
                return false;
            }
 
            var standardOutput = standardOutputTask.GetAwaiter().GetResult();
            var standardError = standardErrorTask.GetAwaiter().GetResult();
            if (taskKill.ExitCode != 0)
            {
                var details = string.IsNullOrWhiteSpace(standardError) ? standardOutput : standardError;
                FailureMessage = $"The process-tree terminator '{taskKillPath}' exited with code {taskKill.ExitCode}." +
                    (string.IsNullOrWhiteSpace(details) ? string.Empty : $" {details.Trim()}");
                return false;
            }
 
            return true;
        }
        catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or NotSupportedException)
        {
            FailureMessage = $"The process-tree terminator '{taskKillPath}' failed: {ex.Message}";
            return false;
        }
    }
#endif
 
    private void LogProcessOutput(string output)
    {
        if (!string.IsNullOrEmpty(output))
        {
            // Setup output is diagnostic only. Logging it as a message prevents a recovered attempt
            // from becoming a warning-as-error failure because the tool wrote warning-like text.
            Log.LogMessage(MessageImportance.Low, "{0}", output);
        }
    }
 
    private static void WaitForProcessOutput(Task<string> standardOutputTask, Task<string> standardErrorTask)
    {
        try
        {
            _ = Task.WaitAll([standardOutputTask, standardErrorTask], ProcessTerminationTimeoutMilliseconds);
        }
        catch
        {
            // Draining outputs is best-effort and a failure to do so (including a timeout) should not interrupt the caller.
        }
    }
 
    private void LogProcessOutputIfCompleted(Task<string> outputTask)
    {
        if (outputTask.Status == TaskStatus.RanToCompletion)
        {
            var output = outputTask.GetAwaiter().GetResult();
            if (!string.IsNullOrEmpty(output))
            {
                LogProcessOutput(output);
            }
 
            return;
        }
 
        // "Observe" task exceptions (for tasks that exceed the wait timeout and eventually fail) so that
        // UnobservedTaskException (if enabled) does not bring down the process.
        _ = outputTask.ContinueWith(
            static completedTask => _ = completedTask.Exception,
            CancellationToken.None,
            TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
            TaskScheduler.Default);
    }
 
    private static string GetArgumentValue(ITaskItem argument)
    {
        var value = argument.GetMetadata(ArgumentValueMetadataName);
        return string.IsNullOrEmpty(value) ? argument.ItemSpec : value;
    }
 
    private static bool IsWindowsCommandShim(string path)
    {
        return Path.DirectorySeparatorChar == '\\'
            && (path.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase)
                || path.EndsWith(".bat", StringComparison.OrdinalIgnoreCase));
    }
 
    private static string GetCommandProcessorPath()
    {
        return Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe";
    }
 
    private static string BuildCommandProcessorArguments(int argumentCount)
    {
        var command = new StringBuilder($"\"%{CommandShimPathEnvironmentVariable}%\"");
 
        for (var index = 0; index < argumentCount; index++)
        {
            command.Append(" \"%");
            command.Append(CommandShimArgumentEnvironmentVariablePrefix);
            command.Append(index.ToString(CultureInfo.InvariantCulture));
            command.Append("%\"");
        }
 
        // Expand each child-only variable once. cmd.exe does not recursively expand percent-delimited
        // text introduced by an environment variable, so literal %NAME% path segments remain intact.
        return $"/D /V:OFF /S /C \"{command}\"";
    }
 
    private static void SetEnvironmentVariable(ProcessStartInfo startInfo, string name, string value)
    {
#if NETFRAMEWORK
        startInfo.EnvironmentVariables[name] = value;
#else
        startInfo.Environment[name] = value;
#endif
    }
 
#if NETFRAMEWORK
    private static string QuoteWindowsArgument(string argument)
    {
        if (argument.Length > 0 && !argument.Any(static character => char.IsWhiteSpace(character) || character == '"'))
        {
            return argument;
        }
 
        var result = new StringBuilder(argument.Length + 2);
        result.Append('"');
        var backslashCount = 0;
 
        foreach (var character in argument)
        {
            if (character == '\\')
            {
                backslashCount++;
                continue;
            }
 
            if (character == '"')
            {
                result.Append('\\', (backslashCount * 2) + 1);
                result.Append('"');
                backslashCount = 0;
                continue;
            }
 
            result.Append('\\', backslashCount);
            backslashCount = 0;
            result.Append(character);
        }
 
        result.Append('\\', backslashCount * 2);
        result.Append('"');
        return result.ToString();
    }
#endif
}