| File: src\Aspire.Hosting\Dcp\Process\ProcessUtil.cs | Web Access |
| Project: src\src\Aspire.Hosting.Azure.Kubernetes\Aspire.Hosting.Azure.Kubernetes.csproj (Aspire.Hosting.Azure.Kubernetes) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; using System.Runtime.InteropServices; using System.Text; using Aspire.Hosting.Utils; namespace Aspire.Hosting.Dcp.Process; internal static partial class ProcessUtil { #region Native Methods [LibraryImport("libc", SetLastError = true, EntryPoint = "kill")] private static partial int sys_kill(int pid, int sig); #endregion private static readonly TimeSpan s_processExitTimeout = TimeSpan.FromSeconds(5); public static (Task<ProcessResult>, IAsyncDisposable) Run(ProcessSpec processSpec) { if (processSpec.Arguments is not null && processSpec.ArgumentList is not null) { throw new ArgumentException($"Specify either {nameof(ProcessSpec.Arguments)} or {nameof(ProcessSpec.ArgumentList)}, not both.", nameof(processSpec)); } var retainedOutputLineCount = processSpec.RetainedOutputLineCount ?? (processSpec.ThrowOnNonZeroReturnCode ? ProcessSpec.DefaultRetainedOutputLineCount : 0); ArgumentOutOfRangeException.ThrowIfNegative(retainedOutputLineCount); ProcessOutputCapture? outputCapture = retainedOutputLineCount > 0 ? new(retainedOutputLineCount) : null; var resolvedExecutablePath = processSpec.ResolveExecutablePath ? PathLookupHelper.ResolveExecutablePath(processSpec.ExecutablePath, processSpec.EnvironmentVariables) : processSpec.ExecutablePath; var process = new System.Diagnostics.Process() { StartInfo = { FileName = resolvedExecutablePath, WorkingDirectory = processSpec.WorkingDirectory ?? string.Empty, Arguments = processSpec.Arguments ?? string.Empty, RedirectStandardOutput = true, RedirectStandardError = true, RedirectStandardInput = processSpec.StandardInputContent != null, UseShellExecute = false, CreateNoWindow = true, WindowStyle = ProcessWindowStyle.Hidden, }, EnableRaisingEvents = true }; if (processSpec.ArgumentList is not null) { foreach (var argument in processSpec.ArgumentList) { process.StartInfo.ArgumentList.Add(argument); } } if (!processSpec.InheritEnv) { process.StartInfo.Environment.Clear(); } foreach (var (key, value) in processSpec.EnvironmentVariables) { process.StartInfo.Environment[key] = value; } // Use a reset event to prevent output processing and exited events from running until OnStart is complete. // OnStart might have logic that sets up data structures that then are used by these events. var startupComplete = new ManualResetEventSlim(false); // Note: even though the child process has exited, its children may be alive and still producing output. // See https://github.com/dotnet/runtime/issues/29232#issuecomment-1451584094 for how this might affect waiting for process exit. // We are going to discard that (grandchild) output by checking process.HasExited. var stdoutComplete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var stderrComplete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); process.OutputDataReceived += (_, e) => { startupComplete.Wait(); if (e.Data is null) { stdoutComplete.TrySetResult(); return; } if (string.IsNullOrEmpty(e.Data)) { return; } outputCapture?.Add(e.Data); processSpec.OnOutputData?.Invoke(e.Data); }; process.ErrorDataReceived += (_, e) => { startupComplete.Wait(); if (e.Data is null) { stderrComplete.TrySetResult(); return; } if (string.IsNullOrEmpty(e.Data)) { return; } outputCapture?.Add(e.Data); processSpec.OnErrorData?.Invoke(e.Data); }; var processLifetimeTcs = new TaskCompletionSource<ProcessResult>(); try { process.Start(); processSpec.OnStart?.Invoke(process.Id); startupComplete.Set(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); // Write standard input after output reads are active so processes can write before reading input. if (processSpec.StandardInputContent != null) { var writer = process.StandardInput; writer.WriteLine(processSpec.StandardInputContent); writer.Flush(); writer.Close(); } _ = Task.Run(async () => { startupComplete.Wait(); try { await process.WaitForExitAsync().ConfigureAwait(false); await Task.WhenAll(stdoutComplete.Task, stderrComplete.Task).ConfigureAwait(false); processSpec.OnStop?.Invoke(process.ExitCode); if (processSpec.ThrowOnNonZeroReturnCode && process.ExitCode != 0) { var message = $"Command {processSpec.ExecutablePath} {FormatProcessArgumentsForDisplay(processSpec)} returned non-zero exit code {process.ExitCode}"; if (outputCapture?.TotalLineCount > 0) { message = $"{message}{Environment.NewLine}{outputCapture.GetFormattedOutput()}"; } processLifetimeTcs.TrySetException(new InvalidOperationException(message)); } else { processLifetimeTcs.TrySetResult(CreateProcessResult(process.ExitCode, outputCapture)); } } catch (Exception ex) { processLifetimeTcs.TrySetException(ex); } }); } finally { startupComplete.Set(); // Allow output/error/exit handlers to start processing data. } return (processLifetimeTcs.Task, new ProcessDisposable(process, processLifetimeTcs.Task, processSpec.KillEntireProcessTree)); } private static string FormatProcessArgumentsForDisplay(ProcessSpec processSpec) { return processSpec.Arguments ?? string.Join(" ", processSpec.ArgumentList?.Select(FormatProcessArgumentForDisplay) ?? []); } private static string FormatProcessArgumentForDisplay(string argument) { // This string is only for diagnostics and EventSource payloads; ProcessStartInfo.ArgumentList is still what // supplies argv to the child process. Quote values using the same escaping shape expected by Windows command // lines so spaces, quotes, and empty strings are readable instead of ambiguous. // See https://learn.microsoft.com/cpp/c-language/parsing-c-command-line-arguments if (argument.Length > 0 && !argument.Any(static c => char.IsWhiteSpace(c) || c == '"')) { return argument; } var builder = new StringBuilder(argument.Length + 2); builder.Append('"'); var backslashCount = 0; foreach (var c in argument) { if (c == '\\') { backslashCount++; continue; } if (c == '"') { builder.Append('\\', backslashCount * 2 + 1); builder.Append('"'); } else { builder.Append('\\', backslashCount); builder.Append(c); } backslashCount = 0; } builder.Append('\\', backslashCount * 2); builder.Append('"'); return builder.ToString(); } private static ProcessResult CreateProcessResult(int exitCode, ProcessOutputCapture? outputCapture) { if (outputCapture is null) { return new ProcessResult(exitCode); } return new ProcessResult(exitCode, outputCapture.ToArray(), outputCapture.TotalLineCount); } private sealed class ProcessDisposable : IAsyncDisposable { private readonly System.Diagnostics.Process _process; private readonly Task _processLifetimeTask; private readonly bool _entireProcessTree; public ProcessDisposable(System.Diagnostics.Process process, Task processLifetimeTask, bool entireProcessTree) { _process = process; _processLifetimeTask = processLifetimeTask; _entireProcessTree = entireProcessTree; } public async ValueTask DisposeAsync() { if (_process.HasExited) { return; // nothing to do } if (OperatingSystem.IsWindows()) { if (!_process.CloseMainWindow()) { Kill(_process, _entireProcessTree); } } else { sys_kill(_process.Id, sig: 2); // SIGINT } try { await _processLifetimeTask.WaitAsync(s_processExitTimeout).ConfigureAwait(false); } catch (TimeoutException) { if (!_process.HasExited) { Kill(_process, entireProcessTree: true); } try { await _processLifetimeTask.WaitAsync(s_processExitTimeout).ConfigureAwait(false); } catch (TimeoutException) { // The process may have exited while inherited stdout/stderr pipes are still held open by descendants. // Do not let cleanup throw and skip the final kill attempt below. } } if (!_process.HasExited) { // Always try to kill the entire process tree here if all of the above has failed. Kill(_process, entireProcessTree: true); } } private static void Kill(System.Diagnostics.Process process, bool entireProcessTree) { try { process.Kill(entireProcessTree); } catch (InvalidOperationException) when (process.HasExited) { } } } }