// 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);
}