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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions TUnit.Aspire.Tests/WaitForHealthyReproductionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,78 @@ public async Task AspireFixture_AllHealthy_Succeeds_AfterFix(CancellationToken c
}
}

[Test]
public async Task ShouldWaitForResource_IncludesComputeResource()
{
var fixture = new InspectableFixture();
var regular = new FakeContainerResource("my-container");

await Assert.That(fixture.TestShouldWaitForResource(regular)).IsTrue();
}

/// <summary>
/// Regression test for https://github.com/thomhurst/TUnit/issues/5260 (Aspire 13.2.0+).
///
/// Aspire 13.2.0 introduced ProjectRebuilderResource: an IComputeResource that also implements
/// IResourceWithParent. Without the fix, ShouldWaitForResource returns true for it, causing
/// WaitForResourceHealthyAsync to hang (it never emits healthy/running state).
/// </summary>
[Test]
public async Task ShouldWaitForResource_ExcludesIResourceWithParent()
{
var fixture = new InspectableFixture();
var regular = new FakeContainerResource("my-container");
var rebuilder = new FakeRebuilderResource("my-container-rebuilder", regular);

await Assert.That(fixture.TestShouldWaitForResource(rebuilder)).IsFalse();
}

[Test]
public async Task ShouldWaitForResource_ExcludesNonComputeResource()
{
var fixture = new InspectableFixture();
var paramResource = new FakeNonComputeResource("my-param");

await Assert.That(fixture.TestShouldWaitForResource(paramResource)).IsFalse();
}

private sealed class HealthyFixture : AspireFixture<Projects.TUnit_Aspire_Tests_AppHost>
{
protected override TimeSpan ResourceTimeout => TimeSpan.FromSeconds(60);
}

/// <summary>
/// Exposes <see cref="AspireFixture{TAppHost}.ShouldWaitForResource"/> for unit testing.
/// </summary>
private sealed class InspectableFixture : AspireFixture<Projects.TUnit_Aspire_Tests_AppHost>
{
public bool TestShouldWaitForResource(IResource resource)
=> ShouldWaitForResource(resource);
}

/// <summary>A plain IComputeResource with no parent.</summary>
private sealed class FakeContainerResource(string name) : IComputeResource
{
public string Name => name;
public ResourceAnnotationCollection Annotations { get; } = new ResourceAnnotationCollection();
}

/// <summary>A non-compute resource (e.g. ParameterResource, ConnectionStringResource).</summary>
private sealed class FakeNonComputeResource(string name) : IResource
{
public string Name => name;
public ResourceAnnotationCollection Annotations { get; } = new ResourceAnnotationCollection();
}

/// <summary>
/// Simulates ProjectRebuilderResource from Aspire 13.2.0:
/// an IComputeResource that also implements IResourceWithParent.
/// </summary>
private sealed class FakeRebuilderResource(string name, IResource parent)
: IComputeResource, IResourceWithParent
{
public string Name => name;
public ResourceAnnotationCollection Annotations { get; } = new ResourceAnnotationCollection();
public IResource Parent => parent;
}
}
83 changes: 71 additions & 12 deletions TUnit.Aspire/AspireFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -391,17 +391,46 @@ private async Task WaitForResourcesWithFailFastAsync(
return;
}

// Runtime safety net: skip resources whose snapshot is marked IsHidden.
// This catches hidden internal resources (e.g. ProjectRebuilderResource registered with
// IsHidden = true) even if they slip past the model-level ShouldWaitForResource filter.
var visibleNames = new List<string>(resourceNames.Count);
List<string>? hiddenNames = null;

foreach (var name in resourceNames)
{
if (notificationService.TryGetCurrentState(name, out var ev) && ev.Snapshot.IsHidden)
{
hiddenNames ??= [];
hiddenNames.Add(name);
}
else
{
visibleNames.Add(name);
}
}

if (hiddenNames is { Count: > 0 })
{
LogProgress($"Skipping {hiddenNames.Count} hidden resource(s) at runtime: [{string.Join(", ", hiddenNames)}]");
}

if (visibleNames.Count == 0)
{
return;
}

// Track which resources have become ready (for timeout reporting)
var readyResources = new ConcurrentBag<string>();

// Linked CTS lets us cancel the failure watchers once all resources are ready
using var failureCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

