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
47 changes: 35 additions & 12 deletions .github/skills/cli-e2e-testing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,29 +45,53 @@ public sealed class SmokeTests(ITestOutputHelper output)
[Fact]
public async Task MyCliTest()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
var workspace = TemporaryWorkspace.Create(output);
var installMode = CliE2ETestHelpers.DetectDockerInstallMode();

using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal();
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace);

var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, TestContext.Current.CancellationToken);

await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliInDockerAsync(installMode, counter);
await auto.InstallAspireCliAsync(strategy, counter);

await auto.TypeAsync("aspire --version");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);

await auto.TypeAsync("exit");
await auto.EnterAsync();
await pendingRun;
}
}
```

### TerminalRun Pattern

**Always use `CliE2ETestHelpers.StartRun`** to wrap the terminal run. This returns a `TerminalRun` (implements `IAsyncDisposable`) that automatically:
1. Captures Aspire diagnostics via `CaptureAspireDiagnosticsAsync` (best effort)
2. Types `exit` and presses Enter to close the terminal
3. Awaits the pending run task

This eliminates the need for manual `exit`/`await pendingRun` at the end of every test and ensures diagnostics are always captured, even when tests fail.

```csharp
// DO: Use StartRun for consistent diagnostics capture and cleanup
using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace);

var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, TestContext.Current.CancellationToken);

// ... test body — no exit/pendingRun needed at the end

