From 0424e9bbad9f98fccafff7e1ae72bff6c391d8cb Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Wed, 9 Sep 2026 15:46:34 -0700 Subject: [PATCH 1/7] Fix extension CLI teardown during AppHost build Route extension stop requests through cooperative cancellation and wait for in-flight pre-build cleanup before the CLI exits. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Backchannel/ExtensionRpcTarget.cs | 9 ++- src/Aspire.Cli/Commands/RunCommand.cs | 13 ++++ .../Backchannel/ExtensionBackchannelTests.cs | 29 +++++++- .../Commands/RunCommandTests.cs | 70 +++++++++++++++++++ tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 3 +- 5 files changed, 118 insertions(+), 6 deletions(-) diff --git a/src/Aspire.Cli/Backchannel/ExtensionRpcTarget.cs b/src/Aspire.Cli/Backchannel/ExtensionRpcTarget.cs index da4a65e2218..d1c0bb9e9b3 100644 --- a/src/Aspire.Cli/Backchannel/ExtensionRpcTarget.cs +++ b/src/Aspire.Cli/Backchannel/ExtensionRpcTarget.cs @@ -29,7 +29,10 @@ internal interface IExtensionRpcTarget Task GetCliCapabilitiesAsync(); } -internal class ExtensionRpcTarget(IConfiguration configuration, CliExecutionContext executionContext) : IExtensionRpcTarget +internal class ExtensionRpcTarget( + IConfiguration configuration, + CliExecutionContext executionContext, + ConsoleCancellationManager cancellationManager) : IExtensionRpcTarget { public Func? ValidationFunction { get; set; } @@ -45,7 +48,9 @@ public Task GetCliVersionAsync() public Task StopCliAsync() { - Environment.Exit(CliExitCodes.Success); + // The extension's stop request is cooperative. Route it through the same cancellation + // path as Ctrl+C so in-flight child processes release their workspace handles before exit. + cancellationManager.Cancel(CliExitCodes.Success); return Task.CompletedTask; } diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 047ae35e83c..fec62a8a071 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -250,6 +250,7 @@ await extensionInteractionService.StartDebugSessionAsync( LauncherLivenessMonitor? launcherMonitor = null; Task? runTask = null; CancellationTokenSource? runCts = null; + var buildWaitCompleted = false; try { @@ -387,6 +388,7 @@ await extensionInteractionService.StartDebugSessionAsync( } buildSuccess = await buildCompletionSource.Task.WaitAsync(cancellationToken); + buildWaitCompleted = true; waitForBuildActivity.SetAppHostBuildSuccess(buildSuccess); } if (!buildSuccess) @@ -672,6 +674,17 @@ ex is ExtensionOperationCanceledException || { runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "canceled"); + // Extension cancellation can interrupt RunCommand's build wait before the linked run token + // has reached the project task. Drain that task while it is still preparing so a late-starting + // build cannot outlive the CLI and retain the workspace directory on Windows. + if (!buildWaitCompleted && + runCts is not null && + runTask is not null && + !runTask.IsCompleted) + { + await CancelAppHostStartupAsync(runCts, runTask, CancellationToken.None).ConfigureAwait(false); + } + // User Ctrl+C is the normal exit path for `aspire run`; surface as success. // Internal failures `return X` directly from GuestAppHostProject.RunAsync rather // than flowing through this catch. diff --git a/tests/Aspire.Cli.Tests/Backchannel/ExtensionBackchannelTests.cs b/tests/Aspire.Cli.Tests/Backchannel/ExtensionBackchannelTests.cs index 8b61cf226b9..c9f5ff8f9a1 100644 --- a/tests/Aspire.Cli.Tests/Backchannel/ExtensionBackchannelTests.cs +++ b/tests/Aspire.Cli.Tests/Backchannel/ExtensionBackchannelTests.cs @@ -10,8 +10,25 @@ namespace Aspire.Cli.Tests.Backchannel; -public class ExtensionBackchannelTests(ITestOutputHelper outputHelper) +public class ExtensionBackchannelTests(ITestOutputHelper outputHelper) : IDisposable { + private readonly ConsoleCancellationManager _cancellationManager = new(Timeout.InfiniteTimeSpan); + + [Fact] + public async Task StopCliAsync_CancelsTheRunningCommand() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + _cancellationManager.SetStartedHandler(Task.CompletedTask); + var rpcTarget = new ExtensionRpcTarget( + new ConfigurationBuilder().Build(), + workspace.CreateExecutionContext(), + _cancellationManager); + + await rpcTarget.StopCliAsync(); + + Assert.True(_cancellationManager.IsCancellationRequested); + } + [Fact] public async Task ConnectAsync_WhenConnectionSetupFails_PropagatesFailureAndAllowsRetry() { @@ -146,7 +163,9 @@ public async Task ConnectAsync_WhenConnectorIsCanceled_ConcurrentWaiterTakesOver Assert.Equal(2, connectAttempts); } - private static ExtensionBackchannel CreateBackchannel( + public void Dispose() => _cancellationManager.Dispose(); + + private ExtensionBackchannel CreateBackchannel( string endpoint, CliExecutionContext executionContext, Func? connectCoreAsyncOverride = null) @@ -159,7 +178,11 @@ private static ExtensionBackchannel CreateBackchannel( }) .Build(); - return new ExtensionBackchannel(NullLogger.Instance, new ExtensionRpcTarget(configuration, executionContext), configuration, connectCoreAsyncOverride); + return new ExtensionBackchannel( + NullLogger.Instance, + new ExtensionRpcTarget(configuration, executionContext, _cancellationManager), + configuration, + connectCoreAsyncOverride); } } diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index 9fee6788182..3e4f8f4a724 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -435,6 +435,76 @@ public async Task RunCommand_WhenCancelledDuringBuild_ExitsSuccessfully() Assert.Empty(interactionService.DisplayedErrors); } + [Fact] + public async Task RunCommand_WhenCancelledDuringBuild_AwaitsRunCleanupBeforeExit() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + using var cts = new CancellationTokenSource(); + var interactionService = new TestInteractionService(); + + var appHostDir = workspace.WorkspaceRoot.CreateSubdirectory("AppHost"); + var appHostFile = new FileInfo(Path.Combine(appHostDir.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(appHostFile.FullName, "", TestContext.Current.CancellationToken); + + var projectLocator = new TestProjectLocator + { + UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) => + Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile])) + }; + + var buildStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cleanupStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cleanupCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cleanupCanFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var projectFactory = new TestAppHostProjectFactory + { + RunAsyncCallback = async (context, cancellationToken) => + { + buildStarted.TrySetResult(); + + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + catch (OperationCanceledException) + { + cleanupStarted.TrySetResult(); + await cleanupCanFinish.Task; + cleanupCompleted.TrySetResult(); + context.BuildCompletionSource?.TrySetResult(false); + } + + return CliExitCodes.Cancelled; + } + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.ProjectLocatorFactory = _ => projectLocator; + options.AppHostProjectFactory = _ => projectFactory; + }); + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse($"run --apphost {appHostFile.FullName}"); + var pendingCommand = result.InvokeAsync(cancellationToken: cts.Token); + + await buildStarted.Task.DefaultTimeout(); + cts.Cancel(); + await cleanupStarted.Task.DefaultTimeout(); + + Assert.False(pendingCommand.IsCompleted, "The CLI exited before build cleanup completed."); + + cleanupCanFinish.TrySetResult(); + + var exitCode = await pendingCommand.DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.True(cleanupCompleted.Task.IsCompletedSuccessfully); + Assert.Empty(interactionService.DisplayedErrors); + } + [Fact] public async Task RunCommand_WhenProjectReturnsCancelledDuringBuild_ExitsSuccessfully() { diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 2183a885d97..e42d379c48b 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -598,7 +598,8 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser { var configuration = serviceProvider.GetRequiredService(); var executionContext = serviceProvider.GetRequiredService(); - return new ExtensionRpcTarget(configuration, executionContext); + var cancellationManager = serviceProvider.GetRequiredService(); + return new ExtensionRpcTarget(configuration, executionContext, cancellationManager); }; public Func ExtensionBackchannelFactory { get; set; } = serviceProvider => From 7e269d60b7c3af4471fb674524c0f2d1bf7e6a1b Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Thu, 10 Sep 2026 10:54:24 -0700 Subject: [PATCH 2/7] Keep extension build cleanup under shutdown deadline Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/RunCommand.cs | 45 ++++++-- .../Commands/RunCommandTests.cs | 108 +++++++++++++++++- 2 files changed, 139 insertions(+), 14 deletions(-) diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index fec62a8a071..58e142537d1 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -74,6 +74,7 @@ internal sealed class RunCommand : BaseCommand private readonly ProfilingTelemetry _profilingTelemetry; private readonly ProfileCaptureState _profileCaptureState; private readonly TimeProvider _timeProvider; + private readonly ConsoleCancellationManager _cancellationManager; private bool _isDetachMode; private const int MaxDisplayedAppHostStartupOutputLines = 80; // Match BackchannelLoggerProvider's 1,000-entry replay buffer. @@ -156,6 +157,7 @@ public RunCommand( _profilingTelemetry = profilingTelemetry; _profileCaptureState = profileCaptureState; _timeProvider = timeProvider; + _cancellationManager = services.CancellationManager; Options.Add(s_detachOption); Options.Add(s_noBuildOption); @@ -675,14 +677,14 @@ ex is ExtensionOperationCanceledException || runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "canceled"); // Extension cancellation can interrupt RunCommand's build wait before the linked run token - // has reached the project task. Drain that task while it is still preparing so a late-starting - // build cannot outlive the CLI and retain the workspace directory on Windows. + // has reached the project task. Keep manager-owned cancellation attached to that task so a + // late-starting build cannot outlive the CLI and retain the workspace directory on Windows. if (!buildWaitCompleted && runCts is not null && runTask is not null && !runTask.IsCompleted) { - await CancelAppHostStartupAsync(runCts, runTask, CancellationToken.None).ConfigureAwait(false); + await CancelAppHostBuildAsync(runCts, runTask).ConfigureAwait(false); } // User Ctrl+C is the normal exit path for `aspire run`; surface as success. @@ -1493,21 +1495,41 @@ private TimeSpan GetRemainingStartupTimeout(long startupStartTimestamp, TimeSpan private async Task CancelAppHostStartupAsync(CancellationTokenSource runCancellationTokenSource, Task pendingRun, CancellationToken cancellationToken) { runCancellationTokenSource.Cancel(); + await WaitForAppHostRunCancellationAsync(pendingRun, cancellationToken).ConfigureAwait(false); + } + + private async Task CancelAppHostBuildAsync(CancellationTokenSource runCancellationTokenSource, Task pendingRun) + { + runCancellationTokenSource.Cancel(); + + if (_cancellationManager.IsCancellationRequested) + { + // BaseCommand is already racing this handler against the process-wide shutdown deadline. + // Keep the handler pending until the project task unwinds so the manager cannot mistake an + // unfinished build teardown for completed shutdown. + await DrainAppHostRunAfterCancellationAsync(pendingRun).ConfigureAwait(false); + return; + } + // Embedded callers can supply a cancellation token that is not owned by the console manager. + // Retain the local timeout for that path because no process-level deadline will release a + // permanently wedged project task. + await WaitForAppHostRunCancellationAsync(pendingRun, CancellationToken.None).ConfigureAwait(false); + } + + private async Task WaitForAppHostRunCancellationAsync(Task pendingRun, CancellationToken cancellationToken) + { try { - // 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 || cancellationToken.IsCancellationRequested) + catch (OperationCanceledException) { } catch (TimeoutException ex) { _logger.LogDebug(ex, "Timed out waiting for AppHost startup cancellation to complete."); - _ = ObserveAppHostRunFailureAsync(pendingRun); + _ = DrainAppHostRunAfterCancellationAsync(pendingRun); } catch (Exception ex) { @@ -1515,15 +1537,18 @@ private async Task CancelAppHostStartupAsync(CancellationTokenSource runCancella } } - private async Task ObserveAppHostRunFailureAsync(Task pendingRun) + private async Task DrainAppHostRunAfterCancellationAsync(Task pendingRun) { try { await pendingRun.ConfigureAwait(false); } + catch (OperationCanceledException) + { + } catch (Exception ex) { - _logger.LogDebug(ex, "AppHost run failed after startup cancellation timeout."); + _logger.LogDebug(ex, "AppHost run failed while startup cancellation was being drained."); } } diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index 3e4f8f4a724..2bfcb3a206a 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -436,11 +436,11 @@ public async Task RunCommand_WhenCancelledDuringBuild_ExitsSuccessfully() } [Fact] - public async Task RunCommand_WhenCancelledDuringBuild_AwaitsRunCleanupBeforeExit() + public async Task RunCommand_WhenExtensionStopsCliDuringBuild_AwaitsRunCleanupPastLocalTimeout() { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); - using var cts = new CancellationTokenSource(); var interactionService = new TestInteractionService(); + var timeProvider = new SignalingFakeTimeProvider(TimeSpan.FromSeconds(5)); var appHostDir = workspace.WorkspaceRoot.CreateSubdirectory("AppHost"); var appHostFile = new FileInfo(Path.Combine(appHostDir.FullName, "AppHost.csproj")); @@ -483,17 +483,27 @@ public async Task RunCommand_WhenCancelledDuringBuild_AwaitsRunCleanupBeforeExit options.InteractionServiceFactory = _ => interactionService; options.ProjectLocatorFactory = _ => projectLocator; options.AppHostProjectFactory = _ => projectFactory; + options.TimeProvider = timeProvider; }); using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); + var cancellationManager = provider.GetRequiredService(); + var rpcTarget = provider.GetRequiredService(); var result = command.Parse($"run --apphost {appHostFile.FullName}"); - var pendingCommand = result.InvokeAsync(cancellationToken: cts.Token); + var pendingCommand = result.InvokeAsync(cancellationToken: cancellationManager.Token); await buildStarted.Task.DefaultTimeout(); - cts.Cancel(); + await rpcTarget.StopCliAsync(); await cleanupStarted.Task.DefaultTimeout(); + // The old path armed the five-second local startup-cancellation timeout here. Advance beyond + // that boundary and confirm manager-owned cancellation still keeps the handler attached to the + // project task; BaseCommand's process-wide deadline is the only allowed escape hatch. + timeProvider.Advance(TimeSpan.FromSeconds(6)); + await Task.Yield(); + + Assert.False(timeProvider.TimerCreated.Task.IsCompleted, "Manager-owned cancellation armed the local startup timeout."); Assert.False(pendingCommand.IsCompleted, "The CLI exited before build cleanup completed."); cleanupCanFinish.TrySetResult(); @@ -505,6 +515,80 @@ public async Task RunCommand_WhenCancelledDuringBuild_AwaitsRunCleanupBeforeExit Assert.Empty(interactionService.DisplayedErrors); } + [Fact] + public async Task RunCommand_WhenDirectlyCancelledDuringBuild_StopsWaitingAtLocalTimeout() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + using var cts = new CancellationTokenSource(); + var interactionService = new TestInteractionService(); + var timeProvider = new SignalingFakeTimeProvider(TimeSpan.FromSeconds(5)); + + var appHostDir = workspace.WorkspaceRoot.CreateSubdirectory("AppHost"); + var appHostFile = new FileInfo(Path.Combine(appHostDir.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(appHostFile.FullName, "", TestContext.Current.CancellationToken); + + var projectLocator = new TestProjectLocator + { + UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) => + Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile])) + }; + + var buildStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cleanupStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cleanupCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cleanupCanFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var projectFactory = new TestAppHostProjectFactory + { + RunAsyncCallback = async (context, cancellationToken) => + { + buildStarted.TrySetResult(); + + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + catch (OperationCanceledException) + { + cleanupStarted.TrySetResult(); + await cleanupCanFinish.Task; + cleanupCompleted.TrySetResult(); + context.BuildCompletionSource?.TrySetResult(false); + } + + return CliExitCodes.Cancelled; + } + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.ProjectLocatorFactory = _ => projectLocator; + options.AppHostProjectFactory = _ => projectFactory; + options.TimeProvider = timeProvider; + }); + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse($"run --apphost {appHostFile.FullName}"); + var pendingCommand = result.InvokeAsync(cancellationToken: cts.Token); + + await buildStarted.Task.DefaultTimeout(); + cts.Cancel(); + await cleanupStarted.Task.DefaultTimeout(); + await timeProvider.TimerCreated.Task.DefaultTimeout(); + + timeProvider.Advance(TimeSpan.FromSeconds(6)); + + var exitCode = await pendingCommand.DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.False(cleanupCompleted.Task.IsCompleted, "Direct cancellation waited past its local safety timeout."); + Assert.Empty(interactionService.DisplayedErrors); + + cleanupCanFinish.TrySetResult(); + await cleanupCompleted.Task.DefaultTimeout(); + } + [Fact] public async Task RunCommand_WhenProjectReturnsCancelledDuringBuild_ExitsSuccessfully() { @@ -3898,6 +3982,22 @@ private sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider public override DateTimeOffset GetUtcNow() => utcNow; } + private sealed class SignalingFakeTimeProvider(TimeSpan signaledDueTime) : FakeTimeProvider + { + public TaskCompletionSource TimerCreated { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + var timer = base.CreateTimer(callback, state, dueTime, period); + if (dueTime == signaledDueTime) + { + TimerCreated.TrySetResult(); + } + + return timer; + } + } + [Fact] public async Task RunCommand_WithNoBuildOption_SkipsBuildAndPassesNoBuildAndNoRestoreToRunner() { From 5b18b18d418a48e063308600e6baf41789860532 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Thu, 10 Sep 2026 12:45:26 -0700 Subject: [PATCH 3/7] Simplify AppHost cancellation cleanup Consolidate startup and pre-build cancellation into one helper with an explicit wait budget. Keep manager-owned shutdown under the central deadline, preserve bounded direct cancellation, and retain late task fault observation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/RunCommand.cs | 48 +++++++------------ .../Commands/RunCommandTests.cs | 4 +- 2 files changed, 19 insertions(+), 33 deletions(-) diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 58e142537d1..9a2698a0ab7 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -443,7 +443,7 @@ await extensionInteractionService.StartDebugSessionAsync( catch (TimeoutException) { runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "startup_timeout"); - await CancelAppHostStartupAsync(runCts, runTask, cancellationToken).ConfigureAwait(false); + await CancelAppHostRunAsync(runCts, runTask, s_appHostStartupCancellationTimeout, cancellationToken).ConfigureAwait(false); return CreateStartupTimeoutResult(timeoutSeconds); } @@ -676,15 +676,20 @@ ex is ExtensionOperationCanceledException || { runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "canceled"); - // Extension cancellation can interrupt RunCommand's build wait before the linked run token - // has reached the project task. Keep manager-owned cancellation attached to that task so a - // late-starting build cannot outlive the CLI and retain the workspace directory on Windows. + // Extension cancellation can interrupt the build wait before the project task unwinds. + // Keep cleanup owned by this handler so a late build cannot outlive the CLI and retain + // the workspace directory on Windows. if (!buildWaitCompleted && runCts is not null && runTask is not null && !runTask.IsCompleted) { - await CancelAppHostBuildAsync(runCts, runTask).ConfigureAwait(false); + // BaseCommand already races manager-owned cancellation against the process-wide + // shutdown deadline. Only direct callers with an unrelated token need a local bound. + var cleanupTimeout = _cancellationManager.IsCancellationRequested + ? Timeout.InfiniteTimeSpan + : s_appHostStartupCancellationTimeout; + await CancelAppHostRunAsync(runCts, runTask, cleanupTimeout, CancellationToken.None).ConfigureAwait(false); } // User Ctrl+C is the normal exit path for `aspire run`; surface as success. @@ -1492,41 +1497,22 @@ private TimeSpan GetRemainingStartupTimeout(long startupStartTimestamp, TimeSpan return elapsed >= startupTimeout ? TimeSpan.Zero : startupTimeout - elapsed; } - private async Task CancelAppHostStartupAsync(CancellationTokenSource runCancellationTokenSource, Task pendingRun, CancellationToken cancellationToken) - { - runCancellationTokenSource.Cancel(); - await WaitForAppHostRunCancellationAsync(pendingRun, cancellationToken).ConfigureAwait(false); - } - - private async Task CancelAppHostBuildAsync(CancellationTokenSource runCancellationTokenSource, Task pendingRun) + private async Task CancelAppHostRunAsync( + CancellationTokenSource runCancellationTokenSource, + Task pendingRun, + TimeSpan timeout, + CancellationToken cancellationToken) { runCancellationTokenSource.Cancel(); - if (_cancellationManager.IsCancellationRequested) - { - // BaseCommand is already racing this handler against the process-wide shutdown deadline. - // Keep the handler pending until the project task unwinds so the manager cannot mistake an - // unfinished build teardown for completed shutdown. - await DrainAppHostRunAfterCancellationAsync(pendingRun).ConfigureAwait(false); - return; - } - - // Embedded callers can supply a cancellation token that is not owned by the console manager. - // Retain the local timeout for that path because no process-level deadline will release a - // permanently wedged project task. - await WaitForAppHostRunCancellationAsync(pendingRun, CancellationToken.None).ConfigureAwait(false); - } - - private async Task WaitForAppHostRunCancellationAsync(Task pendingRun, CancellationToken cancellationToken) - { try { - await pendingRun.WaitAsync(s_appHostStartupCancellationTimeout, _timeProvider, cancellationToken).ConfigureAwait(false); + await pendingRun.WaitAsync(timeout, _timeProvider, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { } - catch (TimeoutException ex) + catch (TimeoutException ex) when (timeout != Timeout.InfiniteTimeSpan) { _logger.LogDebug(ex, "Timed out waiting for AppHost startup cancellation to complete."); _ = DrainAppHostRunAfterCancellationAsync(pendingRun); diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index 2bfcb3a206a..729bd9d19c8 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -694,7 +694,7 @@ public async Task RunCommand_WhenAppHostStartupTimesOut_DisplaysTimeoutGuidance( public async Task RunCommand_WhenCancelledDuringStartupTimeout_ExitsWithoutWaitingForFullTimeout() { // Verifies that when Ctrl+C fires (cancellationToken) during startup, the command exits - // promptly rather than blocking for the 5-second CancelAppHostStartupAsync timeout. + // promptly rather than blocking for the five-second startup-cancellation timeout. using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); using var cts = new CancellationTokenSource(); var interactionService = new TestInteractionService(); @@ -746,7 +746,7 @@ public async Task RunCommand_WhenCancelledDuringStartupTimeout_ExitsWithoutWaiti stopwatch.Stop(); // Without the cancellationToken plumbing, this would block for the full 5-second - // CancelAppHostStartupAsync timeout. With the fix, it exits promptly. + // startup-cancellation timeout. With the fix, it exits promptly. Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(3), $"Expected prompt exit after Ctrl+C, but took {stopwatch.Elapsed}."); Assert.Equal(CliExitCodes.Success, exitCode); } From d00bf509c26c3f6885d321cf94d01b44114989a9 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Thu, 10 Sep 2026 14:59:19 -0700 Subject: [PATCH 4/7] Make CLI teardown regression coverage deterministic Cancel synchronously at the fake project boundary and share the signaling time provider between command tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Commands/AppHostLauncherTests.cs | 16 ---- .../Commands/RunCommandTests.cs | 82 ++++++------------- .../TestServices/SignalingFakeTimeProvider.cs | 22 +++++ 3 files changed, 48 insertions(+), 72 deletions(-) create mode 100644 tests/Aspire.Cli.Tests/TestServices/SignalingFakeTimeProvider.cs diff --git a/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs b/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs index 67b134784a8..bbdfaf74b1d 100644 --- a/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs @@ -1745,22 +1745,6 @@ public void Kill(bool entireProcessTree) public ValueTask DisposeAsync() => ValueTask.CompletedTask; } - private sealed class SignalingFakeTimeProvider(TimeSpan signaledDueTime) : FakeTimeProvider - { - public TaskCompletionSource TimerCreated { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - - public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) - { - var timer = base.CreateTimer(callback, state, dueTime, period); - if (dueTime == signaledDueTime) - { - TimerCreated.TrySetResult(); - } - - return timer; - } - } - private sealed class FixedLayoutDiscovery : ILayoutDiscovery { public LayoutConfiguration? DiscoverLayout(string? projectDirectory = null) => null; diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index 729bd9d19c8..93b185060a5 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -452,31 +452,7 @@ public async Task RunCommand_WhenExtensionStopsCliDuringBuild_AwaitsRunCleanupPa Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile])) }; - var buildStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var cleanupStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var cleanupCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var cleanupCanFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var projectFactory = new TestAppHostProjectFactory - { - RunAsyncCallback = async (context, cancellationToken) => - { - buildStarted.TrySetResult(); - - try - { - await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); - } - catch (OperationCanceledException) - { - cleanupStarted.TrySetResult(); - await cleanupCanFinish.Task; - cleanupCompleted.TrySetResult(); - context.BuildCompletionSource?.TrySetResult(false); - } - - return CliExitCodes.Cancelled; - } - }; + var projectFactory = new TestAppHostProjectFactory(); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { @@ -490,28 +466,38 @@ public async Task RunCommand_WhenExtensionStopsCliDuringBuild_AwaitsRunCleanupPa var command = provider.GetRequiredService(); var cancellationManager = provider.GetRequiredService(); var rpcTarget = provider.GetRequiredService(); + var runCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var runStarted = false; + projectFactory.RunAsyncCallback = (_, runCancellationToken) => + { + // Cancel before RunAsync returns so the build wait observes an already-canceled token. + // With synchronous setup fakes, InvokeAsync cannot yield until cleanup is waiting on this task. + Assert.True(rpcTarget.StopCliAsync().IsCompletedSuccessfully); + Assert.True(runCancellationToken.IsCancellationRequested); + runStarted = true; + return runCompletionSource.Task; + }; var result = command.Parse($"run --apphost {appHostFile.FullName}"); var pendingCommand = result.InvokeAsync(cancellationToken: cancellationManager.Token); - await buildStarted.Task.DefaultTimeout(); - await rpcTarget.StopCliAsync(); - await cleanupStarted.Task.DefaultTimeout(); - - // The old path armed the five-second local startup-cancellation timeout here. Advance beyond - // that boundary and confirm manager-owned cancellation still keeps the handler attached to the - // project task; BaseCommand's process-wide deadline is the only allowed escape hatch. - timeProvider.Advance(TimeSpan.FromSeconds(6)); - await Task.Yield(); - - Assert.False(timeProvider.TimerCreated.Task.IsCompleted, "Manager-owned cancellation armed the local startup timeout."); - Assert.False(pendingCommand.IsCompleted, "The CLI exited before build cleanup completed."); + int exitCode; + try + { + Assert.True(runStarted, "The setup must reach RunAsync synchronously before inspecting cancellation cleanup."); - cleanupCanFinish.TrySetResult(); + // Advance beyond the old local timeout only after the command has entered its cleanup wait. + timeProvider.Advance(TimeSpan.FromSeconds(6)); - var exitCode = await pendingCommand.DefaultTimeout(); + Assert.False(timeProvider.TimerCreated.Task.IsCompleted, "Manager-owned cancellation armed the local startup timeout."); + Assert.False(pendingCommand.IsCompleted, "The CLI exited before build cleanup completed."); + } + finally + { + runCompletionSource.TrySetResult(CliExitCodes.Cancelled); + exitCode = await pendingCommand.DefaultTimeout(); + } Assert.Equal(CliExitCodes.Success, exitCode); - Assert.True(cleanupCompleted.Task.IsCompletedSuccessfully); Assert.Empty(interactionService.DisplayedErrors); } @@ -3982,22 +3968,6 @@ private sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider public override DateTimeOffset GetUtcNow() => utcNow; } - private sealed class SignalingFakeTimeProvider(TimeSpan signaledDueTime) : FakeTimeProvider - { - public TaskCompletionSource TimerCreated { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - - public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) - { - var timer = base.CreateTimer(callback, state, dueTime, period); - if (dueTime == signaledDueTime) - { - TimerCreated.TrySetResult(); - } - - return timer; - } - } - [Fact] public async Task RunCommand_WithNoBuildOption_SkipsBuildAndPassesNoBuildAndNoRestoreToRunner() { diff --git a/tests/Aspire.Cli.Tests/TestServices/SignalingFakeTimeProvider.cs b/tests/Aspire.Cli.Tests/TestServices/SignalingFakeTimeProvider.cs new file mode 100644 index 00000000000..94f71a1fe84 --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/SignalingFakeTimeProvider.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 Microsoft.Extensions.Time.Testing; + +namespace Aspire.Cli.Tests.TestServices; + +internal sealed class SignalingFakeTimeProvider(TimeSpan signaledDueTime) : FakeTimeProvider +{ + public TaskCompletionSource TimerCreated { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + var timer = base.CreateTimer(callback, state, dueTime, period); + if (dueTime == signaledDueTime) + { + TimerCreated.TrySetResult(); + } + + return timer; + } +} From ae1748b17410276d1eef34250fa47ee936cb58f6 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Fri, 11 Sep 2026 07:59:44 -0700 Subject: [PATCH 5/7] Drain AppHost run cleanup after build completion Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/RunCommand.cs | 7 +- .../Commands/RunCommandTests.cs | 161 ++++++++---------- 2 files changed, 75 insertions(+), 93 deletions(-) diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 9a2698a0ab7..cedba3497e5 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -252,7 +252,6 @@ await extensionInteractionService.StartDebugSessionAsync( LauncherLivenessMonitor? launcherMonitor = null; Task? runTask = null; CancellationTokenSource? runCts = null; - var buildWaitCompleted = false; try { @@ -390,7 +389,6 @@ await extensionInteractionService.StartDebugSessionAsync( } buildSuccess = await buildCompletionSource.Task.WaitAsync(cancellationToken); - buildWaitCompleted = true; waitForBuildActivity.SetAppHostBuildSuccess(buildSuccess); } if (!buildSuccess) @@ -676,11 +674,10 @@ ex is ExtensionOperationCanceledException || { runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "canceled"); - // Extension cancellation can interrupt the build wait before the project task unwinds. + // Cancellation can interrupt build or startup readiness waits before the project task unwinds. // Keep cleanup owned by this handler so a late build cannot outlive the CLI and retain // the workspace directory on Windows. - if (!buildWaitCompleted && - runCts is not null && + if (runCts is not null && runTask is not null && !runTask.IsCompleted) { diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index 93b185060a5..2591b960017 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -435,8 +435,10 @@ public async Task RunCommand_WhenCancelledDuringBuild_ExitsSuccessfully() Assert.Empty(interactionService.DisplayedErrors); } - [Fact] - public async Task RunCommand_WhenExtensionStopsCliDuringBuild_AwaitsRunCleanupPastLocalTimeout() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RunCommand_WhenExtensionStopsCliDuringStartup_AwaitsRunCleanupPastLocalTimeout(bool buildCompleted) { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); var interactionService = new TestInteractionService(); @@ -467,14 +469,29 @@ public async Task RunCommand_WhenExtensionStopsCliDuringBuild_AwaitsRunCleanupPa var cancellationManager = provider.GetRequiredService(); var rpcTarget = provider.GetRequiredService(); var runCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var runStarted = false; - projectFactory.RunAsyncCallback = (_, runCancellationToken) => - { - // Cancel before RunAsync returns so the build wait observes an already-canceled token. - // With synchronous setup fakes, InvokeAsync cannot yield until cleanup is waiting on this task. - Assert.True(rpcTarget.StopCliAsync().IsCompletedSuccessfully); - Assert.True(runCancellationToken.IsCancellationRequested); - runStarted = true; + var stopRequested = false; + interactionService.ShowStatusCallback = status => + { + if (buildCompleted && status == RunCommandStrings.ConnectingToAppHost) + { + Assert.True(rpcTarget.StopCliAsync().IsCompletedSuccessfully); + stopRequested = true; + } + }; + projectFactory.RunAsyncCallback = (context, runCancellationToken) => + { + // Cancel synchronously at the selected startup phase so InvokeAsync cannot yield + // until cleanup is waiting on the incomplete run task. + if (buildCompleted) + { + context.BuildCompletionSource!.SetResult(true); + } + else + { + Assert.True(rpcTarget.StopCliAsync().IsCompletedSuccessfully); + Assert.True(runCancellationToken.IsCancellationRequested); + stopRequested = true; + } return runCompletionSource.Task; }; var result = command.Parse($"run --apphost {appHostFile.FullName}"); @@ -483,13 +500,13 @@ public async Task RunCommand_WhenExtensionStopsCliDuringBuild_AwaitsRunCleanupPa int exitCode; try { - Assert.True(runStarted, "The setup must reach RunAsync synchronously before inspecting cancellation cleanup."); + Assert.True(stopRequested, "The setup must request cancellation synchronously before inspecting cleanup."); // Advance beyond the old local timeout only after the command has entered its cleanup wait. timeProvider.Advance(TimeSpan.FromSeconds(6)); Assert.False(timeProvider.TimerCreated.Task.IsCompleted, "Manager-owned cancellation armed the local startup timeout."); - Assert.False(pendingCommand.IsCompleted, "The CLI exited before build cleanup completed."); + Assert.False(pendingCommand.IsCompleted, "The CLI exited before run cleanup completed."); } finally { @@ -501,8 +518,10 @@ public async Task RunCommand_WhenExtensionStopsCliDuringBuild_AwaitsRunCleanupPa Assert.Empty(interactionService.DisplayedErrors); } - [Fact] - public async Task RunCommand_WhenDirectlyCancelledDuringBuild_StopsWaitingAtLocalTimeout() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RunCommand_WhenDirectlyCancelledDuringStartup_StopsWaitingAtLocalTimeout(bool buildCompleted) { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); using var cts = new CancellationTokenSource(); @@ -523,10 +542,22 @@ public async Task RunCommand_WhenDirectlyCancelledDuringBuild_StopsWaitingAtLoca var cleanupStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var cleanupCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var cleanupCanFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var connectingToAppHost = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + interactionService.ShowStatusCallback = status => + { + if (status == RunCommandStrings.ConnectingToAppHost) + { + connectingToAppHost.TrySetResult(); + } + }; var projectFactory = new TestAppHostProjectFactory { RunAsyncCallback = async (context, cancellationToken) => { + if (buildCompleted) + { + context.BuildCompletionSource!.SetResult(true); + } buildStarted.TrySetResult(); try @@ -559,20 +590,31 @@ public async Task RunCommand_WhenDirectlyCancelledDuringBuild_StopsWaitingAtLoca var pendingCommand = result.InvokeAsync(cancellationToken: cts.Token); await buildStarted.Task.DefaultTimeout(); + if (buildCompleted) + { + await connectingToAppHost.Task.DefaultTimeout(); + } cts.Cancel(); await cleanupStarted.Task.DefaultTimeout(); await timeProvider.TimerCreated.Task.DefaultTimeout(); - timeProvider.Advance(TimeSpan.FromSeconds(6)); - - var exitCode = await pendingCommand.DefaultTimeout(); + try + { + Assert.False(pendingCommand.IsCompleted, "Direct cancellation did not wait for run cleanup."); + timeProvider.Advance(TimeSpan.FromSeconds(6)); - Assert.Equal(CliExitCodes.Success, exitCode); - Assert.False(cleanupCompleted.Task.IsCompleted, "Direct cancellation waited past its local safety timeout."); - Assert.Empty(interactionService.DisplayedErrors); + var exitCode = await pendingCommand.DefaultTimeout(); - cleanupCanFinish.TrySetResult(); - await cleanupCompleted.Task.DefaultTimeout(); + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.False(cleanupCompleted.Task.IsCompleted, "Direct cancellation waited past its local safety timeout."); + Assert.Empty(interactionService.DisplayedErrors); + } + finally + { + cleanupCanFinish.TrySetResult(); + await cleanupCompleted.Task.DefaultTimeout(); + await pendingCommand.DefaultTimeout(); + } } [Fact] @@ -676,67 +718,6 @@ public async Task RunCommand_WhenAppHostStartupTimesOut_DisplaysTimeoutGuidance( Assert.True(runCancellationObserved.Task.IsCompletedSuccessfully); } - [Fact] - public async Task RunCommand_WhenCancelledDuringStartupTimeout_ExitsWithoutWaitingForFullTimeout() - { - // Verifies that when Ctrl+C fires (cancellationToken) during startup, the command exits - // promptly rather than blocking for the five-second startup-cancellation timeout. - using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); - using var cts = new CancellationTokenSource(); - var interactionService = new TestInteractionService(); - var buildCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - var appHostDir = workspace.WorkspaceRoot.CreateSubdirectory("AppHost"); - var appHostFile = new FileInfo(Path.Combine(appHostDir.FullName, "AppHost.csproj")); - await File.WriteAllTextAsync(appHostFile.FullName, ""); - - var projectLocator = new TestProjectLocator - { - UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) => - Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile])) - }; - - var projectFactory = new TestAppHostProjectFactory - { - RunAsyncCallback = async (context, _) => - { - context.BuildCompletionSource?.TrySetResult(true); - buildCompleted.SetResult(); - - // Never signal BackchannelCompletionSource and ignore cancellation to - // simulate a hung AppHost process. - await Task.Delay(TimeSpan.FromSeconds(30), CancellationToken.None); - return 0; - } - }; - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.InteractionServiceFactory = _ => interactionService; - options.ProjectLocatorFactory = _ => projectLocator; - options.AppHostProjectFactory = _ => projectFactory; - }); - - using var provider = services.BuildServiceProvider(); - var command = provider.GetRequiredService(); - var result = command.Parse($"run --apphost {appHostFile.FullName}"); - - var pendingRun = result.InvokeAsync(cancellationToken: cts.Token); - - // Cancel after build completes to simulate Ctrl+C during startup. - await buildCompleted.Task.DefaultTimeout(); - cts.Cancel(); - - var stopwatch = Stopwatch.StartNew(); - var exitCode = await pendingRun.DefaultTimeout(); - stopwatch.Stop(); - - // Without the cancellationToken plumbing, this would block for the full 5-second - // startup-cancellation timeout. With the fix, it exits promptly. - Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(3), $"Expected prompt exit after Ctrl+C, but took {stopwatch.Elapsed}."); - Assert.Equal(CliExitCodes.Success, exitCode); - } - [Fact] public async Task RunCommand_DetachedChild_WhenLauncherDiesBeforeReadiness_CancelsRun() { @@ -1866,7 +1847,7 @@ public async Task RunCommand_DetachedEarlyExit_PropagatesExitCodeWithoutUnexpect } [Fact] - public async Task RunCommand_WhenCancelledDuringStartupRpc_CompletesSuccessfully() + public async Task RunCommand_WhenCancelledDuringStartupRpc_AwaitsRunCleanup() { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); using var cts = new CancellationTokenSource(); @@ -1913,17 +1894,21 @@ public async Task RunCommand_WhenCancelledDuringStartupRpc_CompletesSuccessfully var command = provider.GetRequiredService(); var result = command.Parse($"run --apphost {appHostFile.FullName}"); + var pendingCommand = result.InvokeAsync(cancellationToken: cts.Token); + int exitCode; try { - var exitCode = await result.InvokeAsync(cancellationToken: cts.Token).DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); - Assert.Empty(interactionService.DisplayedErrors); + Assert.True(cts.IsCancellationRequested, "The dashboard RPC must request cancellation synchronously."); + Assert.False(pendingCommand.IsCompleted, "The CLI exited before run cleanup completed."); } finally { runCanExit.TrySetResult(); + exitCode = await pendingCommand.DefaultTimeout(); } + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.Empty(interactionService.DisplayedErrors); } [Fact] From 445d80cb8834a9f8cd1394604d90dd2eadf72f21 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Fri, 11 Sep 2026 08:27:23 -0700 Subject: [PATCH 6/7] Keep AppHost cancellation cleanup owned across exit paths Propagate cancellation during timeout cleanup and centralize draining in finally so linked-token cancellation and concurrent RPC failures cannot bypass it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/RunCommand.cs | 48 +++---- .../Commands/RunCommandTests.cs | 132 ++++++++++++++++-- .../TestServices/SignalingFakeTimeProvider.cs | 2 + 3 files changed, 146 insertions(+), 36 deletions(-) diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index cedba3497e5..1568acf47c2 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -252,6 +252,7 @@ await extensionInteractionService.StartDebugSessionAsync( LauncherLivenessMonitor? launcherMonitor = null; Task? runTask = null; CancellationTokenSource? runCts = null; + var cancellationRequested = false; try { @@ -641,16 +642,6 @@ await ProcessResourceStatesAsync((resource, endpoint) => : CommandResult.FromExitCode(exitCode); } } - catch (OperationCanceledException ex) when (ex.CancellationToken == runCts.Token && cancellationToken.IsCancellationRequested) - { - runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "canceled"); - - // User Ctrl+C is the normal exit path for `aspire run`; surface as success. - // Internal failures `return X` directly from GuestAppHostProject.RunAsync rather - // than flowing through this catch, so we don't need to distinguish failure codes - // here. - return CommandResult.Cancelled(CliExitCodes.Success); - } finally { logCaptureCancellationSource.Cancel(); @@ -673,21 +664,7 @@ ex is ExtensionOperationCanceledException || (runCts is not null && ex.CancellationToken == runCts.Token && cancellationToken.IsCancellationRequested)) { runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "canceled"); - - // Cancellation can interrupt build or startup readiness waits before the project task unwinds. - // Keep cleanup owned by this handler so a late build cannot outlive the CLI and retain - // the workspace directory on Windows. - if (runCts is not null && - runTask is not null && - !runTask.IsCompleted) - { - // BaseCommand already races manager-owned cancellation against the process-wide - // shutdown deadline. Only direct callers with an unrelated token need a local bound. - var cleanupTimeout = _cancellationManager.IsCancellationRequested - ? Timeout.InfiniteTimeSpan - : s_appHostStartupCancellationTimeout; - await CancelAppHostRunAsync(runCts, runTask, cleanupTimeout, CancellationToken.None).ConfigureAwait(false); - } + cancellationRequested = true; // User Ctrl+C is the normal exit path for `aspire run`; surface as success. // Internal failures `return X` directly from GuestAppHostProject.RunAsync rather @@ -735,6 +712,21 @@ runTask is not null && } finally { + // Keep cancellation cleanup owned by the command even if a concurrent RPC failure + // or disconnect selected a different catch. Preserve that result while draining the run + // before disposing its token source, so child processes cannot retain the workspace. + if ((cancellationRequested || cancellationToken.IsCancellationRequested) && + runCts is not null && + runTask is { IsCompleted: false }) + { + // BaseCommand already races manager-owned cancellation against the process-wide + // shutdown deadline. Only direct callers with an unrelated token need a local bound. + var cleanupTimeout = _cancellationManager.IsCancellationRequested + ? Timeout.InfiniteTimeSpan + : s_appHostStartupCancellationTimeout; + await CancelAppHostRunAsync(runCts, runTask, cleanupTimeout, CancellationToken.None).ConfigureAwait(false); + } + if (IsDetachedStartChild() && runTask is { IsCompleted: false } detachedAppHostRun) { // If the runTask is still running here, that is an abnormal exit. @@ -1508,6 +1500,9 @@ private async Task CancelAppHostRunAsync( } catch (OperationCanceledException) { + // Cancellation of the wait must reach the command's owning cancellation handler. + // Only cancellation of the run task itself is safe to absorb here. + cancellationToken.ThrowIfCancellationRequested(); } catch (TimeoutException ex) when (timeout != Timeout.InfiniteTimeSpan) { @@ -1518,6 +1513,9 @@ private async Task CancelAppHostRunAsync( { _logger.LogDebug(ex, "AppHost run failed after startup cancellation."); } + + // Cancellation can race the timeout or a run failure and lose the WaitAsync race. + cancellationToken.ThrowIfCancellationRequested(); } private async Task DrainAppHostRunAfterCancellationAsync(Task pendingRun) diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index 2591b960017..082105557e9 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -718,6 +718,106 @@ public async Task RunCommand_WhenAppHostStartupTimesOut_DisplaysTimeoutGuidance( Assert.True(runCancellationObserved.Task.IsCompletedSuccessfully); } + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task RunCommand_WhenCancelledDuringStartupTimeoutCleanup_AwaitsRunCleanup(bool managerOwned, bool timeoutWins) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + using var cts = new CancellationTokenSource(); + var interactionService = new TestInteractionService(); + var timeProvider = new SignalingFakeTimeProvider(TimeSpan.FromSeconds(5)); + var appHostDir = workspace.WorkspaceRoot.CreateSubdirectory("AppHost"); + var appHostFile = new FileInfo(Path.Combine(appHostDir.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(appHostFile.FullName, "", TestContext.Current.CancellationToken); + + var projectLocator = new TestProjectLocator + { + UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) => + Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile])) + }; + var runCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var projectFactory = new TestAppHostProjectFactory + { + RunAsyncCallback = (context, _) => + { + context.BuildCompletionSource!.SetResult(true); + return runCompletion.Task; + } + }; + interactionService.ShowStatusCallback = status => + { + if (status == RunCommandStrings.ConnectingToAppHost) + { + // Expire startup before its wait is created, keeping the transition into + // timeout cleanup synchronous rather than depending on scheduler timing. + timeProvider.Advance(TimeSpan.FromSeconds(1)); + } + }; + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.ProjectLocatorFactory = _ => projectLocator; + options.AppHostProjectFactory = _ => projectFactory; + options.TimeProvider = timeProvider; + options.ConfigurationCallback += config => config[CliConfigNames.AppHostStartupTimeout] = "1"; + }); + + using var provider = services.BuildServiceProvider(); + var cancellationManager = provider.GetRequiredService(); + var rpcTarget = provider.GetRequiredService(); + var cleanupWaits = 0; + timeProvider.TimerCreatedCallback = () => + { + if (++cleanupWaits == 1) + { + if (timeoutWins) + { + // Complete the timeout first, then cancel before the command observes it. + timeProvider.Advance(TimeSpan.FromSeconds(5)); + } + // Interrupt the startup-timeout cleanup wait, not the startup wait itself. + if (managerOwned) + { + Assert.True(rpcTarget.StopCliAsync().IsCompletedSuccessfully); + } + else + { + cts.Cancel(); + } + } + }; + var command = provider.GetRequiredService(); + var pendingCommand = command.Parse($"run --apphost {appHostFile.FullName}") + .InvokeAsync(cancellationToken: managerOwned ? cancellationManager.Token : cts.Token); + + int exitCode; + try + { + Assert.Equal(managerOwned ? 1 : 2, cleanupWaits); + Assert.False(pendingCommand.IsCompleted, "Cancellation escaped the startup-timeout cleanup wait."); + timeProvider.Advance(TimeSpan.FromSeconds(6)); + if (managerOwned) + { + Assert.False(pendingCommand.IsCompleted, "Manager-owned cleanup stopped at the local timeout."); + } + else + { + Assert.Equal(CliExitCodes.Success, await pendingCommand.DefaultTimeout()); + } + } + finally + { + runCompletion.TrySetResult(CliExitCodes.Cancelled); + exitCode = await pendingCommand.DefaultTimeout(); + } + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.Empty(interactionService.DisplayedErrors); + } + [Fact] public async Task RunCommand_DetachedChild_WhenLauncherDiesBeforeReadiness_CancelsRun() { @@ -1846,8 +1946,10 @@ public async Task RunCommand_DetachedEarlyExit_PropagatesExitCodeWithoutUnexpect Assert.DoesNotContain(interactionService.DisplayedErrors, error => error.Contains("An unexpected error occurred", StringComparison.Ordinal)); } - [Fact] - public async Task RunCommand_WhenCancelledDuringStartupRpc_AwaitsRunCleanup() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RunCommand_WhenCancelledDuringStartupRpc_AwaitsRunCleanup(bool useRunCancellationToken) { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); using var cts = new CancellationTokenSource(); @@ -1866,7 +1968,7 @@ public async Task RunCommand_WhenCancelledDuringStartupRpc_AwaitsRunCleanup() var interactionService = new TestInteractionService(); var projectFactory = new TestAppHostProjectFactory { - RunAsyncCallback = async (context, _) => + RunAsyncCallback = async (context, runCancellationToken) => { context.BuildCompletionSource?.TrySetResult(true); context.BackchannelCompletionSource?.TrySetResult(new TestAppHostBackchannel @@ -1874,7 +1976,7 @@ public async Task RunCommand_WhenCancelledDuringStartupRpc_AwaitsRunCleanup() GetDashboardUrlsAsyncCallback = ct => { cts.Cancel(); - return Task.FromCanceled(ct); + return Task.FromCanceled(useRunCancellationToken ? runCancellationToken : ct); } }); @@ -1911,8 +2013,10 @@ public async Task RunCommand_WhenCancelledDuringStartupRpc_AwaitsRunCleanup() Assert.Empty(interactionService.DisplayedErrors); } - [Fact] - public async Task RunCommand_WhenStartupRpcThrowsUnrelatedCancellationAfterUserCancellation_DoesNotTreatRunAsSuccessful() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RunCommand_WhenStartupRpcFailsAfterUserCancellation_AwaitsRunCleanupAndPreservesFailure(bool unrelatedCancellation) { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); using var cts = new CancellationTokenSource(); @@ -1941,7 +2045,9 @@ public async Task RunCommand_WhenStartupRpcThrowsUnrelatedCancellationAfterUserC { cts.Cancel(); unrelatedCts.Cancel(); - return Task.FromCanceled(unrelatedCts.Token); + return unrelatedCancellation + ? Task.FromCanceled(unrelatedCts.Token) + : Task.FromException(new InvalidOperationException("Dashboard RPC failed.")); } }); @@ -1961,17 +2067,21 @@ public async Task RunCommand_WhenStartupRpcThrowsUnrelatedCancellationAfterUserC var command = provider.GetRequiredService(); var result = command.Parse($"run --apphost {appHostFile.FullName}"); + var pendingCommand = result.InvokeAsync(cancellationToken: cts.Token); + int exitCode; try { - var exitCode = await result.InvokeAsync(cancellationToken: cts.Token).DefaultTimeout(); - - Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, exitCode); - Assert.Contains(interactionService.DisplayedErrors, error => error.Contains("unexpected error", StringComparison.OrdinalIgnoreCase)); + Assert.True(cts.IsCancellationRequested, "The dashboard RPC must request cancellation synchronously."); + Assert.False(pendingCommand.IsCompleted, "An RPC failure bypassed cancellation cleanup."); } finally { runCanExit.TrySetResult(); + exitCode = await pendingCommand.DefaultTimeout(); } + + Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, exitCode); + Assert.Contains(interactionService.DisplayedErrors, error => error.Contains("unexpected error", StringComparison.OrdinalIgnoreCase)); } [Fact] diff --git a/tests/Aspire.Cli.Tests/TestServices/SignalingFakeTimeProvider.cs b/tests/Aspire.Cli.Tests/TestServices/SignalingFakeTimeProvider.cs index 94f71a1fe84..29846ef14ac 100644 --- a/tests/Aspire.Cli.Tests/TestServices/SignalingFakeTimeProvider.cs +++ b/tests/Aspire.Cli.Tests/TestServices/SignalingFakeTimeProvider.cs @@ -8,12 +8,14 @@ namespace Aspire.Cli.Tests.TestServices; internal sealed class SignalingFakeTimeProvider(TimeSpan signaledDueTime) : FakeTimeProvider { public TaskCompletionSource TimerCreated { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public Action? TimerCreatedCallback { get; set; } public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) { var timer = base.CreateTimer(callback, state, dueTime, period); if (dueTime == signaledDueTime) { + TimerCreatedCallback?.Invoke(); TimerCreated.TrySetResult(); } From eb77abdeb35cb5a5c8fef293ef0faa8cc5aec55d Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Fri, 11 Sep 2026 08:58:41 -0700 Subject: [PATCH 7/7] Synchronize final AppHost cancellation cleanup ownership Fence cancellation forwarding before the final drain decision, preserve teardown failure cleanup, and upgrade earlier bounded waits for a late manager stop. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/RunCommand.cs | 98 +++++++++----- .../Commands/RunCommandTests.cs | 122 ++++++++++++++++++ .../CallbackConfigurationProvider.cs | 19 +++ 3 files changed, 209 insertions(+), 30 deletions(-) create mode 100644 tests/Aspire.Cli.Tests/TestServices/CallbackConfigurationProvider.cs diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 1568acf47c2..3dc6992dd2a 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -252,7 +252,32 @@ await extensionInteractionService.StartDebugSessionAsync( LauncherLivenessMonitor? launcherMonitor = null; Task? runTask = null; CancellationTokenSource? runCts = null; + CancellationTokenRegistration runCancellationRegistration = default; var cancellationRequested = false; + TimeSpan? completedCancellationCleanupTimeout = null; + + async Task DrainCancelledRunAsync() + { + if ((cancellationRequested || cancellationToken.IsCancellationRequested) && + runCts is not null && + runTask is { IsCompleted: false }) + { + // BaseCommand owns the manager's process-wide shutdown deadline. + // Direct callers with an unrelated token still need a local bound. + var cleanupTimeout = _cancellationManager.IsCancellationRequested + ? Timeout.InfiniteTimeSpan + : s_appHostStartupCancellationTimeout; + // Do not repeat a completed bounded wait, but allow a later manager stop + // to upgrade it to the manager-owned drain. + if (completedCancellationCleanupTimeout == cleanupTimeout) + { + return; + } + + await CancelAppHostRunAsync(runCts, runTask, cleanupTimeout, CancellationToken.None).ConfigureAwait(false); + completedCancellationCleanupTimeout = cleanupTimeout; + } + } try { @@ -350,7 +375,8 @@ await extensionInteractionService.StartDebugSessionAsync( // Start the project run as a pending task - we'll handle UX while it runs var startupTimeout = TimeSpan.FromSeconds(timeoutSeconds); - runCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + runCts = new CancellationTokenSource(); + runCancellationRegistration = cancellationToken.Register(runCts.Cancel); // When this is a detached child, watch the foreground launcher during startup. If the launcher // is killed before the app is ready, cancel the run so the AppHost tree is torn down instead of leaking. @@ -712,44 +738,56 @@ ex is ExtensionOperationCanceledException || } finally { - // Keep cancellation cleanup owned by the command even if a concurrent RPC failure - // or disconnect selected a different catch. Preserve that result while draining the run - // before disposing its token source, so child processes cannot retain the workspace. - if ((cancellationRequested || cancellationToken.IsCancellationRequested) && - runCts is not null && - runTask is { IsCompleted: false }) - { - // BaseCommand already races manager-owned cancellation against the process-wide - // shutdown deadline. Only direct callers with an unrelated token need a local bound. - var cleanupTimeout = _cancellationManager.IsCancellationRequested - ? Timeout.InfiniteTimeSpan - : s_appHostStartupCancellationTimeout; - await CancelAppHostRunAsync(runCts, runTask, cleanupTimeout, CancellationToken.None).ConfigureAwait(false); - } - - if (IsDetachedStartChild() && runTask is { IsCompleted: false } detachedAppHostRun) + try { - // If the runTask is still running here, that is an abnormal exit. - // Cancel the run and wait for the AppHost to teardown so we don't leak child processes. try { - runCts?.Cancel(); - // CancellationToken.None is deliberate: root token is already cancelled. - await detachedAppHostRun.WaitAsync(s_detachedAppHostTeardownTimeout, _timeProvider, CancellationToken.None).ConfigureAwait(false); + // Keep the existing cancellation-first ordering and timeout budgets. + // The fenced call below handles cancellation arriving during later teardown. + await DrainCancelledRunAsync().ConfigureAwait(false); + + if (IsDetachedStartChild() && runTask is { IsCompleted: false } detachedAppHostRun) + { + // If the runTask is still running here, that is an abnormal exit. + // Cancel the run and wait for teardown so we don't leak child processes. + try + { + runCts?.Cancel(); + // Root cancellation must not interrupt this bounded detached-child drain. + await detachedAppHostRun.WaitAsync(s_detachedAppHostTeardownTimeout, _timeProvider, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Detached child timed out or failed while awaiting AppHost teardown during early exit."); + } + } } - catch (Exception ex) + finally { - _logger.LogDebug(ex, "Detached child timed out or failed while awaiting AppHost teardown during early exit."); + if (launcherMonitor is not null) + { + await launcherMonitor.DisposeAsync().ConfigureAwait(false); + } } } - - if (launcherMonitor is not null) + finally { - await launcherMonitor.DisposeAsync().ConfigureAwait(false); + // Close cancellation forwarding before deciding whether a drain is needed. + // DisposeAsync waits for an in-flight callback; cancellation after this fence + // cannot reach runCts and start child cleanup after we have decided to skip it. + await runCancellationRegistration.DisposeAsync().ConfigureAwait(false); + try + { + // Preserve the selected error result, including failures in final teardown, + // while retaining ownership of any cancellation cleanup already requested. + await DrainCancelledRunAsync().ConfigureAwait(false); + } + finally + { + runCts?.Dispose(); + runActivity?.Dispose(); + } } - - runCts?.Dispose(); - runActivity?.Dispose(); } } diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index 082105557e9..22d4d013ebe 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -2084,6 +2084,128 @@ public async Task RunCommand_WhenStartupRpcFailsAfterUserCancellation_AwaitsRunC Assert.Contains(interactionService.DisplayedErrors, error => error.Contains("unexpected error", StringComparison.OrdinalIgnoreCase)); } + [Theory] + [InlineData(true, false, false)] + [InlineData(false, false, false)] + [InlineData(true, true, false)] + [InlineData(false, true, false)] + [InlineData(true, false, true)] + public async Task RunCommand_WhenCancelledDuringFinalTeardown_AwaitsRunCleanup(bool managerOwned, bool teardownFails, bool extensionCancelled) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + using var cts = new CancellationTokenSource(); + var timeProvider = new SignalingFakeTimeProvider(TimeSpan.FromSeconds(5)); + var runCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var configurationProvider = new CallbackConfigurationProvider(); + var rpcFailed = false; + var cancellationRequested = false; + var runToken = CancellationToken.None; + if (extensionCancelled) + { + // Expire the initial extension-operation cancellation wait synchronously. + // A later stopCli must upgrade that already-handled local wait to manager ownership. + timeProvider.TimerCreatedCallback = () => timeProvider.Advance(TimeSpan.FromSeconds(5)); + } + + var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(appHostFile.FullName, "", TestContext.Current.CancellationToken); + var projectLocator = new TestProjectLocator + { + UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) => + Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile])) + }; + var projectFactory = new TestAppHostProjectFactory + { + RunAsyncCallback = (context, token) => + { + runToken = token; + context.BuildCompletionSource!.SetResult(true); + context.BackchannelCompletionSource!.SetResult(new TestAppHostBackchannel + { + GetDashboardUrlsAsyncCallback = _ => + { + rpcFailed = true; + return Task.FromException(extensionCancelled + ? new ExtensionOperationCanceledException("Extension operation canceled.") + : new InvalidOperationException("Dashboard RPC failed.")); + } + }); + return runCompletion.Task; + } + }; + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.ProjectLocatorFactory = _ => projectLocator; + options.AppHostProjectFactory = _ => projectFactory; + options.TimeProvider = timeProvider; + }); + var originalConfiguration = Assert.IsAssignableFrom( + services.Single(service => service.ServiceType == typeof(IConfiguration)).ImplementationInstance); + services.AddSingleton(new ConfigurationBuilder() + .AddConfiguration(originalConfiguration) + .Add(configurationProvider) + .Build()); + + using var provider = services.BuildServiceProvider(); + var manager = provider.GetRequiredService(); + var rpcTarget = provider.GetRequiredService(); + configurationProvider.Reading = key => + { + if (!rpcFailed || cancellationRequested || key != KnownConfigNames.CliRunDetached) + { + return; + } + + // The final detached-child check used to run after the cancellation snapshot. + // Deliver cancellation at that exact boundary, without a scheduler-dependent delay. + cancellationRequested = true; + if (managerOwned) + { + Assert.True(rpcTarget.StopCliAsync().IsCompletedSuccessfully); + } + else + { + cts.Cancel(); + } + + if (teardownFails) + { + throw new InvalidOperationException("Final teardown failed."); + } + }; + + var command = provider.GetRequiredService(); + var pendingCommand = command.Parse($"run --apphost {appHostFile.FullName}") + .InvokeAsync(cancellationToken: managerOwned ? manager.Token : cts.Token); + int exitCode; + try + { + Assert.True(cancellationRequested); + Assert.True(runToken.IsCancellationRequested); + Assert.False(pendingCommand.IsCompleted, "Final teardown bypassed cancellation cleanup."); + + timeProvider.Advance(TimeSpan.FromSeconds(6)); + Assert.Equal(!managerOwned || extensionCancelled, timeProvider.TimerCreated.Task.IsCompleted); + if (managerOwned) + { + Assert.False(pendingCommand.IsCompleted, "Manager-owned cleanup used a local deadline."); + } + else + { + await pendingCommand.DefaultTimeout(); + Assert.False(runCompletion.Task.IsCompleted); + } + } + finally + { + runCompletion.TrySetResult(CliExitCodes.Cancelled); + exitCode = await pendingCommand.DefaultTimeout(); + } + + Assert.Equal(teardownFails ? CliExitCodes.InvalidCommand : + extensionCancelled ? CliExitCodes.Success : CliExitCodes.FailedToDotnetRunAppHost, exitCode); + } + [Fact] public async Task RunCommand_WhenAppHostExitsDuringStartup_DisplaysCapturedAppHostOutput() { diff --git a/tests/Aspire.Cli.Tests/TestServices/CallbackConfigurationProvider.cs b/tests/Aspire.Cli.Tests/TestServices/CallbackConfigurationProvider.cs new file mode 100644 index 00000000000..794de59e7de --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/CallbackConfigurationProvider.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Configuration; + +namespace Aspire.Cli.Tests.TestServices; + +internal sealed class CallbackConfigurationProvider : ConfigurationProvider, IConfigurationSource +{ + public Action? Reading { get; set; } + + public override bool TryGet(string key, out string? value) + { + Reading?.Invoke(key); + return base.TryGet(key, out value); + } + + public IConfigurationProvider Build(IConfigurationBuilder builder) => this; +}