diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs
index 977fc89e851..4dac66ee3fc 100644
--- a/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs
+++ b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs
@@ -238,6 +238,9 @@ static bool IsContinuableState(WaitBehavior waitBehavior, CustomResourceSnapshot
/// control this behavior use
/// or configure the default behavior with .
///
+ ///
+ /// This method can be used independently of or together with the method.
+ ///
///
public async Task WaitForResourceHealthyAsync(string resourceName, CancellationToken cancellationToken = default)
{
@@ -247,6 +250,92 @@ public async Task WaitForResourceHealthyAsync(string resourceName
cancellationToken).ConfigureAwait(false);
}
+ ///
+ /// Waits for a resource to be ready.
+ ///
+ /// The name of the resource.
+ /// The cancellation token.
+ /// A task.
+ ///
+ ///
+ /// This method returns a task that completes when all subscriptions to the ResourceReadyEvent
+ /// have completed (if any). If any throw an exception, this method will throw an exception.
+ /// If none are present this method will return immediately.
+ ///
+ ///
+ /// This method does not explicitly wait for the resource to be healthy and can be used
+ /// independently of or together with the method.
+ ///
+ ///
+ public async Task WaitForResourceReadyAsync(string resourceName, CancellationToken cancellationToken = default)
+ {
+ return await WaitForResourceReadyAsync(
+ resourceName,
+ DefaultWaitBehavior,
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Waits for a resource to be ready.
+ ///
+ /// The name of the resource.
+ /// The wait behavior.
+ /// The cancellation token.
+ /// A task.
+ ///
+ ///
+ /// This method returns a task that completes when all subscriptions to the ResourceReadyEvent
+ /// have completed (if any). If any throw an exception, this method will throw an exception.
+ /// If none are present this method will return immediately. The
+ /// controls how the wait operation behaves when the resource
+ /// enters an unavailable state such as .
+ ///
+ ///
+ /// When is specified the wait operation
+ /// will continue to wait until the resource's ResourceReadyEvent is present.
+ ///
+ ///
+ /// When is specified the wait operation
+ /// will throw a if the resource enters an
+ /// unavailable state.
+ ///
+ ///
+ /// This method does not explicitly wait for the resource to be healthy and can be used
+ /// independently of or together with the method.
+ ///
+ ///
+ public async Task WaitForResourceReadyAsync(string resourceName, WaitBehavior waitBehavior, CancellationToken cancellationToken = default)
+ {
+ _logger.LogDebug("Waiting for resource '{Name}' to be ready.", resourceName);
+ var resourceEvent = await WaitForResourceCoreAsync(resourceName, re => ShouldYield(waitBehavior, re.Snapshot), cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ if (resourceEvent.Snapshot.ResourceReadyEvent is null)
+ {
+ _logger.LogError("Stopped waiting for resource '{ResourceName}' to be ready because it failed to start.", resourceName);
+ throw new DistributedApplicationException($"Stopped waiting for resource '{resourceName}' to be ready because it failed to start.");
+ }
+
+ // Then await the EventTask to complete
+ await resourceEvent.Snapshot.ResourceReadyEvent.EventTask.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ _logger.LogDebug("Finished waiting for resource '{Name}' to be ready.", resourceName);
+
+ return resourceEvent;
+
+ // Determine if we should yield based on the wait behavior and the snapshot of the resource.
+ static bool ShouldYield(WaitBehavior waitBehavior, CustomResourceSnapshot snapshot) =>
+ waitBehavior switch
+ {
+ WaitBehavior.WaitOnResourceUnavailable => snapshot.ResourceReadyEvent is not null,
+ WaitBehavior.StopOnResourceUnavailable => snapshot.ResourceReadyEvent is not null ||
+ snapshot.State?.Text == KnownResourceStates.Finished ||
+ snapshot.State?.Text == KnownResourceStates.Exited ||
+ snapshot.State?.Text == KnownResourceStates.FailedToStart ||
+ snapshot.State?.Text == KnownResourceStates.RuntimeUnhealthy,
+ _ => throw new DistributedApplicationException($"Unexpected wait behavior: {waitBehavior}")
+ };
+ }
+
///
/// Waits for a resource to become healthy.
///
@@ -271,6 +360,9 @@ public async Task WaitForResourceHealthyAsync(string resourceName
/// will throw a if the resource enters an
/// unavailable state.
///
+ ///
+ /// This method can be used independently of or together with the method.
+ ///
///
public async Task WaitForResourceHealthyAsync(string resourceName, WaitBehavior waitBehavior, CancellationToken cancellationToken = default)
{
diff --git a/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs b/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs
index 7f4ba0774b9..aa1b7547237 100644
--- a/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs
+++ b/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs
@@ -2,14 +2,16 @@
// The .NET Foundation licenses this file to you under the MIT license.
using Aspire.Hosting.Tests.Utils;
+using Aspire.Hosting.Utils;
using Microsoft.AspNetCore.InternalTesting;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Testing;
namespace Aspire.Hosting.Tests;
-public class ResourceNotificationTests
+public class ResourceNotificationTests(ITestOutputHelper outputHelper)
{
[Fact]
public void InitialStateCanBeSpecified()
@@ -526,6 +528,284 @@ await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with
Assert.Equal("Starting", value.Snapshot.State?.Text);
}
+ [Fact]
+ public async Task WaitForResourceReadyAsync_ReturnsWhenResourceReadyEventIsPresent()
+ {
+ var resource = new CustomResource("myResource");
+ var notificationService = ResourceNotificationServiceTestHelpers.Create();
+
+ // Create a completed task for the ResourceReadyEvent
+ var completedTask = Task.CompletedTask;
+ var resourceReadyEvent = new EventSnapshot(completedTask);
+
+ var waitTask = notificationService.WaitForResourceReadyAsync("myResource");
+
+ // Publish an update with a ResourceReadyEvent
+ await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with
+ {
+ ResourceReadyEvent = resourceReadyEvent
+ }).DefaultTimeout();
+
+ var result = await waitTask.DefaultTimeout();
+
+ Assert.Equal(resource, result.Resource);
+ Assert.Equal("myResource", result.ResourceId);
+ Assert.NotNull(result.Snapshot.ResourceReadyEvent);
+ }
+
+ [Fact]
+ public async Task WaitForResourceReadyAsync_WaitsForEventTaskToComplete()
+ {
+ var resource = new CustomResource("myResource");
+ var notificationService = ResourceNotificationServiceTestHelpers.Create();
+
+ // Create a task completion source to control when the EventTask completes
+ var tcs = new TaskCompletionSource();
+ var resourceReadyEvent = new EventSnapshot(tcs.Task);
+
+ var waitTask = notificationService.WaitForResourceReadyAsync("myResource");
+
+ // Publish an update with a ResourceReadyEvent that hasn't completed yet
+ await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with
+ {
+ ResourceReadyEvent = resourceReadyEvent
+ }).DefaultTimeout();
+
+ // Wait should not complete yet since the EventTask hasn't completed
+ await Task.Delay(100);
+ Assert.False(waitTask.IsCompleted);
+
+ // Complete the EventTask
+ tcs.SetResult();
+
+ // Now the wait should complete
+ var result = await waitTask.DefaultTimeout();
+
+ Assert.Equal(resource, result.Resource);
+ Assert.Equal("myResource", result.ResourceId);
+ Assert.NotNull(result.Snapshot.ResourceReadyEvent);
+ }
+
+ [Fact]
+ public async Task WaitForResourceReadyAsync_ThrowsOperationCanceledExceptionWhenCanceled()
+ {
+ var notificationService = ResourceNotificationServiceTestHelpers.Create();
+
+ using var cts = new CancellationTokenSource();
+ var waitTask = notificationService.WaitForResourceReadyAsync("myResource", cts.Token);
+
+ cts.Cancel();
+
+ await Assert.ThrowsAsync(async () =>
+ {
+ await waitTask;
+ }).DefaultTimeout();
+ }
+
+ [Fact]
+ public async Task WaitForResourceReadyAsync_ThrowsOperationCanceledExceptionWhenEventTaskIsCanceled()
+ {
+ var resource = new CustomResource("myResource");
+ var notificationService = ResourceNotificationServiceTestHelpers.Create();
+
+ // Create a canceled task for the ResourceReadyEvent
+ using var eventCts = new CancellationTokenSource();
+ eventCts.Cancel();
+ var canceledTask = Task.FromCanceled(eventCts.Token);
+ var resourceReadyEvent = new EventSnapshot(canceledTask);
+
+ var waitTask = notificationService.WaitForResourceReadyAsync("myResource");
+
+ // Publish an update with a ResourceReadyEvent that has a canceled task
+ await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with
+ {
+ ResourceReadyEvent = resourceReadyEvent
+ }).DefaultTimeout();
+
+ await Assert.ThrowsAsync(async () =>
+ {
+ await waitTask;
+ }).DefaultTimeout();
+ }
+
+ [Fact]
+ public async Task WaitForResourceReadyAsync_ReturnsImmediatelyWhenResourceReadyEventAlreadyPresent()
+ {
+ var resource = new CustomResource("myResource");
+ var notificationService = ResourceNotificationServiceTestHelpers.Create();
+
+ // Create a completed task for the ResourceReadyEvent
+ var completedTask = Task.CompletedTask;
+ var resourceReadyEvent = new EventSnapshot(completedTask);
+
+ // Publish the ResourceReadyEvent first
+ await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with
+ {
+ ResourceReadyEvent = resourceReadyEvent
+ }).DefaultTimeout();
+
+ // Wait should complete immediately since the event is already present and completed
+ var waitTask = notificationService.WaitForResourceReadyAsync("myResource");
+ var result = await waitTask.DefaultTimeout();
+
+ Assert.True(waitTask.IsCompletedSuccessfully);
+ Assert.Equal(resource, result.Resource);
+ Assert.Equal("myResource", result.ResourceId);
+ Assert.NotNull(result.Snapshot.ResourceReadyEvent);
+ }
+
+ [Fact]
+ public async Task WaitForResourceReadyAsync_WithWaitBehaviorStopOnResourceUnavailable_ThrowsWhenResourceFailsToStart()
+ {
+ var resource = new CustomResource("myResource");
+ var notificationService = ResourceNotificationServiceTestHelpers.Create();
+
+ var waitTask = notificationService.WaitForResourceReadyAsync("myResource", WaitBehavior.StopOnResourceUnavailable);
+
+ // Publish an update indicating the resource failed to start
+ await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with
+ {
+ State = KnownResourceStates.FailedToStart
+ }).DefaultTimeout();
+
+ await Assert.ThrowsAsync(async () =>
+ {
+ await waitTask;
+ }).DefaultTimeout();
+ }
+
+ [Fact]
+ public async Task WaitForResourceReadyAsync_WithWaitBehaviorStopOnResourceUnavailable_ThrowsWhenResourceEntersRuntimeUnhealthy()
+ {
+ var resource = new CustomResource("myResource");
+ var notificationService = ResourceNotificationServiceTestHelpers.Create();
+
+ var waitTask = notificationService.WaitForResourceReadyAsync("myResource", WaitBehavior.StopOnResourceUnavailable);
+
+ // Publish an update indicating the resource is runtime unhealthy
+ await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with
+ {
+ State = KnownResourceStates.RuntimeUnhealthy
+ }).DefaultTimeout();
+
+ await Assert.ThrowsAsync(async () =>
+ {
+ await waitTask;
+ }).DefaultTimeout();
+ }
+
+ [Fact]
+ public async Task WaitForResourceReadyAsync_WithWaitBehaviorWaitOnResourceUnavailable_ContinuesWaitingWhenResourceFailsToStart()
+ {
+ var resource = new CustomResource("myResource");
+ var notificationService = ResourceNotificationServiceTestHelpers.Create();
+
+ var waitTask = notificationService.WaitForResourceReadyAsync("myResource", WaitBehavior.WaitOnResourceUnavailable);
+
+ // Publish an update indicating the resource failed to start
+ await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with
+ {
+ State = KnownResourceStates.FailedToStart
+ }).DefaultTimeout();
+
+ // Wait should not complete yet
+ await Task.Delay(100);
+ Assert.False(waitTask.IsCompleted);
+
+ // Create a completed task for the ResourceReadyEvent
+ var completedTask = Task.CompletedTask;
+ var resourceReadyEvent = new EventSnapshot(completedTask);
+
+ // Now publish a ResourceReadyEvent - this should complete the wait
+ await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with
+ {
+ ResourceReadyEvent = resourceReadyEvent
+ }).DefaultTimeout();
+
+ var result = await waitTask.DefaultTimeout();
+
+ Assert.Equal(resource, result.Resource);
+ Assert.Equal("myResource", result.ResourceId);
+ Assert.NotNull(result.Snapshot.ResourceReadyEvent);
+ }
+
+ [Fact]
+ public async Task WaitForResourceReadyAsync_WithWaitBehaviorStopOnResourceUnavailable_ReturnsWhenResourceReadyEventIsPresent()
+ {
+ var resource = new CustomResource("myResource");
+ var notificationService = ResourceNotificationServiceTestHelpers.Create();
+
+ // Create a completed task for the ResourceReadyEvent
+ var completedTask = Task.CompletedTask;
+ var resourceReadyEvent = new EventSnapshot(completedTask);
+
+ var waitTask = notificationService.WaitForResourceReadyAsync("myResource", WaitBehavior.StopOnResourceUnavailable);
+
+ // Publish an update with a ResourceReadyEvent
+ await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with
+ {
+ ResourceReadyEvent = resourceReadyEvent
+ }).DefaultTimeout();
+
+ var result = await waitTask.DefaultTimeout();
+
+ Assert.Equal(resource, result.Resource);
+ Assert.Equal("myResource", result.ResourceId);
+ Assert.NotNull(result.Snapshot.ResourceReadyEvent);
+ }
+
+ [Fact]
+ public async Task WaitForResourceReadyAsync_WithWaitBehaviorWaitOnResourceUnavailable_ReturnsWhenResourceReadyEventIsPresent()
+ {
+ var resource = new CustomResource("myResource");
+ var notificationService = ResourceNotificationServiceTestHelpers.Create();
+
+ // Create a completed task for the ResourceReadyEvent
+ var completedTask = Task.CompletedTask;
+ var resourceReadyEvent = new EventSnapshot(completedTask);
+
+ var waitTask = notificationService.WaitForResourceReadyAsync("myResource", WaitBehavior.WaitOnResourceUnavailable);
+
+ // Publish an update with a ResourceReadyEvent
+ await notificationService.PublishUpdateAsync(resource, snapshot => snapshot with
+ {
+ ResourceReadyEvent = resourceReadyEvent
+ }).DefaultTimeout();
+
+ var result = await waitTask.DefaultTimeout();
+
+ Assert.Equal(resource, result.Resource);
+ Assert.Equal("myResource", result.ResourceId);
+ Assert.NotNull(result.Snapshot.ResourceReadyEvent);
+ }
+
+ [Fact]
+ public async Task WaitForResourceReadyAsync_EndToEnd()
+ {
+ using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(outputHelper);
+
+ var ready = false;
+
+ var cache = builder.AddRedis("cache")
+ .OnResourceReady((red, e, ct) =>
+ {
+ ready = true;
+ return Task.CompletedTask;
+ });
+
+ var app = builder.Build();
+
+ await app.StartAsync();
+
+ var resourceNotification = app.Services.GetRequiredService();
+
+ // expected this to not complete until the OnResourceReady callback completes
+ await resourceNotification.WaitForResourceReadyAsync(cache.Resource.Name);
+ Assert.True(ready, "Resource should be ready after waiting.");
+
+ await app.StopAsync();
+ }
+
private sealed class CustomResource(string name) : Resource(name),
IResourceWithEnvironment,
IResourceWithConnectionString,