Skip to content
Open
58 changes: 47 additions & 11 deletions src/Aspire.Cli/Processes/ProcessShutdownService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,41 @@ public Task<bool> StopProcessTreeAsync(
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 +90,7 @@ public async Task<bool> StopAppHostAsync(
processesToMonitor: [appHostProcess],
processesToForceKill,
token => RequestAppHostGracefulShutdownAsync(appHostInfo, requestRpcStopAsync, token),
forceKillEntireProcessTree: !OperatingSystem.IsWindows(),
cancellationToken).ConfigureAwait(false);
}

Expand All @@ -71,37 +103,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 +237,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
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
5 changes: 5 additions & 0 deletions src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,11 @@ public async Task<AppHostServerPrepareResult> PrepareAsync(
UseShellExecute = false,
CreateNoWindow = true
};
if (OperatingSystem.IsWindows())
{
startInfo.CreateNewProcessGroup = true;
}
Comment thread
danegsta marked this conversation as resolved.
Outdated

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
14 changes: 12 additions & 2 deletions src/Aspire.Cli/Projects/GuestRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Cli.Diagnostics;
using Aspire.Cli.Processes;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Aspire.TypeSystem;
Expand All @@ -20,6 +21,7 @@ internal sealed class GuestRuntime
private readonly FileLoggerProvider? _fileLoggerProvider;
private readonly Func<string, string?> _commandResolver;
private readonly ProfilingTelemetry? _profilingTelemetry;
private readonly ProcessShutdownService? _processShutdownService;

/// <summary>
/// Creates a new GuestRuntime for the given runtime specification.
Expand All @@ -29,13 +31,21 @@ internal sealed class GuestRuntime
/// <param name="fileLoggerProvider">Optional file logger for writing output to disk.</param>
/// <param name="commandResolver">Optional command resolver used to locate executables on PATH.</param>
/// <param name="profilingTelemetry">Optional profiling telemetry for child-process diagnostics.</param>
public GuestRuntime(RuntimeSpec spec, ILogger logger, FileLoggerProvider? fileLoggerProvider = null, Func<string, string?>? commandResolver = null, ProfilingTelemetry? profilingTelemetry = null)
/// <param name="processShutdownService">Optional shared process shutdown coordinator.</param>
public GuestRuntime(
RuntimeSpec spec,
ILogger logger,
FileLoggerProvider? fileLoggerProvider = null,
Func<string, string?>? commandResolver = null,
ProfilingTelemetry? profilingTelemetry = null,
ProcessShutdownService? processShutdownService = null)
{
_spec = spec;
_logger = logger;
_fileLoggerProvider = fileLoggerProvider;
_commandResolver = commandResolver ?? PathLookupHelper.FindFullPathFromPath;
_profilingTelemetry = profilingTelemetry;
_processShutdownService = processShutdownService;
}

/// <summary>
Expand Down Expand Up @@ -313,7 +323,7 @@ private async Task EnsureMigrationFilesExistAsync(DirectoryInfo directory, Cance
/// <summary>
/// Creates the default process-based launcher for this runtime.
/// </summary>
public ProcessGuestLauncher CreateDefaultLauncher() => new(_spec.Language, _logger, _fileLoggerProvider, _commandResolver);
public ProcessGuestLauncher CreateDefaultLauncher() => new(_spec.Language, _logger, _fileLoggerProvider, _commandResolver, _processShutdownService);

/// <summary>
/// Replaces placeholders in command arguments with actual values.
Expand Down
4 changes: 4 additions & 0 deletions src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -906,6 +906,10 @@ internal ProcessStartInfo CreateStartInfo(
UseShellExecute = false,
CreateNoWindow = true
};
if (OperatingSystem.IsWindows())
{
startInfo.CreateNewProcessGroup = true;
}
Comment thread
danegsta marked this conversation as resolved.
Outdated

// Insert "server" subcommand, then remaining args
startInfo.ArgumentList.Add("server");
Expand Down
Loading
Loading