diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 6399edd8d0a..0211879a779 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -76,7 +76,14 @@ internal sealed class RunCommand : BaseCommand private bool _isDetachMode; private const int MaxDisplayedAppHostStartupOutputLines = 80; - private static readonly TimeSpan s_appHostStartupCancellationTimeout = TimeSpan.FromSeconds(5); + 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(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 @@ -278,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 d0089a68d54..2174f2da9f2 100644 --- a/src/Aspire.Cli/Processes/ProcessShutdownService.cs +++ b/src/Aspire.Cli/Processes/ProcessShutdownService.cs @@ -24,15 +24,83 @@ 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 DefaultProcessTerminationTimeout => s_processTerminationTimeout; + + public Task StopProcessTreeAsync( + int pid, + DateTimeOffset? startTime, + bool includeStartTimeForDcp, + CancellationToken cancellationToken) + { + return StopProcessTreeAsync( + pid, + startTime, + includeStartTimeForDcp, + forceKillEntireProcessTree: !OperatingSystem.IsWindows(), + processTerminationTimeout: s_processTerminationTimeout, + cancellationToken); + } + public Task StopProcessTreeAsync( int pid, DateTimeOffset? startTime, 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); + } + + public Task StopProcessGroupAsync( + int pid, + DateTimeOffset? startTime, + bool forceKillEntireProcessTree, + TimeSpan processTerminationTimeout, + CancellationToken cancellationToken) + { + return StopProcessesAsync( + [new ProcessTarget(pid, startTime)], + [new ProcessTarget(pid, startTime)], + token => RequestProcessGroupGracefulShutdownAsync(pid, startTime, token), + forceKillEntireProcessTree, + processTerminationTimeout, cancellationToken); } @@ -59,6 +127,8 @@ public async Task StopAppHostAsync( processesToMonitor: [appHostProcess], processesToForceKill, token => RequestAppHostGracefulShutdownAsync(appHostInfo, requestRpcStopAsync, token), + forceKillEntireProcessTree: !OperatingSystem.IsWindows(), + processTerminationTimeout: s_processTerminationTimeout, cancellationToken).ConfigureAwait(false); } @@ -71,6 +141,8 @@ internal async Task StopProcessesAsync( processesToMonitorAndKill, processesToMonitorAndKill, requestGracefulShutdownAsync, + forceKillEntireProcessTree: !OperatingSystem.IsWindows(), + processTerminationTimeout: s_processTerminationTimeout, cancellationToken).ConfigureAwait(false); } @@ -78,30 +150,24 @@ private async Task StopProcessesAsync( IReadOnlyCollection processesToMonitor, 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); + 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); + return await MonitorProcessesForTerminationAsync(processesToMonitor, processTerminationTimeout, 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 +277,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); @@ -275,10 +351,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 < s_processTerminationTimeout) + while (timeProvider.GetUtcNow() - startTime < processTerminationTimeout) { if (processes.All(IsProcessStopped)) { 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 654212b2985..6ab22a50196 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,9 @@ internal sealed class AppHostServerSession : IAppHostServerSession private readonly ProfilingTelemetry.ActivityScope _activity; 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; @@ -34,7 +38,10 @@ internal AppHostServerSession( ILogger logger, ProfilingTelemetry.ActivityScope activity = default, ProfilingTelemetry? profilingTelemetry = null, - IDisposable? projectLifetime = null) + IDisposable? projectLifetime = null, + ProcessShutdownService? processShutdownService = null, + TimeSpan? processTerminationTimeout = null, + TimeSpan? processShutdownTimeout = null) { _serverProcess = serverProcess; _output = output; @@ -44,6 +51,9 @@ internal AppHostServerSession( _activity = activity; _profilingTelemetry = profilingTelemetry; _projectLifetime = projectLifetime; + _processShutdownService = processShutdownService; + _processTerminationTimeout = processTerminationTimeout; + _processShutdownTimeout = processShutdownTimeout; } /// @@ -68,13 +78,19 @@ 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. + /// Optional per-process termination monitoring timeout. + /// Optional total shutdown coordination timeout. /// 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, + TimeSpan? processTerminationTimeout = null, + TimeSpan? processShutdownTimeout = null) { var currentPid = Environment.ProcessId; var serverEnvironmentVariables = environmentVariables is null @@ -127,7 +143,10 @@ internal static AppHostServerSession Start( logger, activity, profilingTelemetry, - appHostServerProject as IDisposable); + appHostServerProject as IDisposable, + processShutdownService, + processTerminationTimeout, + processShutdownTimeout); } /// @@ -156,15 +175,50 @@ 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)) + { + 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, + shutdownCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (shutdownCts.IsCancellationRequested) + { + _logger.LogWarning("Timed out waiting for AppHost server process {ProcessId} shutdown.", _serverProcess.Id); + } + } + else + { + stopped = true; + } } - catch (Exception ex) + + if (!stopped && !_serverProcess.HasExited) { - _logger.LogDebug(ex, "Error killing AppHost server process"); + try + { + 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) + { + _logger.LogDebug(ex, "Error killing AppHost server process"); + } } + } if (_serverProcess.HasExited) @@ -176,6 +230,24 @@ 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; + } + } + + private static TimeSpan GetProcessShutdownTimeout(TimeSpan processTerminationTimeout) + => TimeSpan.FromTicks(processTerminationTimeout.Ticks * 2) + TimeSpan.FromSeconds(1); } /// diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index 1d431a973a6..17c4bc40743 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -482,7 +482,8 @@ public async Task PrepareAsync( WindowStyle = ProcessWindowStyle.Minimized, UseShellExecute = false, CreateNoWindow = true - }; + }.CreateNewProcessGroupOnWindows(); + startInfo.ArgumentList.Add("exec"); startInfo.ArgumentList.Add(assemblyPath); 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 08c79b2bdb6..e00cb1b8f6f 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,10 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken launchSettingsEnvVars, context.Debug, _logger, - _profilingTelemetry); + _profilingTelemetry, + _processShutdownService, + context.ProcessTerminationTimeout, + context.ProcessShutdownTimeout); try { // Give the server a moment to start @@ -602,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" @@ -1008,7 +1016,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 +1808,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, @@ -1878,6 +1887,8 @@ private async Task InstallDependenciesAsync( IAppHostRpcClient rpcClient, IGuestProcessLauncher launcher, Func? afterAppHostLaunchedAsync, + TimeSpan? processTerminationTimeout, + TimeSpan? processShutdownTimeout, CancellationToken cancellationToken) { await EnsureRuntimeCreatedAsync(directory, rpcClient, cancellationToken); @@ -1888,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 f2c892c55dd..9e414664208 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; } /// @@ -144,6 +154,8 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, FileLoggerProvider? fileLo /// 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( @@ -153,6 +165,8 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, FileLoggerProvider? fileLo bool watchMode, IGuestProcessLauncher launcher, CancellationToken cancellationToken, + TimeSpan? processTerminationTimeout = null, + TimeSpan? processShutdownTimeout = null, Func? afterAppHostLaunchedAsync = null) { var useWatchCommand = watchMode && _spec.WatchExecute is not null; @@ -173,7 +187,18 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, FileLoggerProvider? fileLo 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); } /// @@ -252,6 +277,8 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, FileLoggerProvider? fileLo string phase, IGuestProcessLauncher launcher, CancellationToken cancellationToken, + TimeSpan? processTerminationTimeout = null, + TimeSpan? processShutdownTimeout = null, Func? afterLaunchAsync = null) { var args = ReplacePlaceholders(commandSpec.Args, appHostFile, directory, additionalArgs); @@ -262,7 +289,15 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, FileLoggerProvider? fileLo 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) { @@ -313,7 +348,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/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/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index d87fc185867..7bcbfeb4ba1 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -905,7 +905,7 @@ internal ProcessStartInfo CreateStartInfo( WindowStyle = ProcessWindowStyle.Minimized, UseShellExecute = false, CreateNoWindow = 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 3df478d333a..b3f0615078a 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; + private readonly ProcessShutdownService? _processShutdownService; - public ProcessGuestLauncher(string language, ILogger logger, FileLoggerProvider? fileLoggerProvider = null, Func? commandResolver = null) + 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( @@ -33,6 +41,8 @@ public ProcessGuestLauncher(string language, ILogger logger, FileLoggerProvider? DirectoryInfo workingDirectory, IDictionary environmentVariables, CancellationToken cancellationToken, + TimeSpan? processTerminationTimeout = null, + TimeSpan? processShutdownTimeout = null, Func? afterLaunchAsync = null) { var activity = GetCurrentProfilingActivity(); @@ -61,7 +71,7 @@ public ProcessGuestLauncher(string language, ILogger logger, FileLoggerProvider? RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true - }; + }.CreateNewProcessGroupOnWindows(); foreach (var arg in args) { @@ -147,25 +157,57 @@ 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 // (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) { - _logger.LogInformation("Killing {Language} guest process tree {ProcessId}", _language, process.Id); - try + var stopped = false; + if (_processShutdownService is not null) { - process.Kill(entireProcessTree: true); + if (TryGetProcessStartTime(process, out var processStartedAt)) + { + 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, + effectiveProcessTerminationTimeout, + shutdownCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (shutdownCts.IsCancellationRequested) + { + _logger.LogWarning("Timed out waiting for {Language} guest process {ProcessId} shutdown.", _language, process.Id); + } + } + else + { + stopped = true; + } } - catch (Exception killEx) + + if (!stopped && !process.HasExited) { - _logger.LogDebug(killEx, "Failed to kill guest process {ProcessId} after cancellation", process.Id); + _logger.LogInformation("Killing {Language} guest process tree {ProcessId}", _language, process.Id); + try + { + process.Kill(entireProcessTree: true); + } + catch (Exception killEx) + { + _logger.LogDebug(killEx, "Failed to kill guest process {ProcessId} after cancellation", process.Id); + } } } else @@ -174,13 +216,22 @@ public ProcessGuestLauncher(string language, ILogger logger, FileLoggerProvider? } _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(processTerminationTimeout ?? ProcessShutdownService.DefaultProcessTerminationTimeout); + 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 @@ -193,9 +244,27 @@ public ProcessGuestLauncher(string language, ILogger logger, FileLoggerProvider? _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) + { + 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 TimeSpan GetProcessShutdownTimeout(TimeSpan processTerminationTimeout) + => TimeSpan.FromTicks(processTerminationTimeout.Ticks * 2) + TimeSpan.FromSeconds(1); + private static Activity? GetCurrentProfilingActivity() { var activity = Activity.Current; 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; + } +} diff --git a/src/Shared/ProcessSignaler.cs b/src/Shared/ProcessSignaler.cs index 0e3e7cb7ce6..6b913514a0a 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) + { + logger.LogDebug("Requesting graceful shutdown of process group {Pid}...", pid); + + if (OperatingSystem.IsWindows()) + { + RequestGracefulShutdownWindowsProcessGroup(pid, logger); + } + 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); + } + } + 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) + { + 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); + } + } 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/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) 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/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)); diff --git a/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs b/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs new file mode 100644 index 00000000000..ba95a99a0fe --- /dev/null +++ b/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs @@ -0,0 +1,142 @@ +// 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) +{ + 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() + { + 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(s_processStartupTimeout); + 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; + } +}