File: Backchannel\OrphanedAppHostCollector.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 Aspire.Cli.Processes;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Backchannel;
 
/// <summary>
/// Utility class for cleaning up AppHost process trees whose launching CLI is no longer running 
/// (including removing their backchannel sockets).
/// </summary>
internal sealed class OrphanedAppHostCollector(
    IAuxiliaryBackchannelMonitor backchannelMonitor,
    IAppHostStopper processShutdownService,
    ILogger<OrphanedAppHostCollector> logger)
{
    /// <summary>
    /// Scans for running AppHosts and stops every one whose launching CLI is no longer alive (best effort).
    /// </summary>
    /// <returns>The number of orphaned AppHosts that were collected.</returns>
    public async Task<int> CollectAsync(CancellationToken cancellationToken)
    {
        List<IAppHostAuxiliaryBackchannel> orphans;
        try
        {
            await backchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false);
 
            orphans = backchannelMonitor.Connections.Where(IsOrphaned).ToList();
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            // Discovering orphans is best effort: a scan or liveness-probe failure must not fail the
            // caller (e.g. `aspire ps`/`aspire stop`), which honors the "(best effort)" contract without
            // requiring every call site to guard. Cancellation still propagates so callers can abort.
            logger.LogDebug(ex, "Failed to scan for orphaned AppHosts.");
            return 0;
        }
 
        if (orphans.Count == 0)
        {
            return 0;
        }
 
        var collected = 0;
        foreach (var connection in orphans)
        {
            cancellationToken.ThrowIfCancellationRequested();
 
            var appHostInfo = connection.AppHostInfo;
            try
            {
                var stopped = await processShutdownService.StopAppHostAsync(
                    appHostInfo,
                    connection.StopAppHostAsync,
                    cancellationToken).ConfigureAwait(false);
 
                if (stopped)
                {
                    // The process is confirmed gone, so the socket's owner is gone and the file is safe
                    // to remove by exact path (mirrors StopCommand). Leaving it behind would have later
                    // commands rediscover a dead AppHost.
                    AppHostHelper.TryDeleteSocketFile(connection.SocketPath, logger);
                    collected++;
                    logger.LogDebug(
                        "Collected orphaned AppHost {AppHostPath} (PID {AppHostPid}); its launching CLI {CliPid} is no longer running.",
                        appHostInfo?.AppHostPath,
                        appHostInfo?.ProcessId,
                        appHostInfo?.CliProcessId);
                }
                else
                {
                    logger.LogDebug(
                        "Failed to collect orphaned AppHost {AppHostPath} (PID {AppHostPid}).",
                        appHostInfo?.AppHostPath,
                        appHostInfo?.ProcessId);
                }
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                logger.LogDebug(ex, "Error while collecting orphaned AppHost at {SocketPath}.", connection.SocketPath);
            }
        }
 
        return collected;
    }
 
    internal static bool IsOrphaned(IAppHostAuxiliaryBackchannel connection)
    {
        // Only AppHosts launched by a CLI can be attributed to an owner. 
        // Without a CliProcessId we cannot tell whether the AppHost is orphaned, so we leave it alone.
        if (connection.AppHostInfo is not { CliProcessId: int cliPid })
        {
            return false;
        }
 
        if (connection.AppHostInfo.CliStableStartedAt is { } cliStableStartedAt)
        {
            // Current AppHosts report the launching CLI's start time in Unix milliseconds.
            // It is immune to wall-clock shifts, so a millisecond comparison reliably distinguishes the
            // original launcher from a recycled PID: a mismatch here is trustworthy evidence the launcher is gone.
            return !ProcessStartTimeHelper.IsProcessRunning(cliPid, cliStableStartedAt.ToUnixTimeMilliseconds());
        }
 
        // Legacy fallback (older AppHost): only ASPIRE_CLI_STARTED is available, which is stamped from Process.StartTime. 
        // On Linux that value drifts across processes after any clock correction (NTP, container suspend/resume), 
        // so a start-time MISMATCH is NOT trustworthy evidence that the PID was reused. 
        // We therefore only treat the AppHost as orphaned when the launching CLI PID is entirely gone, 
        // biasing toward leaving a possible orphan (still guarded by the in-process watchdogs) over tearing down a live one.
        return !ProcessStartTimeHelper.IsProcessRunning(cliPid);
    }
}