Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Aspire.Cli/Aspire.Cli.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<EnablePackageValidation>false</EnablePackageValidation>
<AssemblyName>aspire</AssemblyName>
<RootNamespace>Aspire.Cli</RootNamespace>
Expand Down
9 changes: 4 additions & 5 deletions src/Aspire.Cli/Commands/CacheCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ protected override Task<int> ExecuteAsync(ParseResult parseResult, CancellationT
{
var cacheDirectory = ExecutionContext.CacheDirectory;
var filesDeleted = 0;

// Delete cache files and subdirectories
if (cacheDirectory.Exists)
{
Expand Down Expand Up @@ -110,14 +110,13 @@ protected override Task<int> 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;
}
Expand Down Expand Up @@ -167,4 +166,4 @@ protected override Task<int> ExecuteAsync(ParseResult parseResult, CancellationT
}
}
}
}
}
112 changes: 55 additions & 57 deletions src/Aspire.Cli/Commands/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -86,6 +87,11 @@ internal sealed class RunCommand : BaseCommand
{
Description = RunCommandStrings.NoBuildArgumentDescription
};
private static readonly Option<string?> s_logFileOption = new("--log-file")
{
Description = "Path to write the log file (used internally by --detach).",
Hidden = true
};
private readonly Option<bool>? _startDebugSessionOption;

public RunCommand(
Expand Down Expand Up @@ -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 _))
{
Expand Down Expand Up @@ -294,9 +301,9 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell

// Handle remote environments (Codespaces, Remote Containers, SSH)
var isCodespaces = dashboardUrls.CodespacesUrlWithLoginToken is not null;
var isRemoteContainers = _configuration.GetValue<bool>("REMOTE_CONTAINERS", false);
var isSshRemote = _configuration.GetValue<string?>("VSCODE_IPC_HOOK_CLI") is not null
&& _configuration.GetValue<string?>("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);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -655,18 +662,23 @@ private async Task<int> 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<string>
{
"run",
"--non-interactive",
"--project",
effectiveAppHostFile.FullName
effectiveAppHostFile.FullName,
"--log-file",
childLogFile
};

// Pass through global options that should be forwarded to child CLI
Expand Down Expand Up @@ -707,29 +719,15 @@ private async Task<int> 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<string>();
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;
Expand All @@ -741,30 +739,10 @@ private async Task<int> 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)
{
Expand Down Expand Up @@ -843,10 +821,8 @@ private async Task<int> 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
{
Expand All @@ -866,11 +842,11 @@ private async Task<int> 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;
}
Expand All @@ -890,7 +866,7 @@ private async Task<int> ExecuteDetachedAsync(ParseResult parseResult, FileInfo?
pid,
childProcess.Id,
dashboardUrls?.BaseUrlWithLoginToken,
_fileLoggerProvider.LogFilePath);
childLogFile);
var json = JsonSerializer.Serialize(result, RunCommandJsonContext.RelaxedEscaping.DetachOutputInfo);
_interactionService.DisplayRawText(json);
}
Expand All @@ -903,7 +879,7 @@ private async Task<int> ExecuteDetachedAsync(ParseResult parseResult, FileInfo?
appHostRelativePath,
dashboardUrls?.BaseUrlWithLoginToken,
codespacesUrl: null,
_fileLoggerProvider.LogFilePath,
childLogFile,
isExtensionHost,
pid);
_ansiConsole.WriteLine();
Expand All @@ -913,4 +889,26 @@ private async Task<int> 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);
}
}
21 changes: 17 additions & 4 deletions src/Aspire.Cli/Diagnostics/FileLoggerProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,22 @@ internal sealed class FileLoggerProvider : ILoggerProvider
/// </summary>
public string LogFilePath => _logFilePath;

/// <summary>
/// Generates a unique, chronologically-sortable log file name.
/// </summary>
/// <param name="logsDirectory">The directory where log files will be written.</param>
/// <param name="timeProvider">The time provider for timestamp generation.</param>
/// <param name="suffix">An optional suffix appended before the extension (e.g. "detach-child").</param>
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);
}

/// <summary>
/// Creates a new FileLoggerProvider that writes to the specified directory.
/// </summary>
Expand All @@ -37,10 +53,7 @@ internal sealed class FileLoggerProvider : ILoggerProvider
/// <param name="errorConsole">Optional console for error messages. Defaults to stderr.</param>
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
{
Expand Down
48 changes: 48 additions & 0 deletions src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
private static Process StartUnix(string fileName, IReadOnlyList<string> 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;
}
}
Loading
Loading