// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.CommandLine;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Channels;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Interaction;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
using Spectre.Console;
namespace Aspire.Cli.Commands;
/// <summary>
/// Represents information about a running AppHost for JSON serialization.
/// Aligned with AppHostListInfo from ListAppHostsTool.
/// </summary>
// `aspire ps --format json` uses this shape; keep docs/specs/cli-output-formats.md in sync when changing it.
internal sealed class AppHostDisplayInfo
{
public required string AppHostPath { get; init; }
public required int AppHostPid { get; init; }
public string Status { get; init; } = AppHostDisplayStatus.Running;
public string? SdkVersion { get; init; }
public int? CliPid { get; init; }
public string? DashboardUrl { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? LogFilePath { get; init; }
}
internal static class AppHostDisplayStatus
{
public const string Running = "running";
public const string Stopped = "stopped";
}
[JsonSerializable(typeof(List<AppHostDisplayInfo>))]
[JsonSerializable(typeof(AppHostDisplayInfo))]
[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
internal sealed partial class PsCommandJsonContext : JsonSerializerContext
{
private static PsCommandJsonContext? s_relaxedEscaping;
private static PsCommandJsonContext? s_compactRelaxedEscaping;
/// <summary>
/// Gets a context with relaxed JSON escaping for non-ASCII character support.
/// </summary>
public static PsCommandJsonContext RelaxedEscaping => s_relaxedEscaping ??= new(new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
/// <summary>
/// Gets a compact context with relaxed JSON escaping for newline-delimited streaming output.
/// </summary>
public static PsCommandJsonContext CompactRelaxedEscaping => s_compactRelaxedEscaping ??= new(new JsonSerializerOptions
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
}
internal sealed partial class PsCommand : BaseCommand
{
internal override HelpGroup HelpGroup => HelpGroup.AppCommands;
private readonly IAuxiliaryBackchannelMonitor _backchannelMonitor;
private readonly IEnvironment _environment;
private readonly OrphanedAppHostCollector _collector;
private readonly ILogger<PsCommand> _logger;
private static readonly Option<OutputFormat> s_formatOption = new("--format")
{
Description = PsCommandStrings.JsonOptionDescription
};
private static readonly Option<bool> s_followOption = new("--follow", "-f")
{
Description = PsCommandStrings.FollowOptionDescription
};
public PsCommand(
IAuxiliaryBackchannelMonitor backchannelMonitor,
IEnvironment environment,
OrphanedAppHostCollector collector,
ILogger<PsCommand> logger,
CommonCommandServices services)
: base("ps", PsCommandStrings.Description, services)
{
_backchannelMonitor = backchannelMonitor;
_environment = environment;
_collector = collector;
_logger = logger;
Options.Add(s_formatOption);
Options.Add(s_followOption);
}
protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
using var activity = Telemetry.StartDiagnosticActivity(Name);
var format = parseResult.GetValue(s_formatOption);
if (parseResult.GetValue(s_followOption))
{
return await ExecuteFollowAsync(format, cancellationToken).ConfigureAwait(false);
}
// Collect AppHosts whose launching CLI has died before listing, so the output reflects reality and
// leaked aspire-managed/AppHost processes are cleaned up. Best effort: CollectAsync swallows scan/stop
// failures (only cancellation propagates), so a collection hiccup never fails `aspire ps`. The listing
// scan below still surfaces its own failures.
await _collector.CollectAsync(cancellationToken).ConfigureAwait(false);
// Scan for running AppHosts (same as ListAppHostsTool). JSON output must not go
// through status rendering because non-interactive status text shares stdout.
var connections = format == OutputFormat.Json
? await ScanForConnectionsAsync(cancellationToken).ConfigureAwait(false)
: await InteractionService.ShowStatusAsync(
SharedCommandStrings.ScanningForRunningAppHosts,
async () => await ScanForConnectionsAsync(cancellationToken).ConfigureAwait(false));
if (connections.Count == 0)
{
if (format == OutputFormat.Json)
{
// Structured output always goes to stdout.
InteractionService.DisplayRawText("[]", ConsoleOutput.Standard);
}
else
{
InteractionService.DisplayMessage(KnownEmojis.Information, SharedCommandStrings.AppHostNotRunning);
}
return CommandResult.Success();
}
// Order: in-scope first, then out-of-scope
var orderedConnections = connections
.OrderByDescending(c => c.IsInScope)
.ToList();
// Gather info for each AppHost
var appHostInfos = await GatherAppHostInfosAsync(orderedConnections, cancellationToken).ConfigureAwait(false);
if (format == OutputFormat.Json)
{
var json = JsonSerializer.Serialize(appHostInfos, PsCommandJsonContext.RelaxedEscaping.ListAppHostDisplayInfo);
// Structured output always goes to stdout.
InteractionService.DisplayRawText(json, ConsoleOutput.Standard);
}
else
{
DisplayTable(appHostInfos);
}
return CommandResult.Success();
}
private async Task<List<IAppHostAuxiliaryBackchannel>> ScanForConnectionsAsync(CancellationToken cancellationToken)
{
await _backchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false);
return _backchannelMonitor.Connections.ToList();
}
private abstract record PsFollowUpdate;
private sealed record ConnectionsUpdate(IReadOnlyList<IAppHostAuxiliaryBackchannel> Connections) : PsFollowUpdate;
private async Task<CommandResult> ExecuteFollowAsync(OutputFormat format, CancellationToken cancellationToken)
{
if (format != OutputFormat.Json)
{
return CommandResult.Failure(CliExitCodes.InvalidCommand, PsCommandStrings.FollowRequiresJson);
}
using var followCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var followCancellationToken = followCancellationTokenSource.Token;
var updates = Channel.CreateUnbounded<PsFollowUpdate>(new UnboundedChannelOptions
{
SingleReader = true
});
var appHostKeyComparer = GetAppHostKeyComparer();
var activeAppHosts = new Dictionary<string, AppHostDisplayInfo>(appHostKeyComparer);
var lastJsonByAppHost = new Dictionary<string, string>(appHostKeyComparer);
_ = Task.Run(async () =>
{
try
{
await foreach (var connections in _backchannelMonitor.WatchConnectionsAsync(followCancellationToken).WithCancellation(followCancellationToken).ConfigureAwait(false))
{
await updates.Writer.WriteAsync(new ConnectionsUpdate(connections), followCancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (followCancellationToken.IsCancellationRequested)
{
// Expected when the caller stops following.
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed while watching AppHost connections for ps --follow.");
}
finally
{
updates.Writer.TryComplete();
}
}, CancellationToken.None);
try
{
await foreach (var update in updates.Reader.ReadAllAsync(followCancellationToken).ConfigureAwait(false))
{
if (update is ConnectionsUpdate connectionsUpdate)
{
var currentConnections = OrderConnections(connectionsUpdate.Connections);
var currentAppHosts = await GatherAppHostInfosAsync(currentConnections, followCancellationToken).ConfigureAwait(false);
var nextActiveAppHosts = new Dictionary<string, AppHostDisplayInfo>(appHostKeyComparer);
foreach (var appHost in currentAppHosts)
{
nextActiveAppHosts[GetAppHostKey(appHost)] = appHost;
if (!await TryWriteAppHostInfoAsync(appHost).ConfigureAwait(false))
{
return CommandResult.Success();
}
}
foreach (var (key, appHost) in activeAppHosts)
{
if (!nextActiveAppHosts.ContainsKey(key) &&
!await TryWriteAppHostInfoAsync(CopyWithStatus(appHost, AppHostDisplayStatus.Stopped)).ConfigureAwait(false))
{
return CommandResult.Success();
}
}
activeAppHosts = nextActiveAppHosts;
}
}
}
catch (OperationCanceledException) when (followCancellationToken.IsCancellationRequested)
{
return CommandResult.Success();
}
catch (IOException ex)
{
_logger.LogDebug(ex, "Stopping ps --follow because the output stream is no longer writable.");
return CommandResult.Success();
}
finally
{
await followCancellationTokenSource.CancelAsync().ConfigureAwait(false);
}
return CommandResult.Success();
async Task<bool> TryWriteAppHostInfoAsync(AppHostDisplayInfo appHost)
{
var key = GetAppHostKey(appHost);
var json = JsonSerializer.Serialize(appHost, PsCommandJsonContext.CompactRelaxedEscaping.AppHostDisplayInfo);
if (lastJsonByAppHost.TryGetValue(key, out var lastJson) &&
string.Equals(json, lastJson, StringComparison.Ordinal))
{
return true;
}
lastJsonByAppHost[key] = json;
try
{
InteractionService.DisplayRawText(json, ConsoleOutput.Standard);
return true;
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException)
{
_logger.LogDebug(ex, "Stopping ps --follow because the output stream is no longer writable.");
await followCancellationTokenSource.CancelAsync().ConfigureAwait(false);
return false;
}
}
}
}
internal sealed partial class PsCommand
{
private static string GetAppHostKey(AppHostDisplayInfo appHost)
{
return string.Concat(appHost.AppHostPath, "\0", appHost.AppHostPid.ToString(CultureInfo.InvariantCulture));
}
private StringComparer GetAppHostKeyComparer()
{
return _environment.IsWindows()
? StringComparer.OrdinalIgnoreCase
: StringComparer.Ordinal;
}
private static AppHostDisplayInfo CopyWithStatus(AppHostDisplayInfo appHost, string status)
{
return new AppHostDisplayInfo
{
AppHostPath = appHost.AppHostPath,
AppHostPid = appHost.AppHostPid,
Status = status,
SdkVersion = appHost.SdkVersion,
CliPid = appHost.CliPid,
DashboardUrl = appHost.DashboardUrl,
LogFilePath = appHost.LogFilePath
};
}
private static List<IAppHostAuxiliaryBackchannel> OrderConnections(IEnumerable<IAppHostAuxiliaryBackchannel> connections)
{
return connections
.OrderByDescending(c => c.IsInScope)
.ToList();
}
private async Task<List<AppHostDisplayInfo>> GatherAppHostInfosAsync(List<IAppHostAuxiliaryBackchannel> connections, CancellationToken cancellationToken)
{
var appHostInfos = new List<AppHostDisplayInfo>();
foreach (var connection in connections)
{
var info = connection.AppHostInfo;
if (info is null)
{
continue;
}
string? sdkVersion = null;
var appHostPath = info.AppHostPath;
var appHostPid = info.ProcessId;
var cliPid = info.CliProcessId;
var cliLogFilePath = info.CliLogFilePath;
try
{
if (connection.SupportsV2)
{
var v2Info = await connection.GetAppHostInfoV2Async(cancellationToken).ConfigureAwait(false);
if (v2Info is not null)
{
sdkVersion = GetSdkVersion(v2Info.AspireHostVersion);
appHostPath = string.IsNullOrWhiteSpace(v2Info.AppHostPath) ? appHostPath : v2Info.AppHostPath;
cliPid = v2Info.CliProcessId ?? cliPid;
cliLogFilePath = v2Info.CliLogFilePath ?? cliLogFilePath;
if (int.TryParse(v2Info.Pid, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedPid))
{
appHostPid = parsedPid;
}
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to get AppHost SDK version for {AppHostPath}", info.AppHostPath);
}
string? dashboardUrl = null;
try
{
var dashboardUrls = await connection.GetDashboardUrlsAsync(cancellationToken).ConfigureAwait(false);
dashboardUrl = dashboardUrls?.BaseUrlWithLoginToken;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to get dashboard URL for {AppHostPath}", info.AppHostPath);
}
appHostInfos.Add(new AppHostDisplayInfo
{
AppHostPath = appHostPath ?? PsCommandStrings.UnknownPath,
AppHostPid = appHostPid,
Status = AppHostDisplayStatus.Running,
SdkVersion = sdkVersion,
CliPid = cliPid,
DashboardUrl = dashboardUrl,
LogFilePath = cliLogFilePath
});
}
return appHostInfos;
}
private static string? GetSdkVersion(string? sdkVersion)
{
if (string.IsNullOrWhiteSpace(sdkVersion) ||
string.Equals(sdkVersion, "unknown", StringComparison.OrdinalIgnoreCase))
{
return null;
}
return sdkVersion;
}
private void DisplayTable(List<AppHostDisplayInfo> appHosts)
{
if (appHosts.Count == 0)
{
return;
}
var shortPaths = FileSystemHelper.ShortenPaths(appHosts.Select(a => a.AppHostPath).ToList(), _environment);
var table = new Table();
table.AddBoldColumn(PsCommandStrings.HeaderPath);
table.AddBoldColumn(SharedCommandStrings.HeaderStatus);
table.AddBoldColumn(PsCommandStrings.HeaderSdk);
table.AddBoldColumn(PsCommandStrings.HeaderPid);
table.AddBoldColumn(PsCommandStrings.HeaderCliPid);
table.AddBoldColumn(PsCommandStrings.HeaderDashboard);
foreach (var appHost in appHosts)
{
var shortPath = shortPaths[appHost.AppHostPath];
var cliPid = appHost.CliPid?.ToString(CultureInfo.InvariantCulture) ?? "-";
var dashboard = "-";
if (!string.IsNullOrEmpty(appHost.DashboardUrl))
{
if (Uri.TryCreate(appHost.DashboardUrl, UriKind.Absolute, out _))
{
dashboard = MarkupHelpers.SafeLink(InteractionService, appHost.DashboardUrl);
}
else
{
dashboard = Markup.Escape(appHost.DashboardUrl);
}
}
var columns = new List<string>
{
Markup.Escape(shortPath),
GetStatusMarkup(appHost.Status),
Markup.Escape(appHost.SdkVersion ?? "-"),
appHost.AppHostPid.ToString(CultureInfo.InvariantCulture),
cliPid,
};
columns.Add(dashboard);
table.AddRow(columns.ToArray());
}
InteractionService.DisplayRenderable(table);
}
internal static string GetStatusMarkup(string status)
{
return status switch
{
AppHostDisplayStatus.Running => $"[green]{AppHostDisplayStatus.Running}[/]",
AppHostDisplayStatus.Stopped => $"[red]{AppHostDisplayStatus.Stopped}[/]",
_ => Markup.Escape(status)
};
}
}