From 08c1c4af142871af2ca1f8d7ac370414acec784e Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 10 Feb 2026 00:51:58 -0800 Subject: [PATCH 1/8] Improve detach mode: fix pipe handle inheritance and unify log naming --- src/Aspire.Cli/Commands/CacheCommand.cs | 9 +-- src/Aspire.Cli/Commands/RunCommand.cs | 73 ++++++++++--------- .../Diagnostics/FileLoggerProvider.cs | 21 +++++- src/Aspire.Cli/Program.cs | 27 ++++++- .../Resources/RunCommandStrings.Designer.cs | 6 ++ .../Resources/RunCommandStrings.resx | 3 + .../Resources/xlf/RunCommandStrings.cs.xlf | 5 ++ .../Resources/xlf/RunCommandStrings.de.xlf | 5 ++ .../Resources/xlf/RunCommandStrings.es.xlf | 5 ++ .../Resources/xlf/RunCommandStrings.fr.xlf | 5 ++ .../Resources/xlf/RunCommandStrings.it.xlf | 5 ++ .../Resources/xlf/RunCommandStrings.ja.xlf | 5 ++ .../Resources/xlf/RunCommandStrings.ko.xlf | 5 ++ .../Resources/xlf/RunCommandStrings.pl.xlf | 5 ++ .../Resources/xlf/RunCommandStrings.pt-BR.xlf | 5 ++ .../Resources/xlf/RunCommandStrings.ru.xlf | 5 ++ .../Resources/xlf/RunCommandStrings.tr.xlf | 5 ++ .../xlf/RunCommandStrings.zh-Hans.xlf | 5 ++ .../xlf/RunCommandStrings.zh-Hant.xlf | 5 ++ 19 files changed, 161 insertions(+), 43 deletions(-) diff --git a/src/Aspire.Cli/Commands/CacheCommand.cs b/src/Aspire.Cli/Commands/CacheCommand.cs index eef3d1501b7..ef69f774a60 100644 --- a/src/Aspire.Cli/Commands/CacheCommand.cs +++ b/src/Aspire.Cli/Commands/CacheCommand.cs @@ -45,7 +45,7 @@ protected override Task ExecuteAsync(ParseResult parseResult, CancellationT { var cacheDirectory = ExecutionContext.CacheDirectory; var filesDeleted = 0; - + // Delete cache files and subdirectories if (cacheDirectory.Exists) { @@ -110,14 +110,13 @@ protected override Task ExecuteAsync(ParseResult parseResult, CancellationT // Also clear the logs directory (skip current process's log file) var logsDirectory = ExecutionContext.LogsDirectory; - // Log files are named cli-{timestamp}-{pid}.log, so we need to check the suffix - var currentLogFileSuffix = $"-{Environment.ProcessId}.log"; + var currentLogFilePath = ExecutionContext.LogFilePath; if (logsDirectory.Exists) { foreach (var file in logsDirectory.GetFiles("*", SearchOption.AllDirectories)) { // Skip the current process's log file to avoid deleting it while in use - if (file.Name.EndsWith(currentLogFileSuffix, StringComparison.OrdinalIgnoreCase)) + if (file.FullName.Equals(currentLogFilePath, StringComparison.OrdinalIgnoreCase)) { continue; } @@ -167,4 +166,4 @@ protected override Task ExecuteAsync(ParseResult parseResult, CancellationT } } } -} \ No newline at end of file +} diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index e8f202b82d1..1621a7ccb01 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -86,6 +86,11 @@ internal sealed class RunCommand : BaseCommand { Description = RunCommandStrings.NoBuildArgumentDescription }; + private static readonly Option s_logFileOption = new("--log-file") + { + Description = "Path to write the log file (used internally by --detach).", + Hidden = true + }; private readonly Option? _startDebugSessionOption; public RunCommand( @@ -126,6 +131,7 @@ public RunCommand( Options.Add(s_formatOption); Options.Add(s_isolatedOption); Options.Add(s_noBuildOption); + Options.Add(s_logFileOption); if (ExtensionHelper.IsExtensionHost(InteractionService, out _, out _)) { @@ -294,9 +300,9 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell // Handle remote environments (Codespaces, Remote Containers, SSH) var isCodespaces = dashboardUrls.CodespacesUrlWithLoginToken is not null; - var isRemoteContainers = _configuration.GetValue("REMOTE_CONTAINERS", false); - var isSshRemote = _configuration.GetValue("VSCODE_IPC_HOOK_CLI") is not null - && _configuration.GetValue("SSH_CONNECTION") is not null; + var isRemoteContainers = string.Equals(_configuration["REMOTE_CONTAINERS"], "true", StringComparison.OrdinalIgnoreCase); + var isSshRemote = _configuration["VSCODE_IPC_HOOK_CLI"] is not null + && _configuration["SSH_CONNECTION"] is not null; AppendCtrlCMessage(longestLocalizedLengthWithColon); @@ -492,7 +498,7 @@ internal static int RenderAppHostSummary( new Align(new Markup($"[bold green]{dashboardLabel}[/]:"), HorizontalAlignment.Right), new Markup("[dim]N/A[/]")); } - grid.AddRow(Text.Empty, Text.Empty); + grid.AddRow(Text.Empty, Text.Empty); } // Logs row @@ -655,18 +661,23 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? _logger.LogDebug("Found {Count} running instance(s) for this AppHost, stopping them first", existingSockets.Length); var manager = new RunningInstanceManager(_logger, _interactionService, _timeProvider); // Stop all running instances in parallel - don't block on failures - var stopTasks = existingSockets.Select(socket => + var stopTasks = existingSockets.Select(socket => manager.StopRunningInstanceAsync(socket, cancellationToken)); await Task.WhenAll(stopTasks).ConfigureAwait(false); } // Build the arguments for the child CLI process + // Tell the child where to write its log so we can find it on failure. + var childLogFile = GenerateChildLogFilePath(); + var args = new List { "run", "--non-interactive", "--project", - effectiveAppHostFile.FullName + effectiveAppHostFile.FullName, + "--log-file", + childLogFile }; // Pass through global options that should be forwarded to child CLI @@ -707,14 +718,17 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? dotnetPath, isDotnetHost, string.Join(" ", args)); _logger.LogDebug("Working directory: {WorkingDirectory}", ExecutionContext.WorkingDirectory.FullName); - // Redirect stdout/stderr to suppress child output - it writes to log file anyway + // Don't redirect stdout/stderr - child writes to log file anyway. + // Redirecting creates pipe handles that get inherited by the AppHost grandchild, + // which prevents callers using synchronous process APIs (e.g. execSync) from + // detecting that the CLI has exited, since the pipe stays open until the AppHost dies. var startInfo = new ProcessStartInfo { FileName = dotnetPath, UseShellExecute = false, CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true, + RedirectStandardOutput = false, + RedirectStandardError = false, RedirectStandardInput = false, WorkingDirectory = ExecutionContext.WorkingDirectory.FullName }; @@ -747,24 +761,6 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? return null; } - // Start async reading of stdout/stderr to prevent buffer blocking - // Log output for debugging purposes - childProcess.OutputDataReceived += (_, e) => - { - if (e.Data is not null) - { - _logger.LogDebug("Child stdout: {Line}", e.Data); - } - }; - childProcess.ErrorDataReceived += (_, e) => - { - if (e.Data is not null) - { - _logger.LogDebug("Child stderr: {Line}", e.Data); - } - }; - childProcess.BeginOutputReadLine(); - childProcess.BeginErrorReadLine(); } catch (Exception ex) { @@ -843,10 +839,13 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? if (childExitedEarly) { - _interactionService.DisplayError(string.Format( - CultureInfo.CurrentCulture, - RunCommandStrings.AppHostExitedWithCode, - childExitCode)); + // Show a friendly message based on well-known exit codes from the child + var errorMessage = childExitCode switch + { + ExitCodeConstants.FailedToBuildArtifacts => RunCommandStrings.AppHostFailedToBuild, + _ => string.Format(CultureInfo.CurrentCulture, RunCommandStrings.AppHostExitedWithCode, childExitCode) + }; + _interactionService.DisplayError(errorMessage); } else { @@ -866,11 +865,11 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? } } - // Always show log file path for troubleshooting + // Point to the child's log file — it contains the actual build/runtime errors _interactionService.DisplayMessage("magnifying_glass_tilted_right", string.Format( CultureInfo.CurrentCulture, RunCommandStrings.CheckLogsForDetails, - _fileLoggerProvider.LogFilePath.EscapeMarkup())); + childLogFile.EscapeMarkup())); return ExitCodeConstants.FailedToDotnetRunAppHost; } @@ -913,4 +912,12 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? return ExitCodeConstants.Success; } + + private string GenerateChildLogFilePath() + { + return Diagnostics.FileLoggerProvider.GenerateLogFilePath( + ExecutionContext.LogsDirectory.FullName, + _timeProvider, + suffix: "detach-child"); + } } diff --git a/src/Aspire.Cli/Diagnostics/FileLoggerProvider.cs b/src/Aspire.Cli/Diagnostics/FileLoggerProvider.cs index 4035352669d..f833c445359 100644 --- a/src/Aspire.Cli/Diagnostics/FileLoggerProvider.cs +++ b/src/Aspire.Cli/Diagnostics/FileLoggerProvider.cs @@ -29,6 +29,22 @@ internal sealed class FileLoggerProvider : ILoggerProvider /// public string LogFilePath => _logFilePath; + /// + /// Generates a unique, chronologically-sortable log file name. + /// + /// The directory where log files will be written. + /// The time provider for timestamp generation. + /// An optional suffix appended before the extension (e.g. "detach-child"). + internal static string GenerateLogFilePath(string logsDirectory, TimeProvider timeProvider, string? suffix = null) + { + var timestamp = timeProvider.GetUtcNow().ToString("yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture); + var id = Guid.NewGuid().ToString("N")[..8]; + var name = suffix is null + ? $"cli_{timestamp}_{id}.log" + : $"cli_{timestamp}_{id}_{suffix}.log"; + return Path.Combine(logsDirectory, name); + } + /// /// Creates a new FileLoggerProvider that writes to the specified directory. /// @@ -37,10 +53,7 @@ internal sealed class FileLoggerProvider : ILoggerProvider /// Optional console for error messages. Defaults to stderr. public FileLoggerProvider(string logsDirectory, TimeProvider timeProvider, IAnsiConsole? errorConsole = null) { - var pid = Environment.ProcessId; - var timestamp = timeProvider.GetUtcNow().ToString("yyyy-MM-dd-HH-mm-ss", CultureInfo.InvariantCulture); - // Timestamp first so files sort chronologically by name - _logFilePath = Path.Combine(logsDirectory, $"cli-{timestamp}-{pid}.log"); + _logFilePath = GenerateLogFilePath(logsDirectory, timeProvider); try { diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index e7e1e91cde5..17ea27ae84c 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -92,6 +92,28 @@ private static (LogLevel? ConsoleLogLevel, bool DebugMode) ParseLoggingOptions(s return (logLevel, debugMode); } + /// + /// Parses --log-file from raw args before the host is built. + /// Used by --detach to tell the child CLI where to write its log. + /// + private static string? ParseLogFileOption(string[]? args) + { + if (args is null) + { + return null; + } + + for (var i = 0; i < args.Length; i++) + { + if (args[i] == "--log-file" && i + 1 < args.Length) + { + return args[i + 1]; + } + } + + return null; + } + private static string GetGlobalSettingsPath() { var usersAspirePath = GetUsersAspirePath(); @@ -159,7 +181,10 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar // Always register FileLoggerProvider to capture logs to disk // This captures complete CLI session details for diagnostics var logsDirectory = Path.Combine(GetUsersAspirePath(), "logs"); - var fileLoggerProvider = new FileLoggerProvider(logsDirectory, TimeProvider.System); + var logFilePath = ParseLogFileOption(args); + var fileLoggerProvider = logFilePath is not null + ? new FileLoggerProvider(logFilePath) + : new FileLoggerProvider(logsDirectory, TimeProvider.System); builder.Services.AddSingleton(fileLoggerProvider); // Register for direct access to LogFilePath builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton(fileLoggerProvider)); diff --git a/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs index 5a58b653555..73fd7e4da7f 100644 --- a/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs @@ -255,6 +255,12 @@ public static string AppHostExitedWithCode { } } + public static string AppHostFailedToBuild { + get { + return ResourceManager.GetString("AppHostFailedToBuild", resourceCulture); + } + } + public static string TimeoutWaitingForAppHost { get { return ResourceManager.GetString("TimeoutWaitingForAppHost", resourceCulture); diff --git a/src/Aspire.Cli/Resources/RunCommandStrings.resx b/src/Aspire.Cli/Resources/RunCommandStrings.resx index 3b5ca858c10..a5fabb4943d 100644 --- a/src/Aspire.Cli/Resources/RunCommandStrings.resx +++ b/src/Aspire.Cli/Resources/RunCommandStrings.resx @@ -226,6 +226,9 @@ AppHost process exited with code {0}. + + AppHost failed to build. + Timeout waiting for AppHost to start. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf index 454f6dc920c..5535b7f2e9b 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf @@ -37,6 +37,11 @@ Proces AppHost byl ukončen s kódem {0}. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. Hostitel aplikací (AppHost) se úspěšně spustil. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf index 37a94547020..1278b7bd643 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf @@ -37,6 +37,11 @@ Der AppHost-Prozess wurde mit Code {0} beendet. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. Der AppHost wurde erfolgreich gestartet. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf index 22f5ddcb6dd..a39b710377e 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf @@ -37,6 +37,11 @@ El proceso AppHost se cerró con código {0}. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. AppHost se ha iniciado correctamente. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf index adaaed0befc..fb0a0a6e08a 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf @@ -37,6 +37,11 @@ Processus AppHost arrêté avec le code {0}. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. AppHost a démarré correctement. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf index df0aadbf184..5296e1ed136 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf @@ -37,6 +37,11 @@ Processo AppHost terminato con codice {0}. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. AppHost avviato correttamente. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf index 79e19c80b02..8e73ec517c9 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf @@ -37,6 +37,11 @@ AppHost プロセスが終了し、コード {0} を返しました。 + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. AppHost が正常に起動しました。 diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf index f88d2ab250b..6d10f499767 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf @@ -37,6 +37,11 @@ AppHost 프로세스가 {0} 코드로 종료되었습니다. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. AppHost를 시작했습니다. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf index 3d299fe03aa..9f2531431c7 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf @@ -37,6 +37,11 @@ Proces hosta aplikacji zakończył się z kodem {0}. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. Host aplikacji został pomyślnie uruchomiony. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf index 68c69dbc585..543eb04e807 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf @@ -37,6 +37,11 @@ O processo AppHost foi encerrado com o código {0}. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. AppHost iniciado com sucesso. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf index f92d6c1056b..9ffd508797b 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf @@ -37,6 +37,11 @@ Процесс AppHost завершился с кодом {0}. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. Запуск AppHost выполнен. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf index f1802c2992a..6bd56480556 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf @@ -37,6 +37,11 @@ AppHost işlemi {0} koduyla sonlandırıldı. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. AppHost başarıyla başlatıldı. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf index b23f5447f08..703a2736efa 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf @@ -37,6 +37,11 @@ AppHost 进程已退出,代码为 {0}。 + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. 已成功启动 AppHost。 diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf index f165b8200a3..20f829b7698 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf @@ -37,6 +37,11 @@ AppHost 程序以返回碼 {0} 結束。 + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. 已成功啟動 AppHost。 From b673bda06244c023020f9fef73246cce1d35de5c Mon Sep 17 00:00:00 2001 From: David Fowler Date: Wed, 11 Feb 2026 20:55:23 -0800 Subject: [PATCH 2/8] Show child log file path on detach success --- src/Aspire.Cli/Commands/RunCommand.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 1621a7ccb01..d4d197811d9 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -889,7 +889,7 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? pid, childProcess.Id, dashboardUrls?.BaseUrlWithLoginToken, - _fileLoggerProvider.LogFilePath); + childLogFile); var json = JsonSerializer.Serialize(result, RunCommandJsonContext.RelaxedEscaping.DetachOutputInfo); _interactionService.DisplayRawText(json); } @@ -902,7 +902,7 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? appHostRelativePath, dashboardUrls?.BaseUrlWithLoginToken, codespacesUrl: null, - _fileLoggerProvider.LogFilePath, + childLogFile, isExtensionHost, pid); _ansiConsole.WriteLine(); From 89af2b62debd2a63f183777d9b447843833aa630 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Wed, 11 Feb 2026 21:37:46 -0800 Subject: [PATCH 3/8] Fix detach: redirect child output to suppress console bleed --- src/Aspire.Cli/Commands/RunCommand.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index d4d197811d9..aed5e19edbf 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -718,17 +718,18 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? dotnetPath, isDotnetHost, string.Join(" ", args)); _logger.LogDebug("Working directory: {WorkingDirectory}", ExecutionContext.WorkingDirectory.FullName); - // Don't redirect stdout/stderr - child writes to log file anyway. - // Redirecting creates pipe handles that get inherited by the AppHost grandchild, - // which prevents callers using synchronous process APIs (e.g. execSync) from - // detecting that the CLI has exited, since the pipe stays open until the AppHost dies. + // Redirect stdout/stderr so the child doesn't write to the parent's console, + // but close the pipe streams immediately after start so no handles are inherited + // by the AppHost grandchild. This avoids the problem where callers using + // synchronous process APIs (e.g. execSync) can't detect CLI exit because + // inherited pipe handles stay open until the AppHost dies. var startInfo = new ProcessStartInfo { FileName = dotnetPath, UseShellExecute = false, CreateNoWindow = true, - RedirectStandardOutput = false, - RedirectStandardError = false, + RedirectStandardOutput = true, + RedirectStandardError = true, RedirectStandardInput = false, WorkingDirectory = ExecutionContext.WorkingDirectory.FullName }; @@ -761,6 +762,10 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? return null; } + // Close redirected streams immediately so pipe handles aren't inherited + // by the AppHost grandchild process + childProcess.StandardOutput.Close(); + childProcess.StandardError.Close(); } catch (Exception ex) { From 488315207c67d2e9dbb4b2406e14bf60dbf2c79c Mon Sep 17 00:00:00 2001 From: David Fowler Date: Wed, 11 Feb 2026 23:42:54 -0800 Subject: [PATCH 4/8] Suppress child console output via --log-file instead of pipe redirection The previous approach (RedirectStandardOutput=true + close streams) still created inheritable pipe handles. Instead, keep Redirect=false to avoid pipe inheritance and have the child process suppress its own console output when --log-file is specified (the signal that it's a detach child). --- src/Aspire.Cli/Commands/RunCommand.cs | 17 ++++++----------- src/Aspire.Cli/Program.cs | 9 +++++++++ 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index aed5e19edbf..5708a8975a1 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -718,18 +718,17 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? dotnetPath, isDotnetHost, string.Join(" ", args)); _logger.LogDebug("Working directory: {WorkingDirectory}", ExecutionContext.WorkingDirectory.FullName); - // Redirect stdout/stderr so the child doesn't write to the parent's console, - // but close the pipe streams immediately after start so no handles are inherited - // by the AppHost grandchild. This avoids the problem where callers using - // synchronous process APIs (e.g. execSync) can't detect CLI exit because - // inherited pipe handles stay open until the AppHost dies. + // Don't redirect stdout/stderr — avoids creating pipe handles that get inherited + // by the AppHost grandchild, which prevents callers using synchronous process APIs + // (e.g. execSync) from detecting that the CLI has exited. + // The child suppresses its own console output when --log-file is specified. var startInfo = new ProcessStartInfo { FileName = dotnetPath, UseShellExecute = false, CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true, + RedirectStandardOutput = false, + RedirectStandardError = false, RedirectStandardInput = false, WorkingDirectory = ExecutionContext.WorkingDirectory.FullName }; @@ -762,10 +761,6 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? return null; } - // Close redirected streams immediately so pipe handles aren't inherited - // by the AppHost grandchild process - childProcess.StandardOutput.Close(); - childProcess.StandardError.Close(); } catch (Exception ex) { diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 17ea27ae84c..c3e7cdb783f 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -553,6 +553,15 @@ private static IAnsiConsole BuildAnsiConsole(IServiceProvider serviceProvider, T public static async Task Main(string[] args) { + // When launched as a detach child (--log-file is specified), suppress all console + // output. The parent uses RedirectStandardOutput=false to avoid creating inheritable + // pipe handles, so the child must silence itself to prevent output bleed. + if (ParseLogFileOption(args) is not null) + { + Console.SetOut(TextWriter.Null); + Console.SetError(TextWriter.Null); + } + // Setup handling of CTRL-C as early as possible so that if // we get a CTRL-C anywhere that is not handled by Spectre Console // already that we know to trigger cancellation. From 9c6d7ad4ec9bbdee011a1a25eadc1031156eee41 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Thu, 12 Feb 2026 00:06:58 -0800 Subject: [PATCH 5/8] Add DetachedProcessLauncher with native CreateProcess on Windows Replace Process.Start with platform-specific launcher that suppresses child output and prevents handle/fd inheritance to grandchildren: - Windows: P/Invoke CreateProcess with STARTUPINFOEX and PROC_THREAD_ATTRIBUTE_HANDLE_LIST (same approach as Docker/hcsshim). Child stdout/stderr go to NUL, only NUL handle is inheritable. - Linux/macOS: Process.Start with RedirectStdout=true + close streams. Pipes are O_CLOEXEC so grandchild never inherits them. Removes the Console.SetOut(TextWriter.Null) workaround from Program.cs. --- src/Aspire.Cli/Aspire.Cli.csproj | 1 + src/Aspire.Cli/Commands/RunCommand.cs | 38 +-- .../Processes/DetachedProcessLauncher.Unix.cs | 45 +++ .../DetachedProcessLauncher.Windows.cs | 256 ++++++++++++++++++ .../Processes/DetachedProcessLauncher.cs | 80 ++++++ src/Aspire.Cli/Program.cs | 9 - 6 files changed, 392 insertions(+), 37 deletions(-) create mode 100644 src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs create mode 100644 src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs create mode 100644 src/Aspire.Cli/Processes/DetachedProcessLauncher.cs diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 654c480a3a3..688c6e0525a 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -6,6 +6,7 @@ net10.0 enable enable + true false aspire Aspire.Cli diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 5708a8975a1..45706718743 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -11,6 +11,7 @@ using Aspire.Cli.Configuration; using Aspire.Cli.DotNet; using Aspire.Cli.Interaction; +using Aspire.Cli.Processes; using Aspire.Cli.Projects; using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; @@ -718,32 +719,15 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? dotnetPath, isDotnetHost, string.Join(" ", args)); _logger.LogDebug("Working directory: {WorkingDirectory}", ExecutionContext.WorkingDirectory.FullName); - // Don't redirect stdout/stderr — avoids creating pipe handles that get inherited - // by the AppHost grandchild, which prevents callers using synchronous process APIs - // (e.g. execSync) from detecting that the CLI has exited. - // The child suppresses its own console output when --log-file is specified. - var startInfo = new ProcessStartInfo - { - FileName = dotnetPath, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = false, - RedirectStandardError = false, - RedirectStandardInput = false, - WorkingDirectory = ExecutionContext.WorkingDirectory.FullName - }; - - // If we're running via `dotnet aspire.dll`, add the DLL as first arg - // When running native AOT, don't add the DLL even if it exists in the same folder + // Build the full argument list for the child process, including the entry assembly + // path when running via `dotnet aspire.dll` + var childArgs = new List(); if (isDotnetHost && !string.IsNullOrEmpty(entryAssemblyPath) && entryAssemblyPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) { - startInfo.ArgumentList.Add(entryAssemblyPath); + childArgs.Add(entryAssemblyPath); } - foreach (var arg in args) - { - startInfo.ArgumentList.Add(arg); - } + childArgs.AddRange(args); // Start the child process and wait for the backchannel in a single status spinner Process? childProcess = null; @@ -755,12 +739,10 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? // Failure mode 2: Failed to spawn child process try { - childProcess = Process.Start(startInfo); - if (childProcess is null) - { - return null; - } - + childProcess = DetachedProcessLauncher.Start( + dotnetPath, + childArgs, + ExecutionContext.WorkingDirectory.FullName); } catch (Exception ex) { diff --git a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs new file mode 100644 index 00000000000..42aad58df3a --- /dev/null +++ b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs @@ -0,0 +1,45 @@ +// 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, .NET creates pipes with O_CLOEXEC so grandchild processes + /// never inherit them across execve(). We just close the parent-side pipe + /// streams immediately after start to suppress child output. + /// + private static Process StartUnix(string fileName, IReadOnlyList arguments, string workingDirectory) + { + 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); + } + + var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Failed to start detached process"); + + // Close the parent's read-end of the pipes. The child's write-end has + // O_CLOEXEC set by .NET, so when the child calls execve() to launch the + // AppHost grandchild, the pipe fds are automatically closed. + 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 new file mode 100644 index 00000000000..2b951b9bd41 --- /dev/null +++ b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs @@ -0,0 +1,256 @@ +// 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 STARTUPINFOEX and + /// PROC_THREAD_ATTRIBUTE_HANDLE_LIST to prevent handle inheritance to grandchildren. + /// + [SupportedOSPlatform("windows")] + private static Process StartWindows(string fileName, IReadOnlyList arguments, string workingDirectory) + { + // 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; + si.hStdInput = nint.Zero; + si.hStdOutput = nulRawHandle; + si.hStdError = nulRawHandle; + si.lpAttributeList = attrList; + + // Build the command line string: "fileName" arg1 arg2 ... + var commandLine = BuildCommandLine(fileName, arguments); + + var flags = CreateUnicodeEnvironment | ExtendedStartupInfoPresent | CreateNoWindow; + + if (!CreateProcessW( + null, + commandLine, + nint.Zero, + nint.Zero, + bInheritHandles: true, // TRUE but HANDLE_LIST restricts what's actually inherited + flags, + nint.Zero, + workingDirectory, + ref si, + out var pi)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to create detached process"); + } + + // Close the process and thread handles — we only need the PID + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + + return Process.GetProcessById(pi.dwProcessId); + } + finally + { + pinnedHandles.Free(); + } + } + finally + { + DeleteProcThreadAttributeList(attrList); + } + } + finally + { + Marshal.FreeHGlobal(attrList); + } + } + + 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(' '); + if (arg.Contains(' ') || arg.Contains('"')) + { + sb.Append('"').Append(arg.Replace("\"", "\\\"")).Append('"'); + } + else + { + sb.Append(arg); + } + } + + return sb; + } + + // --- 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 CreateUnicodeEnvironment = 0x00000400; + private const uint ExtendedStartupInfoPresent = 0x00080000; + private const uint CreateNoWindow = 0x08000000; + 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 new file mode 100644 index 00000000000..d0cce8c58ac --- /dev/null +++ b/src/Aspire.Cli/Processes/DetachedProcessLauncher.cs @@ -0,0 +1,80 @@ +// 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; + +// ============================================================================ +// DetachedProcessLauncher — Platform-aware child process launcher for --detach +// ============================================================================ +// +// When `aspire run --detach` is used, the CLI spawns a child CLI process which +// in turn spawns the AppHost (the "grandchild"). Two constraints must hold: +// +// 1. The child's stdout/stderr must NOT appear on the parent's console. +// The parent renders its own summary UX (dashboard URL, PID, log path) and +// if the child's output (spinners, "Press CTRL+C", etc.) bleeds through, it +// corrupts the parent's terminal — and breaks E2E tests that pattern-match +// on the parent's output. +// +// 2. No pipe or handle from the parent→child stdio redirection may leak into +// the grandchild (AppHost). If it does, callers that wait for the CLI's +// 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: +// +// • 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. +// +// • 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 STARTUPINFOEX and an explicit │ +// │ │ PROC_THREAD_ATTRIBUTE_HANDLE_LIST. This lets us set │ +// │ │ bInheritHandles=TRUE (required to assign hStdOutput to NUL) │ +// │ │ while restricting inheritance to ONLY the NUL handle — so the │ +// │ │ grandchild inherits nothing useful. Child stdout/stderr go to │ +// │ │ the NUL device. This is the same approach used by Docker's │ +// │ │ Windows container runtime (microsoft/hcsshim). │ +// │ │ │ +// │ Linux / │ Process.Start with RedirectStandard{Output,Error} = true, │ +// │ macOS │ then immediately close the pipe streams. On Unix, .NET │ +// │ │ creates pipes with O_CLOEXEC, so the grandchild never │ +// │ │ inherits them across execve() — unlike Windows, this is safe. │ +// │ │ This is the same model used by runc (opencontainers/runc), │ +// │ │ which relies on O_CLOEXEC + close-on-exec to prevent fd leaks │ +// │ │ into container init processes. │ +// └─────────┴────────────────────────────────────────────────────────────────┘ +// + +/// +/// Launches a child process with stdout/stderr suppressed and no handle/fd +/// inheritance to grandchild processes. Used by aspire run --detach. +/// +internal static partial class DetachedProcessLauncher +{ + /// + /// Starts a detached child process with stdout/stderr going to the null device + /// and no inheritable handles/fds leaking to grandchildren. + /// + /// The executable path (e.g. dotnet or the native CLI). + /// The command-line arguments for the child process. + /// The working directory for the child process. + /// A object representing the launched child. + public static Process Start(string fileName, IReadOnlyList arguments, string workingDirectory) + { + if (OperatingSystem.IsWindows()) + { + return StartWindows(fileName, arguments, workingDirectory); + } + + return StartUnix(fileName, arguments, workingDirectory); + } +} diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index c3e7cdb783f..17ea27ae84c 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -553,15 +553,6 @@ private static IAnsiConsole BuildAnsiConsole(IServiceProvider serviceProvider, T public static async Task Main(string[] args) { - // When launched as a detach child (--log-file is specified), suppress all console - // output. The parent uses RedirectStandardOutput=false to avoid creating inheritable - // pipe handles, so the child must silence itself to prevent output bleed. - if (ParseLogFileOption(args) is not null) - { - Console.SetOut(TextWriter.Null); - Console.SetError(TextWriter.Null); - } - // Setup handling of CTRL-C as early as possible so that if // we get a CTRL-C anywhere that is not handled by Spectre Console // already that we know to trigger cancellation. From 76d80852b87f2ba528e575b98793abc2bf18ea88 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Thu, 12 Feb 2026 00:14:02 -0800 Subject: [PATCH 6/8] Fix misleading O_CLOEXEC comments in Unix launcher dup2 onto fd 0/1/2 clears O_CLOEXEC, so grandchildren DO inherit the pipe as their stdio. With the parent's read-end closed, writes produce harmless EPIPE. Updated comments to accurately describe the Unix fd inheritance model based on dotnet/runtime pal_process.c source. --- .../Processes/DetachedProcessLauncher.Unix.cs | 15 +++++++++------ .../Processes/DetachedProcessLauncher.cs | 12 ++++++------ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs index 42aad58df3a..ee7620a2fd2 100644 --- a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs +++ b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs @@ -9,9 +9,11 @@ internal static partial class DetachedProcessLauncher { /// /// Unix implementation using Process.Start with stdio redirection. - /// On Linux/macOS, .NET creates pipes with O_CLOEXEC so grandchild processes - /// never inherit them across execve(). We just close the parent-side pipe - /// streams immediately after start to suppress child output. + /// 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) { @@ -34,9 +36,10 @@ private static Process StartUnix(string fileName, IReadOnlyList argument var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start detached process"); - // Close the parent's read-end of the pipes. The child's write-end has - // O_CLOEXEC set by .NET, so when the child calls execve() to launch the - // AppHost grandchild, the pipe fds are automatically closed. + // 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(); diff --git a/src/Aspire.Cli/Processes/DetachedProcessLauncher.cs b/src/Aspire.Cli/Processes/DetachedProcessLauncher.cs index d0cce8c58ac..006a4c86025 100644 --- a/src/Aspire.Cli/Processes/DetachedProcessLauncher.cs +++ b/src/Aspire.Cli/Processes/DetachedProcessLauncher.cs @@ -45,12 +45,12 @@ namespace Aspire.Cli.Processes; // │ │ Windows container runtime (microsoft/hcsshim). │ // │ │ │ // │ Linux / │ Process.Start with RedirectStandard{Output,Error} = true, │ -// │ macOS │ then immediately close the pipe streams. On Unix, .NET │ -// │ │ creates pipes with O_CLOEXEC, so the grandchild never │ -// │ │ inherits them across execve() — unlike Windows, this is safe. │ -// │ │ This is the same model used by runc (opencontainers/runc), │ -// │ │ which relies on O_CLOEXEC + close-on-exec to prevent fd leaks │ -// │ │ into container init processes. │ +// │ 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. │ // └─────────┴────────────────────────────────────────────────────────────────┘ // From 40486d47eed4bc0640e801f87d412dd9896b6cd0 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Thu, 12 Feb 2026 00:29:36 -0800 Subject: [PATCH 7/8] Fix command-line quoting for Windows backslash escaping --- .../DetachedProcessLauncher.Windows.cs | 71 +++++++++++++++++-- 1 file changed, 66 insertions(+), 5 deletions(-) diff --git a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs index 2b951b9bd41..fe9e1bc8ef0 100644 --- a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs +++ b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs @@ -125,6 +125,10 @@ private static Process StartWindows(string fileName, IReadOnlyList argum } } + /// + /// 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(); @@ -135,17 +139,74 @@ private static StringBuilder BuildCommandLine(string fileName, IReadOnlyList + /// 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 == '\\') { - sb.Append('"').Append(arg.Replace("\"", "\\\"")).Append('"'); + 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; } - else + + if (c == '"') { - sb.Append(arg); + sb.Append('\\'); + sb.Append('"'); + continue; } + + sb.Append(c); } - return sb; + sb.Append('"'); } // --- Constants --- From 6e14e8abf64a7b633af63aacf32a982f13ee04a2 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Thu, 12 Feb 2026 10:25:23 -0800 Subject: [PATCH 8/8] Fix detach log parsing and child log path handling --- src/Aspire.Cli/Commands/RunCommand.cs | 29 ++++++++++----- .../DetachedProcessLauncher.Windows.cs | 16 ++++++-- src/Aspire.Cli/Program.cs | 8 +++- .../Commands/RunCommandTests.cs | 37 +++++++++++++++++++ tests/Aspire.Cli.Tests/ProgramTests.cs | 31 ++++++++++++++++ 5 files changed, 105 insertions(+), 16 deletions(-) create mode 100644 tests/Aspire.Cli.Tests/ProgramTests.cs diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 45706718743..3e00efb00c2 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -822,12 +822,7 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? if (childExitedEarly) { // Show a friendly message based on well-known exit codes from the child - var errorMessage = childExitCode switch - { - ExitCodeConstants.FailedToBuildArtifacts => RunCommandStrings.AppHostFailedToBuild, - _ => string.Format(CultureInfo.CurrentCulture, RunCommandStrings.AppHostExitedWithCode, childExitCode) - }; - _interactionService.DisplayError(errorMessage); + _interactionService.DisplayError(GetDetachedFailureMessage(childExitCode)); } else { @@ -895,11 +890,25 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? return ExitCodeConstants.Success; } + internal static string GetDetachedFailureMessage(int childExitCode) + { + return childExitCode switch + { + ExitCodeConstants.FailedToBuildArtifacts => RunCommandStrings.AppHostFailedToBuild, + _ => string.Format(CultureInfo.CurrentCulture, RunCommandStrings.AppHostExitedWithCode, childExitCode) + }; + } + + internal static string GenerateChildLogFilePath(string logsDirectory, TimeProvider timeProvider) + { + var timestamp = timeProvider.GetUtcNow().ToString("yyyyMMddTHHmmssfff", CultureInfo.InvariantCulture); + var uniqueId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + var fileName = $"cli_{timestamp}_detach-child_{uniqueId}.log"; + return Path.Combine(logsDirectory, fileName); + } + private string GenerateChildLogFilePath() { - return Diagnostics.FileLoggerProvider.GenerateLogFilePath( - ExecutionContext.LogsDirectory.FullName, - _timeProvider, - suffix: "detach-child"); + return GenerateChildLogFilePath(ExecutionContext.LogsDirectory.FullName, _timeProvider); } } diff --git a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs index fe9e1bc8ef0..da509a0be25 100644 --- a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs +++ b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs @@ -103,11 +103,19 @@ private static Process StartWindows(string fileName, IReadOnlyList argum throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to create detached process"); } - // Close the process and thread handles — we only need the PID - CloseHandle(pi.hProcess); - CloseHandle(pi.hThread); + 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 Process.GetProcessById(pi.dwProcessId); + return detachedProcess; } finally { diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 17ea27ae84c..ebc0c3ea7c1 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -96,7 +96,7 @@ private static (LogLevel? ConsoleLogLevel, bool DebugMode) ParseLoggingOptions(s /// Parses --log-file from raw args before the host is built. /// Used by --detach to tell the child CLI where to write its log. /// - private static string? ParseLogFileOption(string[]? args) + internal static string? ParseLogFileOption(string[]? args) { if (args is null) { @@ -105,6 +105,11 @@ private static (LogLevel? ConsoleLogLevel, bool DebugMode) ParseLoggingOptions(s for (var i = 0; i < args.Length; i++) { + if (args[i] == "--") + { + break; + } + if (args[i] == "--log-file" && i + 1 < args.Length) { return args[i + 1]; @@ -731,4 +736,3 @@ public void Enrich(Profile profile) profile.Capabilities.Interactive = true; } } - diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index 2a84fbbe614..0cdf41939b4 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -87,6 +87,38 @@ public async Task RunCommand_WhenProjectFileDoesNotExist_ReturnsNonZeroExitCode( Assert.Equal(ExitCodeConstants.FailedToFindProject, exitCode); } + [Fact] + public void GetDetachedFailureMessage_ReturnsBuildSpecificMessage_ForBuildFailureExitCode() + { + var message = RunCommand.GetDetachedFailureMessage(ExitCodeConstants.FailedToBuildArtifacts); + + Assert.Equal(RunCommandStrings.AppHostFailedToBuild, message); + } + + [Fact] + public void GetDetachedFailureMessage_ReturnsExitCodeMessage_ForUnknownExitCode() + { + var message = RunCommand.GetDetachedFailureMessage(123); + + Assert.Contains("123", message, StringComparison.Ordinal); + } + + [Fact] + public void GenerateChildLogFilePath_UsesDetachChildNamingWithoutProcessId() + { + var logsDirectory = Path.Combine(Path.GetTempPath(), "aspire-cli-tests"); + var now = new DateTimeOffset(2026, 02, 12, 18, 00, 00, TimeSpan.Zero); + var timeProvider = new FixedTimeProvider(now); + + var path = RunCommand.GenerateChildLogFilePath(logsDirectory, timeProvider); + var fileName = Path.GetFileName(path); + + Assert.StartsWith(logsDirectory, path, StringComparison.OrdinalIgnoreCase); + Assert.StartsWith("cli_20260212T180000000_detach-child_", fileName, StringComparison.Ordinal); + Assert.EndsWith(".log", fileName, StringComparison.Ordinal); + Assert.DoesNotContain($"_{Environment.ProcessId}", fileName, StringComparison.Ordinal); + } + private sealed class ProjectFileDoesNotExistLocator : Aspire.Cli.Projects.IProjectLocator { public Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, MultipleAppHostProjectsFoundBehavior multipleAppHostProjectsFoundBehavior, bool createSettingsFile, CancellationToken cancellationToken) @@ -166,6 +198,11 @@ public Task UseOrFindAppHostProjectFileAsync(FileInf } } + private sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => utcNow; + } + private async IAsyncEnumerable ReturnLogEntriesUntilCancelledAsync([EnumeratorCancellation] CancellationToken cancellationToken) { var logEntryIndex = 0; diff --git a/tests/Aspire.Cli.Tests/ProgramTests.cs b/tests/Aspire.Cli.Tests/ProgramTests.cs new file mode 100644 index 00000000000..bcaa4d6b7fa --- /dev/null +++ b/tests/Aspire.Cli.Tests/ProgramTests.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Tests; + +public class ProgramTests +{ + [Fact] + public void ParseLogFileOption_ReturnsNull_WhenArgsAreNull() + { + var result = Program.ParseLogFileOption(null); + + Assert.Null(result); + } + + [Fact] + public void ParseLogFileOption_ReturnsValue_WhenOptionAppearsBeforeDelimiter() + { + var result = Program.ParseLogFileOption(["run", "--log-file", "cli.log", "--", "--log-file", "app.log"]); + + Assert.Equal("cli.log", result); + } + + [Fact] + public void ParseLogFileOption_IgnoresValue_WhenOptionAppearsAfterDelimiter() + { + var result = Program.ParseLogFileOption(["run", "--", "--log-file", "app.log"]); + + Assert.Null(result); + } +}