var targetState = waitForHealthy ? "healthy" : "running";
LogProgress($"Waiting for {resourceNames.Count} resource(s) to become {targetState}: [{string.Join(", ", resourceNames)}]");
LogProgress($"Waiting for {visibleNames.Count} resource(s) to become {targetState}: [{string.Join(", ", visibleNames)}]");

// Success path: wait for all resources to reach the desired state
var readyTask = Task.WhenAll(resourceNames.Select(async name =>
var readyTask = Task.WhenAll(visibleNames.Select(async name =>
{
if (waitForHealthy)
{
Expand All @@ -413,11 +442,11 @@ private async Task WaitForResourcesWithFailFastAsync(
}

readyResources.Add(name);
LogProgress($" Resource '{name}' is {targetState} ({readyResources.Count}/{resourceNames.Count})");
LogProgress($" Resource '{name}' is {targetState} ({readyResources.Count}/{visibleNames.Count})");
}));

// Fail-fast path: complete as soon as ANY resource enters FailedToStart
var failureTasks = resourceNames.Select(async name =>
var failureTasks = visibleNames.Select(async name =>
{
await notificationService.WaitForResourceAsync(name, KnownResourceStates.FailedToStart, failureCts.Token);
return name;
Expand Down Expand Up @@ -452,7 +481,7 @@ private async Task WaitForResourcesWithFailFastAsync(
failureCts.Cancel();

var readySet = new HashSet<string>(readyResources);
var pending = resourceNames.Where(n => !readySet.Contains(n)).ToList();
var pending = visibleNames.Where(n => !readySet.Contains(n)).ToList();

var sb = new StringBuilder();
sb.Append("Resources not ready: [");
Expand Down Expand Up @@ -523,29 +552,59 @@ private async Task<string> CollectResourceLogsAsync(
return string.Join(Environment.NewLine, lines);
}

// Opt-in: only wait for IComputeResource (containers, projects, executables).
// Non-compute resources (parameters, connection strings) never report healthy and would hang.
/// <summary>
/// Returns whether <paramref name="resource"/> should be included in the wait list.
/// Override to add custom inclusion/exclusion logic.
/// </summary>
/// <remarks>
/// <para>
/// Default: waits for <see cref="IComputeResource"/> (containers, projects, executables) unless
/// the resource also implements <see cref="IResourceWithParent"/>. Child resources are assumed to
/// be internally managed by Aspire and should not be independently awaited.
/// </para>
/// <para>
/// For example, Aspire 13.2.0+ adds <c>ProjectRebuilderResource</c> (an <see cref="IComputeResource"/>
/// that implements <see cref="IResourceWithParent"/>) for hot-reload support. It never emits a
/// healthy/running state and would cause a guaranteed timeout if waited on.
/// </para>
/// <para>
/// Non-compute resources (parameters, connection strings) are always excluded because they never
/// report healthy and would hang indefinitely.
/// </para>
/// <example>
/// Override to exclude a slow resource from the wait list:
/// <code>
/// protected override bool ShouldWaitForResource(IResource resource)
/// => base.ShouldWaitForResource(resource) &amp;&amp; resource.Name != "slow-service";
/// </code>
/// </example>
/// </remarks>
/// <param name="resource">The resource to evaluate.</param>
/// <returns><see langword="true"/> if the resource should be waited for; otherwise <see langword="false"/>.</returns>
protected virtual bool ShouldWaitForResource(IResource resource)
=> resource is IComputeResource && resource is not IResourceWithParent;

private List<string> GetWaitableResourceNames(DistributedApplicationModel model)
{
var waitable = new List<string>();
List<string>? skipped = null;

foreach (var r in model.Resources)
{
if (r is not IComputeResource)
if (ShouldWaitForResource(r))
{
skipped ??= [];
skipped.Add(r.Name);
waitable.Add(r.Name);
}
else
{
waitable.Add(r.Name);
skipped ??= [];
skipped.Add(r.Name);
}
}

if (skipped is { Count: > 0 })
{
LogProgress($"Skipping {skipped.Count} non-compute resource(s): [{string.Join(", ", skipped)}]");
LogProgress($"Skipping {skipped.Count} non-waitable resource(s): [{string.Join(", ", skipped)}]");
}

return waitable;
Expand Down
Loading