diff --git a/src/Aspire.Cli/Commands/BaseCommand.cs b/src/Aspire.Cli/Commands/BaseCommand.cs index 0e23e7b0e93..5e1301c1684 100644 --- a/src/Aspire.Cli/Commands/BaseCommand.cs +++ b/src/Aspire.Cli/Commands/BaseCommand.cs @@ -58,7 +58,7 @@ protected BaseCommand(string name, string description, IFeatures features, ICliU catch (NonInteractiveException) { // Error messages have already been displayed by the interaction service. - result = CommandResult.Failure((int)CliExitCodes.MissingRequiredArgument); + result = CommandResult.Failure(CliExitCodes.MissingRequiredArgument); } var isErrorExitCode = result.ExitCode != CliExitCodes.Success; diff --git a/src/Aspire.Cli/ConsoleCancellationManager.cs b/src/Aspire.Cli/ConsoleCancellationManager.cs index bf66e4e33e2..d4588f3a550 100644 --- a/src/Aspire.Cli/ConsoleCancellationManager.cs +++ b/src/Aspire.Cli/ConsoleCancellationManager.cs @@ -6,41 +6,89 @@ namespace Aspire.Cli; /// -/// Manages Ctrl+C and SIGTERM signal handling with a shared CancellationTokenSource. +/// Manages Ctrl+C, SIGINT, and SIGTERM signal handling with a shared CancellationTokenSource. +/// After cancellation is requested, waits up to processTerminationTimeout for the running +/// handler to complete before signaling forced termination via . /// Disposing this instance unregisters all signal handlers and disposes the token source. /// internal sealed class ConsoleCancellationManager : IDisposable { + private const int SigIntExitCode = 130; + private const int SigTermExitCode = 143; + private readonly CancellationTokenSource _cts = new(); - private readonly CancellationToken _token; - private readonly ConsoleCancelEventHandler _cancelKeyPressHandler; + private readonly TimeSpan _processTerminationTimeout; + private readonly PosixSignalRegistration? _sigIntRegistration; private readonly PosixSignalRegistration? _sigTermRegistration; + private readonly CancellationToken _token; + private Task? _startedHandler; + private int _cancelCalled; + + private readonly TaskCompletionSource _processTerminationCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); - public ConsoleCancellationManager() + /// + /// A completion source that is signaled with a native exit code when the running handler + /// does not complete within the configured timeout after a termination signal. + /// + internal TaskCompletionSource ProcessTerminationCompletionSource => _processTerminationCompletionSource; + + /// + /// Sets the handler task that represents the currently executing command. When a termination + /// signal arrives, the manager will wait for this task to complete within the configured timeout. + /// + internal void SetStartedHandler(Task handler) => Volatile.Write(ref _startedHandler, handler); + + public ConsoleCancellationManager(TimeSpan processTerminationTimeout) { + _processTerminationTimeout = processTerminationTimeout; + + // Set to a field so getting the token doesn't error after dispose. _token = _cts.Token; - _cancelKeyPressHandler = (sender, eventArgs) => + + // Prefer PosixSignalRegistration for both SIGINT and SIGTERM as it handles + // both signals uniformly and allows cancelling SIGTERM (which Console.CancelKeyPress cannot). + // Despite the name, PosixSignalRegistration is supported on Windows: the runtime maps + // SIGINT to CTRL_C_EVENT and SIGTERM to CTRL_CLOSE_EVENT/CTRL_SHUTDOWN_EVENT. + if (!OperatingSystem.IsAndroid() + && !OperatingSystem.IsIOS() + && !OperatingSystem.IsTvOS() + && !OperatingSystem.IsBrowser()) { - TryCancel(); - eventArgs.Cancel = true; - }; - Console.CancelKeyPress += _cancelKeyPressHandler; - - _sigTermRegistration = OperatingSystem.IsWindows() - ? null - : PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => - { - TryCancel(); - context.Cancel = true; - }); + _sigIntRegistration = PosixSignalRegistration.Create(PosixSignal.SIGINT, OnPosixSignal); + _sigTermRegistration = PosixSignalRegistration.Create(PosixSignal.SIGTERM, OnPosixSignal); + } + + Console.CancelKeyPress += OnCancelKeyPress; + AppDomain.CurrentDomain.ProcessExit += OnProcessExit; } public CancellationToken Token => _token; public bool IsCancellationRequested => _cts.IsCancellationRequested; - private void TryCancel() + private void OnPosixSignal(PosixSignalContext context) + { + context.Cancel = true; + Cancel(context.Signal == PosixSignal.SIGINT ? SigIntExitCode : SigTermExitCode); + } + + private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e) { + e.Cancel = true; + Cancel(SigIntExitCode); + } + + private void OnProcessExit(object? sender, EventArgs e) => Cancel(SigTermExitCode); + + private void Cancel(int forcedTerminationExitCode) + { + // Ensure only the first signal triggers cancellation logic; subsequent signals are no-ops. + if (Interlocked.CompareExchange(ref _cancelCalled, 1, 0) != 0) + { + return; + } + + // Request cancellation so cooperative listeners can begin shutting down. try { _cts.Cancel(); @@ -48,13 +96,35 @@ private void TryCancel() catch (ObjectDisposedException) { // A signal can race with process shutdown after cancellation resources are disposed. + return; + } + + try + { + var startedHandler = Volatile.Read(ref _startedHandler); + + // Wait for the configured interval to allow graceful shutdown. + if (startedHandler is null || !startedHandler.Wait(_processTerminationTimeout)) + { + // If the handler does not finish within configured time, use the completion + // source to signal forced completion (preserving native exit code). + _processTerminationCompletionSource.TrySetResult(forcedTerminationExitCode); + } + } + catch (AggregateException) + { + // The task was cancelled or an exception was thrown during task execution. } } public void Dispose() { - Console.CancelKeyPress -= _cancelKeyPressHandler; + _sigIntRegistration?.Dispose(); _sigTermRegistration?.Dispose(); + + Console.CancelKeyPress -= OnCancelKeyPress; + AppDomain.CurrentDomain.ProcessExit -= OnProcessExit; + _cts.Dispose(); } } diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 262913bc453..be8e2e9934b 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -729,7 +729,7 @@ public static async Task Main(string[] args) // Setup handling of CTRL-C and SIGTERM as early as possible so that if // we get a signal anywhere that is not handled by Spectre Console // already that we know to trigger cancellation. - using var cancellationManager = new ConsoleCancellationManager(); + using var cancellationManager = new ConsoleCancellationManager(processTerminationTimeout: TimeSpan.FromSeconds(5)); Console.OutputEncoding = Encoding.UTF8; @@ -788,7 +788,9 @@ public static async Task Main(string[] args) var invokeConfig = new InvocationConfiguration() { // Disable default exception handler so we can log exceptions to telemetry. - EnableDefaultExceptionHandler = false + EnableDefaultExceptionHandler = false, + // Set timeout to null so that System.Commandline doesn't manage cancellation tokens or timeouts for us. + ProcessTerminationTimeout = null }; app.Services.GetRequiredService(); @@ -833,7 +835,22 @@ public static async Task Main(string[] args) profileCommandActivity = profilingTelemetry.StartCommand(commandName); } - exitCode = await parseResult.InvokeAsync(invokeConfig, cancellationManager.Token); + // Parse commandline and invoke the handler. + var handlerTask = parseResult.InvokeAsync(invokeConfig, cancellationManager.Token); + cancellationManager.SetStartedHandler(handlerTask); + + // Wait for either the handler to complete or a termination signal to trigger cancellation and timeout. + var firstCompletedTask = await Task.WhenAny(handlerTask, cancellationManager.ProcessTerminationCompletionSource.Task); + if (firstCompletedTask != handlerTask) + { + // The termination signal triggered cancellation and the timeout has completed. Kill the process. + // handlerTask is not awaited because the process is shutting down and we assume the task is hung. + logger.LogWarning("Timeout waiting for cancellation from termination signal."); + } + + exitCode = await firstCompletedTask; // return the result or propagate the exception + + // Set telemetry tags based on how the command completed. profileCommandActivity.SetProcessExitCode(exitCode); if (exitCode != CliExitCodes.Success) { @@ -848,33 +865,24 @@ public static async Task Main(string[] args) // Log exit code for debugging logger.LogInformation("Exit code: {ExitCode}", exitCode); } + catch (OperationCanceledException ex) when (cancellationManager.IsCancellationRequested || ex is ExtensionOperationCanceledException) + { + exitCode = CliExitCodes.Cancelled; + } catch (Exception ex) { - exitCode = 1; - // Catch block is used instead of System.Commandline's default handler behavior. - // Allows logging of exceptions to telemetry. - - // Don't log or display cancellation exceptions. - // Check both Ctrl+C cancellation (cancellationManager.IsCancellationRequested) and - // extension prompt cancellation (ExtensionOperationCanceledException). - if (!(ex is OperationCanceledException && cancellationManager.IsCancellationRequested) && ex is not ExtensionOperationCanceledException) - { - logger.LogError(ex, "An unexpected error occurred."); - - telemetry.RecordError("An unexpected error occurred.", ex); - - errorWriter.WriteLine(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, ex.Message)); - } - - // Log exit code for debugging - logger.LogError("Exit code: {ExitCode} (exception)", exitCode); + exitCode = CliExitCodes.InvalidCommand; + logger.LogError(ex, "An unexpected error occurred."); + telemetry.RecordError("An unexpected error occurred.", ex); + errorWriter.WriteLine(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, ex.Message)); + } + finally + { mainActivity?.SetTag(TelemetryConstants.Tags.ProcessExitCode, exitCode); + mainActivity?.Stop(); } - mainActivity?.SetTag(TelemetryConstants.Tags.ProcessExitCode, exitCode); - mainActivity?.Stop(); - if (profileCaptureSession is not null) { try