File: Commands\StopCommand.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// 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 Aspire.Cli.Backchannel;
using Aspire.Cli.Interaction;
using Aspire.Cli.Processes;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Logging;
using Semver;
 
namespace Aspire.Cli.Commands;
 
internal sealed class StopCommand : BaseCommand
{
    internal override HelpGroup HelpGroup => HelpGroup.AppCommands;
 
    protected override bool UpdateNotificationsEnabled => true;
 
    private readonly AppHostConnectionResolver _connectionResolver;
    private readonly ILogger<StopCommand> _logger;
    private readonly ICliHostEnvironment _hostEnvironment;
    private readonly IEnvironment _environment;
    private readonly ProcessTreeGracefulShutdownService _processShutdownService;
    private readonly OrphanedAppHostCollector _collector;
    private readonly ProfilingTelemetry _profilingTelemetry;
    private readonly IProjectLocator _projectLocator;
    private readonly IAppHostInfoResolver _appHostInfoResolver;
    private readonly ILanguageDiscovery _languageDiscovery;
    private readonly DcpWorkloadCleanupService _dcpCleanupService;
 
    private const int MinimumHostingMajorVersionForPersistentResourceCleanup = 13;
    private const int MinimumHostingMinorVersionForPersistentResourceCleanup = 5;
    private const string MinimumHostingVersionForPersistentResourceCleanupDisplay = "13.5.0";
 
    private static readonly OptionWithLegacy<FileInfo?> s_appHostOption = new("--apphost", "--project", StopCommandStrings.ProjectArgumentDescription);
 
    private static readonly Option<bool> s_allOption = new("--all")
    {
        Description = StopCommandStrings.AllOptionDescription
    };
 
    private static readonly Option<bool> s_forceOption = new("--force")
    {
        Description = StopCommandStrings.ForceOptionDescription
    };
 
    public StopCommand(
        AppHostConnectionResolver connectionResolver,
        ICliHostEnvironment hostEnvironment,
        IEnvironment environment,
        ProcessTreeGracefulShutdownService processShutdownService,
        IProjectLocator projectLocator,
        IAppHostInfoResolver appHostInfoResolver,
        ILanguageDiscovery languageDiscovery,
        DcpWorkloadCleanupService dcpCleanupService,
        OrphanedAppHostCollector collector,
        ILogger<StopCommand> logger,
        ProfilingTelemetry profilingTelemetry,
        CommonCommandServices services)
        : base("stop", StopCommandStrings.Description, services)
    {
        _connectionResolver = connectionResolver;
        _hostEnvironment = hostEnvironment;
        _environment = environment;
        _processShutdownService = processShutdownService;
        _projectLocator = projectLocator;
        _appHostInfoResolver = appHostInfoResolver;
        _languageDiscovery = languageDiscovery;
        _dcpCleanupService = dcpCleanupService;
        _collector = collector;
        _logger = logger;
        _profilingTelemetry = profilingTelemetry;
 
        Options.Add(s_appHostOption);
        Options.Add(s_allOption);
        Options.Add(s_forceOption);
    }
 
    protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        var passedAppHostProjectFile = parseResult.GetValue(s_appHostOption);
        var stopAll = parseResult.GetValue(s_allOption);
        var force = parseResult.GetValue(s_forceOption);
        using var activity = _profilingTelemetry.StartStopCommand(stopAll, passedAppHostProjectFile is not null);
 
        // Validate mutual exclusivity of --all and --project
        if (stopAll && passedAppHostProjectFile is not null)
        {
            return CommandResult.Failure(CompleteStopActivity(activity, CliExitCodes.FailedToFindProject), string.Format(CultureInfo.InvariantCulture, StopCommandStrings.AllAndProjectMutuallyExclusive, s_allOption.Name, s_appHostOption.Name));
        }
 
