Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Aspire.Cli/Commands/BaseCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
108 changes: 89 additions & 19 deletions src/Aspire.Cli/ConsoleCancellationManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,55 +6,125 @@
namespace Aspire.Cli;

/// <summary>
/// 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 <c>processTerminationTimeout</c> for the running
/// handler to complete before signaling forced termination via <see cref="ProcessTerminationCompletionSource"/>.
/// Disposing this instance unregisters all signal handlers and disposes the token source.
/// </summary>
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<int>? _startedHandler;
private int _cancelCalled;

private readonly TaskCompletionSource<int> _processTerminationCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously);

public ConsoleCancellationManager()
/// <summary>
/// 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.
/// </summary>
internal TaskCompletionSource<int> ProcessTerminationCompletionSource => _processTerminationCompletionSource;

/// <summary>
/// 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.
/// </summary>
internal void SetStartedHandler(Task<int> 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())
{
Comment thread
JamesNK marked this conversation as resolved.
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);
Comment thread
JamesNK marked this conversation as resolved.
}

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();
}
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();
}
}
56 changes: 32 additions & 24 deletions src/Aspire.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -729,7 +729,7 @@ public static async Task<int> 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;

Expand Down Expand Up @@ -788,7 +788,9 @@ public static async Task<int> 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<CliExecutionContext>();
Expand Down Expand Up @@ -833,7 +835,22 @@ public static async Task<int> 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)
{
Expand All @@ -848,33 +865,24 @@ public static async Task<int> 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
Expand Down
Loading