// DON'T: Manually handle exit and pendingRun
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
// ... test body ...
await auto.TypeAsync("exit");
await auto.EnterAsync();
await pendingRun;
```

## Running Tests Locally

CLI E2E tests run inside Docker containers on Linux. The workflow is: build a portable archive with `localhive`, then point the tests at it. This is the primary way to iterate on E2E tests during development.
Expand Down Expand Up @@ -246,10 +270,10 @@ await auto.WaitUntilAsync(

| Method | Description |
|--------|-------------|
| `WaitForSuccessPromptAsync(counter, timeout?)` | Waits for `[N OK] $ ` prompt and increments counter |
| `WaitForSuccessPromptAsync(counter, timeout?)` | Waits for `[N OK] $ ` prompt, fails immediately if error prompt appears, and increments counter |
| `WaitForAnyPromptAsync(counter, timeout?)` | Waits for any prompt (`OK` or `ERR`) and increments counter |
| `WaitForErrorPromptAsync(counter, timeout?)` | Waits for `[N ERR:code] $ ` prompt and increments counter |
| `WaitForSuccessPromptFailFastAsync(counter, timeout?)` | Waits for success prompt, fails immediately if error prompt appears |
| `RunCommandAsync(command, counter, timeout?)` | Types a command, presses Enter, and waits for success prompt (fails fast on error) |
| `DeclineAgentInitPromptAsync()` | Declines the `aspire agent init` prompt if it appears |
| `AspireNewAsync(projectName, counter, template?, useRedisCache?)` | Runs `aspire new` interactively, handling template selection, project name, output path, URLs, Redis, and test project prompts |

Expand Down Expand Up @@ -277,8 +301,7 @@ The following extensions on `Hex1bTerminalInputSequenceBuilder` are still availa
|--------|-------------|
| `WaitForSuccessPrompt(counter, timeout?)` | *(legacy)* Waits for `[N OK] $ ` prompt and increments counter |
| `PrepareEnvironment(workspace, counter)` | *(legacy)* Sets up custom prompt with command tracking |
| `InstallAspireCliFromPullRequest(prNumber, counter)` | *(legacy)* Downloads and installs CLI from PR artifacts |
| `SourceAspireCliEnvironment(counter)` | *(legacy)* Adds `~/.aspire/bin` to PATH |
| `SourceAspireBundleEnvironment(counter)` | *(legacy)* Sources bundle PATH environment variables |

## DO: Use CellPatternSearcher for Output Detection

Expand Down
13 changes: 8 additions & 5 deletions src/Aspire.Cli/Commands/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResul
catch (TimeoutException)
{
runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "startup_timeout");
await CancelAppHostStartupAsync(runCancellationTokenSource, pendingRun).ConfigureAwait(false);
await CancelAppHostStartupAsync(runCancellationTokenSource, pendingRun, cancellationToken).ConfigureAwait(false);
return CreateStartupTimeoutResult(timeoutSeconds);
}

Expand Down Expand Up @@ -351,7 +351,7 @@ protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResul
catch (TimeoutException)
{
runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "startup_timeout");
await CancelAppHostStartupAsync(runCancellationTokenSource, pendingRun).ConfigureAwait(false);
await CancelAppHostStartupAsync(runCancellationTokenSource, pendingRun, cancellationToken).ConfigureAwait(false);
return CreateStartupTimeoutResult(timeoutSeconds);
}

Expand Down Expand Up @@ -1124,15 +1124,18 @@ private TimeSpan GetRemainingStartupTimeout(long startupStartTimestamp, TimeSpan
return elapsed >= startupTimeout ? TimeSpan.Zero : startupTimeout - elapsed;
}

private async Task CancelAppHostStartupAsync(CancellationTokenSource runCancellationTokenSource, Task<int> pendingRun)
private async Task CancelAppHostStartupAsync(CancellationTokenSource runCancellationTokenSource, Task<int> pendingRun, CancellationToken cancellationToken)
{
runCancellationTokenSource.Cancel();

try
{
await pendingRun.WaitAsync(s_appHostStartupCancellationTimeout, _timeProvider).ConfigureAwait(false);
// The timeout is a safety net for the startup-timeout path (no Ctrl+C). When the user
// presses Ctrl+C, cancellationToken fires and WaitAsync exits immediately via the token
// rather than waiting for the full timeout duration.
await pendingRun.WaitAsync(s_appHostStartupCancellationTimeout, _timeProvider, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (runCancellationTokenSource.IsCancellationRequested)
catch (OperationCanceledException) when (runCancellationTokenSource.IsCancellationRequested || cancellationToken.IsCancellationRequested)
{
}
catch (TimeoutException ex)
Expand Down
115 changes: 92 additions & 23 deletions src/Aspire.Cli/ConsoleCancellationManager.cs
Original file line number Diff line number Diff line change
@@ -1,26 +1,35 @@
// 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;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;

namespace Aspire.Cli;

/// <summary>
/// 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"/>.
/// After cancellation is requested, schedules an asynchronous timeout for the running handler
/// to complete before signaling forced termination via <see cref="ProcessTerminationCompletionSource"/>.
/// A second signal forces immediate termination without waiting for the timeout.
/// Disposing this instance unregisters all signal handlers and disposes the token source.
/// </summary>
internal sealed class ConsoleCancellationManager : IDisposable
{
// Standard Unix exit codes: 128 + signal number (SIGINT=2, SIGTERM=15).
// SigIntExitCode (130): used when the user presses Ctrl+C (SIGINT) or Ctrl+Break/SIGQUIT.
// SigTermExitCode (143): used when the process receives SIGTERM (e.g. container stop, ProcessExit).
private const int SigIntExitCode = 130;
private const int SigTermExitCode = 143;

private readonly CancellationTokenSource _cts = new();
private readonly TimeSpan _processTerminationTimeout;
private readonly PosixSignalRegistration? _sigIntRegistration;
private readonly PosixSignalRegistration? _sigTermRegistration;
private readonly PosixSignalRegistration? _sigQuitRegistration;
private readonly CancellationToken _token;
private ILogger _logger;
private Task<int>? _startedHandler;
private int _cancelCalled;

Expand All @@ -38,9 +47,16 @@ internal sealed class ConsoleCancellationManager : IDisposable
/// </summary>
internal void SetStartedHandler(Task<int> handler) => Volatile.Write(ref _startedHandler, handler);

/// <summary>
/// Sets the logger instance used for diagnostic messages during signal handling.
/// Call this once the logging infrastructure is available.
/// </summary>
internal void SetLogger(ILogger logger) => Volatile.Write(ref _logger, logger);

public ConsoleCancellationManager(TimeSpan processTerminationTimeout)
{
_processTerminationTimeout = processTerminationTimeout;
_logger = NullLogger.Instance;

// Set to a field so getting the token doesn't error after dispose.
_token = _cts.Token;
Expand All @@ -56,9 +72,22 @@ public ConsoleCancellationManager(TimeSpan processTerminationTimeout)
{
_sigIntRegistration = PosixSignalRegistration.Create(PosixSignal.SIGINT, OnPosixSignal);
_sigTermRegistration = PosixSignalRegistration.Create(PosixSignal.SIGTERM, OnPosixSignal);

// SIGQUIT maps to CTRL_BREAK_EVENT on Windows. Register it to maintain parity with
// Console.CancelKeyPress which handled both Ctrl+C and Ctrl+Break.
// On Linux/macOS, SIGQUIT's default action produces a core dump which is useful for
// debugging hung processes — don't intercept it there.
if (OperatingSystem.IsWindows())
{
_sigQuitRegistration = PosixSignalRegistration.Create(PosixSignal.SIGQUIT, OnPosixSignal);
}
}
else
{
// Fall back to Console.CancelKeyPress on platforms that don't support PosixSignalRegistration.
Console.CancelKeyPress += OnCancelKeyPress;
}

Console.CancelKeyPress += OnCancelKeyPress;
AppDomain.CurrentDomain.ProcessExit += OnProcessExit;
}

Expand All @@ -69,7 +98,13 @@ public ConsoleCancellationManager(TimeSpan processTerminationTimeout)
private void OnPosixSignal(PosixSignalContext context)
{
context.Cancel = true;
Cancel(context.Signal == PosixSignal.SIGINT ? SigIntExitCode : SigTermExitCode);
var exitCode = context.Signal switch
{
PosixSignal.SIGINT => SigIntExitCode,
PosixSignal.SIGQUIT => SigIntExitCode,
_ => SigTermExitCode
};
Cancel(exitCode);
}

private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e)
Expand All @@ -80,47 +115,81 @@ private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e)

private void OnProcessExit(object? sender, EventArgs e) => Cancel(SigTermExitCode);

private void Cancel(int forcedTerminationExitCode)
internal 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;
}
var signalCount = Interlocked.Increment(ref _cancelCalled);

// Request cancellation so cooperative listeners can begin shutting down.
try
if (signalCount == 1)
{
_cts.Cancel();
// First signal: request cooperative cancellation and schedule an async timeout
// that will force-terminate if the handler doesn't complete in time.
_logger.LogInformation("Termination signal received, requesting cancellation.");

try
{
_cts.Cancel();
}
catch (ObjectDisposedException)
{
// A signal can race with process shutdown after cancellation resources are disposed.
return;
}

// Schedule the forced-completion timeout asynchronously so the signal handler
// returns immediately. This allows Program.Main's Task.WhenAny to observe
// handlerTask completion without being blocked by the signal handler thread.
_ = ForceTerminationAfterTimeoutAsync(forcedTerminationExitCode);
}
catch (ObjectDisposedException)
else
{
// A signal can race with process shutdown after cancellation resources are disposed.
return;
// Second (or subsequent) signal: force immediate termination without waiting.
_logger.LogWarning("Second termination signal received, forcing immediate exit.");
_processTerminationCompletionSource.TrySetResult(forcedTerminationExitCode);
}
}

private async Task ForceTerminationAfterTimeoutAsync(int forcedTerminationExitCode)
{
try
{
// When a debugger is attached, don't force-terminate — the developer needs
// unlimited time to step through cancellation/cleanup logic.
if (Debugger.IsAttached)
{
return;
}

var startedHandler = Volatile.Read(ref _startedHandler);

// Wait for the configured interval to allow graceful shutdown.
if (startedHandler is null || !startedHandler.Wait(_processTerminationTimeout))
if (startedHandler is not null)
{
// If the handler does not finish within configured time, use the completion
// source to signal forced completion (preserving native exit code).
_processTerminationCompletionSource.TrySetResult(forcedTerminationExitCode);
// Give the handler a chance to finish gracefully within the configured timeout.
// Task.WhenAny completes when either the handler or the delay finishes first,
// without propagating exceptions from the losing task.
// It's ok that this delay isn't cancellable. The process is ending.
var timeoutTask = Task.Delay(_processTerminationTimeout);
if (await Task.WhenAny(startedHandler, timeoutTask).ConfigureAwait(false) == startedHandler)
{
// Handler finished within the timeout; no forced termination needed.
return;
}
}

_logger.LogWarning("Handler did not complete within {Timeout}s, forcing termination.", _processTerminationTimeout.TotalSeconds);
_processTerminationCompletionSource.TrySetResult(forcedTerminationExitCode);
}
catch (AggregateException)
catch (Exception)
{
// The task was cancelled or an exception was thrown during task execution.
// Any failure in the timeout path should still force termination rather than hang.
_processTerminationCompletionSource.TrySetResult(forcedTerminationExitCode);
}
}

public void Dispose()
{
_sigIntRegistration?.Dispose();
_sigTermRegistration?.Dispose();
_sigQuitRegistration?.Dispose();

Console.CancelKeyPress -= OnCancelKeyPress;
AppDomain.CurrentDomain.ProcessExit -= OnProcessExit;
Expand Down
6 changes: 4 additions & 2 deletions src/Aspire.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,7 @@ public static async Task<int> Main(string[] args)
var logBufferContext = new ConsoleLogBufferContext();
var (loggerFactory, fileLoggerProvider) = CreateLoggerFactory(args, loggingOptions, errorWriter, logBufferContext);
var logger = loggerFactory.CreateLogger(RootLoggerName);
cancellationManager.SetLogger(logger);
using var startupContext = new CliStartupContext(loggingOptions, errorWriter, loggerFactory, fileLoggerProvider, logBufferContext, logger);

logger.LogInformation("Aspire CLI version: {Version}", AspireCliTelemetry.GetCliVersion());
Expand Down Expand Up @@ -899,9 +900,10 @@ public static async Task<int> Main(string[] args)
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.
// ProcessTerminationCompletionSource was signaled — either the graceful-shutdown
// timeout elapsed, or a second signal forced immediate termination.
// 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.");
logger.LogWarning("Termination signal forced process exit.");
}

exitCode = await firstCompletedTask; // return the result or propagate the exception
Expand Down
Loading
Loading