diff --git a/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs b/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs
index 5043782c6a0..5e220a441ea 100644
--- a/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs
+++ b/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs
@@ -151,6 +151,15 @@ public async Task ExecuteAsyncCore(ResourceViewModel resource, CommandViewModel
toastParameters.Intent = ToastIntent.Success;
toastParameters.Icon = GetIntentIcon(ToastIntent.Success);
}
+ else if (response.Kind == ResourceCommandResponseKind.Cancelled)
+ {
+ // For cancelled commands, just close the existing toast and don't show any success or error message
+ if (!toastClosed)
+ {
+ toastService.CloseToast(toastParameters.Id);
+ }
+ return;
+ }
else
{
toastParameters.Title = string.Format(CultureInfo.InvariantCulture, loc[nameof(Dashboard.Resources.Resources.ResourceCommandFailed)], messageResourceName, command.GetDisplayName(commandsLoc));
diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs
index fae81cce73a..5113048517f 100644
--- a/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs
+++ b/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs
@@ -130,6 +130,11 @@ public static class CommandResults
/// An optional error message.
public static ExecuteCommandResult Failure(string? errorMessage = null) => new() { Success = false, ErrorMessage = errorMessage };
+ ///
+ /// Produces a canceled result.
+ ///
+ public static ExecuteCommandResult Canceled() => new() { Success = false, Canceled = true };
+
///
/// Produces an unsuccessful result from an . is used as the error message.
///
@@ -147,6 +152,11 @@ public sealed class ExecuteCommandResult
///
public required bool Success { get; init; }
+ ///
+ /// A flag that indicates whether the command was canceled by the user.
+ ///
+ public bool Canceled { get; init; }
+
///
/// An optional error message that can be set when the command is unsuccessful.
///
diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs
index 0e21dacbf8d..416a878e18d 100644
--- a/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs
+++ b/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs
@@ -75,24 +75,37 @@ public async Task ExecuteCommandAsync(IResource resource,
tasks.Add(ExecuteCommandCoreAsync(name, resource, commandName, cancellationToken));
}
- // Check for failures.
+ // Check for failures and cancellations.
var results = await Task.WhenAll(tasks).ConfigureAwait(false);
var failures = new List<(string resourceId, ExecuteCommandResult result)>();
+ var cancellations = new List<(string resourceId, ExecuteCommandResult result)>();
for (var i = 0; i < results.Length; i++)
{
if (!results[i].Success)
{
- failures.Add((names[i], results[i]));
+ if (results[i].Canceled)
+ {
+ cancellations.Add((names[i], results[i]));
+ }
+ else
+ {
+ failures.Add((names[i], results[i]));
+ }
}
}
- if (failures.Count == 0)
+ if (failures.Count == 0 && cancellations.Count == 0)
{
return new ExecuteCommandResult { Success = true };
}
+ else if (failures.Count == 0 && cancellations.Count > 0)
+ {
+ // All non-successful commands were cancelled
+ return new ExecuteCommandResult { Success = false, Canceled = true };
+ }
else
{
- // Aggregate error results together.
+ // There were actual failures (possibly with some cancellations)
var errorMessage = $"{failures.Count} command executions failed.";
errorMessage += Environment.NewLine + string.Join(Environment.NewLine, failures.Select(f => $"Resource '{f.resourceId}' failed with error message: {f.result.ErrorMessage}"));
@@ -128,12 +141,22 @@ internal async Task ExecuteCommandCoreAsync(string resourc
logger.LogInformation("Successfully executed command '{CommandName}'.", commandName);
return result;
}
+ else if (result.Canceled)
+ {
+ logger.LogDebug("Command '{CommandName}' was canceled.", commandName);
+ return result;
+ }
else
{
logger.LogInformation("Failure executing command '{CommandName}'. Error message: {ErrorMessage}", commandName, result.ErrorMessage);
return result;
}
}
+ catch (OperationCanceledException)
+ {
+ logger.LogDebug("Command '{CommandName}' was canceled.", commandName);
+ return CommandResults.Canceled();
+ }
catch (Exception ex)
{
logger.LogError(ex, "Error executing command '{CommandName}'.", commandName);
diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs
index 10cd9da9067..82721e80162 100644
--- a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs
+++ b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs
@@ -99,6 +99,10 @@ public void Dispose()
try
{
var result = await _resourceCommandService.ExecuteCommandAsync(resourceId, type, cancellationToken).ConfigureAwait(false);
+ if (result.Canceled)
+ {
+ return (ExecuteCommandResultType.Canceled, result.ErrorMessage);
+ }
return (result.Success ? ExecuteCommandResultType.Success : ExecuteCommandResultType.Failure, result.ErrorMessage);
}
catch
diff --git a/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs b/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs
index c97e0faae70..32fd1753e7f 100644
--- a/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs
+++ b/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs
@@ -188,6 +188,138 @@ 2 command executions failed.
""", result.ErrorMessage);
}
+ [Fact]
+ public async Task ExecuteCommandAsync_Canceled_Success()
+ {
+ // Arrange
+ using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
+
+ var custom = builder.AddResource(new CustomResource("myResource"));
+ custom.WithCommand(name: "mycommand",
+ displayName: "My command",
+ executeCommand: e =>
+ {
+ return Task.FromResult(CommandResults.Canceled());
+ });
+
+ var app = builder.Build();
+ await app.StartAsync();
+
+ // Act
+ var result = await app.ResourceCommands.ExecuteCommandAsync(custom.Resource, "mycommand");
+
+ // Assert
+ Assert.False(result.Success);
+ Assert.True(result.Canceled);
+ Assert.Null(result.ErrorMessage);
+ }
+
+ [Fact]
+ public async Task ExecuteCommandAsync_HasReplicas_Canceled_CalledPerReplica()
+ {
+ // Arrange
+ using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
+
+ var resourceBuilder = builder.AddProject("servicea")
+ .WithReplicas(2)
+ .WithCommand(name: "mycommand",
+ displayName: "My command",
+ executeCommand: e =>
+ {
+ return Task.FromResult(CommandResults.Canceled());
+ });
+
+ // Act
+ var app = builder.Build();
+ await app.StartAsync();
+ await app.ResourceNotifications.WaitForResourceHealthyAsync("servicea").DefaultTimeout(TestConstants.LongTimeoutTimeSpan);
+
+ var result = await app.ResourceCommands.ExecuteCommandAsync(resourceBuilder.Resource, "mycommand");
+
+ // Assert
+ Assert.False(result.Success);
+ Assert.True(result.Canceled);
+ Assert.Null(result.ErrorMessage);
+ }
+
+ [Fact]
+ public async Task ExecuteCommandAsync_HasReplicas_MixedFailureAndCanceled_OnlyFailuresInErrorMessage()
+ {
+ // Arrange
+ using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
+
+ var callCount = 0;
+ var resourceBuilder = builder.AddProject("servicea")
+ .WithReplicas(3)
+ .WithCommand(name: "mycommand",
+ displayName: "My command",
+ executeCommand: e =>
+ {
+ var count = Interlocked.Increment(ref callCount);
+ return Task.FromResult(count switch
+ {
+ 1 => CommandResults.Failure("Failure!"),
+ 2 => CommandResults.Canceled(),
+ _ => CommandResults.Success()
+ });
+ });
+
+ // Act
+ var app = builder.Build();
+ await app.StartAsync();
+ await app.ResourceNotifications.WaitForResourceHealthyAsync("servicea").DefaultTimeout(TestConstants.LongTimeoutTimeSpan);
+
+ var result = await app.ResourceCommands.ExecuteCommandAsync(resourceBuilder.Resource, "mycommand");
+
+ // Assert
+ Assert.False(result.Success);
+ Assert.False(result.Canceled); // Should not be canceled since there was at least one failure
+
+ var resourceNames = resourceBuilder.Resource.GetResolvedResourceNames();
+ Assert.Equal($"""
+ 1 command executions failed.
+ Resource '{resourceNames[0]}' failed with error message: Failure!
+ """, result.ErrorMessage);
+ }
+
+ [Fact]
+ public void CommandResults_Canceled_ProducesCorrectResult()
+ {
+ // Act
+ var result = CommandResults.Canceled();
+
+ // Assert
+ Assert.False(result.Success);
+ Assert.True(result.Canceled);
+ Assert.Null(result.ErrorMessage);
+ }
+
+ [Fact]
+ public async Task ExecuteCommandAsync_OperationCanceledException_Canceled()
+ {
+ // Arrange
+ using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
+
+ var custom = builder.AddResource(new CustomResource("myResource"));
+ custom.WithCommand(name: "mycommand",
+ displayName: "My command",
+ executeCommand: e =>
+ {
+ throw new OperationCanceledException("Command was canceled");
+ });
+
+ var app = builder.Build();
+ await app.StartAsync();
+
+ // Act
+ var result = await app.ResourceCommands.ExecuteCommandAsync(custom.Resource, "mycommand");
+
+ // Assert
+ Assert.False(result.Success);
+ Assert.True(result.Canceled);
+ Assert.Null(result.ErrorMessage);
+ }
+
private sealed class CustomResource(string name) : Resource(name), IResourceWithEndpoints, IResourceWithWaitSupport
{