File: Processes\ProcessTreeGracefulShutdownService.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.Diagnostics;
using System.Globalization;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Bundles;
using Aspire.Cli.Layout;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Processes;
 
/// <summary>
/// Coordinates graceful process-tree shutdown requests, termination monitoring, and force-kill
/// fallback. Shared by both the detached <c>aspire stop</c> path and the in-process <c>aspire run</c>
/// shutdown ladders, the latter reaching it through <see cref="IProcessTreeGracefulShutdownSignaler"/>.
/// </summary>
internal sealed class ProcessTreeGracefulShutdownService(
    ILayoutDiscovery layoutDiscovery,
    IBundleService bundleService,
    LayoutProcessRunner layoutProcessRunner,
    CliExecutionContext executionContext,
    IEnvironment environment,
    ILogger<ProcessTreeGracefulShutdownService> logger,
    TimeProvider timeProvider) : IProcessTreeGracefulShutdownSignaler, IAppHostStopper
{
    private static readonly TimeSpan s_processTerminationTimeout = TimeSpan.FromSeconds(10);
    private static readonly TimeSpan s_processTerminationPollInterval = TimeSpan.FromMilliseconds(250);
 
    public Task<bool> StopProcessTreeAsync(
        int pid,
        DateTimeOffset? startTime,
        bool includeStartTimeForDcp,
        CancellationToken cancellationToken)
    {
        var processTarget = new ProcessTarget(pid, startTime, UseRuntimeStartTime: false);
        return StopProcessesAsync(
            [processTarget],
            token => RequestProcessTreeGracefulShutdownAsync(processTarget, includeStartTimeForDcp, token),
            cancellationToken);
    }
 
    public async Task<bool> StopAppHostAsync(
        AppHostInformation? appHostInfo,
        Func<CancellationToken, Task<bool>>? requestRpcStopAsync,
        CancellationToken cancellationToken)
    {
        if (appHostInfo is null)
        {
            return requestRpcStopAsync is not null && await TryRequestRpcStopAsync(requestRpcStopAsync, cancellationToken).ConfigureAwait(false);
        }
 
        var appHostProcess = CreateAppHostProcessTarget(appHostInfo);
        var processesToForceKill = new List<ProcessTarget> { appHostProcess };
        if (appHostInfo.CliProcessId is int cliPid)
        {
            // The CLI process is a shutdown handle, not the success condition. On Unix it can remain
            // observable until its parent reaps it after the AppHost has already stopped.
            //
            // AppHostInfo.CliStartedAt comes from ASPIRE_CLI_STARTED, which is intentionally stamped
            // from Process.StartTime for released-AppHost compatibility. Keep that shutdown handle in
            // the legacy/runtime clock domain. AppHost StartedAt can also be legacy/runtime metadata
            // when talking to released AppHosts; current AppHosts send StableStartedAt so their primary
            // process target uses the exact verifier.
            processesToForceKill.Add(new ProcessTarget(cliPid, appHostInfo.CliStartedAt, UseRuntimeStartTime: true));
        }
 
        return await StopProcessesAsync(
            processesToMonitor: [appHostProcess],
            processesToForceKill,
            token => RequestAppHostGracefulShutdownAsync(appHostInfo, requestRpcStopAsync, token),
            cancellationToken).ConfigureAwait(false);
    }
 
    internal async Task<bool> StopProcessesAsync(
        IReadOnlyCollection<ProcessTarget> processesToMonitorAndKill,
        Func<CancellationToken, Task<bool>> requestGracefulShutdownAsync,
        CancellationToken cancellationToken)
    {
        return await StopProcessesAsync(
            processesToMonitorAndKill,
            processesToMonitorAndKill,
            requestGracefulShutdownAsync,
            cancellationToken).ConfigureAwait(false);
    }
 
    private async Task<bool> StopProcessesAsync(
        IReadOnlyCollection<ProcessTarget> processesToMonitor,
        IReadOnlyCollection<ProcessTarget> processesToForceKill,
        Func<CancellationToken, Task<bool>> requestGracefulShutdownAsync,
        CancellationToken cancellationToken)
    {
        var gracefulShutdownRequested = await TryRequestGracefulShutdownAsync(requestGracefulShutdownAsync, cancellationToken).ConfigureAwait(false);
        if (gracefulShutdownRequested && await MonitorProcessesForTerminationAsync(processesToMonitor, cancellationToken).ConfigureAwait(false))
        {
            ForceKillRemainingProcesses(processesToForceKill.Except(processesToMonitor), afterTimeout: false);
            return true;
        }
 
        ForceKillRemainingProcesses(processesToForceKill, afterTimeout: true);
 
        return await MonitorProcessesForTerminationAsync(processesToMonitor, cancellationToken).ConfigureAwait(false);
    }
 
    private void ForceKillRemainingProcesses(IEnumerable<ProcessTarget> processes, bool afterTimeout)
    {
        // On Windows DCP is normally an in-tree descendant of the AppHost, so the success path
        // (afterTimeout: false, which only mops up leftover shutdown-handle processes after a
        // graceful stop succeeded) must NOT tree-kill — that would tear DCP down mid-cleanup.
        // But once graceful shutdown has failed/timed out (afterTimeout: true) the whole point of
        // this escalation is a guaranteed teardown, so we tree-kill on Windows too; otherwise the
        // root PID dies while orphaned descendants (DCP, tsx/node, the guest) keep running. On Unix
        // we always tree-kill.
        var killEntireProcessTree = afterTimeout || !environment.IsWindows();
 
        foreach (var process in processes.Distinct())
        {
            if (afterTimeout)
            {
                logger.LogWarning("Process {Pid} did not stop gracefully within timeout. Forcing process to terminate.", process.Pid);
            }
            else
            {
                logger.LogDebug("Forcing remaining shutdown handle process {Pid} to terminate.", process.Pid);
            }
 
            // Resolve the pid against its expected start time and hard-kill. This path never requests
            // graceful shutdown — the graceful attempt already happened (or was intentionally skipped),
            // so we go straight to the kill that the shared shutdown helper's force mode also performs.
            ForceKill(process, killEntireProcessTree);
        }
    }
 
    private async Task<bool> RequestAppHostGracefulShutdownAsync(
        AppHostInformation appHostInfo,
        Func<CancellationToken, Task<bool>>? requestRpcStopAsync,
        CancellationToken cancellationToken)
    {
        if (!environment.IsWindows())
        {
            // Signal the AppHost directly with SIGTERM. The AppHost catches SIGTERM via
            // Microsoft.Extensions.Hosting and invokes IHostApplicationLifetime.StopApplication,
            // which gives DCP and all in-process resources the orderly shutdown they expect.
            //
            // Routing the graceful signal through the launcher CLI (CliProcessId) cascades via
            // `dotnet run`'s child kill. That walk depends on the AppHost being visible in /proc
            // as a descendant of the `dotnet` process at the moment of the walk, and on the
            // AppHost being reaped by its parent rather than orphaned. When either of those races
            // misfires the AppHost is left running (or lingering as a zombie reparented to PID 1)
            // and the StopCommand monitor then times out reporting "Failed to stop apphost".
            // Targeting the AppHost PID directly avoids the cascade entirely.
            logger.LogDebug("Sending graceful shutdown to AppHost PID {Pid}", appHostInfo.ProcessId);
            return await RequestProcessTreeGracefulShutdownAsync(CreateAppHostProcessTarget(appHostInfo), includeStartTimeForDcp: false, cancellationToken).ConfigureAwait(false);
        }
 
        // On Windows DCP is an in-tree descendant of the AppHost, so we cannot tree-kill the
        // AppHost without also taking DCP down. Instead, run the graceful shutdown against the
        // launcher CLI's process tree (DCP performs `stop-process-tree --skip-descendants`),
        // which signals the AppHost via DCP without disrupting the descendant cleanup DCP is
        // responsible for.
        if (appHostInfo.CliProcessId is int cliPid)
        {
            logger.LogDebug("Requesting AppHost process tree shutdown via root CLI PID {Pid}", cliPid);
            // CliStartedAt is recorded in the legacy Process.StartTime clock domain and with
            // second-level precision, so validate it locally with the runtime verifier instead of
            // passing it to DCP's millisecond-precision process-start-time option.
            return await RequestProcessTreeGracefulShutdownAsync(
                new ProcessTarget(cliPid, appHostInfo.CliStartedAt, UseRuntimeStartTime: true),
                includeStartTimeForDcp: false,
                cancellationToken).ConfigureAwait(false);
        }
 
        if (requestRpcStopAsync is not null && await TryRequestRpcStopAsync(requestRpcStopAsync, cancellationToken).ConfigureAwait(false))
        {
            return true;
        }
 
        logger.LogDebug("RPC stop not available, requesting shutdown via AppHost PID {Pid}", appHostInfo.ProcessId);
        return await RequestProcessTreeGracefulShutdownAsync(CreateAppHostProcessTarget(appHostInfo), includeStartTimeForDcp: true, cancellationToken).ConfigureAwait(false);
    }
 
    private async Task<bool> TryRequestGracefulShutdownAsync(
        Func<CancellationToken, Task<bool>> requestGracefulShutdownAsync,
        CancellationToken cancellationToken)
    {
        try
        {
            return await requestGracefulShutdownAsync(cancellationToken).ConfigureAwait(false);
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            logger.LogWarning(ex, "Failed to request graceful process shutdown.");
            return false;
        }
    }
 
    private async Task<bool> TryRequestRpcStopAsync(Func<CancellationToken, Task<bool>> requestRpcStopAsync, CancellationToken cancellationToken)
    {
        try
        {
            return await requestRpcStopAsync(cancellationToken).ConfigureAwait(false);
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            logger.LogWarning(ex, "Failed to send stop signal via RPC");
            return false;
        }
    }
 
    /// <summary>
    /// Issues an OS-correct graceful shutdown signal to the entire process tree rooted at <paramref name="pid"/>:
    /// on Windows, shells out to DCP's <c>stop-process-tree</c> (which performs the AttachConsole +
    /// GenerateConsoleCtrlEvent dance against a child running in its own console group);
    /// on Unix, sends SIGTERM via <see cref="ProcessSignaler"/>. This method does NOT wait for exit or
    /// escalate to <c>Kill</c> — callers are expected to own that ladder (bounded by a central graceful
    /// shutdown budget) and force-kill on escalation. Used by both the detached <c>aspire stop</c> path
    /// and the in-process <c>aspire run</c> shutdown ladders for AppHost server + guest siblings.
    /// </summary>
    public async Task<bool> RequestProcessTreeGracefulShutdownAsync(
        int pid,
        DateTimeOffset? startTime,
        bool includeStartTimeForDcp,
        CancellationToken cancellationToken)
    {
        return await RequestProcessTreeGracefulShutdownAsync(
            new ProcessTarget(pid, startTime, UseRuntimeStartTime: false),
            includeStartTimeForDcp,
            cancellationToken).ConfigureAwait(false);
    }
 
    private async Task<bool> RequestProcessTreeGracefulShutdownAsync(
        ProcessTarget target,
        bool includeStartTimeForDcp,
        CancellationToken cancellationToken)
    {
        using var process = TryGetRunningProcess(target);
        if (process is null)
        {
            return true;
        }
 
        if (environment.IsWindows())
        {
            return await TryStopProcessTreeWithDcpAsync(target, includeStartTimeForDcp, cancellationToken).ConfigureAwait(false);
        }
 
        logger.LogDebug("Sending stop signal to process {Pid}", target.Pid);
        if (target.UseRuntimeStartTime && target.StartTime is { } runtimeStartTime)
        {
            ProcessSignaler.RequestGracefulShutdownWithRuntimeStartTime(target.Pid, runtimeStartTime, ProcessStartTimeHelper.LegacyStartTimeMatchTolerance, logger);
        }
        else
        {
            ProcessSignaler.RequestGracefulShutdown(target.Pid, target.StartTime, logger);
        }
        return true;
    }
 
    internal async Task<bool> TryStopProcessTreeWithDcpAsync(int pid, DateTimeOffset? startTime, bool includeStartTime, CancellationToken cancellationToken)
    {
        return await TryStopProcessTreeWithDcpAsync(
            new ProcessTarget(pid, startTime, UseRuntimeStartTime: false),
            includeStartTime,
            cancellationToken).ConfigureAwait(false);
    }
 
    private async Task<bool> TryStopProcessTreeWithDcpAsync(ProcessTarget target, bool includeStartTime, CancellationToken cancellationToken)
    {
        using var process = TryGetRunningProcess(target);
        if (process is null)
        {
            return true;
        }
 
        using var dcpExecutable = await DcpExecutableResolver.TryGetDcpExecutableAsync(
            layoutDiscovery,
            bundleService,
            executionContext,
            "dcp-stop-process-tree",
            cancellationToken).ConfigureAwait(false);
        if (dcpExecutable is null)
        {
            logger.LogWarning("Could not find DCP executable in the Aspire layout.");
            return false;
        }
 
        // Ensure we only stop the target process and not all children to allow DCP to avoid accidentally killing the child DCP instance.
        var arguments = new List<string>
        {
            "stop-process-tree",
            "--skip-descendants",
            "--pid",
            target.Pid.ToString(CultureInfo.InvariantCulture)
        };
 
        if (includeStartTime && target.StartTime is not null && (!target.UseRuntimeStartTime || environment.IsWindows()))
        {
            // Runtime-domain start times are whole-second legacy metadata on Linux/macOS, while DCP
            // compares the platform start time with millisecond precision. On Windows the AppHost
            // fallback also uses Process.StartTime, so keep the PID-reuse guard for that DCP path.
            arguments.Add("--process-start-time");
            arguments.Add(FormatDcpProcessStartTime(target.StartTime.Value));
        }
 
        var (exitCode, output, error) = await layoutProcessRunner.RunAsync(
            dcpExecutable.ExecutablePath,
            arguments,
            workingDirectory: executionContext.WorkingDirectory.FullName,
            ct: cancellationToken).ConfigureAwait(false);
 
        if (!string.IsNullOrWhiteSpace(output))
        {
            logger.LogDebug("DCP stop-process-tree stdout: {Output}", output.Trim());
        }
 
        if (!string.IsNullOrWhiteSpace(error))
        {
            logger.LogDebug("DCP stop-process-tree stderr: {Error}", error.Trim());
        }
 
        if (exitCode != 0)
        {
            logger.LogWarning("DCP stop-process-tree exited with code {ExitCode}.", exitCode);
            return false;
        }
 
        return true;
    }
 
    private async Task<bool> MonitorProcessesForTerminationAsync(IReadOnlyCollection<ProcessTarget> processes, CancellationToken cancellationToken)
    {
        var startTime = timeProvider.GetUtcNow();
        while (timeProvider.GetUtcNow() - startTime < s_processTerminationTimeout)
        {
            if (processes.All(IsProcessStopped))
            {
                return true;
            }
 
            await Task.Delay(s_processTerminationPollInterval, timeProvider, cancellationToken).ConfigureAwait(false);
        }
 
        return false;
    }
 
    private bool IsProcessStopped(ProcessTarget process)
    {
        using var runningProcess = TryGetRunningProcess(process);
        return runningProcess is null;
    }
 
    internal static ProcessTarget CreateAppHostProcessTarget(AppHostInformation appHostInfo)
    {
        if (appHostInfo.StableStartedAt is { } stableStartedAt)
        {
            return new ProcessTarget(appHostInfo.ProcessId, stableStartedAt, UseRuntimeStartTime: false);
        }
 
        // Released AppHosts only report StartedAt, and that value was produced from Process.StartTime.
        // Keep those mixed-version stop paths in the runtime clock domain; current AppHosts also send
        // StableStartedAt above so they retain exact /proc-based PID-reuse protection.
        return new ProcessTarget(appHostInfo.ProcessId, appHostInfo.StartedAt, UseRuntimeStartTime: appHostInfo.StartedAt is not null);
    }
 
    private Process? TryGetRunningProcess(ProcessTarget target)
    {
        if (!target.UseRuntimeStartTime || target.StartTime is null)
        {
            return ProcessSignaler.TryGetRunningProcess(target.Pid, target.StartTime, logger);
        }
 
        return ProcessSignaler.TryGetRunningProcessWithRuntimeStartTime(target.Pid, target.StartTime.Value, ProcessStartTimeHelper.LegacyStartTimeMatchTolerance, logger);
    }
 
    private void ForceKill(ProcessTarget target, bool killEntireProcessTree)
    {
        using var process = TryGetRunningProcess(target);
        if (process is null)
        {
            return;
        }
 
        logger.LogDebug("Killing process {Pid} (entireProcessTree={EntireProcessTree})...", target.Pid, killEntireProcessTree);
        try
        {
            process.Kill(entireProcessTree: killEntireProcessTree);
        }
        catch (InvalidOperationException)
        {
            // Process already exited.
        }
    }
 
    internal static string FormatDcpProcessStartTime(DateTimeOffset startTime)
    {
        return startTime.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);
    }
 
    internal readonly record struct ProcessTarget(int Pid, DateTimeOffset? StartTime, bool UseRuntimeStartTime);
}