From 4a64cdf4fd6988a93667753a2793e56b94e68bd7 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Thu, 14 May 2026 10:14:34 -0500 Subject: [PATCH] Simplify Aspire.Cli process code using .NET 11 Process APIs Use the new .NET 11 Preview 4 Process APIs to simplify process creation and output capture throughout src/Aspire.Cli: - Process.RunAndCaptureTextAsync for start+capture+wait patterns - Process.RunAndCaptureText for synchronous equivalents - Process.Run for fire-and-check-exit-code patterns - Process.ReadAllLinesAsync for line-by-line multiplexed streaming - File.OpenNullHandle() + StandardOutputHandle/StandardErrorHandle for null-device redirection - ProcessStartInfo.InheritedHandles for controlled handle inheritance The biggest win is DetachedProcessLauncher, where ~490 lines of platform-specific P/Invoke (Windows) and pipe-close workarounds (Unix) are replaced by a single cross-platform implementation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- global.json | 4 +- .../Agents/ClaudeCode/ClaudeCodeCliRunner.cs | 27 +- .../Agents/CopilotCli/CopilotCliRunner.cs | 28 +- .../Agents/OpenCode/OpenCodeCliRunner.cs | 27 +- .../Agents/Playwright/PlaywrightCliRunner.cs | 40 +- .../Agents/VsCode/VsCodeCliRunner.cs | 24 +- src/Aspire.Cli/Aspire.Cli.csproj | 2 +- .../MacOSCertificateManager.cs | 65 ++- .../UnixCertificateManager.cs | 44 +- .../Certificates/CertificateHelpers.cs | 24 +- src/Aspire.Cli/Commands/UpdateCommand.cs | 22 +- src/Aspire.Cli/DotNet/DotNetSdkInstaller.cs | 22 +- src/Aspire.Cli/DotNet/ProcessExecution.cs | 155 ++----- src/Aspire.Cli/Git/GitRepository.cs | 50 +-- src/Aspire.Cli/Npm/NpmRunner.cs | 21 +- src/Aspire.Cli/OpenCode/OpenCodeCliRunner.cs | 27 +- .../Processes/DetachedProcessLauncher.Unix.cs | 76 ---- .../DetachedProcessLauncher.Windows.cs | 413 ------------------ .../Processes/DetachedProcessLauncher.cs | 96 ++-- .../DotNetBasedAppHostServerProject.cs | 21 +- .../Projects/PrebuiltAppHostServer.cs | 4 + .../Projects/ProcessGuestLauncher.cs | 60 +-- src/Aspire.Cli/Utils/CliDownloader.cs | 20 +- .../DeprecatedWorkloadCheck.cs | 30 +- 24 files changed, 254 insertions(+), 1048 deletions(-) delete mode 100644 src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs delete mode 100644 src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs diff --git a/global.json b/global.json index 0527dc9d64d..fd96a158725 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.201", + "version": "11.0.100-preview.4.26230.115", "rollForward": "major", "allowPrerelease": true, "paths": [ @@ -10,7 +10,7 @@ "errorMessage": "The .NET SDK could not be found. Run ./restore.sh (Linux/macOS) or ./restore.cmd (Windows) to install the local SDK." }, "tools": { - "dotnet": "10.0.201", + "dotnet": "11.0.100-preview.4.26230.115", "runtimes": { "dotnet/x64": [ "$(DotNetRuntimePreviousVersionForTesting)", diff --git a/src/Aspire.Cli/Agents/ClaudeCode/ClaudeCodeCliRunner.cs b/src/Aspire.Cli/Agents/ClaudeCode/ClaudeCodeCliRunner.cs index 2e863de50e1..3c067d8e86e 100644 --- a/src/Aspire.Cli/Agents/ClaudeCode/ClaudeCodeCliRunner.cs +++ b/src/Aspire.Cli/Agents/ClaudeCode/ClaudeCodeCliRunner.cs @@ -27,32 +27,15 @@ internal sealed class ClaudeCodeCliRunner(ILogger logger) : try { - var startInfo = new ProcessStartInfo(executablePath, "--version") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var process = new Process { StartInfo = startInfo }; - - process.Start(); - - var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); - - await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + var result = await Process.RunAndCaptureTextAsync(executablePath, ["--version"], cancellationToken).ConfigureAwait(false); - if (process.ExitCode != 0) + if (result.ExitStatus.ExitCode != 0) { - var errorOutput = await errorTask.ConfigureAwait(false); - logger.LogDebug("Claude Code CLI returned non-zero exit code {ExitCode}: {Error}", process.ExitCode, errorOutput.Trim()); + logger.LogDebug("Claude Code CLI returned non-zero exit code {ExitCode}: {Error}", result.ExitStatus.ExitCode, result.StandardError.Trim()); return null; } - var output = await outputTask.ConfigureAwait(false); - var versionString = output.Trim(); + var versionString = result.StandardOutput.Trim(); if (string.IsNullOrEmpty(versionString)) { @@ -79,7 +62,7 @@ internal sealed class ClaudeCodeCliRunner(ILogger logger) : return version; } - logger.LogDebug("Could not parse Claude Code CLI version from output: {Output}", output.Trim()); + logger.LogDebug("Could not parse Claude Code CLI version from output: {Output}", result.StandardOutput.Trim()); return null; } catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) diff --git a/src/Aspire.Cli/Agents/CopilotCli/CopilotCliRunner.cs b/src/Aspire.Cli/Agents/CopilotCli/CopilotCliRunner.cs index 1f5c1a3110e..ba0f2c26962 100644 --- a/src/Aspire.Cli/Agents/CopilotCli/CopilotCliRunner.cs +++ b/src/Aspire.Cli/Agents/CopilotCli/CopilotCliRunner.cs @@ -27,39 +27,21 @@ internal sealed class CopilotCliRunner(ILogger logger) : ICopi try { - var startInfo = new ProcessStartInfo(executablePath, "--version") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var process = new Process { StartInfo = startInfo }; - - process.Start(); + var result = await Process.RunAndCaptureTextAsync(executablePath, ["--version"], cancellationToken).ConfigureAwait(false); - var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); - - await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); - - if (process.ExitCode != 0) + if (result.ExitStatus.ExitCode != 0) { - var errorOutput = await errorTask.ConfigureAwait(false); - logger.LogDebug("GitHub Copilot CLI returned non-zero exit code {ExitCode}: {Error}", process.ExitCode, errorOutput.Trim()); + logger.LogDebug("GitHub Copilot CLI returned non-zero exit code {ExitCode}: {Error}", result.ExitStatus.ExitCode, result.StandardError.Trim()); return null; } - var output = await outputTask.ConfigureAwait(false); - - if (TryParseVersionOutput(output, out var version)) + if (TryParseVersionOutput(result.StandardOutput, out var version)) { logger.LogDebug("Found GitHub Copilot CLI version: {Version}", version); return version; } - logger.LogDebug("Could not parse GitHub Copilot CLI version from output: {Output}", output.Trim()); + logger.LogDebug("Could not parse GitHub Copilot CLI version from output: {Output}", result.StandardOutput.Trim()); return null; } catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) diff --git a/src/Aspire.Cli/Agents/OpenCode/OpenCodeCliRunner.cs b/src/Aspire.Cli/Agents/OpenCode/OpenCodeCliRunner.cs index c84f0691f4c..a6066b6de24 100644 --- a/src/Aspire.Cli/Agents/OpenCode/OpenCodeCliRunner.cs +++ b/src/Aspire.Cli/Agents/OpenCode/OpenCodeCliRunner.cs @@ -27,32 +27,15 @@ internal sealed class OpenCodeCliRunner(ILogger logger) : IOp try { - var startInfo = new ProcessStartInfo(executablePath, "--version") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var process = new Process { StartInfo = startInfo }; - - process.Start(); - - var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); - - await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + var result = await Process.RunAndCaptureTextAsync(executablePath, ["--version"], cancellationToken).ConfigureAwait(false); - if (process.ExitCode != 0) + if (result.ExitStatus.ExitCode != 0) { - var errorOutput = await errorTask.ConfigureAwait(false); - logger.LogDebug("OpenCode CLI returned non-zero exit code {ExitCode}: {Error}", process.ExitCode, errorOutput.Trim()); + logger.LogDebug("OpenCode CLI returned non-zero exit code {ExitCode}: {Error}", result.ExitStatus.ExitCode, result.StandardError.Trim()); return null; } - var output = await outputTask.ConfigureAwait(false); - var versionString = output.Trim(); + var versionString = result.StandardOutput.Trim(); if (string.IsNullOrEmpty(versionString)) { @@ -72,7 +55,7 @@ internal sealed class OpenCodeCliRunner(ILogger logger) : IOp return version; } - logger.LogDebug("Could not parse OpenCode CLI version from output: {Output}", output.Trim()); + logger.LogDebug("Could not parse OpenCode CLI version from output: {Output}", result.StandardOutput.Trim()); return null; } catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) diff --git a/src/Aspire.Cli/Agents/Playwright/PlaywrightCliRunner.cs b/src/Aspire.Cli/Agents/Playwright/PlaywrightCliRunner.cs index 6ce7f52004c..299c5016f17 100644 --- a/src/Aspire.Cli/Agents/Playwright/PlaywrightCliRunner.cs +++ b/src/Aspire.Cli/Agents/Playwright/PlaywrightCliRunner.cs @@ -24,31 +24,15 @@ internal sealed class PlaywrightCliRunner(ILogger logger) : try { - var startInfo = new ProcessStartInfo(executablePath, "--version") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; + var result = await Process.RunAndCaptureTextAsync(executablePath, ["--version"], cancellationToken).ConfigureAwait(false); - using var process = new Process { StartInfo = startInfo }; - process.Start(); - - var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); - - await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); - - if (process.ExitCode != 0) + if (result.ExitStatus.ExitCode != 0) { - var errorOutput = await errorTask.ConfigureAwait(false); - logger.LogDebug("playwright-cli --version returned non-zero exit code {ExitCode}: {Error}", process.ExitCode, errorOutput.Trim()); + logger.LogDebug("playwright-cli --version returned non-zero exit code {ExitCode}: {Error}", result.ExitStatus.ExitCode, result.StandardError.Trim()); return null; } - var output = await outputTask.ConfigureAwait(false); - var versionString = output.Trim().Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim(); + var versionString = result.StandardOutput.Trim().Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim(); if (string.IsNullOrEmpty(versionString)) { @@ -101,23 +85,15 @@ public async Task InstallSkillsAsync(string workingDirectory, Cancellation startInfo.ArgumentList.Add("install"); startInfo.ArgumentList.Add("--skills"); - using var process = new Process { StartInfo = startInfo }; - process.Start(); - - var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); - - await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + var result = await Process.RunAndCaptureTextAsync(startInfo, cancellationToken).ConfigureAwait(false); - if (process.ExitCode != 0) + if (result.ExitStatus.ExitCode != 0) { - var errorOutput = await errorTask.ConfigureAwait(false); - logger.LogDebug("playwright-cli install --skills returned non-zero exit code {ExitCode}: {Error}", process.ExitCode, errorOutput.Trim()); + logger.LogDebug("playwright-cli install --skills returned non-zero exit code {ExitCode}: {Error}", result.ExitStatus.ExitCode, result.StandardError.Trim()); return false; } - var output = await outputTask.ConfigureAwait(false); - logger.LogDebug("playwright-cli install --skills output: {Output}", output.Trim()); + logger.LogDebug("playwright-cli install --skills output: {Output}", result.StandardOutput.Trim()); return true; } catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) diff --git a/src/Aspire.Cli/Agents/VsCode/VsCodeCliRunner.cs b/src/Aspire.Cli/Agents/VsCode/VsCodeCliRunner.cs index be7425d5024..a4ee963fe8e 100644 --- a/src/Aspire.Cli/Agents/VsCode/VsCodeCliRunner.cs +++ b/src/Aspire.Cli/Agents/VsCode/VsCodeCliRunner.cs @@ -28,31 +28,15 @@ internal sealed class VsCodeCliRunner(ILogger logger) : IVsCode try { - var startInfo = new ProcessStartInfo(executablePath, "--version") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var process = new Process { StartInfo = startInfo }; - - process.Start(); - - var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); - - await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + var result = await Process.RunAndCaptureTextAsync(executablePath, ["--version"], cancellationToken).ConfigureAwait(false); - if (process.ExitCode != 0) + if (result.ExitStatus.ExitCode != 0) { - var errorOutput = await errorTask.ConfigureAwait(false); - logger.LogDebug("VS Code CLI ({Command}) returned non-zero exit code {ExitCode}: {Error}", command, process.ExitCode, errorOutput.Trim()); + logger.LogDebug("VS Code CLI ({Command}) returned non-zero exit code {ExitCode}: {Error}", command, result.ExitStatus.ExitCode, result.StandardError.Trim()); return null; } - var output = await outputTask.ConfigureAwait(false); + var output = result.StandardOutput; if (string.IsNullOrEmpty(output)) { diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index beef80e797b..5948d9ebb72 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -4,7 +4,7 @@ Exe win-x64;win-arm64;linux-x64;linux-arm64;linux-musl-x64;osx-x64;osx-arm64 $(RuntimeIdentifiers) - net10.0 + net11.0 enable enable true diff --git a/src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs index b76d09c7405..4b6a0c038b5 100644 --- a/src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs @@ -111,15 +111,17 @@ protected override TrustLevel TrustCertificateCore(X509Certificate2 publicCertif { Log.MacOSTrustCommandStart($"{MacOSTrustCertificateCommandLine} {s_macOSTrustCertificateCommandLineArguments}{tmpFile}"); } - using (var process = Process.Start(MacOSTrustCertificateCommandLine, s_macOSTrustCertificateCommandLineArguments + tmpFile)) + + // Process.Run doesn't support argument string, only ProcessStartInfo or IList. + // The trust command arguments are built as a single string with embedded quotes, so we + // pass them through ProcessStartInfo.Arguments to preserve the exact quoting. + var status = Process.Run(new ProcessStartInfo(MacOSTrustCertificateCommandLine) { Arguments = s_macOSTrustCertificateCommandLineArguments + tmpFile }); + if (status.ExitCode != 0) { - process.WaitForExit(); - if (process.ExitCode != 0) - { - Log.MacOSTrustCommandError(process.ExitCode); - throw new InvalidOperationException("There was an error trusting the certificate."); - } + Log.MacOSTrustCommandError(status.ExitCode); + throw new InvalidOperationException("There was an error trusting the certificate."); } + Log.MacOSTrustCommandEnd(); } finally @@ -173,7 +175,7 @@ public override TrustLevel GetTrustLevel(X509Certificate2 certificate) // We can't guarantee that the temp file is in a directory with sensible permissions, but we're not exporting the private key ExportCertificate(certificate, tmpFile, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pem); - using var checkTrustProcess = Process.Start(new ProcessStartInfo( + var status = Process.Run(new ProcessStartInfo( MacOSVerifyCertificateCommandLine, string.Format(CultureInfo.InvariantCulture, MacOSVerifyCertificateCommandLineArgumentsFormat, tmpFile)) { @@ -182,8 +184,7 @@ public override TrustLevel GetTrustLevel(X509Certificate2 certificate) // the cert and replicate the command to see details. RedirectStandardError = true, }); - checkTrustProcess!.WaitForExit(); - return checkTrustProcess.ExitCode == 0 ? TrustLevel.Full : TrustLevel.None; + return status.ExitCode == 0 ? TrustLevel.Full : TrustLevel.None; } finally { @@ -228,12 +229,11 @@ private void RemoveAdminTrustRule(X509Certificate2 certificate) certificatePath )); - using var process = Process.Start(processInfo); - process!.WaitForExit(); + var status = Process.Run(processInfo); - if (process.ExitCode != 0) + if (status.ExitCode != 0) { - Log.MacOSRemoveCertificateTrustRuleError(process.ExitCode); + Log.MacOSRemoveCertificateTrustRuleError(status.ExitCode); } Log.MacOSRemoveCertificateTrustRuleEnd(); @@ -271,18 +271,14 @@ private void RemoveCertificateFromKeychain(string keychain, X509Certificate2 cer Log.MacOSRemoveCertificateFromKeyChainStart(keychain, GetDescription(certificate)); } - using (var process = Process.Start(processInfo)) - { - var output = process!.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd(); - process.WaitForExit(); + var result = Process.RunAndCaptureText(processInfo); - if (process.ExitCode != 0) - { - Log.MacOSRemoveCertificateFromKeyChainError(process.ExitCode); - throw new InvalidOperationException($@"There was an error removing the certificate with thumbprint '{certificate.Thumbprint}'. + if (result.ExitStatus.ExitCode != 0) + { + Log.MacOSRemoveCertificateFromKeyChainError(result.ExitStatus.ExitCode); + throw new InvalidOperationException($@"There was an error removing the certificate with thumbprint '{certificate.Thumbprint}'. -{output}"); - } +{result.StandardOutput}{result.StandardError}"); } Log.MacOSRemoveCertificateFromKeyChainEnd(); @@ -302,17 +298,14 @@ private static bool IsCertOnKeychain(string keychain, X509Certificate2 certifica var subject = subjectMatch.Groups[1].Value; // Run the find-certificate command, and look for the cert's hash in the output - using var findCertificateProcess = Process.Start(new ProcessStartInfo( + var result = Process.RunAndCaptureText(new ProcessStartInfo( MacOSFindCertificateOnKeychainCommandLine, string.Format(CultureInfo.InvariantCulture, MacOSFindCertificateOnKeychainCommandLineArgumentsFormat, subject, keychain)) { RedirectStandardOutput = true }); - var output = findCertificateProcess!.StandardOutput.ReadToEnd(); - findCertificateProcess.WaitForExit(); - - var matches = Regex.Matches(output, MacOSFindCertificateOutputRegex, RegexOptions.Multiline, maxRegexTimeout); + var matches = Regex.Matches(result.StandardOutput, MacOSFindCertificateOutputRegex, RegexOptions.Multiline, maxRegexTimeout); var hashes = matches.OfType().Select(m => m.Groups[1].Value).ToList(); return hashes.Any(h => string.Equals(h, certificate.Thumbprint, StringComparison.Ordinal)); @@ -370,16 +363,12 @@ private void SaveCertificateToUserKeychain(X509Certificate2 certificate) Log.MacOSAddCertificateToKeyChainStart(s_macOSUserKeychain, GetDescription(certificate)); } - using (var process = Process.Start(processInfo)) - { - var output = process!.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd(); - process.WaitForExit(); + var result = Process.RunAndCaptureText(processInfo); - if (process.ExitCode != 0) - { - Log.MacOSAddCertificateToKeyChainError(process.ExitCode, output); - throw new InvalidOperationException("Failed to add the certificate to the keychain. Are you running in a non-interactive session perhaps?"); - } + if (result.ExitStatus.ExitCode != 0) + { + Log.MacOSAddCertificateToKeyChainError(result.ExitStatus.ExitCode, result.StandardOutput + result.StandardError); + throw new InvalidOperationException("Failed to add the certificate to the keychain. Are you running in a non-interactive session perhaps?"); } Log.MacOSAddCertificateToKeyChainEnd(); diff --git a/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs index d5efbc01cdb..64783e8050d 100644 --- a/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs @@ -691,9 +691,8 @@ private static bool TrustCertificateInWindowsStore(X509Certificate2 certificate) RedirectStandardError = true, }; - using var process = Process.Start(startInfo)!; - process.WaitForExit(); - return process.ExitCode == 0; + var status = Process.Run(startInfo); + return status.ExitCode == 0; } /// @@ -714,9 +713,8 @@ private bool IsCertificateInNssDb(string nickname, NssDb nssDb) try { - using var process = Process.Start(startInfo)!; - process.WaitForExit(); - return process.ExitCode == 0; + var status = Process.Run(startInfo); + return status.ExitCode == 0; } catch (Exception ex) { @@ -743,9 +741,8 @@ private bool TryAddCertificateToNssDb(string certificatePath, string nickname, N try { - using var process = Process.Start(startInfo)!; - process.WaitForExit(); - return process.ExitCode == 0; + var status = Process.Run(startInfo); + return status.ExitCode == 0; } catch (Exception ex) { @@ -767,9 +764,8 @@ private bool TryRemoveCertificateFromNssDb(string nickname, NssDb nssDb) try { - using var process = Process.Start(startInfo)!; - process.WaitForExit(); - if (process.ExitCode == 0) + var status = Process.Run(startInfo); + if (status.ExitCode == 0) { return true; } @@ -933,23 +929,19 @@ private bool TryGetOpenSslDirectory([NotNullWhen(true)] out string? openSslDir) try { - var processInfo = new ProcessStartInfo(OpenSslCommand, $"version -d") + var result = Process.RunAndCaptureText(new ProcessStartInfo(OpenSslCommand, $"version -d") { RedirectStandardOutput = true, RedirectStandardError = true - }; + }); - using var process = Process.Start(processInfo); - var stdout = process!.StandardOutput.ReadToEnd(); - - process.WaitForExit(); - if (process.ExitCode != 0) + if (result.ExitStatus.ExitCode != 0) { Log.UnixOpenSslVersionFailed(); return false; } - var match = OpenSslVersionRegex.Match(stdout); + var match = OpenSslVersionRegex.Match(result.StandardOutput); if (!match.Success) { Log.UnixOpenSslVersionParsingFailed(); @@ -977,23 +969,19 @@ private bool TryGetOpenSslHash(string certificatePath, [NotNullWhen(true)] out s { // c_rehash actually does this twice: once with -subject_hash (equivalent to -hash) and again // with -subject_hash_old. Old hashes are only needed for pre-1.0.0, so we skip that. - var processInfo = new ProcessStartInfo(OpenSslCommand, $"x509 -hash -noout -in {certificatePath}") + var result = Process.RunAndCaptureText(new ProcessStartInfo(OpenSslCommand, $"x509 -hash -noout -in {certificatePath}") { RedirectStandardOutput = true, RedirectStandardError = true - }; - - using var process = Process.Start(processInfo); - var stdout = process!.StandardOutput.ReadToEnd(); + }); - process.WaitForExit(); - if (process.ExitCode != 0) + if (result.ExitStatus.ExitCode != 0) { Log.UnixOpenSslHashFailed(certificatePath); return false; } - hash = stdout.Trim(); + hash = result.StandardOutput.Trim(); return true; } catch (Exception ex) diff --git a/src/Aspire.Cli/Certificates/CertificateHelpers.cs b/src/Aspire.Cli/Certificates/CertificateHelpers.cs index d8608b7edc3..cdaf460d0a0 100644 --- a/src/Aspire.Cli/Certificates/CertificateHelpers.cs +++ b/src/Aspire.Cli/Certificates/CertificateHelpers.cs @@ -89,32 +89,14 @@ internal static bool TryGetOpenSslDirectory([NotNullWhen(true)] out string? open try { - var processInfo = new ProcessStartInfo("openssl", "version -d") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var process = Process.Start(processInfo); - if (process is null) - { - return false; - } - - var stdout = process.StandardOutput.ReadToEnd(); - if (!process.WaitForExit(TimeSpan.FromSeconds(5))) - { - return false; - } + var result = Process.RunAndCaptureText("openssl", ["version", "-d"], TimeSpan.FromSeconds(5)); - if (process.ExitCode != 0) + if (result.ExitStatus.ExitCode != 0) { return false; } - var match = OpenSslVersionRegex().Match(stdout); + var match = OpenSslVersionRegex().Match(result.StandardOutput); if (!match.Success) { return false; diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index 6290f31f994..739cc4b554b 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -523,27 +523,11 @@ private void SetExecutablePermission(string filePath) { try { - var psi = new ProcessStartInfo - { - FileName = exePath, - Arguments = "--version", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - - using var process = Process.Start(psi); - if (process is null) - { - return null; - } - - var output = await process.StandardOutput.ReadToEndAsync(cancellationToken); - await process.WaitForExitAsync(cancellationToken); + var result = await Process.RunAndCaptureTextAsync(exePath, ["--version"], cancellationToken); - if (process.ExitCode == 0) + if (result.ExitStatus.ExitCode == 0) { - var version = output.Trim(); + var version = result.StandardOutput.Trim(); InteractionService.DisplaySuccess($"Updated to version: {version}"); return version; } diff --git a/src/Aspire.Cli/DotNet/DotNetSdkInstaller.cs b/src/Aspire.Cli/DotNet/DotNetSdkInstaller.cs index 95d88d5c198..2940551257e 100644 --- a/src/Aspire.Cli/DotNet/DotNetSdkInstaller.cs +++ b/src/Aspire.Cli/DotNet/DotNetSdkInstaller.cs @@ -27,26 +27,10 @@ internal sealed class DotNetSdkInstaller(IConfiguration configuration) : IDotNet { // Add --arch flag to ensure we only get SDKs that match the current architecture var currentArch = GetCurrentArchitecture(); - var arguments = $"--list-sdks --arch {currentArch}"; - using var process = new Process - { - StartInfo = new ProcessStartInfo - { - FileName = "dotnet", - Arguments = arguments, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - } - }; - - process.Start(); - var output = await process.StandardOutput.ReadToEndAsync(cancellationToken); - await process.WaitForExitAsync(cancellationToken); + var result = await Process.RunAndCaptureTextAsync("dotnet", ["--list-sdks", "--arch", currentArch], cancellationToken); - if (process.ExitCode != 0) + if (result.ExitStatus.ExitCode != 0) { return (false, null, minimumVersion); } @@ -58,7 +42,7 @@ internal sealed class DotNetSdkInstaller(IConfiguration configuration) : IDotNet } // Parse each line of the output to find SDK versions - var lines = output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + var lines = result.StandardOutput.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); SemVersion? highestDetectedVersion = null; bool meetsMinimum = false; diff --git a/src/Aspire.Cli/DotNet/ProcessExecution.cs b/src/Aspire.Cli/DotNet/ProcessExecution.cs index 2f9eb4f5ba5..b59d044cb2d 100644 --- a/src/Aspire.Cli/DotNet/ProcessExecution.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecution.cs @@ -11,15 +11,12 @@ namespace Aspire.Cli.DotNet; /// internal sealed class ProcessExecution : IProcessExecution { - private static readonly TimeSpan s_forwarderIdleTimeout = TimeSpan.FromSeconds(5); - private static readonly TimeSpan s_forwarderPollInterval = TimeSpan.FromMilliseconds(100); + private static readonly TimeSpan s_drainTimeout = TimeSpan.FromSeconds(5); private readonly Process _process; private readonly ILogger _logger; private readonly ProcessInvocationOptions _options; - private Task? _stdoutForwarder; - private Task? _stderrForwarder; - private long _lastForwarderActivityTimestamp = Stopwatch.GetTimestamp(); + private Task? _readTask; internal ProcessExecution(Process process, ILogger logger, ProcessInvocationOptions options) { @@ -59,23 +56,41 @@ public bool Start() } _logger.LogDebug("{FileName}({ProcessId}) started in {WorkingDirectory}", FileName, _process.Id, _process.StartInfo.WorkingDirectory); - RecordForwarderActivity(); - // Start stream forwarders - _stdoutForwarder = Task.Run(async () => + // Use ReadAllLinesAsync to multiplex stdout and stderr on a single task, + // replacing the previous two-forwarder approach with idle-timeout drain logic. + _readTask = Task.Run(async () => { - await ForwardStreamToLoggerAsync( - _process.StandardOutput, - "stdout", - _options.StandardOutputCallback); - }); - - _stderrForwarder = Task.Run(async () => - { - await ForwardStreamToLoggerAsync( - _process.StandardError, - "stderr", - _options.StandardErrorCallback); + try + { + await foreach (var line in _process.ReadAllLinesAsync()) + { + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogTrace( + "{FileName}({ProcessId}) {Identifier}: {Line}", + FileName, + _process.Id, + line.StandardError ? "stderr" : "stdout", + line.Content + ); + } + + if (line.StandardError) + { + _options.StandardErrorCallback?.Invoke(line.Content); + } + else + { + _options.StandardOutputCallback?.Invoke(line.Content); + } + } + } + catch (ObjectDisposedException) + { + // Stream was closed externally (e.g., after process exit). This is expected. + _logger.LogDebug("{FileName}({ProcessId}) read loop completed - stream was closed", FileName, _process.Id); + } }); return true; @@ -98,26 +113,21 @@ public async Task WaitForExitAsync(CancellationToken cancellationToken) _logger.LogDebug("{FileName}({ProcessId}) exited with code: {ExitCode}", FileName, _process.Id, _process.ExitCode); } - // Give the forwarders a fresh idle window to consume any buffered tail output produced right before exit. - RecordForwarderActivity(); - - // Wait for the stream forwarders to drain naturally first so we don't cut off the - // tail of the process output. In some environments the stream handles can stay open - // after the process exits, so we fall back to closing them only if the forwarders - // stop making progress for the idle timeout. - if (_stdoutForwarder is not null && _stderrForwarder is not null) + // Wait for the read loop to drain any remaining output. In some environments + // the stream handles can stay open after the process exits (e.g., when a grandchild + // holds the handle), so apply a timeout to avoid hanging indefinitely. + if (_readTask is not null) { - var forwardersCompleted = Task.WhenAll([_stdoutForwarder, _stderrForwarder]); - if (!await WaitForForwardersAsync(forwardersCompleted, cancellationToken).ConfigureAwait(false)) + using var drainCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + drainCts.CancelAfter(s_drainTimeout); + try { - _logger.LogDebug("{FileName}({ProcessId}) closing stdout/stderr streams after forwarder idle timeout", FileName, _process.Id); - _process.StandardOutput.Close(); - _process.StandardError.Close(); - - if (!await WaitForForwardersAsync(forwardersCompleted, cancellationToken).ConfigureAwait(false)) - { - _logger.LogWarning("{FileName}({ProcessId}) stream forwarders did not complete within idle timeout after stream close", FileName, _process.Id); - } + await _readTask.WaitAsync(drainCts.Token).ConfigureAwait(false); + _logger.LogDebug("{FileName}({ProcessId}) read loop completed", FileName, _process.Id); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogWarning("{FileName}({ProcessId}) read loop did not complete within drain timeout", FileName, _process.Id); } } @@ -135,73 +145,4 @@ public void Dispose() { _process.Dispose(); } - - private async Task ForwardStreamToLoggerAsync(StreamReader reader, string identifier, Action? lineCallback) - { - _logger.LogDebug( - "{FileName}({ProcessId}) starting to forward {Identifier} stream", - FileName, - _process.Id, - identifier - ); - - try - { - string? line; - while ((line = await reader.ReadLineAsync()) is not null) - { - RecordForwarderActivity(); - - if (_logger.IsEnabled(LogLevel.Trace)) - { - _logger.LogTrace( - "{FileName}({ProcessId}) {Identifier}: {Line}", - FileName, - _process.Id, - identifier, - line - ); - } - lineCallback?.Invoke(line); - RecordForwarderActivity(); - } - } - catch (ObjectDisposedException) - { - // Stream was closed externally (e.g., after process exit). This is expected. - _logger.LogDebug("{FileName}({ProcessId}) {Identifier} stream forwarder completed - stream was closed", FileName, _process.Id, identifier); - } - } - - private async Task WaitForForwardersAsync(Task forwardersCompleted, CancellationToken cancellationToken) - { - while (true) - { - if (forwardersCompleted.IsCompleted) - { - await forwardersCompleted.ConfigureAwait(false); - _logger.LogDebug("{FileName}({ProcessId}) forwarders completed", FileName, _process.Id); - return true; - } - - if (Stopwatch.GetElapsedTime(Interlocked.Read(ref _lastForwarderActivityTimestamp)) >= s_forwarderIdleTimeout) - { - return false; - } - - try - { - await Task.Delay(s_forwarderPollInterval, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - return false; - } - } - } - - private void RecordForwarderActivity() - { - Interlocked.Exchange(ref _lastForwarderActivityTimestamp, Stopwatch.GetTimestamp()); - } } diff --git a/src/Aspire.Cli/Git/GitRepository.cs b/src/Aspire.Cli/Git/GitRepository.cs index 974a136ded3..14c058cad8e 100644 --- a/src/Aspire.Cli/Git/GitRepository.cs +++ b/src/Aspire.Cli/Git/GitRepository.cs @@ -33,30 +33,21 @@ internal sealed class GitRepository(CliExecutionContext executionContext, ILogge startInfo.ArgumentList.Add("rev-parse"); startInfo.ArgumentList.Add("--show-toplevel"); - using var process = new Process { StartInfo = startInfo }; using var activity = profilingTelemetry.StartGitCommand("rev-parse", startInfo.ArgumentList.Count, executionContext.WorkingDirectory); - process.Start(); - activity.SetProcessId(process.Id); + var result = await Process.RunAndCaptureTextAsync(startInfo, cancellationToken).ConfigureAwait(false); + activity.SetProcessId(result.ProcessId); + activity.SetProcessExitCode(result.ExitStatus.ExitCode); + activity.SetGitOutputLengths(result.StandardOutput.Length, result.StandardError.Length); - var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); - - await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); - activity.SetProcessExitCode(process.ExitCode); - - var output = await outputTask.ConfigureAwait(false); - var errorOutput = await errorTask.ConfigureAwait(false); - activity.SetGitOutputLengths(output.Length, errorOutput.Length); - - if (process.ExitCode != 0) + if (result.ExitStatus.ExitCode != 0) { - activity.SetError($"git rev-parse exited with code {process.ExitCode}."); - logger.LogDebug("Git command returned non-zero exit code {ExitCode}: {Error}", process.ExitCode, errorOutput.Trim()); + activity.SetError($"git rev-parse exited with code {result.ExitStatus.ExitCode}."); + logger.LogDebug("Git command returned non-zero exit code {ExitCode}: {Error}", result.ExitStatus.ExitCode, result.StandardError.Trim()); return null; } - var rootPath = output.Trim(); + var rootPath = result.StandardOutput.Trim(); if (string.IsNullOrEmpty(rootPath)) { @@ -116,26 +107,17 @@ internal sealed class GitRepository(CliExecutionContext executionContext, ILogge startInfo.ArgumentList.Add("--exclude-standard"); startInfo.ArgumentList.Add("-z"); - using var process = new Process { StartInfo = startInfo }; using var activity = profilingTelemetry.StartGitCommand("ls-files", startInfo.ArgumentList.Count, searchRoot); - process.Start(); - activity.SetProcessId(process.Id); - - var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); - - await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); - activity.SetProcessExitCode(process.ExitCode); - - var output = await outputTask.ConfigureAwait(false); - var errorOutput = await errorTask.ConfigureAwait(false); - activity.SetGitOutputLengths(output.Length, errorOutput.Length); + var result = await Process.RunAndCaptureTextAsync(startInfo, cancellationToken).ConfigureAwait(false); + activity.SetProcessId(result.ProcessId); + activity.SetProcessExitCode(result.ExitStatus.ExitCode); + activity.SetGitOutputLengths(result.StandardOutput.Length, result.StandardError.Length); - if (process.ExitCode != 0) + if (result.ExitStatus.ExitCode != 0) { - activity.SetError($"git ls-files exited with code {process.ExitCode}."); - logger.LogDebug("git ls-files returned non-zero exit code {ExitCode} from {SearchRoot}: {Error}", process.ExitCode, searchRoot.FullName, errorOutput.Trim()); + activity.SetError($"git ls-files exited with code {result.ExitStatus.ExitCode}."); + logger.LogDebug("git ls-files returned non-zero exit code {ExitCode} from {SearchRoot}: {Error}", result.ExitStatus.ExitCode, searchRoot.FullName, result.StandardError.Trim()); return null; } @@ -145,7 +127,7 @@ internal sealed class GitRepository(CliExecutionContext executionContext, ILogge var includedFiles = new HashSet(pathComparer); var rootFullName = searchRoot.FullName; - foreach (var rawPath in output.Split('\0', StringSplitOptions.RemoveEmptyEntries)) + foreach (var rawPath in result.StandardOutput.Split('\0', StringSplitOptions.RemoveEmptyEntries)) { // git always emits paths with '/' separators; normalize to the OS separator. var relativePath = Path.DirectorySeparatorChar == '/' diff --git a/src/Aspire.Cli/Npm/NpmRunner.cs b/src/Aspire.Cli/Npm/NpmRunner.cs index f768db40d2d..ce69f622b87 100644 --- a/src/Aspire.Cli/Npm/NpmRunner.cs +++ b/src/Aspire.Cli/Npm/NpmRunner.cs @@ -354,26 +354,19 @@ internal static bool TryExtractLastVersion(string npmOutput, [NotNullWhen(true)] { var startInfo = CreateNpmProcessStartInfo(npmPath, args, workingDirectory); - using var process = new Process { StartInfo = startInfo }; using var activity = profilingTelemetry.StartNpmCommand(npmPath, args.Length, workingDirectory); - process.Start(); - activity.SetProcessId(process.Id); + var result = await Process.RunAndCaptureTextAsync(startInfo, cancellationToken).ConfigureAwait(false); + activity.SetProcessId(result.ProcessId); + activity.SetProcessExitCode(result.ExitStatus.ExitCode); - var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); - - await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); - activity.SetProcessExitCode(process.ExitCode); - - if (process.ExitCode != 0) + if (result.ExitStatus.ExitCode != 0) { - activity.SetError($"npm exited with code {process.ExitCode}."); - var errorOutput = await errorTask.ConfigureAwait(false); - logger.LogDebug("npm {Args} returned non-zero exit code {ExitCode}: {Error}", argsString, process.ExitCode, errorOutput.Trim()); + activity.SetError($"npm exited with code {result.ExitStatus.ExitCode}."); + logger.LogDebug("npm {Args} returned non-zero exit code {ExitCode}: {Error}", argsString, result.ExitStatus.ExitCode, result.StandardError.Trim()); return null; } - return await outputTask.ConfigureAwait(false); + return result.StandardOutput; } catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) { diff --git a/src/Aspire.Cli/OpenCode/OpenCodeCliRunner.cs b/src/Aspire.Cli/OpenCode/OpenCodeCliRunner.cs index 7a0aff6c1ef..75bef458b73 100644 --- a/src/Aspire.Cli/OpenCode/OpenCodeCliRunner.cs +++ b/src/Aspire.Cli/OpenCode/OpenCodeCliRunner.cs @@ -20,32 +20,15 @@ internal sealed class OpenCodeCliRunner(ILogger logger) : IOp try { - var startInfo = new ProcessStartInfo("opencode", "--version") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var process = new Process { StartInfo = startInfo }; - - process.Start(); - - var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); - - await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + var result = await Process.RunAndCaptureTextAsync("opencode", ["--version"], cancellationToken).ConfigureAwait(false); - if (process.ExitCode != 0) + if (result.ExitStatus.ExitCode != 0) { - var errorOutput = await errorTask.ConfigureAwait(false); - logger.LogDebug("OpenCode CLI returned non-zero exit code {ExitCode}: {Error}", process.ExitCode, errorOutput.Trim()); + logger.LogDebug("OpenCode CLI returned non-zero exit code {ExitCode}: {Error}", result.ExitStatus.ExitCode, result.StandardError.Trim()); return null; } - var output = await outputTask.ConfigureAwait(false); - var versionString = output.Trim(); + var versionString = result.StandardOutput.Trim(); if (string.IsNullOrEmpty(versionString)) { @@ -65,7 +48,7 @@ internal sealed class OpenCodeCliRunner(ILogger logger) : IOp return version; } - logger.LogDebug("Could not parse OpenCode CLI version from output: {Output}", output.Trim()); + logger.LogDebug("Could not parse OpenCode CLI version from output: {Output}", result.StandardOutput.Trim()); return null; } catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) diff --git a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs deleted file mode 100644 index 0914b30c133..00000000000 --- a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs +++ /dev/null @@ -1,76 +0,0 @@ -// 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; - -namespace Aspire.Cli.Processes; - -internal static partial class DetachedProcessLauncher -{ - /// - /// Unix implementation using Process.Start with stdio redirection. - /// On Linux/macOS, the redirect pipes' original fds are created with O_CLOEXEC, - /// but dup2 onto fd 0/1/2 clears that flag — so grandchildren DO inherit the pipe - /// as their stdio. However, since we close the parent's read-end immediately, the - /// pipe has no reader and writes produce EPIPE (harmless). The key difference from - /// Windows is that on Unix, only fds 0/1/2 survive exec — no extra handle leakage. - /// - private static Process StartUnix(string fileName, IReadOnlyList arguments, string workingDirectory, Func? shouldRemoveEnvironmentVariable, IReadOnlyDictionary? additionalEnvironmentVariables) - { - var startInfo = new ProcessStartInfo - { - FileName = fileName, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - RedirectStandardInput = false, - WorkingDirectory = workingDirectory - }; - - foreach (var arg in arguments) - { - startInfo.ArgumentList.Add(arg); - } - - // Remove specified environment variables from the child process. - // Accessing startInfo.Environment auto-populates from the current process. - if (shouldRemoveEnvironmentVariable is not null) - { - var keysToRemove = new List(); - foreach (var key in startInfo.Environment.Keys) - { - if (shouldRemoveEnvironmentVariable(key)) - { - keysToRemove.Add(key); - } - } - - foreach (var key in keysToRemove) - { - startInfo.Environment.Remove(key); - } - } - - // Add additional environment variables to the child process without mutating the parent. - if (additionalEnvironmentVariables is not null) - { - foreach (var (key, value) in additionalEnvironmentVariables) - { - startInfo.Environment[key] = value; - } - } - - var process = Process.Start(startInfo) - ?? throw new InvalidOperationException("Failed to start detached process"); - - // Close the parent's read-end of the pipes. This means the pipe has no reader, - // so if the grandchild (AppHost) writes to inherited stdout/stderr, it gets EPIPE - // which is harmless. The important thing is no caller is blocked waiting on the - // pipe — unlike Windows where the handle stays open and blocks execSync callers. - process.StandardOutput.Close(); - process.StandardError.Close(); - - return process; - } -} diff --git a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs deleted file mode 100644 index f197f51d04e..00000000000 --- a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs +++ /dev/null @@ -1,413 +0,0 @@ -// 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.Runtime.InteropServices; -using System.Runtime.Versioning; -using System.Text; -using Microsoft.Win32.SafeHandles; - -namespace Aspire.Cli.Processes; - -internal static partial class DetachedProcessLauncher -{ - /// - /// Windows implementation using CreateProcess with CREATE_NEW_CONSOLE, - /// STARTUPINFOEX, SW_HIDE, and PROC_THREAD_ATTRIBUTE_HANDLE_LIST - /// to detach from the launching console and prevent handle inheritance to grandchildren. - /// - [SupportedOSPlatform("windows")] - private static Process StartWindows(string fileName, IReadOnlyList arguments, string workingDirectory, Func? shouldRemoveEnvironmentVariable, IReadOnlyDictionary? additionalEnvironmentVariables) - { - // Open NUL device for stdout/stderr — child writes go nowhere - using var nulHandle = CreateFileW( - "NUL", - GenericWrite, - FileShareWrite, - nint.Zero, - OpenExisting, - 0, - nint.Zero); - - if (nulHandle.IsInvalid) - { - throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to open NUL device"); - } - - // Mark the NUL handle as inheritable (required for STARTUPINFO hStdOutput assignment) - if (!SetHandleInformation(nulHandle, HandleFlagInherit, HandleFlagInherit)) - { - throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to set NUL handle inheritance"); - } - - // Initialize a process thread attribute list with 1 slot (HANDLE_LIST) - var attrListSize = nint.Zero; - InitializeProcThreadAttributeList(nint.Zero, 1, 0, ref attrListSize); - - var attrList = Marshal.AllocHGlobal(attrListSize); - try - { - if (!InitializeProcThreadAttributeList(attrList, 1, 0, ref attrListSize)) - { - throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to initialize process thread attribute list"); - } - - try - { - // Whitelist only the NUL handle for inheritance. - // The grandchild (AppHost) will inherit this harmless handle instead of - // any pipes from the caller's process tree. - var handles = new[] { nulHandle.DangerousGetHandle() }; - var pinnedHandles = GCHandle.Alloc(handles, GCHandleType.Pinned); - try - { - if (!UpdateProcThreadAttribute( - attrList, - 0, - s_procThreadAttributeHandleList, - pinnedHandles.AddrOfPinnedObject(), - (nint)(nint.Size * handles.Length), - nint.Zero, - nint.Zero)) - { - throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to update process thread attribute list"); - } - - var nulRawHandle = nulHandle.DangerousGetHandle(); - - var si = new STARTUPINFOEX(); - si.cb = Marshal.SizeOf(); - si.dwFlags = StartfUseStdHandles | StartfUseShowWindow; - si.hStdInput = nint.Zero; - si.hStdOutput = nulRawHandle; - si.hStdError = nulRawHandle; - si.lpAttributeList = attrList; - // CREATE_NO_WINDOW is ignored with CREATE_NEW_CONSOLE; hide the independent - // console through STARTUPINFO instead. - si.wShowWindow = ShowWindowHide; - - // Build the command line string: "fileName" arg1 arg2 ... - var commandLine = BuildCommandLine(fileName, arguments); - - var flags = WindowsDetachedProcessCreationFlags; - - // Build a custom environment block if variables need to be removed or added. - // CreateProcessW with lpEnvironment=nint.Zero inherits the parent's - // environment, so we only build a custom block when customization is needed. - var envBlockHandle = nint.Zero; - try - { - if (shouldRemoveEnvironmentVariable is not null || additionalEnvironmentVariables is not null) - { - envBlockHandle = BuildCustomEnvironmentBlock(shouldRemoveEnvironmentVariable, additionalEnvironmentVariables); - } - - if (!CreateProcessW( - null, - commandLine, - nint.Zero, - nint.Zero, - bInheritHandles: true, // TRUE but HANDLE_LIST restricts what's actually inherited - flags, - envBlockHandle, - workingDirectory, - ref si, - out var pi)) - { - throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to create detached process"); - } - - Process detachedProcess; - try - { - detachedProcess = Process.GetProcessById(pi.dwProcessId); - } - finally - { - // Close the process and thread handles returned by CreateProcess. - CloseHandle(pi.hProcess); - CloseHandle(pi.hThread); - } - - return detachedProcess; - } - finally - { - if (envBlockHandle != nint.Zero) - { - Marshal.FreeHGlobal(envBlockHandle); - } - } - } - finally - { - pinnedHandles.Free(); - } - } - finally - { - DeleteProcThreadAttributeList(attrList); - } - } - finally - { - Marshal.FreeHGlobal(attrList); - } - } - - /// - /// Builds a Windows command line string with correct quoting rules. - /// Adapted from dotnet/runtime PasteArguments.AppendArgument. - /// - private static StringBuilder BuildCommandLine(string fileName, IReadOnlyList arguments) - { - var sb = new StringBuilder(); - - // Quote the executable path - sb.Append('"').Append(fileName).Append('"'); - - foreach (var arg in arguments) - { - sb.Append(' '); - AppendArgument(sb, arg); - } - - return sb; - } - - /// - /// Appends a correctly-quoted argument to the command line. - /// Copied from dotnet/runtime src/libraries/System.Private.CoreLib/src/System/PasteArguments.cs - /// - private static void AppendArgument(StringBuilder sb, string argument) - { - // Windows command-line parsing rules: - // - Backslash is normal except when followed by a quote - // - 2N backslashes + quote → N literal backslashes + unescaped quote - // - 2N+1 backslashes + quote → N literal backslashes + literal quote - if (argument.Length != 0 && !argument.AsSpan().ContainsAny(' ', '\t', '"')) - { - sb.Append(argument); - return; - } - - sb.Append('"'); - var idx = 0; - while (idx < argument.Length) - { - var c = argument[idx++]; - if (c == '\\') - { - var numBackslash = 1; - while (idx < argument.Length && argument[idx] == '\\') - { - idx++; - numBackslash++; - } - - if (idx == argument.Length) - { - // Trailing backslashes before closing quote — must double them - sb.Append('\\', numBackslash * 2); - } - else if (argument[idx] == '"') - { - // Backslashes followed by quote — double them + escape the quote - sb.Append('\\', numBackslash * 2 + 1); - sb.Append('"'); - idx++; - } - else - { - // Backslashes not followed by quote — emit as-is - sb.Append('\\', numBackslash); - } - - continue; - } - - if (c == '"') - { - sb.Append('\\'); - sb.Append('"'); - continue; - } - - sb.Append(c); - } - - sb.Append('"'); - } - - /// - /// Builds a Unicode environment block for CreateProcessW with specified variables - /// removed and/or added. The block is sorted by variable name (case-insensitive, - /// as required by Windows) and double-null-terminated. The caller must free the - /// returned pointer with Marshal.FreeHGlobal. - /// - [SupportedOSPlatform("windows")] - private static nint BuildCustomEnvironmentBlock(Func? shouldRemove, IReadOnlyDictionary? additionalVariables) - { - // Collect current environment variables, excluding the ones to remove. - var envVars = new SortedDictionary(StringComparer.OrdinalIgnoreCase); - foreach (System.Collections.DictionaryEntry entry in Environment.GetEnvironmentVariables()) - { - var key = (string)entry.Key; - if (shouldRemove is null || !shouldRemove(key)) - { - envVars[key] = (string?)entry.Value ?? string.Empty; - } - } - - // Add additional variables (overwrites any existing keys with the same name). - if (additionalVariables is not null) - { - foreach (var (key, value) in additionalVariables) - { - envVars[key] = value; - } - } - - // Build the double-null-terminated Unicode environment block: - // KEY1=VALUE1\0KEY2=VALUE2\0...\0\0 - var blockBuilder = new StringBuilder(); - foreach (var kvp in envVars) - { - blockBuilder.Append(kvp.Key); - blockBuilder.Append('='); - blockBuilder.Append(kvp.Value); - blockBuilder.Append('\0'); - } - - if (envVars.Count == 0) - { - blockBuilder.Append('\0'); - } - - blockBuilder.Append('\0'); // Final terminator - - var blockString = blockBuilder.ToString(); - var byteCount = Encoding.Unicode.GetByteCount(blockString); - var ptr = Marshal.AllocHGlobal(byteCount); - unsafe - { - fixed (char* pStr = blockString) - { - Encoding.Unicode.GetBytes(pStr, blockString.Length, (byte*)ptr, byteCount); - } - } - - return ptr; - } - - // --- Constants --- - private const uint GenericWrite = 0x40000000; - private const uint FileShareWrite = 0x00000002; - private const uint OpenExisting = 3; - private const uint HandleFlagInherit = 0x00000001; - private const uint StartfUseStdHandles = 0x00000100; - private const uint StartfUseShowWindow = 0x00000001; - private const uint CreateUnicodeEnvironment = 0x00000400; - private const uint ExtendedStartupInfoPresent = 0x00080000; - private const uint CreateNewConsole = 0x00000010; - private const uint WindowsDetachedProcessCreationFlags = - CreateUnicodeEnvironment | ExtendedStartupInfoPresent | CreateNewConsole; - private const ushort ShowWindowHide = 0x0000; - private static readonly nint s_procThreadAttributeHandleList = (nint)0x00020002; - - // --- Structs --- - - [StructLayout(LayoutKind.Sequential)] - private struct STARTUPINFOEX - { - public int cb; - public nint lpReserved; - public nint lpDesktop; - public nint lpTitle; - public int dwX; - public int dwY; - public int dwXSize; - public int dwYSize; - public int dwXCountChars; - public int dwYCountChars; - public int dwFillAttribute; - public uint dwFlags; - public ushort wShowWindow; - public ushort cbReserved2; - public nint lpReserved2; - public nint hStdInput; - public nint hStdOutput; - public nint hStdError; - public nint lpAttributeList; - } - - [StructLayout(LayoutKind.Sequential)] - private struct PROCESS_INFORMATION - { - public nint hProcess; - public nint hThread; - public int dwProcessId; - public int dwThreadId; - } - - // --- P/Invoke declarations --- - - [LibraryImport("kernel32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] - private static partial SafeFileHandle CreateFileW( - string lpFileName, - uint dwDesiredAccess, - uint dwShareMode, - nint lpSecurityAttributes, - uint dwCreationDisposition, - uint dwFlagsAndAttributes, - nint hTemplateFile); - - [LibraryImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static partial bool SetHandleInformation( - SafeFileHandle hObject, - uint dwMask, - uint dwFlags); - - [LibraryImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static partial bool InitializeProcThreadAttributeList( - nint lpAttributeList, - int dwAttributeCount, - int dwFlags, - ref nint lpSize); - - [LibraryImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static partial bool UpdateProcThreadAttribute( - nint lpAttributeList, - uint dwFlags, - nint attribute, - nint lpValue, - nint cbSize, - nint lpPreviousValue, - nint lpReturnSize); - - [LibraryImport("kernel32.dll", SetLastError = true)] - private static partial void DeleteProcThreadAttributeList(nint lpAttributeList); - - [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] -#pragma warning disable CA1838 // CreateProcessW requires a mutable command line buffer - private static extern bool CreateProcessW( - string? lpApplicationName, - StringBuilder lpCommandLine, - nint lpProcessAttributes, - nint lpThreadAttributes, - bool bInheritHandles, - uint dwCreationFlags, - nint lpEnvironment, - string? lpCurrentDirectory, - ref STARTUPINFOEX lpStartupInfo, - out PROCESS_INFORMATION lpProcessInformation); -#pragma warning restore CA1838 - - [LibraryImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static partial bool CloseHandle(nint hObject); -} diff --git a/src/Aspire.Cli/Processes/DetachedProcessLauncher.cs b/src/Aspire.Cli/Processes/DetachedProcessLauncher.cs index 1caa6d10e54..03f2d5ea7fb 100644 --- a/src/Aspire.Cli/Processes/DetachedProcessLauncher.cs +++ b/src/Aspire.Cli/Processes/DetachedProcessLauncher.cs @@ -23,43 +23,23 @@ namespace Aspire.Cli.Processes; // stdout to close (e.g. Node.js `execSync`, shell `$(...)` substitution) // will hang until the AppHost exits — which defeats the purpose of --detach. // -// These two constraints conflict when using .NET's Process.Start: +// The .NET 11 Process APIs provide a clean cross-platform solution: // -// • RedirectStandardOutput = true → solves (1) but violates (2) on Windows, -// because .NET calls CreateProcess with bInheritHandles=TRUE, and the pipe -// write-handle is duplicated into the child. The child passes it to the -// grandchild (AppHost), keeping the pipe alive. +// • StandardOutputHandle / StandardErrorHandle = File.OpenNullHandle() +// → sends child output to the null device, solving (1). // -// • RedirectStandardOutput = false → solves (2) but violates (1), because -// the child inherits the parent's console and writes directly to it. -// -// The solution is platform-specific: -// -// ┌─────────┬────────────────────────────────────────────────────────────────┐ -// │ Windows │ P/Invoke CreateProcess with CREATE_NEW_CONSOLE, │ -// │ │ STARTUPINFOEX, SW_HIDE, and an explicit │ -// │ │ PROC_THREAD_ATTRIBUTE_HANDLE_LIST. This gives the child an │ -// │ │ independent console lifetime while still letting us set │ -// │ │ bInheritHandles=TRUE (required to assign hStdOutput to NUL) │ -// │ │ and restrict inheritance to ONLY the NUL handle — so the │ -// │ │ grandchild inherits nothing useful. Child stdout/stderr go to │ -// │ │ the NUL device. │ -// │ │ │ -// │ Linux / │ Process.Start with RedirectStandard{Output,Error} = true, │ -// │ macOS │ then immediately close the parent's read-end pipe streams. │ -// │ │ The original pipe fds have O_CLOEXEC, but dup2 onto fd 0/1/2 │ -// │ │ clears it — so grandchildren inherit the pipe as their stdio. │ -// │ │ With no reader, writes produce harmless EPIPE. The critical │ -// │ │ difference from Windows is that no caller gets stuck waiting │ -// │ │ on a pipe handle — closing the read-end is sufficient. │ -// └─────────┴────────────────────────────────────────────────────────────────┘ +// • InheritedHandles = [] → restricts handle inheritance to only the +// standard handles (the NUL handles), preventing accidental leaks to +// grandchildren, solving (2). Note: InheritedHandles is not supported +// on macOS, but on Unix only fds 0/1/2 survive exec, so there is no +// accidental handle leakage to grandchildren anyway. // /// /// Launches a child process with stdout/stderr suppressed and no handle/fd /// inheritance to grandchild processes. Used by aspire start. /// -internal static partial class DetachedProcessLauncher +internal static class DetachedProcessLauncher { /// /// Starts a detached child process with stdout/stderr going to the null device @@ -73,11 +53,63 @@ internal static partial class DetachedProcessLauncher /// A object representing the launched child. public static Process Start(string fileName, IReadOnlyList arguments, string workingDirectory, Func? shouldRemoveEnvironmentVariable = null, IReadOnlyDictionary? additionalEnvironmentVariables = null) { - if (OperatingSystem.IsWindows()) + using var nullHandle = File.OpenNullHandle(); + + var startInfo = new ProcessStartInfo + { + FileName = fileName, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = workingDirectory, + // Redirect stdout/stderr to the null device so the child's output + // never appears on the parent's console. + StandardOutputHandle = nullHandle, + StandardErrorHandle = nullHandle, + }; + + // On Windows, restrict handle inheritance to only the standard handles (the NUL + // handles) so the grandchild (AppHost) doesn't inherit any pipes from the parent. + // On macOS InheritedHandles is not supported, but on Unix only fds 0/1/2 survive + // exec so there is no accidental handle leakage to grandchildren anyway. + if (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()) + { + startInfo.InheritedHandles = []; + } + + foreach (var arg in arguments) + { + startInfo.ArgumentList.Add(arg); + } + + // Remove specified environment variables from the child process. + // Accessing startInfo.Environment auto-populates from the current process. + if (shouldRemoveEnvironmentVariable is not null) + { + var keysToRemove = new List(); + foreach (var key in startInfo.Environment.Keys) + { + if (shouldRemoveEnvironmentVariable(key)) + { + keysToRemove.Add(key); + } + } + + foreach (var key in keysToRemove) + { + startInfo.Environment.Remove(key); + } + } + + // Add additional environment variables to the child process without mutating the parent. + if (additionalEnvironmentVariables is not null) { - return StartWindows(fileName, arguments, workingDirectory, shouldRemoveEnvironmentVariable, additionalEnvironmentVariables); + foreach (var (key, value) in additionalEnvironmentVariables) + { + startInfo.Environment[key] = value; + } } - return StartUnix(fileName, arguments, workingDirectory, shouldRemoveEnvironmentVariable, additionalEnvironmentVariables); + return Process.Start(startInfo) + ?? throw new InvalidOperationException("Failed to start detached process"); } } diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index 67540c8ddfa..99fa512b8fe 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -514,6 +514,10 @@ public async Task PrepareAsync( outputCollector.AppendError(e.Data); } }; + // BeginOutputReadLine/BeginErrorReadLine is used here (instead of ReadAllLinesAsync) because + // the process is long-lived: this method returns immediately while the AppHost server continues + // running. ReadAllLinesAsync would require an async enumeration loop that runs for the entire + // process lifetime, which doesn't fit this fire-and-return pattern. process.BeginOutputReadLine(); process.BeginErrorReadLine(); @@ -524,7 +528,7 @@ public async Task PrepareAsync( { try { - var startInfo = new ProcessStartInfo("dotnet") + var result = Process.RunAndCaptureText(new ProcessStartInfo("dotnet") { Arguments = "nuget config paths", WorkingDirectory = workingDirectory, @@ -532,23 +536,14 @@ public async Task PrepareAsync( RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true - }; + }); - using var process = Process.Start(startInfo); - if (process is null) + if (result.ExitStatus.ExitCode != 0) { return null; } - var output = process.StandardOutput.ReadToEnd(); - process.WaitForExit(); - - if (process.ExitCode != 0) - { - return null; - } - - var configPaths = output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + var configPaths = result.StandardOutput.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); var workingDirFullPath = Path.GetFullPath(workingDirectory); var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var globalNuGetPath = Path.Combine(userProfile, ".nuget"); diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index df04aabc075..623c13d066b 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -537,6 +537,10 @@ internal static string GenerateIntegrationProjectFile( } }; + // BeginOutputReadLine/BeginErrorReadLine is used here (instead of ReadAllLinesAsync) because + // the process is long-lived: this method returns immediately while the AppHost server continues + // running. ReadAllLinesAsync would require an async enumeration loop that runs for the entire + // process lifetime, which doesn't fit this fire-and-return pattern. process.BeginOutputReadLine(); process.BeginErrorReadLine(); diff --git a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs index 3487afa415c..0cab8be2c83 100644 --- a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs +++ b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs @@ -63,67 +63,29 @@ public ProcessGuestLauncher(string language, ILogger logger, FileLoggerProvider? startInfo.EnvironmentVariables[key] = value; } - using var process = new Process { StartInfo = startInfo }; + using var process = Process.Start(startInfo) + ?? throw new InvalidOperationException($"Failed to start process: {resolvedCommand}"); var outputCollector = new OutputCollector(_fileLoggerProvider, "AppHost"); - var stdoutCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var stderrCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - process.OutputDataReceived += (sender, e) => + // Use ReadAllLinesAsync to multiplex stdout and stderr on a single loop, + // replacing the previous OutputDataReceived/ErrorDataReceived event-based approach. + await foreach (var line in process.ReadAllLinesAsync(cancellationToken)) { - if (e.Data is null) + if (line.StandardError) { - // ProcessDataReceivedEventArgs.Data is null when the redirected stdout stream closes. - stdoutCompleted.TrySetResult(); + _logger.LogTrace("{Language}({ProcessId}) stderr: {Line}", _language, process.Id, line.Content); + outputCollector.AppendError(line.Content); } else { - _logger.LogTrace("{Language}({ProcessId}) stdout: {Line}", _language, process.Id, e.Data); - outputCollector.AppendOutput(e.Data); + _logger.LogTrace("{Language}({ProcessId}) stdout: {Line}", _language, process.Id, line.Content); + outputCollector.AppendOutput(line.Content); } - }; - - process.ErrorDataReceived += (sender, e) => - { - if (e.Data is null) - { - // ProcessDataReceivedEventArgs.Data is null when the redirected stderr stream closes. - stderrCompleted.TrySetResult(); - } - else - { - _logger.LogTrace("{Language}({ProcessId}) stderr: {Line}", _language, process.Id, e.Data); - outputCollector.AppendError(e.Data); - } - }; - - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); + } await process.WaitForExitAsync(cancellationToken); - // Wait for the redirected streams to finish draining so no trailing lines are lost. - if (!await WaitForDrainAsync(Task.WhenAll(stdoutCompleted.Task, stderrCompleted.Task), cancellationToken)) - { - _logger.LogWarning("{Language}({ProcessId}): Timed out waiting for output streams to drain after process exit", _language, process.Id); - } - return (process.ExitCode, outputCollector); } - - private static async Task WaitForDrainAsync(Task drainTask, CancellationToken cancellationToken) - { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(TimeSpan.FromSeconds(5)); - try - { - await drainTask.WaitAsync(timeoutCts.Token); - return true; - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - return false; - } - } } diff --git a/src/Aspire.Cli/Utils/CliDownloader.cs b/src/Aspire.Cli/Utils/CliDownloader.cs index 8ae84a0a130..38d85d36cd2 100644 --- a/src/Aspire.Cli/Utils/CliDownloader.cs +++ b/src/Aspire.Cli/Utils/CliDownloader.cs @@ -142,23 +142,11 @@ private static string DetectOperatingSystem() var lddPath = "/usr/bin/ldd"; if (File.Exists(lddPath)) { - var psi = new ProcessStartInfo + var result = Process.RunAndCaptureText(lddPath, ["--version"]); + if (result.ExitStatus.ExitCode == 0 && + (result.StandardOutput + result.StandardError).Contains("musl", StringComparison.OrdinalIgnoreCase)) { - FileName = lddPath, - Arguments = "--version", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - using var process = Process.Start(psi); - if (process is not null) - { - var output = process.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd(); - process.WaitForExit(); - if (output.Contains("musl", StringComparison.OrdinalIgnoreCase)) - { - return "linux-musl"; - } + return "linux-musl"; } } } diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/DeprecatedWorkloadCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/DeprecatedWorkloadCheck.cs index c9feddf5f6d..4799ca0fae6 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/DeprecatedWorkloadCheck.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/DeprecatedWorkloadCheck.cs @@ -23,43 +23,23 @@ public async Task> CheckAsync(Cancellation { try { - var processInfo = new ProcessStartInfo - { - FileName = "dotnet", - Arguments = "workload list", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var process = Process.Start(processInfo); - if (process is null) - { - logger.LogDebug("Failed to start dotnet workload list process"); - // Don't fail the check if we can't run the command - the SDK check will catch SDK issues - return []; - } - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(s_processTimeout); - string output; + ProcessTextOutput result; try { - output = await process.StandardOutput.ReadToEndAsync(timeoutCts.Token); - await process.WaitForExitAsync(timeoutCts.Token); + result = await Process.RunAndCaptureTextAsync("dotnet", ["workload", "list"], timeoutCts.Token); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { - process.Kill(); logger.LogDebug("dotnet workload list timed out"); return []; } - if (process.ExitCode != 0) + if (result.ExitStatus.ExitCode != 0) { - logger.LogDebug("dotnet workload list exited with code {ExitCode}", process.ExitCode); + logger.LogDebug("dotnet workload list exited with code {ExitCode}", result.ExitStatus.ExitCode); return []; } @@ -68,7 +48,7 @@ public async Task> CheckAsync(Cancellation // Installed Workload Id Manifest Version Installation Source // -------------------------------------------------------------------- // aspire 8.0.0/8.0.100 SDK 8.0.100 - if (IsAspireWorkloadInstalled(output)) + if (IsAspireWorkloadInstalled(result.StandardOutput)) { return [new EnvironmentCheckResult {