| File: Layout\LayoutProcessRunner.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.Text; using Aspire.Cli.DotNet; using Aspire.Cli.Processes; namespace Aspire.Cli.Layout; /// <summary> /// Runs processes using layout tools via an <see cref="IProcessExecutionFactory"/>. /// </summary> internal sealed class LayoutProcessRunner(IProcessExecutionFactory executionFactory) { /// <inheritdoc /> public async Task<(int ExitCode, string Output, string Error)> RunAsync( string toolPath, IEnumerable<string> arguments, string? workingDirectory = null, IDictionary<string, string>? environmentVariables = null, bool killOnParentExit = false, CancellationToken ct = default) { var outputBuilder = new StringBuilder(); var errorBuilder = new StringBuilder(); var options = new ProcessInvocationOptions { SuppressLogging = true, StandardOutputCallback = line => outputBuilder.AppendLine(line), StandardErrorCallback = line => errorBuilder.AppendLine(line), KillOnParentExit = killOnParentExit, }; var args = arguments.ToArray(); var workDir = new DirectoryInfo(workingDirectory ?? Directory.GetCurrentDirectory()); // The Windows kill-on-close job (KillOnParentExit, above) and the cross-platform cooperative // parent-liveness watchdog (activated by the ASPIRE_CLI_PID identity that // WithOrphanDetectionEnvironment stamps) are two implementations of the SAME "don't outlive the // CLI" policy. Arming BOTH on one child races the job's kernel TerminateProcess against the // watchdog's Environment.Exit(124) when the CLI exits, which can get the child stuck mid-teardown. // So we use exactly one mechanism per child: on Windows the kill-on-close // job is authoritative (kernel-enforced), and we do not use the watchdog. // Everywhere else KillOnParentExit is a no-op, and the cooperative watchdog remains the sole mechanism // and MUST have relevant environment variables set. var effectiveEnvironment = options.KillOnParentExit && OperatingSystem.IsWindows() ? CopyEnvironment(environmentVariables) : WithOrphanDetectionEnvironment(environmentVariables); await using var execution = executionFactory.CreateExecution(toolPath, args, effectiveEnvironment, workDir, options); if (!await execution.StartAsync(ct).ConfigureAwait(false)) { throw new InvalidOperationException($"Failed to start process: {toolPath}"); } var exitCode = await execution.WaitForExitAsync(ct).ConfigureAwait(false); return (exitCode, outputBuilder.ToString(), errorBuilder.ToString()); } /// <inheritdoc /> public async Task<IProcessExecution> StartAsync( string toolPath, IEnumerable<string> arguments, string? workingDirectory = null, IDictionary<string, string>? environmentVariables = null, ProcessInvocationOptions? options = null, bool killOnParentExit = false) { var args = arguments.ToArray(); var workDir = new DirectoryInfo(workingDirectory ?? Directory.GetCurrentDirectory()); // Clone so the KillOnParentExit flip below never mutates the caller's options instance — the // caller may reuse it across invocations. Falls back to a fresh instance when none was passed. var effectiveOptions = options?.Clone() ?? new ProcessInvocationOptions(); if (killOnParentExit) { effectiveOptions.KillOnParentExit = true; } // Compare with RunAsync: the same logic applies here. var effectiveEnvironment = effectiveOptions.KillOnParentExit && OperatingSystem.IsWindows() ? CopyEnvironment(environmentVariables) : WithOrphanDetectionEnvironment(environmentVariables); var execution = executionFactory.CreateExecution(toolPath, args, effectiveEnvironment, workDir, effectiveOptions); // StartAsync returns a background execution handle. Its caller owns the lifetime and must // explicitly wait, kill, or dispose it; cancellation here would only abort launch setup, // not define how the background process should be stopped after it starts. if (!await execution.StartAsync(CancellationToken.None).ConfigureAwait(false)) { await execution.DisposeAsync().ConfigureAwait(false); throw new InvalidOperationException($"Failed to start process: {toolPath}"); } return execution; } private static IDictionary<string, string> WithOrphanDetectionEnvironment(IDictionary<string, string>? environmentVariables) { var environment = CopyEnvironment(environmentVariables); // Stamp the launching CLI's identity, but never override values the caller already supplied // so an explicit caller override always wins. OrphanDetectionEnvironment.ApplyCurrentProcess(environment, overwrite: false); return environment; } private static IDictionary<string, string> CopyEnvironment(IDictionary<string, string>? environmentVariables) => environmentVariables is null ? new Dictionary<string, string>(StringComparer.Ordinal) : new Dictionary<string, string>(environmentVariables, StringComparer.Ordinal); }