From 5bfd6c2d71335d7c41b9211407207347b9edab83 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 28 May 2026 18:46:33 -0700 Subject: [PATCH 01/11] Fix guest AppHost Ctrl+C shutdown Launch guest AppHost processes and AppHost servers in Windows process groups so aspire run can request graceful shutdown with CTRL_BREAK_EVENT before falling back to force termination. Route guest and server cancellation through ProcessShutdownService so guest fallbacks can kill the full tree while server fallbacks avoid killing in-tree DCP on Windows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Processes/ProcessShutdownService.cs | 58 ++++++-- .../Projects/AppHostServerSession.cs | 49 +++++- .../DotNetBasedAppHostServerProject.cs | 5 + .../Projects/GuestAppHostProject.cs | 15 +- src/Aspire.Cli/Projects/GuestRuntime.cs | 14 +- .../Projects/PrebuiltAppHostServer.cs | 4 + .../Projects/ProcessGuestLauncher.cs | 46 ++++-- src/Shared/ProcessSignaler.cs | 39 +++++ .../Projects/GuestAppHostProjectTests.cs | 11 +- .../Projects/ProcessGuestLauncherTests.cs | 140 ++++++++++++++++++ 10 files changed, 344 insertions(+), 37 deletions(-) create mode 100644 tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs diff --git a/src/Aspire.Cli/Processes/ProcessShutdownService.cs b/src/Aspire.Cli/Processes/ProcessShutdownService.cs index d0089a68d54..1e5ae7d337d 100644 --- a/src/Aspire.Cli/Processes/ProcessShutdownService.cs +++ b/src/Aspire.Cli/Processes/ProcessShutdownService.cs @@ -29,10 +29,41 @@ public Task StopProcessTreeAsync( DateTimeOffset? startTime, bool includeStartTimeForDcp, CancellationToken cancellationToken) + { + return StopProcessTreeAsync( + pid, + startTime, + includeStartTimeForDcp, + forceKillEntireProcessTree: !OperatingSystem.IsWindows(), + cancellationToken); + } + + public Task 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 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); } @@ -59,6 +90,7 @@ public async Task StopAppHostAsync( processesToMonitor: [appHostProcess], processesToForceKill, token => RequestAppHostGracefulShutdownAsync(appHostInfo, requestRpcStopAsync, token), + forceKillEntireProcessTree: !OperatingSystem.IsWindows(), cancellationToken).ConfigureAwait(false); } @@ -71,6 +103,7 @@ internal async Task StopProcessesAsync( processesToMonitorAndKill, processesToMonitorAndKill, requestGracefulShutdownAsync, + forceKillEntireProcessTree: !OperatingSystem.IsWindows(), cancellationToken).ConfigureAwait(false); } @@ -78,30 +111,23 @@ private async Task StopProcessesAsync( IReadOnlyCollection processesToMonitor, IReadOnlyCollection processesToForceKill, Func> 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 processes, bool afterTimeout) + private void ForceKillRemainingProcesses(IEnumerable 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) @@ -211,6 +237,16 @@ private async Task RequestProcessTreeGracefulShutdownAsync( return true; } + private Task RequestProcessGroupGracefulShutdownAsync( + int pid, + DateTimeOffset? startTime, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ProcessSignaler.RequestGracefulShutdownForProcessGroup(pid, startTime, logger); + return Task.FromResult(true); + } + internal async Task TryStopProcessTreeWithDcpAsync(int pid, DateTimeOffset? startTime, bool includeStartTime, CancellationToken cancellationToken) { using var process = ProcessSignaler.TryGetRunningProcess(pid, startTime, logger); diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index 654212b2985..b3347d45fb8 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -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; @@ -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; @@ -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; @@ -44,6 +47,7 @@ internal AppHostServerSession( _activity = activity; _profilingTelemetry = profilingTelemetry; _projectLifetime = projectLifetime; + _processShutdownService = processShutdownService; } /// @@ -68,13 +72,15 @@ internal AppHostServerSession( /// Whether to enable debug logging for the server. /// The logger to use for lifecycle diagnostics. /// Optional profiling telemetry for the server process lifetime. + /// Optional shared process shutdown coordinator. /// The started AppHost server session. internal static AppHostServerSession Start( IAppHostServerProject appHostServerProject, Dictionary? environmentVariables, bool debug, ILogger logger, - ProfilingTelemetry? profilingTelemetry = null) + ProfilingTelemetry? profilingTelemetry = null, + ProcessShutdownService? processShutdownService = null) { var currentPid = Environment.ProcessId; var serverEnvironmentVariables = environmentVariables is null @@ -127,7 +133,8 @@ internal static AppHostServerSession Start( logger, activity, profilingTelemetry, - appHostServerProject as IDisposable); + appHostServerProject as IDisposable, + processShutdownService); } /// @@ -156,14 +163,27 @@ 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."); + stopped = await _processShutdownService.StopProcessGroupAsync( + _serverProcess.Id, + TryGetServerProcessStartTime(), + forceKillEntireProcessTree: !OperatingSystem.IsWindows(), + CancellationToken.None).ConfigureAwait(false); } - 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"); + } } } @@ -176,6 +196,19 @@ public async ValueTask DisposeAsync() _projectLifetime?.Dispose(); _activity.Dispose(); } + + private DateTimeOffset? TryGetServerProcessStartTime() + { + try + { + return new DateTimeOffset(_serverProcess.StartTime); + } + catch (InvalidOperationException) + { + // The process exited between the HasExited check and shutdown coordination. + return null; + } + } } /// diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index 1d431a973a6..e56f9a4813a 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -483,6 +483,11 @@ public async Task PrepareAsync( UseShellExecute = false, CreateNoWindow = true }; + if (OperatingSystem.IsWindows()) + { + startInfo.CreateNewProcessGroup = true; + } + startInfo.ArgumentList.Add("exec"); startInfo.ArgumentList.Add(assemblyPath); diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 08c79b2bdb6..176c4d5572f 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -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; @@ -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; @@ -67,6 +69,7 @@ public GuestAppHostProject( ILogger logger, FileLoggerProvider fileLoggerProvider, ProfilingTelemetry profilingTelemetry, + ProcessShutdownService processShutdownService, TimeProvider? timeProvider = null) { _resolvedLanguage = language; @@ -83,6 +86,7 @@ public GuestAppHostProject( _logger = logger; _fileLoggerProvider = fileLoggerProvider; _profilingTelemetry = profilingTelemetry; + _processShutdownService = processShutdownService; _timeProvider = timeProvider ?? TimeProvider.System; _runningInstanceManager = new RunningInstanceManager(_logger, _interactionService, _timeProvider); } @@ -272,7 +276,8 @@ private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, Aspir environmentVariables: null, debug: false, _logger, - _profilingTelemetry); + _profilingTelemetry, + _processShutdownService); // Step 3: Connect to server var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken); @@ -428,7 +433,8 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken launchSettingsEnvVars, context.Debug, _logger, - _profilingTelemetry); + _profilingTelemetry, + _processShutdownService); try { // Give the server a moment to start @@ -1008,7 +1014,8 @@ public async Task 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. @@ -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, diff --git a/src/Aspire.Cli/Projects/GuestRuntime.cs b/src/Aspire.Cli/Projects/GuestRuntime.cs index f2c892c55dd..533a733bda0 100644 --- a/src/Aspire.Cli/Projects/GuestRuntime.cs +++ b/src/Aspire.Cli/Projects/GuestRuntime.cs @@ -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; @@ -20,6 +21,7 @@ internal sealed class GuestRuntime private readonly FileLoggerProvider? _fileLoggerProvider; private readonly Func _commandResolver; private readonly ProfilingTelemetry? _profilingTelemetry; + private readonly ProcessShutdownService? _processShutdownService; /// /// Creates a new GuestRuntime for the given runtime specification. @@ -29,13 +31,21 @@ internal sealed class GuestRuntime /// Optional file logger for writing output to disk. /// Optional command resolver used to locate executables on PATH. /// Optional profiling telemetry for child-process diagnostics. - public GuestRuntime(RuntimeSpec spec, ILogger logger, FileLoggerProvider? fileLoggerProvider = null, Func? commandResolver = null, ProfilingTelemetry? profilingTelemetry = null) + /// Optional shared process shutdown coordinator. + public GuestRuntime( + RuntimeSpec spec, + ILogger logger, + FileLoggerProvider? fileLoggerProvider = null, + Func? commandResolver = null, + ProfilingTelemetry? profilingTelemetry = null, + ProcessShutdownService? processShutdownService = null) { _spec = spec; _logger = logger; _fileLoggerProvider = fileLoggerProvider; _commandResolver = commandResolver ?? PathLookupHelper.FindFullPathFromPath; _profilingTelemetry = profilingTelemetry; + _processShutdownService = processShutdownService; } /// @@ -313,7 +323,7 @@ private async Task EnsureMigrationFilesExistAsync(DirectoryInfo directory, Cance /// /// Creates the default process-based launcher for this runtime. /// - public ProcessGuestLauncher CreateDefaultLauncher() => new(_spec.Language, _logger, _fileLoggerProvider, _commandResolver); + public ProcessGuestLauncher CreateDefaultLauncher() => new(_spec.Language, _logger, _fileLoggerProvider, _commandResolver, _processShutdownService); /// /// Replaces placeholders in command arguments with actual values. diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index d87fc185867..b2a51b212df 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -906,6 +906,10 @@ internal ProcessStartInfo CreateStartInfo( UseShellExecute = false, CreateNoWindow = true }; + if (OperatingSystem.IsWindows()) + { + startInfo.CreateNewProcessGroup = true; + } // Insert "server" subcommand, then remaining args startInfo.ArgumentList.Add("server"); diff --git a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs index fdf137100f7..ce3f240265c 100644 --- a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs +++ b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using Aspire.Cli.Diagnostics; +using Aspire.Cli.Processes; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; using Microsoft.Extensions.Logging; @@ -18,13 +19,20 @@ internal sealed class ProcessGuestLauncher : IGuestProcessLauncher private readonly ILogger _logger; private readonly FileLoggerProvider? _fileLoggerProvider; private readonly Func _commandResolver; - - public ProcessGuestLauncher(string language, ILogger logger, FileLoggerProvider? fileLoggerProvider = null, Func? commandResolver = null) + private readonly ProcessShutdownService? _processShutdownService; + + public ProcessGuestLauncher( + string language, + ILogger logger, + FileLoggerProvider? fileLoggerProvider = null, + Func? commandResolver = null, + ProcessShutdownService? processShutdownService = null) { _language = language; _logger = logger; _fileLoggerProvider = fileLoggerProvider; _commandResolver = commandResolver ?? PathLookupHelper.FindFullPathFromPath; + _processShutdownService = processShutdownService; } public async Task<(int ExitCode, OutputCollector? Output)> LaunchAsync( @@ -62,6 +70,10 @@ public ProcessGuestLauncher(string language, ILogger logger, FileLoggerProvider? UseShellExecute = false, CreateNoWindow = true }; + if (OperatingSystem.IsWindows()) + { + startInfo.CreateNewProcessGroup = true; + } foreach (var arg in args) { @@ -123,6 +135,7 @@ public ProcessGuestLauncher(string language, ILogger logger, FileLoggerProvider? AddEvent(activity, ProfilingTelemetry.Events.GuestProcessStart); process.Start(); + var processStartedAt = new DateTimeOffset(process.StartTime); activity?.SetTag(TelemetryConstants.Tags.ProcessPid, process.Id); AddEvent(activity, ProfilingTelemetry.Events.GuestProcessStarted, TelemetryConstants.Tags.ProcessPid, process.Id); if (afterLaunchAsync is not null) @@ -141,9 +154,10 @@ public ProcessGuestLauncher(string language, ILogger logger, FileLoggerProvider? { // The guest process is the AppHost's primary process for this language. When the caller // cancels - either because the user pressed Ctrl+C or because a fatal startup condition - // (e.g. the AppHost server backchannel timed out) escalated into a teardown - we must kill - // the process tree, otherwise the AppHost stays alive after the CLI returns and the run - // appears to hang from the user's perspective. + // (e.g. the AppHost server backchannel timed out) escalated into a teardown - use the + // same graceful-then-force shutdown flow as AppHostLauncher. The command cancellation + // token is already canceled here, so using it for the process-exit wait would race the + // graceful shutdown and immediately force-kill the guest AppHost. // // We don't rethrow the OperationCanceledException because the caller in GuestAppHostProject // uses the returned exit code to distinguish user cancellation from internal teardown @@ -152,13 +166,23 @@ public ProcessGuestLauncher(string language, ILogger logger, FileLoggerProvider? // the redirected output streams have time to drain. if (!process.HasExited) { - try - { - process.Kill(entireProcessTree: true); - } - catch (Exception killEx) + var stopped = _processShutdownService is not null && + await _processShutdownService.StopProcessGroupAsync( + process.Id, + processStartedAt, + forceKillEntireProcessTree: true, + CancellationToken.None).ConfigureAwait(false); + + if (!stopped && !process.HasExited) { - _logger.LogDebug(killEx, "Failed to kill guest process {ProcessId} after cancellation", process.Id); + try + { + process.Kill(entireProcessTree: true); + } + catch (Exception killEx) + { + _logger.LogDebug(killEx, "Failed to kill guest process {ProcessId} after cancellation", process.Id); + } } } diff --git a/src/Shared/ProcessSignaler.cs b/src/Shared/ProcessSignaler.cs index 0e3e7cb7ce6..3d4d31bf0a9 100644 --- a/src/Shared/ProcessSignaler.cs +++ b/src/Shared/ProcessSignaler.cs @@ -30,6 +30,26 @@ public static void RequestGracefulShutdown(int pid, DateTimeOffset? expectedStar } } + public static void RequestGracefulShutdownForProcessGroup(int pid, DateTimeOffset? expectedStartTime, ILogger logger) + { + using var process = TryGetRunningProcess(pid, expectedStartTime, logger); + if (process is null) + { + return; // Process is not running or does not match the expected start time + } + + logger.LogDebug("Requesting graceful shutdown of process group {Pid}...", pid); + + if (OperatingSystem.IsWindows()) + { + RequestGracefulShutdownWindowsProcessGroup(pid, logger); + } + else + { + RequestGracefulShutdownUnix(pid, logger); + } + } + public static void ForceKill(int pid, DateTimeOffset? expectedStartTime, ILogger logger, bool killEntireProcessTree = false) { using var process = TryGetRunningProcess(pid, expectedStartTime, logger); @@ -91,6 +111,21 @@ private static bool AreClose(DateTimeOffset? expectedStartTime, DateTime process } private const int SigTerm = 15; + // Use CTRL_BREAK_EVENT for Windows process-group shutdown. Ctrl+C is not suitable here because + // Windows disables Ctrl+C delivery for processes launched with CREATE_NEW_PROCESS_GROUP. + // See https://learn.microsoft.com/windows/console/generateconsolectrlevent. + private const uint CtrlBreakEvent = 1; + + private static void RequestGracefulShutdownWindowsProcessGroup(int pid, ILogger logger) + { + var result = GenerateConsoleCtrlEvent(CtrlBreakEvent, (uint)pid); + if (!result) + { + int error = Marshal.GetLastWin32Error(); + // Best effort. + logger.LogWarning("Could not gracefully stop Aspire application host process group {Pid}; the error code from signal send operation was {ErrorCode}", pid, error); + } + } private static void RequestGracefulShutdownUnix(int pid, ILogger logger) { @@ -107,4 +142,8 @@ private static void RequestGracefulShutdownUnix(int pid, ILogger logger) // See https://developers.redhat.com/blog/2019/03/25/using-net-pinvoke-for-linux-system-functions [LibraryImport("libc", SetLastError = true, EntryPoint = "kill")] private static partial int kill(int pid, int sig); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool GenerateConsoleCtrlEvent(uint dwCtrlEvent, uint dwProcessGroupId); } diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs index 17ad8f53aac..7fb3ef332d0 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs @@ -4,7 +4,9 @@ using Aspire.Cli.Configuration; using Aspire.Cli.Diagnostics; using Aspire.Cli.Interaction; +using Aspire.Cli.Layout; using Aspire.Cli.Packaging; +using Aspire.Cli.Processes; using Aspire.Cli.Projects; using Aspire.Cli.Telemetry; using Aspire.Cli.Tests.TestServices; @@ -935,7 +937,14 @@ private GuestAppHostProject CreateGuestAppHostProject( executionContext: executionContext, logger: NullLogger.Instance, fileLoggerProvider: new FileLoggerProvider(logFilePath, new TestStartupErrorWriter()), - profilingTelemetry: _profilingTelemetry); + profilingTelemetry: _profilingTelemetry, + processShutdownService: new ProcessShutdownService( + new NullLayoutDiscovery(), + new NullBundleService(), + new LayoutProcessRunner(new TestProcessExecutionFactory()), + executionContext, + NullLogger.Instance, + TimeProvider.System)); } } diff --git a/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs b/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs new file mode 100644 index 00000000000..44e75c9f41c --- /dev/null +++ b/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs @@ -0,0 +1,140 @@ +// 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.Layout; +using Aspire.Cli.Processes; +using Aspire.Cli.Projects; +using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Tests.Utils; +using Aspire.Shared; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Cli.Tests.Projects; + +public class ProcessGuestLauncherTests(ITestOutputHelper outputHelper) +{ + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "This verifies the Unix SIGTERM graceful shutdown path.")] + public async Task LaunchAsync_CancellationUsesUnixGracefulShutdownBeforeForceKill() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var readyFile = Path.Combine(workspace.WorkspaceRoot.FullName, "ready.txt"); + var launcher = new ProcessGuestLauncher( + "test", + NullLogger.Instance, + commandResolver: command => command == "/bin/sh" ? command : null, + processShutdownService: CreateProcessShutdownService(workspace)); + using var cancellationTokenSource = new CancellationTokenSource(); + + var (exitCode, _) = await launcher.LaunchAsync( + "/bin/sh", + [ + "-c", + "trap 'exit 0' TERM; printf ready > \"$1\"; while :; do sleep 0.1; done", + "ignored", + readyFile + ], + workspace.WorkspaceRoot, + new Dictionary(), + cancellationTokenSource.Token, + afterLaunchAsync: async () => + { + await WaitForFileAsync(readyFile); + await cancellationTokenSource.CancelAsync(); + }).WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(0, exitCode); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "This verifies Windows CTRL_BREAK_EVENT process-group shutdown.")] + public async Task LaunchAsync_CancellationUsesWindowsCtrlBreakBeforeForceKill() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var scriptsDirectory = workspace.CreateDirectory("scripts"); + var outputDirectory = workspace.CreateDirectory("output"); + var scriptPath = Path.Combine(scriptsDirectory.FullName, "ctrl-break.ps1"); + var readyFile = Path.Combine(outputDirectory.FullName, "ready.txt"); + var breakFile = Path.Combine(outputDirectory.FullName, "break.txt"); + await File.WriteAllTextAsync( + scriptPath, + """ + param( + [string] $ReadyPath, + [string] $BreakPath + ) + + $receivedBreak = [System.Threading.ManualResetEventSlim]::new($false) + [Console]::CancelKeyPress += { + param($Sender, $EventArgs) + if ($EventArgs.SpecialKey -eq [ConsoleSpecialKey]::ControlBreak) { + $EventArgs.Cancel = $true + Set-Content -Path $BreakPath -Value 'break' + $receivedBreak.Set() + } + } + + Set-Content -Path $ReadyPath -Value 'ready' + if ($receivedBreak.Wait([TimeSpan]::FromSeconds(30))) { + exit 0 + } + + exit 2 + """); + var launcher = new ProcessGuestLauncher( + "test", + NullLogger.Instance, + processShutdownService: CreateProcessShutdownService(workspace)); + using var cancellationTokenSource = new CancellationTokenSource(); + + var (exitCode, _) = await launcher.LaunchAsync( + "powershell.exe", + ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath, readyFile, breakFile], + workspace.WorkspaceRoot, + new Dictionary(), + cancellationTokenSource.Token, + afterLaunchAsync: async () => + { + await WaitForFileAsync(readyFile); + await cancellationTokenSource.CancelAsync(); + }).WaitAsync(TimeSpan.FromSeconds(20)); + + Assert.Equal(0, exitCode); + Assert.True(File.Exists(breakFile)); + } + + private static ProcessShutdownService CreateProcessShutdownService(TemporaryWorkspace workspace) + { + var dcpDirectory = workspace.WorkspaceRoot.CreateSubdirectory("dcp"); + File.WriteAllText(BundleDiscovery.GetDcpExecutablePath(dcpDirectory.FullName), string.Empty); + + return new ProcessShutdownService( + new FixedLayoutDiscovery(dcpDirectory.FullName), + new NullBundleService(), + new LayoutProcessRunner(new TestProcessExecutionFactory()), + workspace.CreateExecutionContext(), + NullLogger.Instance, + TimeProvider.System); + } + + private static async Task WaitForFileAsync(string path) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + while (!File.Exists(path)) + { + await Task.Delay(TimeSpan.FromMilliseconds(20), timeout.Token); + } + } + + private sealed class FixedLayoutDiscovery(string dcpDirectory) : ILayoutDiscovery + { + public LayoutConfiguration? DiscoverLayout(string? projectDirectory = null) => null; + + public string? GetComponentPath(LayoutComponent component, string? projectDirectory = null) + { + return component == LayoutComponent.Dcp ? dcpDirectory : null; + } + + public bool IsBundleModeAvailable(string? projectDirectory = null) => true; + } +} From 87098b363586b10d7b788586f9ee83ab6e3bc3ff Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 28 May 2026 18:54:03 -0700 Subject: [PATCH 02/11] Avoid signaling exited AppHost server PIDs Treat an AppHost server that exits before shutdown coordination can read its start time as already stopped. This preserves process identity validation and avoids signaling a reused PID without a start-time guard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Projects/AppHostServerSession.cs | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index b3347d45fb8..5b304f5af35 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -166,11 +166,18 @@ public async ValueTask DisposeAsync() var stopped = false; if (_processShutdownService is not null) { - stopped = await _processShutdownService.StopProcessGroupAsync( - _serverProcess.Id, - TryGetServerProcessStartTime(), - forceKillEntireProcessTree: !OperatingSystem.IsWindows(), - CancellationToken.None).ConfigureAwait(false); + if (TryGetServerProcessStartTime(out var serverProcessStartTime)) + { + stopped = await _processShutdownService.StopProcessGroupAsync( + _serverProcess.Id, + serverProcessStartTime, + forceKillEntireProcessTree: !OperatingSystem.IsWindows(), + CancellationToken.None).ConfigureAwait(false); + } + else + { + stopped = true; + } } if (!stopped && !_serverProcess.HasExited) @@ -197,16 +204,18 @@ public async ValueTask DisposeAsync() _activity.Dispose(); } - private DateTimeOffset? TryGetServerProcessStartTime() + private bool TryGetServerProcessStartTime(out DateTimeOffset? startTime) { try { - return new DateTimeOffset(_serverProcess.StartTime); + startTime = new DateTimeOffset(_serverProcess.StartTime); + return true; } catch (InvalidOperationException) { // The process exited between the HasExited check and shutdown coordination. - return null; + startTime = null; + return false; } } } From 5904a8be27ffb20c592a82eec1e7d67e1fce0f20 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 28 May 2026 19:17:10 -0700 Subject: [PATCH 03/11] Avoid signaling exited guest process PIDs Treat a guest AppHost process that exits before shutdown coordination can read its start time as already stopped. This preserves process identity validation and avoids signaling a reused PID without a start-time guard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Projects/ProcessGuestLauncher.cs | 38 +++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs index d007c5b676d..d331ad10d2b 100644 --- a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs +++ b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs @@ -135,7 +135,6 @@ public ProcessGuestLauncher( AddEvent(activity, ProfilingTelemetry.Events.GuestProcessStart); process.Start(); - var processStartedAt = new DateTimeOffset(process.StartTime); _logger.LogDebug("{Language} guest process {ProcessId} started: {Command}", _language, process.Id, resolvedCommandPath); activity?.SetTag(TelemetryConstants.Tags.ProcessPid, process.Id); AddEvent(activity, ProfilingTelemetry.Events.GuestProcessStarted, TelemetryConstants.Tags.ProcessPid, process.Id); @@ -172,12 +171,22 @@ public ProcessGuestLauncher( // the redirected output streams have time to drain. if (!process.HasExited) { - var stopped = _processShutdownService is not null && - await _processShutdownService.StopProcessGroupAsync( - process.Id, - processStartedAt, - forceKillEntireProcessTree: true, - CancellationToken.None).ConfigureAwait(false); + var stopped = false; + if (_processShutdownService is not null) + { + if (TryGetProcessStartTime(process, out var processStartedAt)) + { + stopped = await _processShutdownService.StopProcessGroupAsync( + process.Id, + processStartedAt, + forceKillEntireProcessTree: true, + CancellationToken.None).ConfigureAwait(false); + } + else + { + stopped = true; + } + } if (!stopped && !process.HasExited) { @@ -220,6 +229,21 @@ await _processShutdownService.StopProcessGroupAsync( return (process.ExitCode, outputCollector); } + private static bool TryGetProcessStartTime(Process process, out DateTimeOffset? startTime) + { + try + { + startTime = new DateTimeOffset(process.StartTime); + return true; + } + catch (InvalidOperationException) + { + // The process exited between the HasExited check and shutdown coordination. + startTime = null; + return false; + } + } + private static Activity? GetCurrentProfilingActivity() { var activity = Activity.Current; From 550016da055e17af605150f1f91e4b8a7993ca3c Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 28 May 2026 19:27:34 -0700 Subject: [PATCH 04/11] Share Windows process group setup Add a ProcessStartInfo extension for Windows process-group creation and use it for guest AppHost and AppHost server launches. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DotNetBasedAppHostServerProject.cs | 6 +---- .../Projects/PrebuiltAppHostServer.cs | 6 +---- .../Projects/ProcessGuestLauncher.cs | 6 +---- .../Utils/ProcessStartInfoExtensions.cs | 22 +++++++++++++++++++ 4 files changed, 25 insertions(+), 15 deletions(-) create mode 100644 src/Aspire.Cli/Utils/ProcessStartInfoExtensions.cs diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index e56f9a4813a..17c4bc40743 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -482,11 +482,7 @@ public async Task PrepareAsync( WindowStyle = ProcessWindowStyle.Minimized, UseShellExecute = false, CreateNoWindow = true - }; - if (OperatingSystem.IsWindows()) - { - startInfo.CreateNewProcessGroup = true; - } + }.CreateNewProcessGroupOnWindows(); startInfo.ArgumentList.Add("exec"); startInfo.ArgumentList.Add(assemblyPath); diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index b2a51b212df..7bcbfeb4ba1 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -905,11 +905,7 @@ internal ProcessStartInfo CreateStartInfo( WindowStyle = ProcessWindowStyle.Minimized, UseShellExecute = false, CreateNoWindow = true - }; - if (OperatingSystem.IsWindows()) - { - startInfo.CreateNewProcessGroup = true; - } + }.CreateNewProcessGroupOnWindows(); // Insert "server" subcommand, then remaining args startInfo.ArgumentList.Add("server"); diff --git a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs index d331ad10d2b..8cc24fc03d6 100644 --- a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs +++ b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs @@ -69,11 +69,7 @@ public ProcessGuestLauncher( RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true - }; - if (OperatingSystem.IsWindows()) - { - startInfo.CreateNewProcessGroup = true; - } + }.CreateNewProcessGroupOnWindows(); foreach (var arg in args) { diff --git a/src/Aspire.Cli/Utils/ProcessStartInfoExtensions.cs b/src/Aspire.Cli/Utils/ProcessStartInfoExtensions.cs new file mode 100644 index 00000000000..55314a0f329 --- /dev/null +++ b/src/Aspire.Cli/Utils/ProcessStartInfoExtensions.cs @@ -0,0 +1,22 @@ +// 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; + +namespace Aspire.Cli.Utils; + +internal static class ProcessStartInfoExtensions +{ + public static ProcessStartInfo CreateNewProcessGroupOnWindows(this ProcessStartInfo startInfo) + { + if (OperatingSystem.IsWindows()) + { + // Windows can only target CTRL_BREAK_EVENT at a child process group when the child is + // started with CREATE_NEW_PROCESS_GROUP. Ctrl+C is not usable for that case, so Aspire + // creates groups for guest AppHosts and AppHost servers that it needs to stop gracefully. + startInfo.CreateNewProcessGroup = true; + } + + return startInfo; + } +} From e713a09bb61918c6f6218bdb08dff8bb45cabff3 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 28 May 2026 19:36:03 -0700 Subject: [PATCH 05/11] Align run cancellation timeout with shutdown service Derive the AppHost startup cancellation wait from ProcessShutdownService so aspire run allows the graceful shutdown monitor and force-kill verification windows to complete instead of timing out after five seconds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/RunCommand.cs | 9 ++++++++- src/Aspire.Cli/Processes/ProcessShutdownService.cs | 4 +++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 6399edd8d0a..2d1290ad4b8 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -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; @@ -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 diff --git a/src/Aspire.Cli/Processes/ProcessShutdownService.cs b/src/Aspire.Cli/Processes/ProcessShutdownService.cs index 1e5ae7d337d..e43078ef3b3 100644 --- a/src/Aspire.Cli/Processes/ProcessShutdownService.cs +++ b/src/Aspire.Cli/Processes/ProcessShutdownService.cs @@ -24,6 +24,8 @@ 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 StopProcessTreeAsync( int pid, DateTimeOffset? startTime, @@ -314,7 +316,7 @@ internal async Task TryStopProcessTreeWithDcpAsync(int pid, DateTimeOffset private async Task MonitorProcessesForTerminationAsync(IReadOnlyCollection processes, CancellationToken cancellationToken) { var startTime = timeProvider.GetUtcNow(); - while (timeProvider.GetUtcNow() - startTime < s_processTerminationTimeout) + while (timeProvider.GetUtcNow() - startTime < ProcessTerminationTimeout) { if (processes.All(IsProcessStopped)) { From cb67486a19aa09042f2e689b582b62df7deecaa8 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 28 May 2026 19:49:52 -0700 Subject: [PATCH 06/11] Use shorter run shutdown timeout Make ProcessShutdownService termination monitoring configurable and use a shorter timeout for aspire run owned guest/server processes while keeping the longer default for detached/start/stop paths. Relax guest launcher test startup waits for slower CI process startup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/RunCommand.cs | 7 ++- .../Processes/ProcessShutdownService.cs | 52 +++++++++++++++++-- .../Projects/AppHostServerSession.cs | 1 + .../Projects/ProcessGuestLauncher.cs | 1 + .../Projects/ProcessGuestLauncherTests.cs | 4 +- 5 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 2d1290ad4b8..0ab6501dc3f 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -78,11 +78,10 @@ internal sealed class RunCommand : BaseCommand private const int MaxDisplayedAppHostStartupOutputLines = 80; // 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. + // ProcessShutdownService to stop the guest and server processes. Each stop can spend + // one run timeout waiting for graceful shutdown and another after force-kill fallback. private static readonly TimeSpan s_appHostStartupCancellationTimeout = - ProcessShutdownService.ProcessTerminationTimeout + - ProcessShutdownService.ProcessTerminationTimeout + + TimeSpan.FromTicks(ProcessShutdownService.RunProcessTerminationTimeout.Ticks * 4) + TimeSpan.FromSeconds(1); // Guest AppHosts can bring up the temporary server/backchannel and then fail immediately diff --git a/src/Aspire.Cli/Processes/ProcessShutdownService.cs b/src/Aspire.Cli/Processes/ProcessShutdownService.cs index e43078ef3b3..02da5b34b65 100644 --- a/src/Aspire.Cli/Processes/ProcessShutdownService.cs +++ b/src/Aspire.Cli/Processes/ProcessShutdownService.cs @@ -22,10 +22,13 @@ internal sealed class ProcessShutdownService( TimeProvider timeProvider) { private static readonly TimeSpan s_processTerminationTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan s_runProcessTerminationTimeout = TimeSpan.FromSeconds(3); private static readonly TimeSpan s_processTerminationPollInterval = TimeSpan.FromMilliseconds(250); internal static TimeSpan ProcessTerminationTimeout => s_processTerminationTimeout; + internal static TimeSpan RunProcessTerminationTimeout => s_runProcessTerminationTimeout; + public Task StopProcessTreeAsync( int pid, DateTimeOffset? startTime, @@ -37,6 +40,7 @@ public Task StopProcessTreeAsync( startTime, includeStartTimeForDcp, forceKillEntireProcessTree: !OperatingSystem.IsWindows(), + processTerminationTimeout: s_processTerminationTimeout, cancellationToken); } @@ -46,12 +50,44 @@ public Task StopProcessTreeAsync( bool includeStartTimeForDcp, bool forceKillEntireProcessTree, CancellationToken cancellationToken) + { + return StopProcessTreeAsync( + pid, + startTime, + includeStartTimeForDcp, + forceKillEntireProcessTree, + processTerminationTimeout: s_processTerminationTimeout, + cancellationToken); + } + + public Task StopProcessTreeAsync( + int pid, + DateTimeOffset? startTime, + bool includeStartTimeForDcp, + bool forceKillEntireProcessTree, + TimeSpan processTerminationTimeout, + CancellationToken cancellationToken) { return StopProcessesAsync( [new ProcessTarget(pid, startTime)], [new ProcessTarget(pid, startTime)], token => RequestProcessTreeGracefulShutdownAsync(pid, startTime, includeStartTimeForDcp, token), forceKillEntireProcessTree, + processTerminationTimeout, + cancellationToken); + } + + public Task StopProcessGroupAsync( + int pid, + DateTimeOffset? startTime, + bool forceKillEntireProcessTree, + CancellationToken cancellationToken) + { + return StopProcessGroupAsync( + pid, + startTime, + forceKillEntireProcessTree, + processTerminationTimeout: s_processTerminationTimeout, cancellationToken); } @@ -59,6 +95,7 @@ public Task StopProcessGroupAsync( int pid, DateTimeOffset? startTime, bool forceKillEntireProcessTree, + TimeSpan processTerminationTimeout, CancellationToken cancellationToken) { return StopProcessesAsync( @@ -66,6 +103,7 @@ [new ProcessTarget(pid, startTime)], [new ProcessTarget(pid, startTime)], token => RequestProcessGroupGracefulShutdownAsync(pid, startTime, token), forceKillEntireProcessTree, + processTerminationTimeout, cancellationToken); } @@ -93,6 +131,7 @@ public async Task StopAppHostAsync( processesToForceKill, token => RequestAppHostGracefulShutdownAsync(appHostInfo, requestRpcStopAsync, token), forceKillEntireProcessTree: !OperatingSystem.IsWindows(), + processTerminationTimeout: s_processTerminationTimeout, cancellationToken).ConfigureAwait(false); } @@ -106,6 +145,7 @@ internal async Task StopProcessesAsync( processesToMonitorAndKill, requestGracefulShutdownAsync, forceKillEntireProcessTree: !OperatingSystem.IsWindows(), + processTerminationTimeout: s_processTerminationTimeout, cancellationToken).ConfigureAwait(false); } @@ -114,10 +154,11 @@ private async Task StopProcessesAsync( IReadOnlyCollection processesToForceKill, Func> requestGracefulShutdownAsync, bool forceKillEntireProcessTree, + TimeSpan processTerminationTimeout, CancellationToken cancellationToken) { var gracefulShutdownRequested = await TryRequestGracefulShutdownAsync(requestGracefulShutdownAsync, cancellationToken).ConfigureAwait(false); - if (gracefulShutdownRequested && await MonitorProcessesForTerminationAsync(processesToMonitor, cancellationToken).ConfigureAwait(false)) + if (gracefulShutdownRequested && await MonitorProcessesForTerminationAsync(processesToMonitor, processTerminationTimeout, cancellationToken).ConfigureAwait(false)) { ForceKillRemainingProcesses(processesToForceKill.Except(processesToMonitor), afterTimeout: false, killEntireProcessTree: forceKillEntireProcessTree); return true; @@ -125,7 +166,7 @@ private async Task StopProcessesAsync( ForceKillRemainingProcesses(processesToForceKill, afterTimeout: true, killEntireProcessTree: forceKillEntireProcessTree); - return await MonitorProcessesForTerminationAsync(processesToMonitor, cancellationToken).ConfigureAwait(false); + return await MonitorProcessesForTerminationAsync(processesToMonitor, processTerminationTimeout, cancellationToken).ConfigureAwait(false); } private void ForceKillRemainingProcesses(IEnumerable processes, bool afterTimeout, bool killEntireProcessTree) @@ -313,10 +354,13 @@ internal async Task TryStopProcessTreeWithDcpAsync(int pid, DateTimeOffset return true; } - private async Task MonitorProcessesForTerminationAsync(IReadOnlyCollection processes, CancellationToken cancellationToken) + private async Task MonitorProcessesForTerminationAsync( + IReadOnlyCollection processes, + TimeSpan processTerminationTimeout, + CancellationToken cancellationToken) { var startTime = timeProvider.GetUtcNow(); - while (timeProvider.GetUtcNow() - startTime < ProcessTerminationTimeout) + while (timeProvider.GetUtcNow() - startTime < processTerminationTimeout) { if (processes.All(IsProcessStopped)) { diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index 5b304f5af35..b55a3eb022a 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -172,6 +172,7 @@ public async ValueTask DisposeAsync() _serverProcess.Id, serverProcessStartTime, forceKillEntireProcessTree: !OperatingSystem.IsWindows(), + processTerminationTimeout: ProcessShutdownService.RunProcessTerminationTimeout, CancellationToken.None).ConfigureAwait(false); } else diff --git a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs index 8cc24fc03d6..0389e2ccdf6 100644 --- a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs +++ b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs @@ -176,6 +176,7 @@ public ProcessGuestLauncher( process.Id, processStartedAt, forceKillEntireProcessTree: true, + processTerminationTimeout: ProcessShutdownService.RunProcessTerminationTimeout, CancellationToken.None).ConfigureAwait(false); } else diff --git a/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs b/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs index 44e75c9f41c..ba95a99a0fe 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs @@ -13,6 +13,8 @@ namespace Aspire.Cli.Tests.Projects; public class ProcessGuestLauncherTests(ITestOutputHelper outputHelper) { + private static readonly TimeSpan s_processStartupTimeout = TimeSpan.FromSeconds(30); + [Fact] [SkipOnPlatform(TestPlatforms.Windows, "This verifies the Unix SIGTERM graceful shutdown path.")] public async Task LaunchAsync_CancellationUsesUnixGracefulShutdownBeforeForceKill() @@ -119,7 +121,7 @@ private static ProcessShutdownService CreateProcessShutdownService(TemporaryWork private static async Task WaitForFileAsync(string path) { - using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + using var timeout = new CancellationTokenSource(s_processStartupTimeout); while (!File.Exists(path)) { await Task.Delay(TimeSpan.FromMilliseconds(20), timeout.Token); From 95f6b6148b6a4692d8ba0068301c1a6b6bf1044d Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 28 May 2026 19:51:54 -0700 Subject: [PATCH 07/11] Update process group signaling diagnostics Include the ProcessSignaler diagnostic update for process-group shutdown signaling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Shared/ProcessSignaler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Shared/ProcessSignaler.cs b/src/Shared/ProcessSignaler.cs index 3d4d31bf0a9..516c587a75b 100644 --- a/src/Shared/ProcessSignaler.cs +++ b/src/Shared/ProcessSignaler.cs @@ -121,7 +121,7 @@ private static void RequestGracefulShutdownWindowsProcessGroup(int pid, ILogger var result = GenerateConsoleCtrlEvent(CtrlBreakEvent, (uint)pid); if (!result) { - int error = Marshal.GetLastWin32Error(); + var error = Marshal.GetLastWin32Error(); // Best effort. logger.LogWarning("Could not gracefully stop Aspire application host process group {Pid}; the error code from signal send operation was {ErrorCode}", pid, error); } From 471cf39dcb46bbe6008715356e1ef471292dc1dc Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 28 May 2026 19:59:08 -0700 Subject: [PATCH 08/11] Bound run shutdown cancellation waits Use fresh timeout cancellation tokens for aspire run guest and server shutdown coordination instead of passing CancellationToken.None after command cancellation. Keep shutdown bounded while avoiding the already-canceled command token. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/RunCommand.cs | 3 +- .../Processes/ProcessShutdownService.cs | 3 ++ .../Projects/AppHostServerSession.cs | 20 ++++++--- .../Projects/ProcessGuestLauncher.cs | 43 +++++++++++++------ 4 files changed, 48 insertions(+), 21 deletions(-) diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 0ab6501dc3f..654155d4cc9 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -81,8 +81,7 @@ internal sealed class RunCommand : BaseCommand // ProcessShutdownService to stop the guest and server processes. Each stop can spend // one run timeout waiting for graceful shutdown and another after force-kill fallback. private static readonly TimeSpan s_appHostStartupCancellationTimeout = - TimeSpan.FromTicks(ProcessShutdownService.RunProcessTerminationTimeout.Ticks * 4) + - TimeSpan.FromSeconds(1); + TimeSpan.FromTicks(ProcessShutdownService.RunProcessShutdownTimeout.Ticks * 2); // 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 diff --git a/src/Aspire.Cli/Processes/ProcessShutdownService.cs b/src/Aspire.Cli/Processes/ProcessShutdownService.cs index 02da5b34b65..35860878bec 100644 --- a/src/Aspire.Cli/Processes/ProcessShutdownService.cs +++ b/src/Aspire.Cli/Processes/ProcessShutdownService.cs @@ -29,6 +29,9 @@ internal sealed class ProcessShutdownService( internal static TimeSpan RunProcessTerminationTimeout => s_runProcessTerminationTimeout; + internal static TimeSpan RunProcessShutdownTimeout => + TimeSpan.FromTicks(s_runProcessTerminationTimeout.Ticks * 2) + TimeSpan.FromSeconds(1); + public Task StopProcessTreeAsync( int pid, DateTimeOffset? startTime, diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index b55a3eb022a..def38bf068d 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -168,12 +168,20 @@ public async ValueTask DisposeAsync() { if (TryGetServerProcessStartTime(out var serverProcessStartTime)) { - stopped = await _processShutdownService.StopProcessGroupAsync( - _serverProcess.Id, - serverProcessStartTime, - forceKillEntireProcessTree: !OperatingSystem.IsWindows(), - processTerminationTimeout: ProcessShutdownService.RunProcessTerminationTimeout, - CancellationToken.None).ConfigureAwait(false); + using var shutdownCts = new CancellationTokenSource(ProcessShutdownService.RunProcessShutdownTimeout); + try + { + stopped = await _processShutdownService.StopProcessGroupAsync( + _serverProcess.Id, + serverProcessStartTime, + forceKillEntireProcessTree: !OperatingSystem.IsWindows(), + processTerminationTimeout: ProcessShutdownService.RunProcessTerminationTimeout, + shutdownCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (shutdownCts.IsCancellationRequested) + { + _logger.LogWarning("Timed out waiting for AppHost server process {ProcessId} shutdown.", _serverProcess.Id); + } } else { diff --git a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs index 0389e2ccdf6..a8ed57f72f8 100644 --- a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs +++ b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs @@ -163,8 +163,8 @@ public ProcessGuestLauncher( // We don't rethrow the OperationCanceledException because the caller in GuestAppHostProject // uses the returned exit code to distinguish user cancellation from internal teardown // (e.g. surfacing captured output when the guest was killed because the AppHost system - // failed). Wait without honoring cancellation so the OS reports the final exit code and - // the redirected output streams have time to drain. + // failed). Use fresh timeout tokens instead of the already-canceled command token so + // shutdown gets its budget without turning into an unbounded wait. if (!process.HasExited) { var stopped = false; @@ -172,12 +172,20 @@ public ProcessGuestLauncher( { if (TryGetProcessStartTime(process, out var processStartedAt)) { - stopped = await _processShutdownService.StopProcessGroupAsync( - process.Id, - processStartedAt, - forceKillEntireProcessTree: true, - processTerminationTimeout: ProcessShutdownService.RunProcessTerminationTimeout, - CancellationToken.None).ConfigureAwait(false); + using var shutdownCts = new CancellationTokenSource(ProcessShutdownService.RunProcessShutdownTimeout); + try + { + stopped = await _processShutdownService.StopProcessGroupAsync( + process.Id, + processStartedAt, + forceKillEntireProcessTree: true, + processTerminationTimeout: ProcessShutdownService.RunProcessTerminationTimeout, + shutdownCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (shutdownCts.IsCancellationRequested) + { + _logger.LogWarning("Timed out waiting for {Language} guest process {ProcessId} shutdown.", _language, process.Id); + } } else { @@ -204,13 +212,22 @@ public ProcessGuestLauncher( } _logger.LogDebug("Waiting for {Language} guest process {ProcessId} to exit after kill", _language, process.Id); - await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); + using var finalExitCts = new CancellationTokenSource(ProcessShutdownService.RunProcessTerminationTimeout); + try + { + await process.WaitForExitAsync(finalExitCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (finalExitCts.IsCancellationRequested) + { + _logger.LogWarning("Timed out waiting for {Language} guest process {ProcessId} to exit after shutdown.", _language, process.Id); + } } - _logger.LogDebug("{Language} guest process {ProcessId} exited with code {ExitCode}", _language, process.Id, process.ExitCode); + var exitCode = process.HasExited ? process.ExitCode : -1; + _logger.LogDebug("{Language} guest process {ProcessId} exited with code {ExitCode}", _language, process.Id, exitCode); - activity?.SetTag(TelemetryConstants.Tags.ProcessExitCode, process.ExitCode); - AddEvent(activity, ProfilingTelemetry.Events.GuestProcessExited, TelemetryConstants.Tags.ProcessExitCode, process.ExitCode); + activity?.SetTag(TelemetryConstants.Tags.ProcessExitCode, exitCode); + AddEvent(activity, ProfilingTelemetry.Events.GuestProcessExited, TelemetryConstants.Tags.ProcessExitCode, exitCode); // Wait for the redirected streams to finish draining so no trailing lines are lost. // Pass a fresh token rather than the outer cancellation token: when WaitForExitAsync @@ -223,7 +240,7 @@ public ProcessGuestLauncher( _logger.LogWarning("{Language}({ProcessId}): Timed out waiting for output streams to drain after process exit", _language, process.Id); } - return (process.ExitCode, outputCollector); + return (exitCode, outputCollector); } private static bool TryGetProcessStartTime(Process process, out DateTimeOffset? startTime) From b922159e4cb8a9cd99cfe32b9f5cf3478d9cbb7c Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 28 May 2026 20:24:03 -0700 Subject: [PATCH 09/11] Thread run shutdown timeout policy Let RunCommand own the shorter run shutdown budget and pass it down explicitly to guest AppHost and AppHost server shutdown coordination. Use bounded timeout tokens instead of CancellationToken.None and add AppHost server shutdown logging before the shared shutdown service takes over. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/RunCommand.cs | 14 +++++--- .../Processes/ProcessShutdownService.cs | 8 +---- .../Projects/AppHostProjectContext.cs | 10 ++++++ .../Projects/AppHostServerSession.cs | 33 +++++++++++++++---- .../Projects/ExtensionGuestLauncher.cs | 2 ++ .../Projects/GuestAppHostProject.cs | 19 +++++++++-- src/Aspire.Cli/Projects/GuestRuntime.cs | 29 ++++++++++++++-- .../Projects/IGuestProcessLauncher.cs | 2 ++ .../Projects/ProcessGuestLauncher.cs | 13 ++++++-- .../Projects/GuestRuntimeTests.cs | 2 ++ 10 files changed, 106 insertions(+), 26 deletions(-) diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 654155d4cc9..0211879a779 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -12,7 +12,6 @@ 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; @@ -77,11 +76,14 @@ internal sealed class RunCommand : BaseCommand private bool _isDetachMode; private const int MaxDisplayedAppHostStartupOutputLines = 80; - // Startup cancellation waits for the AppHost run task to complete after it asks - // ProcessShutdownService to stop the guest and server processes. Each stop can spend - // one run timeout waiting for graceful shutdown and another after force-kill fallback. + private static readonly TimeSpan s_runProcessTerminationTimeout = TimeSpan.FromSeconds(3); + private static readonly TimeSpan s_runProcessShutdownTimeout = + TimeSpan.FromTicks(s_runProcessTerminationTimeout.Ticks * 2) + TimeSpan.FromSeconds(1); + + // Startup cancellation waits for the AppHost run task to complete after it stops the + // directly-owned guest and server processes. Each stop gets its own run shutdown budget. private static readonly TimeSpan s_appHostStartupCancellationTimeout = - TimeSpan.FromTicks(ProcessShutdownService.RunProcessShutdownTimeout.Ticks * 2); + TimeSpan.FromTicks(s_runProcessShutdownTimeout.Ticks * 2); // 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 @@ -283,6 +285,8 @@ protected override async Task ExecuteAsync(ParseResult parseResul WorkingDirectory = ExecutionContext.WorkingDirectory, BuildCompletionSource = buildCompletionSource, BackchannelCompletionSource = backchannelCompletionSource, + ProcessTerminationTimeout = s_runProcessTerminationTimeout, + ProcessShutdownTimeout = s_runProcessShutdownTimeout, }; ProfilingTelemetry.AddCurrentContextToEnvironment(context.EnvironmentVariables); if (captureProfile) diff --git a/src/Aspire.Cli/Processes/ProcessShutdownService.cs b/src/Aspire.Cli/Processes/ProcessShutdownService.cs index 35860878bec..2174f2da9f2 100644 --- a/src/Aspire.Cli/Processes/ProcessShutdownService.cs +++ b/src/Aspire.Cli/Processes/ProcessShutdownService.cs @@ -22,15 +22,9 @@ internal sealed class ProcessShutdownService( TimeProvider timeProvider) { private static readonly TimeSpan s_processTerminationTimeout = TimeSpan.FromSeconds(10); - private static readonly TimeSpan s_runProcessTerminationTimeout = TimeSpan.FromSeconds(3); private static readonly TimeSpan s_processTerminationPollInterval = TimeSpan.FromMilliseconds(250); - internal static TimeSpan ProcessTerminationTimeout => s_processTerminationTimeout; - - internal static TimeSpan RunProcessTerminationTimeout => s_runProcessTerminationTimeout; - - internal static TimeSpan RunProcessShutdownTimeout => - TimeSpan.FromTicks(s_runProcessTerminationTimeout.Ticks * 2) + TimeSpan.FromSeconds(1); + internal static TimeSpan DefaultProcessTerminationTimeout => s_processTerminationTimeout; public Task StopProcessTreeAsync( int pid, diff --git a/src/Aspire.Cli/Projects/AppHostProjectContext.cs b/src/Aspire.Cli/Projects/AppHostProjectContext.cs index d37af5174f6..2ecca7cd623 100644 --- a/src/Aspire.Cli/Projects/AppHostProjectContext.cs +++ b/src/Aspire.Cli/Projects/AppHostProjectContext.cs @@ -86,6 +86,16 @@ internal sealed class AppHostProjectContext /// public required DirectoryInfo WorkingDirectory { get; init; } + /// + /// Gets the per-process termination monitoring timeout for AppHost-owned child processes. + /// + public TimeSpan? ProcessTerminationTimeout { get; init; } + + /// + /// Gets the total shutdown coordination timeout for AppHost-owned child processes. + /// + public TimeSpan? ProcessShutdownTimeout { get; init; } + /// /// Gets or sets the output collector for capturing stdout/stderr. /// Project implementations populate this during execution. diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index def38bf068d..6ab22a50196 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -25,6 +25,8 @@ internal sealed class AppHostServerSession : IAppHostServerSession private readonly ProfilingTelemetry? _profilingTelemetry; private readonly IDisposable? _projectLifetime; private readonly ProcessShutdownService? _processShutdownService; + private readonly TimeSpan? _processTerminationTimeout; + private readonly TimeSpan? _processShutdownTimeout; private IAppHostRpcClient? _rpcClient; private bool _disposed; @@ -37,7 +39,9 @@ internal AppHostServerSession( ProfilingTelemetry.ActivityScope activity = default, ProfilingTelemetry? profilingTelemetry = null, IDisposable? projectLifetime = null, - ProcessShutdownService? processShutdownService = null) + ProcessShutdownService? processShutdownService = null, + TimeSpan? processTerminationTimeout = null, + TimeSpan? processShutdownTimeout = null) { _serverProcess = serverProcess; _output = output; @@ -48,6 +52,8 @@ internal AppHostServerSession( _profilingTelemetry = profilingTelemetry; _projectLifetime = projectLifetime; _processShutdownService = processShutdownService; + _processTerminationTimeout = processTerminationTimeout; + _processShutdownTimeout = processShutdownTimeout; } /// @@ -73,6 +79,8 @@ internal AppHostServerSession( /// The logger to use for lifecycle diagnostics. /// Optional profiling telemetry for the server process lifetime. /// Optional shared process shutdown coordinator. + /// Optional per-process termination monitoring timeout. + /// Optional total shutdown coordination timeout. /// The started AppHost server session. internal static AppHostServerSession Start( IAppHostServerProject appHostServerProject, @@ -80,7 +88,9 @@ internal static AppHostServerSession Start( bool debug, ILogger logger, ProfilingTelemetry? profilingTelemetry = null, - ProcessShutdownService? processShutdownService = null) + ProcessShutdownService? processShutdownService = null, + TimeSpan? processTerminationTimeout = null, + TimeSpan? processShutdownTimeout = null) { var currentPid = Environment.ProcessId; var serverEnvironmentVariables = environmentVariables is null @@ -134,7 +144,9 @@ internal static AppHostServerSession Start( activity, profilingTelemetry, appHostServerProject as IDisposable, - processShutdownService); + processShutdownService, + processTerminationTimeout, + processShutdownTimeout); } /// @@ -168,14 +180,17 @@ public async ValueTask DisposeAsync() { if (TryGetServerProcessStartTime(out var serverProcessStartTime)) { - using var shutdownCts = new CancellationTokenSource(ProcessShutdownService.RunProcessShutdownTimeout); + var processTerminationTimeout = _processTerminationTimeout ?? ProcessShutdownService.DefaultProcessTerminationTimeout; + var processShutdownTimeout = _processShutdownTimeout ?? GetProcessShutdownTimeout(processTerminationTimeout); + using var shutdownCts = new CancellationTokenSource(processShutdownTimeout); try { + _logger.LogInformation("Requesting shutdown of AppHost server process {ProcessId}", _serverProcess.Id); stopped = await _processShutdownService.StopProcessGroupAsync( _serverProcess.Id, serverProcessStartTime, forceKillEntireProcessTree: !OperatingSystem.IsWindows(), - processTerminationTimeout: ProcessShutdownService.RunProcessTerminationTimeout, + processTerminationTimeout, shutdownCts.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (shutdownCts.IsCancellationRequested) @@ -193,7 +208,9 @@ public async ValueTask DisposeAsync() { try { - _serverProcess.Kill(entireProcessTree: !OperatingSystem.IsWindows()); + var killEntireProcessTree = !OperatingSystem.IsWindows(); + _logger.LogInformation("Killing AppHost server process {ProcessId} (entireProcessTree={EntireProcessTree})", _serverProcess.Id, killEntireProcessTree); + _serverProcess.Kill(entireProcessTree: killEntireProcessTree); _activity.SetError("AppHost server process was terminated during session disposal."); } catch (Exception ex) @@ -201,6 +218,7 @@ public async ValueTask DisposeAsync() _logger.LogDebug(ex, "Error killing AppHost server process"); } } + } if (_serverProcess.HasExited) @@ -227,6 +245,9 @@ private bool TryGetServerProcessStartTime(out DateTimeOffset? startTime) return false; } } + + private static TimeSpan GetProcessShutdownTimeout(TimeSpan processTerminationTimeout) + => TimeSpan.FromTicks(processTerminationTimeout.Ticks * 2) + TimeSpan.FromSeconds(1); } /// diff --git a/src/Aspire.Cli/Projects/ExtensionGuestLauncher.cs b/src/Aspire.Cli/Projects/ExtensionGuestLauncher.cs index e9ba0c3c641..2e4780dc384 100644 --- a/src/Aspire.Cli/Projects/ExtensionGuestLauncher.cs +++ b/src/Aspire.Cli/Projects/ExtensionGuestLauncher.cs @@ -34,6 +34,8 @@ public ExtensionGuestLauncher( DirectoryInfo workingDirectory, IDictionary environmentVariables, CancellationToken cancellationToken, + TimeSpan? processTerminationTimeout = null, + TimeSpan? processShutdownTimeout = null, Func? afterLaunchAsync = null) { // Prepend the runtime command (e.g., "npx") as the first argument so the diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 176c4d5572f..e00cb1b8f6f 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -434,7 +434,9 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken context.Debug, _logger, _profilingTelemetry, - _processShutdownService); + _processShutdownService, + context.ProcessTerminationTimeout, + context.ProcessShutdownTimeout); try { // Give the server a moment to start @@ -608,7 +610,7 @@ Task StartBackchannelConnectionAfterGuestAppHostLaunchesAsync() // appHostSystemCts is linked to cancellationToken) tears down the guest process. The // launcher will kill the guest's process tree when this token cancels. (guestExitCode, guestOutput) = await ExecuteGuestAppHostAsync( - appHostFile, directory, environmentVariables, enableHotReload, rpcClient, launcher, StartBackchannelConnectionAfterGuestAppHostLaunchesAsync, appHostSystemToken); + appHostFile, directory, environmentVariables, enableHotReload, rpcClient, launcher, StartBackchannelConnectionAfterGuestAppHostLaunchesAsync, context.ProcessTerminationTimeout, context.ProcessShutdownTimeout, appHostSystemToken); } // If the user cancelled (Ctrl+C), surface that as cancellation instead of a "guest failed" @@ -1885,6 +1887,8 @@ private async Task InstallDependenciesAsync( IAppHostRpcClient rpcClient, IGuestProcessLauncher launcher, Func? afterAppHostLaunchedAsync, + TimeSpan? processTerminationTimeout, + TimeSpan? processShutdownTimeout, CancellationToken cancellationToken) { await EnsureRuntimeCreatedAsync(directory, rpcClient, cancellationToken); @@ -1895,7 +1899,16 @@ private async Task InstallDependenciesAsync( return (CliExitCodes.FailedToDotnetRunAppHost, new OutputCollector()); } - return await _guestRuntime.RunAsync(appHostFile, directory, environmentVariables, watchMode, launcher, cancellationToken, afterAppHostLaunchedAsync: afterAppHostLaunchedAsync); + return await _guestRuntime.RunAsync( + appHostFile, + directory, + environmentVariables, + watchMode, + launcher, + cancellationToken, + processTerminationTimeout, + processShutdownTimeout, + afterAppHostLaunchedAsync: afterAppHostLaunchedAsync); } /// diff --git a/src/Aspire.Cli/Projects/GuestRuntime.cs b/src/Aspire.Cli/Projects/GuestRuntime.cs index 533a733bda0..9e414664208 100644 --- a/src/Aspire.Cli/Projects/GuestRuntime.cs +++ b/src/Aspire.Cli/Projects/GuestRuntime.cs @@ -154,6 +154,8 @@ public GuestRuntime( /// Whether to run in watch mode for hot reload. /// Strategy for launching the process. /// Cancellation token. + /// Optional per-process termination monitoring timeout. + /// Optional total shutdown coordination timeout. /// Callback invoked after the AppHost execute command has launched. /// A tuple of the exit code and captured output (null when launched via extension). public async Task<(int ExitCode, OutputCollector? Output)> RunAsync( @@ -163,6 +165,8 @@ public GuestRuntime( bool watchMode, IGuestProcessLauncher launcher, CancellationToken cancellationToken, + TimeSpan? processTerminationTimeout = null, + TimeSpan? processShutdownTimeout = null, Func? afterAppHostLaunchedAsync = null) { var useWatchCommand = watchMode && _spec.WatchExecute is not null; @@ -183,7 +187,18 @@ public GuestRuntime( var phase = useWatchCommand ? ProfilingTelemetry.Values.GuestCommandPhaseWatchExecute : ProfilingTelemetry.Values.GuestCommandPhaseExecute; - return await ExecuteCommandAsync(commandSpec, appHostFile, directory, environmentVariables, null, phase, launcher, cancellationToken, afterLaunchAsync: afterAppHostLaunchedAsync); + return await ExecuteCommandAsync( + commandSpec, + appHostFile, + directory, + environmentVariables, + null, + phase, + launcher, + cancellationToken, + processTerminationTimeout, + processShutdownTimeout, + afterLaunchAsync: afterAppHostLaunchedAsync); } /// @@ -262,6 +277,8 @@ public GuestRuntime( string phase, IGuestProcessLauncher launcher, CancellationToken cancellationToken, + TimeSpan? processTerminationTimeout = null, + TimeSpan? processShutdownTimeout = null, Func? afterLaunchAsync = null) { var args = ReplacePlaceholders(commandSpec.Args, appHostFile, directory, additionalArgs); @@ -272,7 +289,15 @@ public GuestRuntime( using var activity = _profilingTelemetry is null ? default : _profilingTelemetry.StartGuestExecuteCommand(_spec.Language, _spec.DisplayName, commandSpec.Command, args, directory, phase); - var (exitCode, output) = await launcher.LaunchAsync(commandSpec.Command, args, directory, mergedEnvironment, cancellationToken, afterLaunchAsync: afterLaunchAsync); + var (exitCode, output) = await launcher.LaunchAsync( + commandSpec.Command, + args, + directory, + mergedEnvironment, + cancellationToken, + processTerminationTimeout, + processShutdownTimeout, + afterLaunchAsync); activity.SetProcessExitCode(exitCode); if (exitCode != 0) { diff --git a/src/Aspire.Cli/Projects/IGuestProcessLauncher.cs b/src/Aspire.Cli/Projects/IGuestProcessLauncher.cs index 1b0d5bc4e83..650f54e74ae 100644 --- a/src/Aspire.Cli/Projects/IGuestProcessLauncher.cs +++ b/src/Aspire.Cli/Projects/IGuestProcessLauncher.cs @@ -19,5 +19,7 @@ internal interface IGuestProcessLauncher DirectoryInfo workingDirectory, IDictionary environmentVariables, CancellationToken cancellationToken, + TimeSpan? processTerminationTimeout = null, + TimeSpan? processShutdownTimeout = null, Func? afterLaunchAsync = null); } diff --git a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs index a8ed57f72f8..b3f0615078a 100644 --- a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs +++ b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs @@ -41,6 +41,8 @@ public ProcessGuestLauncher( DirectoryInfo workingDirectory, IDictionary environmentVariables, CancellationToken cancellationToken, + TimeSpan? processTerminationTimeout = null, + TimeSpan? processShutdownTimeout = null, Func? afterLaunchAsync = null) { var activity = GetCurrentProfilingActivity(); @@ -172,14 +174,16 @@ public ProcessGuestLauncher( { if (TryGetProcessStartTime(process, out var processStartedAt)) { - using var shutdownCts = new CancellationTokenSource(ProcessShutdownService.RunProcessShutdownTimeout); + var effectiveProcessTerminationTimeout = processTerminationTimeout ?? ProcessShutdownService.DefaultProcessTerminationTimeout; + var effectiveProcessShutdownTimeout = processShutdownTimeout ?? GetProcessShutdownTimeout(effectiveProcessTerminationTimeout); + using var shutdownCts = new CancellationTokenSource(effectiveProcessShutdownTimeout); try { stopped = await _processShutdownService.StopProcessGroupAsync( process.Id, processStartedAt, forceKillEntireProcessTree: true, - processTerminationTimeout: ProcessShutdownService.RunProcessTerminationTimeout, + effectiveProcessTerminationTimeout, shutdownCts.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (shutdownCts.IsCancellationRequested) @@ -212,7 +216,7 @@ public ProcessGuestLauncher( } _logger.LogDebug("Waiting for {Language} guest process {ProcessId} to exit after kill", _language, process.Id); - using var finalExitCts = new CancellationTokenSource(ProcessShutdownService.RunProcessTerminationTimeout); + using var finalExitCts = new CancellationTokenSource(processTerminationTimeout ?? ProcessShutdownService.DefaultProcessTerminationTimeout); try { await process.WaitForExitAsync(finalExitCts.Token).ConfigureAwait(false); @@ -258,6 +262,9 @@ private static bool TryGetProcessStartTime(Process process, out DateTimeOffset? } } + private static TimeSpan GetProcessShutdownTimeout(TimeSpan processTerminationTimeout) + => TimeSpan.FromTicks(processTerminationTimeout.Ticks * 2) + TimeSpan.FromSeconds(1); + private static Activity? GetCurrentProfilingActivity() { var activity = Activity.Current; diff --git a/tests/Aspire.Cli.Tests/Projects/GuestRuntimeTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestRuntimeTests.cs index 4b8a5607d88..0a4b95e4484 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestRuntimeTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestRuntimeTests.cs @@ -886,6 +886,8 @@ private sealed class RecordingLauncher : IGuestProcessLauncher DirectoryInfo workingDirectory, IDictionary environmentVariables, CancellationToken cancellationToken, + TimeSpan? processTerminationTimeout = null, + TimeSpan? processShutdownTimeout = null, Func? afterLaunchAsync = null) { Calls.Add((command, args)); From 6833f85d1fdf559a39c6ab824ecd479b44e0e8e1 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 28 May 2026 20:34:24 -0700 Subject: [PATCH 10/11] Always send Windows Ctrl+Break to process groups Do not gate Windows process-group CTRL_BREAK_EVENT delivery on process start-time validation. Aspire owns these child process groups directly, and skipping the event after a failed PID lookup makes the shutdown service wait as though a graceful signal was sent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Shared/ProcessSignaler.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Shared/ProcessSignaler.cs b/src/Shared/ProcessSignaler.cs index 516c587a75b..6b913514a0a 100644 --- a/src/Shared/ProcessSignaler.cs +++ b/src/Shared/ProcessSignaler.cs @@ -32,12 +32,6 @@ public static void RequestGracefulShutdown(int pid, DateTimeOffset? expectedStar public static void RequestGracefulShutdownForProcessGroup(int pid, DateTimeOffset? expectedStartTime, ILogger logger) { - using var process = TryGetRunningProcess(pid, expectedStartTime, logger); - if (process is null) - { - return; // Process is not running or does not match the expected start time - } - logger.LogDebug("Requesting graceful shutdown of process group {Pid}...", pid); if (OperatingSystem.IsWindows()) @@ -46,6 +40,12 @@ public static void RequestGracefulShutdownForProcessGroup(int pid, DateTimeOffse } else { + using var process = TryGetRunningProcess(pid, expectedStartTime, logger); + if (process is null) + { + return; // Process is not running or does not match the expected start time + } + RequestGracefulShutdownUnix(pid, logger); } } From 2d94bd6d540779244b044c43fc238721134cf942 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 28 May 2026 20:42:23 -0700 Subject: [PATCH 11/11] Add ProcessSignaler start time tests Cover TryGetRunningProcess with a real child process start-time round trip and a mismatched start-time rejection, matching how guest AppHost shutdown captures process identity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Processes/ProcessShutdownServiceTests.cs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/Aspire.Cli.Tests/Processes/ProcessShutdownServiceTests.cs b/tests/Aspire.Cli.Tests/Processes/ProcessShutdownServiceTests.cs index bb0879e0d1d..8799768ea00 100644 --- a/tests/Aspire.Cli.Tests/Processes/ProcessShutdownServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Processes/ProcessShutdownServiceTests.cs @@ -17,6 +17,43 @@ namespace Aspire.Cli.Tests.Processes; public class ProcessShutdownServiceTests(ITestOutputHelper outputHelper) { + [Fact] + public async Task TryGetRunningProcess_MatchesGuestAppHostStyleChildStartTime() + { + using var process = StartLongRunningProcess(); + try + { + var startTime = new DateTimeOffset(process.StartTime); + + using var resolvedProcess = ProcessSignaler.TryGetRunningProcess(process.Id, startTime, NullLogger.Instance); + + Assert.NotNull(resolvedProcess); + Assert.Equal(process.Id, resolvedProcess.Id); + } + finally + { + await StopProcessAsync(process); + } + } + + [Fact] + public async Task TryGetRunningProcess_RejectsMismatchedStartTime() + { + using var process = StartLongRunningProcess(); + try + { + var wrongStartTime = new DateTimeOffset(process.StartTime).AddMinutes(-5); + + using var resolvedProcess = ProcessSignaler.TryGetRunningProcess(process.Id, wrongStartTime, NullLogger.Instance); + + Assert.Null(resolvedProcess); + } + finally + { + await StopProcessAsync(process); + } + } + [Fact] public async Task TryStopProcessTreeWithDcpAsync_UsesDcpStopProcessTreeArguments() { @@ -183,6 +220,20 @@ private static Process StartSignalIgnoringShellProcess() return process; } + private static Process StartLongRunningProcess() + { + var startInfo = OperatingSystem.IsWindows() + ? new ProcessStartInfo("cmd.exe", "/c ping -n 60 127.0.0.1 > nul") + : new ProcessStartInfo("/bin/sh", "-c 'sleep 60'"); + startInfo.RedirectStandardError = true; + startInfo.RedirectStandardOutput = true; + startInfo.UseShellExecute = false; + + var process = Process.Start(startInfo); + Assert.NotNull(process); + return process; + } + private static async Task StopProcessAsync(Process process) { if (process.HasExited)