Skip to content
Open
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
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
}
}
}
}
}
73 changes: 40 additions & 33 deletions src/Aspire.Cli/Commands/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ internal sealed class RunCommand : BaseCommand
{
Description = RunCommandStrings.IsolatedArgumentDescription
};
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 @@ -121,6 +126,7 @@ public RunCommand(
Options.Add(s_detachOption);
Options.Add(s_formatOption);
Options.Add(s_isolatedOption);
Options.Add(s_logFileOption);

if (ExtensionHelper.IsExtensionHost(InteractionService, out _, out _))
{
Expand Down Expand Up @@ -278,9 +284,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 @@ -476,7 +482,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 @@ -639,18 +645,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 @@ -687,14 +698,17 @@ 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
// 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
};
Expand Down Expand Up @@ -727,24 +741,6 @@ private async Task<int> 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)
{
Expand Down Expand Up @@ -823,10 +819,13 @@ 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
var errorMessage = childExitCode switch
{
ExitCodeConstants.FailedToBuildArtifacts => RunCommandStrings.AppHostFailedToBuild,
_ => string.Format(CultureInfo.CurrentCulture, RunCommandStrings.AppHostExitedWithCode, childExitCode)
};
_interactionService.DisplayError(errorMessage);
Comment on lines +822 to +828
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes to detached execution introduced new behaviors that are currently untested (e.g., --log-file propagation, exit-code-to-message mapping for FailedToBuildArtifacts, and ensuring the correct log file path is surfaced on failure). There are existing RunCommandTests, but none cover --detach; adding focused unit/integration tests for these paths would help prevent regressions on Windows and in programmatic consumers.

Copilot uses AI. Check for mistakes.
}
else
{
Expand All @@ -846,11 +845,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 Down Expand Up @@ -893,4 +892,12 @@ private async Task<int> ExecuteDetachedAsync(ParseResult parseResult, FileInfo?

return ExitCodeConstants.Success;
}

private string GenerateChildLogFilePath()
{
return Diagnostics.FileLoggerProvider.GenerateLogFilePath(
ExecutionContext.LogsDirectory.FullName,
_timeProvider,
suffix: "detach-child");
Comment on lines +898 to +901
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GenerateChildLogFilePath() calls FileLoggerProvider.GenerateLogFilePath(), which embeds Environment.ProcessId. Since this method runs in the parent process, the child log file name ends up containing the parent PID, which is misleading and breaks the “pid in filename == process writing the log” convention used elsewhere. Consider adjusting the helper to accept an explicit PID (or use a different uniqueness token for precomputed paths) so detached child log files can reflect the child process ID.

This issue also appears on line 653 of the same file.

Suggested change
return Diagnostics.FileLoggerProvider.GenerateLogFilePath(
ExecutionContext.LogsDirectory.FullName,
_timeProvider,
suffix: "detach-child");
var logsDirectory = ExecutionContext.LogsDirectory.FullName;
var now = _timeProvider.GetUtcNow();
var timestamp = now.ToString("yyyyMMddHHmmssfff", CultureInfo.InvariantCulture);
var uniqueId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
var fileName = $"aspire-detach-child-{timestamp}-{uniqueId}.log";
return Path.Combine(logsDirectory, fileName);

Copilot uses AI. Check for mistakes.
}
}
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
27 changes: 26 additions & 1 deletion src/Aspire.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,28 @@ private static (LogLevel? ConsoleLogLevel, bool DebugMode) ParseLoggingOptions(s
return (logLevel, debugMode);
}

/// <summary>
/// Parses --log-file from raw args before the host is built.
/// Used by --detach to tell the child CLI where to write its log.
/// </summary>
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();
Expand Down Expand Up @@ -158,7 +180,10 @@ internal static async Task<IHost> 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<ILoggerProvider>(fileLoggerProvider));

Expand Down
6 changes: 6 additions & 0 deletions src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/Aspire.Cli/Resources/RunCommandStrings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,9 @@
<data name="AppHostExitedWithCode" xml:space="preserve">
<value>AppHost process exited with code {0}.</value>
</data>
<data name="AppHostFailedToBuild" xml:space="preserve">
<value>AppHost failed to build.</value>
</data>
<data name="TimeoutWaitingForAppHost" xml:space="preserve">
<value>Timeout waiting for AppHost to start.</value>
</data>
Expand Down
5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading