Skip to content
Open
9 changes: 8 additions & 1 deletion src/Aspire.Cli/Commands/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using Aspire.Cli.Diagnostics;
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.Processes;
using Aspire.Cli.Profiling;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
Expand Down Expand Up @@ -76,7 +77,13 @@ internal sealed class RunCommand : BaseCommand
private bool _isDetachMode;
private const int MaxDisplayedAppHostStartupOutputLines = 80;

private static readonly TimeSpan s_appHostStartupCancellationTimeout = TimeSpan.FromSeconds(5);
// Startup cancellation waits for the AppHost run task to complete after it asks
// ProcessShutdownService to stop processes. That service can spend one full timeout
// waiting for graceful shutdown and another after force-kill fallback.
private static readonly TimeSpan s_appHostStartupCancellationTimeout =
ProcessShutdownService.ProcessTerminationTimeout +
ProcessShutdownService.ProcessTerminationTimeout +
TimeSpan.FromSeconds(1);

// Guest AppHosts can bring up the temporary server/backchannel and then fail immediately
// afterward when the guest startup process hits a syntax, pre-execute, or model validation
Expand Down
62 changes: 50 additions & 12 deletions src/Aspire.Cli/Processes/ProcessShutdownService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,48 @@ internal sealed class ProcessShutdownService(
private static readonly TimeSpan s_processTerminationTimeout = TimeSpan.FromSeconds(10);
private static readonly TimeSpan s_processTerminationPollInterval = TimeSpan.FromMilliseconds(250);

internal static TimeSpan ProcessTerminationTimeout => s_processTerminationTimeout;

public Task<bool> StopProcessTreeAsync(
int pid,
DateTimeOffset? startTime,
bool includeStartTimeForDcp,
CancellationToken cancellationToken)
{
return StopProcessTreeAsync(
pid,
startTime,
includeStartTimeForDcp,
forceKillEntireProcessTree: !OperatingSystem.IsWindows(),
cancellationToken);
}

public Task<bool> StopProcessTreeAsync(
int pid,
DateTimeOffset? startTime,
bool includeStartTimeForDcp,
bool forceKillEntireProcessTree,
CancellationToken cancellationToken)
{
return StopProcessesAsync(
[new ProcessTarget(pid, startTime)],
[new ProcessTarget(pid, startTime)],
token => RequestProcessTreeGracefulShutdownAsync(pid, startTime, includeStartTimeForDcp, token),
forceKillEntireProcessTree,
cancellationToken);
}

public Task<bool> StopProcessGroupAsync(
int pid,
DateTimeOffset? startTime,
bool forceKillEntireProcessTree,
CancellationToken cancellationToken)
{
return StopProcessesAsync(
[new ProcessTarget(pid, startTime)],
[new ProcessTarget(pid, startTime)],
token => RequestProcessGroupGracefulShutdownAsync(pid, startTime, token),
forceKillEntireProcessTree,
cancellationToken);
}

Expand All @@ -59,6 +92,7 @@ public async Task<bool> StopAppHostAsync(
processesToMonitor: [appHostProcess],
processesToForceKill,
token => RequestAppHostGracefulShutdownAsync(appHostInfo, requestRpcStopAsync, token),
forceKillEntireProcessTree: !OperatingSystem.IsWindows(),
cancellationToken).ConfigureAwait(false);
}

Expand All @@ -71,37 +105,31 @@ internal async Task<bool> StopProcessesAsync(
processesToMonitorAndKill,
processesToMonitorAndKill,
requestGracefulShutdownAsync,
forceKillEntireProcessTree: !OperatingSystem.IsWindows(),
cancellationToken).ConfigureAwait(false);
}

private async Task<bool> StopProcessesAsync(
IReadOnlyCollection<ProcessTarget> processesToMonitor,
IReadOnlyCollection<ProcessTarget> processesToForceKill,
Func<CancellationToken, Task<bool>> requestGracefulShutdownAsync,
bool forceKillEntireProcessTree,
CancellationToken cancellationToken)
{
var gracefulShutdownRequested = await TryRequestGracefulShutdownAsync(requestGracefulShutdownAsync, cancellationToken).ConfigureAwait(false);
if (gracefulShutdownRequested && await MonitorProcessesForTerminationAsync(processesToMonitor, cancellationToken).ConfigureAwait(false))
{
ForceKillRemainingProcesses(processesToForceKill.Except(processesToMonitor), afterTimeout: false);
ForceKillRemainingProcesses(processesToForceKill.Except(processesToMonitor), afterTimeout: false, killEntireProcessTree: forceKillEntireProcessTree);
return true;
}

ForceKillRemainingProcesses(processesToForceKill, afterTimeout: true);
ForceKillRemainingProcesses(processesToForceKill, afterTimeout: true, killEntireProcessTree: forceKillEntireProcessTree);

return await MonitorProcessesForTerminationAsync(processesToMonitor, cancellationToken).ConfigureAwait(false);
}

private void ForceKillRemainingProcesses(IEnumerable<ProcessTarget> processes, bool afterTimeout)
private void ForceKillRemainingProcesses(IEnumerable<ProcessTarget> processes, bool afterTimeout, bool killEntireProcessTree)
{
// On Unix the AppHost's process tree does not include DCP (it is launched in its own
// session/process group), so a tree kill of the AppHost is safe: DCP will detect the
// AppHost exiting and gracefully tear down its own children. The same applies to the
// launcher CLI handle - any leftover `dotnet run` / AppHost descendants get cleaned up.
// On Windows DCP is an in-tree descendant of the AppHost, so we must single-process-kill
// here and rely on the graceful DCP `stop-process-tree` path for orderly resource cleanup.
var killEntireProcessTree = !OperatingSystem.IsWindows();

foreach (var process in processes.Distinct())
{
if (afterTimeout)
Expand Down Expand Up @@ -211,6 +239,16 @@ private async Task<bool> RequestProcessTreeGracefulShutdownAsync(
return true;
}

private Task<bool> RequestProcessGroupGracefulShutdownAsync(
int pid,
DateTimeOffset? startTime,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
ProcessSignaler.RequestGracefulShutdownForProcessGroup(pid, startTime, logger);
return Task.FromResult(true);
}

internal async Task<bool> TryStopProcessTreeWithDcpAsync(int pid, DateTimeOffset? startTime, bool includeStartTime, CancellationToken cancellationToken)
{
using var process = ProcessSignaler.TryGetRunningProcess(pid, startTime, logger);
Expand Down Expand Up @@ -278,7 +316,7 @@ internal async Task<bool> TryStopProcessTreeWithDcpAsync(int pid, DateTimeOffset
private async Task<bool> MonitorProcessesForTerminationAsync(IReadOnlyCollection<ProcessTarget> processes, CancellationToken cancellationToken)
{
var startTime = timeProvider.GetUtcNow();
while (timeProvider.GetUtcNow() - startTime < s_processTerminationTimeout)
while (timeProvider.GetUtcNow() - startTime < ProcessTerminationTimeout)
{
if (processes.All(IsProcessStopped))
{
Expand Down
58 changes: 50 additions & 8 deletions src/Aspire.Cli/Projects/AppHostServerSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Diagnostics;
using Aspire.Cli.Configuration;
using Aspire.Cli.Processes;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Aspire.Hosting;
Expand All @@ -23,6 +24,7 @@ internal sealed class AppHostServerSession : IAppHostServerSession
private readonly ProfilingTelemetry.ActivityScope _activity;
private readonly ProfilingTelemetry? _profilingTelemetry;
private readonly IDisposable? _projectLifetime;
private readonly ProcessShutdownService? _processShutdownService;
private IAppHostRpcClient? _rpcClient;
private bool _disposed;

Expand All @@ -34,7 +36,8 @@ internal AppHostServerSession(
ILogger logger,
ProfilingTelemetry.ActivityScope activity = default,
ProfilingTelemetry? profilingTelemetry = null,
IDisposable? projectLifetime = null)
IDisposable? projectLifetime = null,
ProcessShutdownService? processShutdownService = null)
{
_serverProcess = serverProcess;
_output = output;
Expand All @@ -44,6 +47,7 @@ internal AppHostServerSession(
_activity = activity;
_profilingTelemetry = profilingTelemetry;
_projectLifetime = projectLifetime;
_processShutdownService = processShutdownService;
}

/// <inheritdoc />
Expand All @@ -68,13 +72,15 @@ internal AppHostServerSession(
/// <param name="debug">Whether to enable debug logging for the server.</param>
/// <param name="logger">The logger to use for lifecycle diagnostics.</param>
/// <param name="profilingTelemetry">Optional profiling telemetry for the server process lifetime.</param>
/// <param name="processShutdownService">Optional shared process shutdown coordinator.</param>
/// <returns>The started AppHost server session.</returns>
internal static AppHostServerSession Start(
IAppHostServerProject appHostServerProject,
Dictionary<string, string>? environmentVariables,
bool debug,
ILogger logger,
ProfilingTelemetry? profilingTelemetry = null)
ProfilingTelemetry? profilingTelemetry = null,
ProcessShutdownService? processShutdownService = null)
{
var currentPid = Environment.ProcessId;
var serverEnvironmentVariables = environmentVariables is null
Expand Down Expand Up @@ -127,7 +133,8 @@ internal static AppHostServerSession Start(
logger,
activity,
profilingTelemetry,
appHostServerProject as IDisposable);
appHostServerProject as IDisposable,
processShutdownService);
}

/// <inheritdoc />
Expand Down Expand Up @@ -156,14 +163,34 @@ public async ValueTask DisposeAsync()

if (!_serverProcess.HasExited)
{
try
var stopped = false;
if (_processShutdownService is not null)
{
_serverProcess.Kill(entireProcessTree: true);
_activity.SetError("AppHost server process was terminated during session disposal.");
if (TryGetServerProcessStartTime(out var serverProcessStartTime))
{
stopped = await _processShutdownService.StopProcessGroupAsync(
_serverProcess.Id,
serverProcessStartTime,
forceKillEntireProcessTree: !OperatingSystem.IsWindows(),
CancellationToken.None).ConfigureAwait(false);
}
else
{
stopped = true;
}
}
catch (Exception ex)

if (!stopped && !_serverProcess.HasExited)
{
_logger.LogDebug(ex, "Error killing AppHost server process");
try
{
_serverProcess.Kill(entireProcessTree: !OperatingSystem.IsWindows());
_activity.SetError("AppHost server process was terminated during session disposal.");
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error killing AppHost server process");
}
}
}

Expand All @@ -176,6 +203,21 @@ public async ValueTask DisposeAsync()
_projectLifetime?.Dispose();
_activity.Dispose();
}

private bool TryGetServerProcessStartTime(out DateTimeOffset? startTime)
{
try
{
startTime = new DateTimeOffset(_serverProcess.StartTime);
return true;
}
catch (InvalidOperationException)
{
// The process exited between the HasExited check and shutdown coordination.
startTime = null;
return false;
}
}
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,8 @@ public async Task<AppHostServerPrepareResult> PrepareAsync(
WindowStyle = ProcessWindowStyle.Minimized,
UseShellExecute = false,
CreateNoWindow = true
};
}.CreateNewProcessGroupOnWindows();

startInfo.ArgumentList.Add("exec");
startInfo.ArgumentList.Add(assemblyPath);

Expand Down
15 changes: 11 additions & 4 deletions src/Aspire.Cli/Projects/GuestAppHostProject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.Packaging;
using Aspire.Cli.Processes;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
Expand Down Expand Up @@ -47,6 +48,7 @@ internal sealed class GuestAppHostProject : IAppHostProject, IGuestAppHostSdkGen
private readonly TimeProvider _timeProvider;
private readonly RunningInstanceManager _runningInstanceManager;
private readonly ProfilingTelemetry _profilingTelemetry;
private readonly ProcessShutdownService _processShutdownService;

// Language is always resolved via constructor
private readonly LanguageInfo _resolvedLanguage;
Expand All @@ -67,6 +69,7 @@ public GuestAppHostProject(
ILogger<GuestAppHostProject> logger,
FileLoggerProvider fileLoggerProvider,
ProfilingTelemetry profilingTelemetry,
ProcessShutdownService processShutdownService,
TimeProvider? timeProvider = null)
{
_resolvedLanguage = language;
Expand All @@ -83,6 +86,7 @@ public GuestAppHostProject(
_logger = logger;
_fileLoggerProvider = fileLoggerProvider;
_profilingTelemetry = profilingTelemetry;
_processShutdownService = processShutdownService;
_timeProvider = timeProvider ?? TimeProvider.System;
_runningInstanceManager = new RunningInstanceManager(_logger, _interactionService, _timeProvider);
}
Expand Down Expand Up @@ -272,7 +276,8 @@ private async Task<bool> BuildAndGenerateSdkAsync(DirectoryInfo directory, Aspir
environmentVariables: null,
debug: false,
_logger,
_profilingTelemetry);
_profilingTelemetry,
_processShutdownService);

// Step 3: Connect to server
var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken);
Expand Down Expand Up @@ -428,7 +433,8 @@ public async Task<int> RunAsync(AppHostProjectContext context, CancellationToken
launchSettingsEnvVars,
context.Debug,
_logger,
_profilingTelemetry);
_profilingTelemetry,
_processShutdownService);
try
{
// Give the server a moment to start
Expand Down Expand Up @@ -1008,7 +1014,8 @@ public async Task<int> PublishAsync(PublishContext context, CancellationToken ca
launchSettingsEnvVars,
context.Debug,
_logger,
_profilingTelemetry);
_profilingTelemetry,
_processShutdownService);

// Start connecting to the backchannel (fire-and-forget) so the caller is unblocked
// as soon as the server is reachable; the post-start work below races alongside it.
Expand Down Expand Up @@ -1799,7 +1806,7 @@ private async Task EnsureRuntimeCreatedAsync(
runtimeSpec = TypeScriptAppHostToolchainResolver.ApplyToRuntimeSpec(runtimeSpec, toolchain);
}

_guestRuntime = new GuestRuntime(runtimeSpec, _logger, _fileLoggerProvider, profilingTelemetry: _profilingTelemetry);
_guestRuntime = new GuestRuntime(runtimeSpec, _logger, _fileLoggerProvider, profilingTelemetry: _profilingTelemetry, processShutdownService: _processShutdownService);

_logger.LogDebug("Created GuestRuntime for {RuntimeDisplayName}: Execute={Command} {Args}",
runtimeSpec.DisplayName,
Expand Down
Loading
Loading