        if (stopAll && force)
        {
            return CommandResult.Failure(CompleteStopActivity(activity, CliExitCodes.InvalidCommand), string.Format(CultureInfo.InvariantCulture, StopCommandStrings.AllAndProjectMutuallyExclusive, s_allOption.Name, s_forceOption.Name));
        }
 
        if (force)
        {
            return CommandResult.FromExitCode(CompleteStopActivity(activity, await ForceStopAppHostAsync(passedAppHostProjectFile, cancellationToken).ConfigureAwait(false)));
        }
 
        // Handle --all: stop all running AppHosts
        if (stopAll)
        {
            return CommandResult.FromExitCode(CompleteStopActivity(activity, await StopAllAppHostsAsync(cancellationToken)));
        }
 
        // In non-interactive mode, try to auto-resolve without prompting
        if (!_hostEnvironment.SupportsInteractiveInput)
        {
            return CommandResult.FromExitCode(CompleteStopActivity(activity, await ExecuteNonInteractiveAsync(passedAppHostProjectFile, cancellationToken)));
        }
 
        return CommandResult.FromExitCode(CompleteStopActivity(activity, await ExecuteInteractiveAsync(passedAppHostProjectFile, cancellationToken)));
    }
 
    private async Task<int> ForceStopAppHostAsync(FileInfo? passedAppHostProjectFile, CancellationToken cancellationToken)
    {
        var stopResult = _hostEnvironment.SupportsInteractiveInput
            ? await ExecuteInteractiveWithResultAsync(passedAppHostProjectFile, cancellationToken).ConfigureAwait(false)
            : await ExecuteNonInteractiveWithResultAsync(passedAppHostProjectFile, cancellationToken, treatNotRunningAsSuccess: true).ConfigureAwait(false);
 
        if (stopResult.ExitCode != CliExitCodes.Success)
        {
            return stopResult.ExitCode;
        }
 
        var appHostFile = stopResult.AppHostFile;
        if (appHostFile is null && passedAppHostProjectFile is not null && passedAppHostProjectFile.Exists)
        {
            appHostFile = passedAppHostProjectFile;
        }
 
        appHostFile ??= await TryResolveAppHostFileAsync(passedAppHostProjectFile, cancellationToken).ConfigureAwait(false);
        if (appHostFile is null)
        {
            InteractionService.DisplayError(StopCommandStrings.CouldNotDetermineAppHostPath);
            return CliExitCodes.FailedToFindProject;
        }
 
        return await CleanupPersistentResourcesAsync(appHostFile, cancellationToken).ConfigureAwait(false);
    }
 
    private async Task<FileInfo?> TryResolveAppHostFileAsync(
        FileInfo? passedAppHostProjectFile,
        CancellationToken cancellationToken,
        MultipleAppHostProjectsFoundBehavior? multipleAppHostBehaviorOverride = null)
    {
        var multipleAppHostBehavior = multipleAppHostBehaviorOverride ?? (_hostEnvironment.SupportsInteractiveInput
            ? MultipleAppHostProjectsFoundBehavior.Prompt
            : MultipleAppHostProjectsFoundBehavior.Throw);
 
        try
        {
            var searchResult = await _projectLocator.UseOrFindAppHostProjectFileAsync(
                passedAppHostProjectFile,
                multipleAppHostBehavior,
                createSettingsFile: false,
                cancellationToken).ConfigureAwait(false);
 
            return searchResult.SelectedProjectFile;
        }
        catch (ProjectLocatorException ex)
        {
            if (passedAppHostProjectFile is not null)
            {
                var projectOptionSpecifiedAsDirectory = Directory.Exists(passedAppHostProjectFile.FullName);
                var (_, errorMessage) = ProjectLocatorErrorHelper.GetExitCodeAndMessage(ex, projectOptionSpecifiedAsDirectory);
                InteractionService.DisplayError(errorMessage);
            }
            else
            {
                _logger.LogDebug(ex, "Failed to resolve AppHost project file for resource cleanup.");
            }
 
            return null;
        }
    }
 
    /// <summary>
    /// Handles the stop command in non-interactive mode by auto-resolving a single AppHost
    /// or returning an error when multiple AppHosts are running.
    /// </summary>
    private async Task<int> ExecuteNonInteractiveAsync(FileInfo? passedAppHostProjectFile, CancellationToken cancellationToken)
    {
        var result = await ExecuteNonInteractiveWithResultAsync(passedAppHostProjectFile, cancellationToken).ConfigureAwait(false);
        return result.ExitCode;
    }
 
    private async Task<StopAppHostResult> ExecuteNonInteractiveWithResultAsync(FileInfo? passedAppHostProjectFile, CancellationToken cancellationToken, bool treatNotRunningAsSuccess = false)
    {
        // If --project is specified, use the standard resolver (no prompting needed)
        if (passedAppHostProjectFile is not null)
        {
            return await ExecuteInteractiveWithResultAsync(passedAppHostProjectFile, cancellationToken).ConfigureAwait(false);
        }
 
        // Scan for all running AppHosts
        var allConnections = await _connectionResolver.ResolveAllConnectionsAsync(
            SharedCommandStrings.ScanningForRunningAppHosts,
            cancellationToken);
 
        if (allConnections.Length == 0)
        {
            if (treatNotRunningAsSuccess)
            {
                InteractionService.DisplayMessage(KnownEmojis.Information, SharedCommandStrings.AppHostNotRunning);
                return new StopAppHostResult(CliExitCodes.Success, null);
            }
 
            InteractionService.DisplayError(SharedCommandStrings.AppHostNotRunning);
            return new StopAppHostResult(CliExitCodes.FailedToFindProject, null);
        }
 
        // In non-interactive mode, only consider in-scope AppHosts (under current directory)
        // to avoid accidentally stopping unrelated AppHosts
        var inScopeConnections = allConnections.Where(c => c.Connection!.IsInScope).ToArray();
        if (inScopeConnections.Length == 0 && treatNotRunningAsSuccess)
        {
            InteractionService.DisplayMessage(KnownEmojis.Information, SharedCommandStrings.AppHostNotRunning);
            return new StopAppHostResult(CliExitCodes.Success, null);
        }
 
        // Single in-scope AppHost: auto-select it
        if (inScopeConnections.Length == 1)
        {
            var connection = inScopeConnections[0].Connection!;
            var appHostFile = GetAppHostFile(connection);
            if (appHostFile is not null)
            {
                // Reuse the connections already scanned above rather than scanning every socket a second
                // time. A rescan pays each unreachable socket's connect retry budget again.
                return await StopRunningAppHostsForResolvedFileAsync(appHostFile, allConnections, cancellationToken).ConfigureAwait(false);
            }
 
            _profilingTelemetry.CurrentActivity.SetAppHostStopCount(1);
            var exitCode = await StopAppHostAsync(connection, GetSingleAppHostDisplayPath(connection), cancellationToken).ConfigureAwait(false);
            return new StopAppHostResult(exitCode, appHostFile);
        }
 
        // Multiple in-scope AppHosts or none in scope: error with guidance
        InteractionService.DisplayError(string.Format(CultureInfo.InvariantCulture, StopCommandStrings.MultipleAppHostsNonInteractive, s_appHostOption.Name, s_allOption.Name));
        return new StopAppHostResult(CliExitCodes.FailedToFindProject, null);
    }
 
    /// <summary>
    /// Handles the stop command in interactive mode, prompting the user to select an AppHost if multiple are running.
    /// </summary>
    private async Task<int> ExecuteInteractiveAsync(FileInfo? passedAppHostProjectFile, CancellationToken cancellationToken)
    {
        var result = await ExecuteInteractiveWithResultAsync(passedAppHostProjectFile, cancellationToken).ConfigureAwait(false);
        return result.ExitCode;
    }
 
    private async Task<StopAppHostResult> ExecuteInteractiveWithResultAsync(FileInfo? passedAppHostProjectFile, CancellationToken cancellationToken)
    {
        var result = await _connectionResolver.ResolveConnectionAsync(
            passedAppHostProjectFile,
            SharedCommandStrings.ScanningForRunningAppHosts,
            string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, StopCommandStrings.SelectAppHostAction),
            SharedCommandStrings.AppHostNotRunning,
            cancellationToken,
            restrictToCurrentWorktree: true);
 
        if (!result.Success)
        {
            return new StopAppHostResult(AppHostConnectionResultHandler.DisplayFailureAsInformation(result, InteractionService), null);
        }
 
        var appHostFile = GetAppHostFile(result.Connection!);
        if (appHostFile is not null)
        {
            // The resolver returns a single connection, but every instance running at that path should be
            // stopped, so the full set is needed here.
            var allConnections = await _connectionResolver.ResolveAllConnectionsAsync(
                SharedCommandStrings.ScanningForRunningAppHosts,
                cancellationToken).ConfigureAwait(false);
 
            return await StopRunningAppHostsForResolvedFileAsync(appHostFile, allConnections, cancellationToken).ConfigureAwait(false);
        }
 
        _profilingTelemetry.CurrentActivity.SetAppHostStopCount(1);
        var exitCode = await StopAppHostAsync(result.Connection!, GetSingleAppHostDisplayPath(result.Connection!), cancellationToken).ConfigureAwait(false);
        return new StopAppHostResult(exitCode, appHostFile);
    }
 
    private async Task<StopAppHostResult> StopRunningAppHostsForResolvedFileAsync(FileInfo appHostFile, AppHostConnectionResult[] allConnections, CancellationToken cancellationToken)
    {
        var canonicalAppHostPath = PathNormalizer.ResolveToFilesystemPath(appHostFile.FullName);
 
        var matchingConnections = allConnections
            .Where(result =>
                result is { Success: true, Connection.AppHostInfo.AppHostPath: { } connectionAppHostPath } &&
                StringComparers.FileSystemPath.Equals(
                    PathNormalizer.ResolveToFilesystemPath(connectionAppHostPath),
                    canonicalAppHostPath))
            .Select(result => result.Connection!)
            .ToArray();
 
        if (matchingConnections.Length == 0)
        {
            // Reachable when the AppHost exits between being resolved and these connections being
            // enumerated. Reporting it as not running is the right outcome for that race.
            var displayPath = Path.GetRelativePath(ExecutionContext.WorkingDirectory.FullName, appHostFile.FullName);
            InteractionService.DisplayMessage(KnownEmojis.Information, string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.AppHostNotRunningAtPath, displayPath));
 
            return new StopAppHostResult(CliExitCodes.Success, null);
        }
 
        _profilingTelemetry.CurrentActivity.SetAppHostStopCount(matchingConnections.Length);
        var appHostPaths = matchingConnections.Select(GetAppHostPath).ToArray();
        var displayPaths = FileSystemHelper.ShortenPaths(appHostPaths, _environment);
        var includeProcessId = matchingConnections.Length > 1;
        var stopTasks = matchingConnections.Select(connection =>
        {
            var appHostPath = GetAppHostPath(connection);
            var displayPath = includeProcessId
                ? displayPaths[appHostPath]
                : GetSingleAppHostDisplayPath(connection);
            var appHostIdentifier = GetAppHostIdentifier(connection, displayPath, includeProcessId);
            return StopAppHostAsync(connection, appHostIdentifier, cancellationToken);
        }).ToArray();
 
        var results = await Task.WhenAll(stopTasks).ConfigureAwait(false);
        var allStopped = results.All(exitCode => exitCode == CliExitCodes.Success);
 
        return new StopAppHostResult(allStopped ? CliExitCodes.Success : CliExitCodes.FailedToDotnetRunAppHost, appHostFile);
    }
 
    private async Task<int> CleanupPersistentResourcesAsync(FileInfo appHostFile, CancellationToken cancellationToken)
    {
        await WarnIfPersistentResourceCleanupMayBeUnsupportedAsync(appHostFile, cancellationToken).ConfigureAwait(false);
 
        var appHostPath = appHostFile.FullName;
        var appHostDisplayPath = FileSystemHelper.ShortenPaths([appHostPath], _environment)[appHostPath];
        var workloadId = AppHostWorkloadId.Create(appHostFile);
        var cleanupResult = await InteractionService.ShowStatusAsync(
            string.Format(CultureInfo.CurrentCulture, StopCommandStrings.CleaningPersistentResources, appHostDisplayPath),
            () => _dcpCleanupService.CleanupAsync(workloadId, cancellationToken),
            emoji: KnownEmojis.Gear).ConfigureAwait(false);
 
        InteractionService.DisplayPlainText("");
 
        if (!cleanupResult.DcpFound)
        {
            InteractionService.DisplayError(StopCommandStrings.DcpCleanupUnavailable);
            return CliExitCodes.FailedToDotnetRunAppHost;
        }
 
        if (cleanupResult.ExitCode != 0)
        {
            var details = GetDcpCleanupErrorDetails(cleanupResult);
            InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, StopCommandStrings.DcpCleanupFailed, appHostDisplayPath, details));
            return CliExitCodes.FailedToDotnetRunAppHost;
        }
 
        InteractionService.DisplaySuccess(string.Format(CultureInfo.CurrentCulture, StopCommandStrings.PersistentResourcesCleaned, appHostDisplayPath));
        return CliExitCodes.Success;
    }
 
    private async Task WarnIfPersistentResourceCleanupMayBeUnsupportedAsync(FileInfo appHostFile, CancellationToken cancellationToken)
    {
        if (!IsDotNetAppHost(appHostFile))
        {
            return;
        }
 
        AppHostProjectInfo appHostInfo;
        try
        {
            appHostInfo = await _appHostInfoResolver.GetAppHostInfoAsync(appHostFile, cancellationToken).ConfigureAwait(false);
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            _logger.LogDebug(ex, "Failed to inspect AppHost project for persistent resource cleanup compatibility.");
            InteractionService.DisplayMessage(KnownEmojis.Warning, StopCommandStrings.DcpCleanupCompatibilityCheckFailed);
            return;
        }
 
        if (appHostInfo.ExitCode != 0 || !appHostInfo.IsAspireHost)
        {
            InteractionService.DisplayMessage(KnownEmojis.Warning, StopCommandStrings.DcpCleanupCompatibilityCheckFailed);
            return;
        }
 
        if (appHostInfo.IsUsingCliBundle || SupportsPersistentResourceCleanup(appHostInfo.AspireHostingVersion))
        {
            return;
        }
 
        var appHostVersion = string.IsNullOrWhiteSpace(appHostInfo.AspireHostingVersion)
            ? StopCommandStrings.UnknownAspireHostingVersion
            : appHostInfo.AspireHostingVersion;
        InteractionService.DisplayMessage(KnownEmojis.Warning, string.Format(
            CultureInfo.CurrentCulture,
            StopCommandStrings.DcpCleanupUnsupportedAppHostVersion,
            appHostVersion,
            MinimumHostingVersionForPersistentResourceCleanupDisplay));
    }
 
    private static bool SupportsPersistentResourceCleanup(string? aspireHostingVersion)
    {
        if (string.IsNullOrWhiteSpace(aspireHostingVersion) ||
            !SemVersion.TryParse(aspireHostingVersion, SemVersionStyles.Any, out var version))
        {
            return false;
        }
 
        return version.Major > MinimumHostingMajorVersionForPersistentResourceCleanup ||
            (version.Major == MinimumHostingMajorVersionForPersistentResourceCleanup &&
             version.Minor >= MinimumHostingMinorVersionForPersistentResourceCleanup);
    }
 
    private bool IsDotNetAppHost(FileInfo appHostFile)
    {
        var language = _languageDiscovery.GetLanguageByFile(appHostFile);
        return language?.LanguageId.Value.Equals(KnownLanguageId.CSharp, StringComparison.OrdinalIgnoreCase) == true;
    }
 
    /// <summary>
    /// Stops all running AppHosts discovered via socket scanning.
    /// </summary>
    private async Task<int> StopAllAppHostsAsync(CancellationToken cancellationToken)
    {
        // First collect AppHosts whose launching CLI has died.
        // Collecting first guarantees orphaned trees and their stale sockets are cleaned up
        // even if the normal stop path can't connect to one of them. CollectAsync is best effort and
        // never throws for scan/stop failures (only cancellation propagates), so no guard is needed here.
        var collected = await _collector.CollectAsync(cancellationToken).ConfigureAwait(false);
        if (collected > 0)
        {
            _logger.LogDebug("Collected {Count} orphaned AppHost(s) before stopping the rest.", collected);
        }
 
        var allConnections = await _connectionResolver.ResolveAllConnectionsAsync(
            SharedCommandStrings.ScanningForRunningAppHosts,
            cancellationToken);
        _profilingTelemetry.CurrentActivity.SetAppHostStopCount(allConnections.Length);
 
        if (allConnections.Length == 0)
        {
            InteractionService.DisplayMessage(KnownEmojis.Information, SharedCommandStrings.AppHostNotRunning);
            return CliExitCodes.Success;
        }
 
        _logger.LogDebug("Found {Count} running AppHost(s) to stop", allConnections.Length);
 
        var connections = allConnections.Select(connectionResult => connectionResult.Connection!).ToArray();
        var appHostPaths = connections.Select(GetAppHostPath).ToArray();
        var appHostPathComparer = GetAppHostPathComparer();
        var displayPaths = FileSystemHelper.ShortenPaths(appHostPaths, _environment);
        var appHostPathCounts = appHostPaths
            .GroupBy(path => path, appHostPathComparer)
            .ToDictionary(group => group.Key, group => group.Count(), appHostPathComparer);
 
        // Stop all AppHosts in parallel
        var stopTasks = connections.Select(connection =>
        {
            var appHostPath = GetAppHostPath(connection);
            var displayPath = displayPaths[appHostPath];
            var appHostIdentifier = GetAppHostIdentifier(connection, displayPath, appHostPathCounts[appHostPath] > 1);
            _logger.LogDebug("Queuing stop for AppHost: {AppHostPath}", appHostPath);
            return StopAppHostAsync(connection, appHostIdentifier, cancellationToken);
        }).ToArray();
 
        var results = await Task.WhenAll(stopTasks);
        var allStopped = results.All(exitCode => exitCode == CliExitCodes.Success);
 
        _logger.LogDebug("Stop all completed. All stopped: {AllStopped}", allStopped);
 
        return allStopped ? CliExitCodes.Success : CliExitCodes.FailedToDotnetRunAppHost;
    }
 
    /// <summary>
    /// Stops a single AppHost by sending a stop signal to its CLI process or falling back to RPC.
    /// </summary>
    private async Task<int> StopAppHostAsync(IAppHostAuxiliaryBackchannel connection, string appHostIdentifier, CancellationToken cancellationToken)
    {
        // Stop the selected AppHost
        var appHostPath = connection.AppHostInfo?.AppHostPath ?? "Unknown";
        var appHostInfo = connection.AppHostInfo;
        using var activity = _profilingTelemetry.StartStopAppHost(appHostInfo);
        InteractionService.DisplayMessage(KnownEmojis.Package, string.Format(CultureInfo.CurrentCulture, StopCommandStrings.FoundRunningAppHost, appHostIdentifier));
        _logger.LogDebug("Stopping AppHost: {AppHostPath}", appHostPath);
 
        InteractionService.DisplayMessage(KnownEmojis.StopSign, string.Format(CultureInfo.CurrentCulture, StopCommandStrings.SendingStopSignal, appHostIdentifier));
 
        var stopped = await InteractionService.ShowStatusAsync(
            string.Format(CultureInfo.CurrentCulture, StopCommandStrings.StoppingAppHost, appHostIdentifier),
            async () => await _processShutdownService.StopAppHostAsync(appHostInfo, connection.StopAppHostAsync, cancellationToken).ConfigureAwait(false));
 
        // Reset cursor position after spinner
        InteractionService.DisplayPlainText("");
 
        if (stopped)
        {
            // ProcessShutdownService only reports success once it has confirmed the AppHost process has
            // terminated, so the socket's owner is gone and the file is safe to remove by exact path. Doing
            // it here is the primary guard against a stale socket tripping up later commands: the AppHost's own
            // cleanup is skipped if it crashes hard, and the orphan-pruning backstop misfires on Windows when the
            // dead PID is reused (https://github.com/microsoft/aspire/issues/17587).
            connection.Socket.TryDelete();
            InteractionService.DisplaySuccess(string.Format(CultureInfo.CurrentCulture, StopCommandStrings.AppHostStoppedSuccessfully, appHostIdentifier));
            return CompleteStopActivity(activity, CliExitCodes.Success);
        }
        else
        {
            InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, StopCommandStrings.FailedToStopAppHost, appHostIdentifier));
            return CompleteStopActivity(activity, CliExitCodes.FailedToDotnetRunAppHost);
        }
    }
 
    private static int CompleteStopActivity(ProfilingTelemetry.ActivityScope activity, int exitCode)
    {
        activity.SetProcessExitCode(exitCode);
        if (exitCode != CliExitCodes.Success)
        {
            activity.SetError($"Stop exited with code {exitCode}.");
        }
 
        return exitCode;
    }
 
    private string GetSingleAppHostDisplayPath(IAppHostAuxiliaryBackchannel connection)
    {
        if (string.IsNullOrEmpty(connection.AppHostInfo?.AppHostPath))
        {
            return "Unknown";
        }
 
        var appHostPath = connection.AppHostInfo.AppHostPath;
        return connection.IsInScope
            ? Path.GetRelativePath(ExecutionContext.WorkingDirectory.FullName, appHostPath)
            : appHostPath;
    }
 
    private static string GetAppHostPath(IAppHostAuxiliaryBackchannel connection)
    {
        return string.IsNullOrEmpty(connection.AppHostInfo?.AppHostPath)
            ? "Unknown"
            : connection.AppHostInfo.AppHostPath;
    }
 
    private static FileInfo? GetAppHostFile(IAppHostAuxiliaryBackchannel connection)
    {
        return string.IsNullOrEmpty(connection.AppHostInfo?.AppHostPath)
            ? null
            : new FileInfo(connection.AppHostInfo.AppHostPath);
    }
 
    private static string GetDcpCleanupErrorDetails(DcpWorkloadCleanupResult cleanupResult)
    {
        var details = !string.IsNullOrWhiteSpace(cleanupResult.Error)
            ? cleanupResult.Error
            : cleanupResult.Output;
 
        return string.IsNullOrWhiteSpace(details)
            ? string.Format(CultureInfo.CurrentCulture, StopCommandStrings.DcpCleanupExitCode, cleanupResult.ExitCode)
            : details.Trim();
    }
 
    private StringComparer GetAppHostPathComparer()
    {
        return _environment.IsWindows()
            ? StringComparer.OrdinalIgnoreCase
            : StringComparer.Ordinal;
    }
 
    private static string GetAppHostIdentifier(IAppHostAuxiliaryBackchannel connection, string displayPath, bool includeProcessId)
    {
        return includeProcessId && connection.AppHostInfo is { } appHostInfo
            ? string.Format(CultureInfo.CurrentCulture, StopCommandStrings.AppHostIdentifierWithProcessId, displayPath, appHostInfo.ProcessId)
            : displayPath;
    }
 
    private sealed record StopAppHostResult(int ExitCode, FileInfo? AppHostFile);
}