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/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..3e00efb00c2 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;
@@ -86,6 +87,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 +132,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 +301,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 +499,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 +662,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,29 +719,15 @@ 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
- var startInfo = new ProcessStartInfo
- {
- FileName = dotnetPath,
- UseShellExecute = false,
- CreateNoWindow = true,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- 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;
@@ -741,30 +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;
- }
-
- // 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();
+ childProcess = DetachedProcessLauncher.Start(
+ dotnetPath,
+ childArgs,
+ ExecutionContext.WorkingDirectory.FullName);
}
catch (Exception ex)
{
@@ -843,10 +821,8 @@ 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
+ _interactionService.DisplayError(GetDetachedFailureMessage(childExitCode));
}
else
{
@@ -866,11 +842,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;
}
@@ -890,7 +866,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);
}
@@ -903,7 +879,7 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo?
appHostRelativePath,
dashboardUrls?.BaseUrlWithLoginToken,
codespacesUrl: null,
- _fileLoggerProvider.LogFilePath,
+ childLogFile,
isExtensionHost,
pid);
_ansiConsole.WriteLine();
@@ -913,4 +889,26 @@ 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 GenerateChildLogFilePath(ExecutionContext.LogsDirectory.FullName, _timeProvider);
+ }
}
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/Processes/DetachedProcessLauncher.Unix.cs b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs
new file mode 100644
index 00000000000..ee7620a2fd2
--- /dev/null
+++ b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs
@@ -0,0 +1,48 @@
+// 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)
+ {
+ 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. 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
new file mode 100644
index 00000000000..da509a0be25
--- /dev/null
+++ b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs
@@ -0,0 +1,325 @@
+// 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");
+ }
+
+ 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
+ {
+ 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('"');
+ }
+
+ // --- 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..006a4c86025
--- /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 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. │
+// └─────────┴────────────────────────────────────────────────────────────────┘
+//
+
+///
+/// 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 e7e1e91cde5..ebc0c3ea7c1 100644
--- a/src/Aspire.Cli/Program.cs
+++ b/src/Aspire.Cli/Program.cs
@@ -92,6 +92,33 @@ 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.
+ ///
+ internal static string? ParseLogFileOption(string[]? args)
+ {
+ if (args is null)
+ {
+ return null;
+ }
+
+ 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];
+ }
+ }
+
+ return null;
+ }
+
private static string GetGlobalSettingsPath()
{
var usersAspirePath = GetUsersAspirePath();
@@ -159,7 +186,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));
@@ -706,4 +736,3 @@ public void Enrich(Profile profile)
profile.Capabilities.Interactive = true;
}
}
-
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。
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);
+ }
+}