From 944d0a01d87a6024a6e04ef68a6deaa6e2f17bb1 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 1 Apr 2026 09:33:38 +0000
Subject: [PATCH 1/6] Fix timeout for ProjectRebuilderResource in Aspire
13.2.0+ by filtering IResourceWithParent resources
Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/ee4f5b19-6216-4842-be89-1a193cb86be7
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
.../WaitForHealthyReproductionTests.cs | 55 +++++++++++++++++++
TUnit.Aspire/AspireFixture.cs | 9 ++-
2 files changed, 61 insertions(+), 3 deletions(-)
diff --git a/TUnit.Aspire.Tests/WaitForHealthyReproductionTests.cs b/TUnit.Aspire.Tests/WaitForHealthyReproductionTests.cs
index ca9837edcf..ee62328240 100644
--- a/TUnit.Aspire.Tests/WaitForHealthyReproductionTests.cs
+++ b/TUnit.Aspire.Tests/WaitForHealthyReproductionTests.cs
@@ -88,8 +88,63 @@ public async Task AspireFixture_AllHealthy_Succeeds_AfterFix(CancellationToken c
}
}
+ ///
+ /// Regression test for https://github.com/thomhurst/TUnit/issues/5260 (Aspire 13.2.0+).
+ /// Aspire 13.2.0 introduced ProjectRebuilderResource — an internal IComputeResource that
+ /// also implements IResourceWithParent and never reports as healthy. Without the fix,
+ /// GetWaitableResourceNames would include it and WaitForResourceHealthyAsync would time out.
+ ///
+ [Test]
+ public async Task GetWaitableResourceNames_ExcludesIResourceWithParent_Resources()
+ {
+ // Arrange: build a DistributedApplicationModel that contains
+ // - a regular IComputeResource (should be included in the waitable list)
+ // - a fake "rebuilder" resource implementing both IComputeResource and IResourceWithParent
+ // (should be excluded — simulates ProjectRebuilderResource added by Aspire 13.2.0)
+ var regularResource = new FakeContainerResource("my-container");
+ var rebuilderResource = new FakeRebuilderResource("my-container-rebuilder", regularResource);
+
+ var model = new DistributedApplicationModel([regularResource, rebuilderResource]);
+ var fixture = new InspectableFixture();
+
+ // Act
+ var waitableNames = fixture.GetWaitableNames(model);
+
+ // Assert: only the regular compute resource should be in the list
+ await Assert.That(waitableNames).Contains("my-container");
+ await Assert.That(waitableNames).DoesNotContain("my-container-rebuilder");
+ }
+
private sealed class HealthyFixture : AspireFixture
{
protected override TimeSpan ResourceTimeout => TimeSpan.FromSeconds(60);
}
+
+ ///
+ /// Exposes for unit testing.
+ ///
+ private sealed class InspectableFixture : AspireFixture
+ {
+ public List GetWaitableNames(DistributedApplicationModel model)
+ => GetWaitableResourceNames(model);
+ }
+
+ /// A plain IComputeResource with no parent.
+ private sealed class FakeContainerResource(string name) : IComputeResource
+ {
+ public string Name => name;
+ public ResourceAnnotationCollection Annotations { get; } = new ResourceAnnotationCollection();
+ }
+
+ ///
+ /// Simulates ProjectRebuilderResource from Aspire 13.2.0:
+ /// an IComputeResource that also implements IResourceWithParent.
+ ///
+ private sealed class FakeRebuilderResource(string name, IResource parent)
+ : IComputeResource, IResourceWithParent
+ {
+ public string Name => name;
+ public ResourceAnnotationCollection Annotations { get; } = new ResourceAnnotationCollection();
+ public IResource Parent => parent;
+ }
}
diff --git a/TUnit.Aspire/AspireFixture.cs b/TUnit.Aspire/AspireFixture.cs
index 990de9d449..d556666d02 100644
--- a/TUnit.Aspire/AspireFixture.cs
+++ b/TUnit.Aspire/AspireFixture.cs
@@ -525,14 +525,17 @@ private async Task CollectResourceLogsAsync(
// Opt-in: only wait for IComputeResource (containers, projects, executables).
// Non-compute resources (parameters, connection strings) never report healthy and would hang.
- private List GetWaitableResourceNames(DistributedApplicationModel model)
+ // Also skip resources that implement IResourceWithParent — these are internally-managed child
+ // resources such as ProjectRebuilderResource (introduced in Aspire 13.2.0) that are orchestrated
+ // by Aspire itself and are not user-visible resources that need to be awaited.
+ protected virtual List GetWaitableResourceNames(DistributedApplicationModel model)
{
var waitable = new List();
List? skipped = null;
foreach (var r in model.Resources)
{
- if (r is not IComputeResource)
+ if (r is not IComputeResource || r is IResourceWithParent)
{
skipped ??= [];
skipped.Add(r.Name);
@@ -545,7 +548,7 @@ private List GetWaitableResourceNames(DistributedApplicationModel model)
if (skipped is { Count: > 0 })
{
- LogProgress($"Skipping {skipped.Count} non-compute resource(s): [{string.Join(", ", skipped)}]");
+ LogProgress($"Skipping {skipped.Count} non-waitable resource(s) (non-compute or child resources): [{string.Join(", ", skipped)}]");
}
return waitable;
From 03ee5cd0f2fe747cbcbd475de1b110a5c3f5dc02 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 1 Apr 2026 10:55:29 +0000
Subject: [PATCH 2/6] Improve test to reproduce exact TimeoutException from the
bug
Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/b74cfd11-b4ba-4686-9a3d-40fb7428b591
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
.../WaitForHealthyReproductionTests.cs | 49 ++++++++++++++-----
1 file changed, 37 insertions(+), 12 deletions(-)
diff --git a/TUnit.Aspire.Tests/WaitForHealthyReproductionTests.cs b/TUnit.Aspire.Tests/WaitForHealthyReproductionTests.cs
index ee62328240..ab0c57f6cb 100644
--- a/TUnit.Aspire.Tests/WaitForHealthyReproductionTests.cs
+++ b/TUnit.Aspire.Tests/WaitForHealthyReproductionTests.cs
@@ -90,29 +90,54 @@ public async Task AspireFixture_AllHealthy_Succeeds_AfterFix(CancellationToken c
///
/// Regression test for https://github.com/thomhurst/TUnit/issues/5260 (Aspire 13.2.0+).
- /// Aspire 13.2.0 introduced ProjectRebuilderResource — an internal IComputeResource that
- /// also implements IResourceWithParent and never reports as healthy. Without the fix,
- /// GetWaitableResourceNames would include it and WaitForResourceHealthyAsync would time out.
+ ///
+ /// Aspire 13.2.0 introduced ProjectRebuilderResource: an IComputeResource that also implements
+ /// IResourceWithParent. Without the fix, GetWaitableResourceNames included it in the wait list,
+ /// so WaitForResourcesWithFailFastAsync called WaitForResourceHealthyAsync on it. That call never
+ /// completes (the rebuilder never emits a healthy state), producing:
+ /// TimeoutException: Resources not ready: ['my-container-rebuilder']
+ ///
+ /// This test exercises the actual wait loop using the real GetWaitableResourceNames output:
+ /// • WITHOUT the fix: the rebuilder is in the wait list → the wait times out →
+ /// TimeoutException: Resources not ready: ['my-container-rebuilder']
+ /// • WITH the fix: the rebuilder is excluded → the wait completes immediately → no exception
///
[Test]
- public async Task GetWaitableResourceNames_ExcludesIResourceWithParent_Resources()
+ public async Task GetWaitableResourceNames_ExcludesIResourceWithParent_PreventingTimeout()
{
- // Arrange: build a DistributedApplicationModel that contains
- // - a regular IComputeResource (should be included in the waitable list)
- // - a fake "rebuilder" resource implementing both IComputeResource and IResourceWithParent
- // (should be excluded — simulates ProjectRebuilderResource added by Aspire 13.2.0)
var regularResource = new FakeContainerResource("my-container");
var rebuilderResource = new FakeRebuilderResource("my-container-rebuilder", regularResource);
var model = new DistributedApplicationModel([regularResource, rebuilderResource]);
+ var resourceLookup = model.Resources.ToDictionary(r => r.Name);
var fixture = new InspectableFixture();
- // Act
var waitableNames = fixture.GetWaitableNames(model);
- // Assert: only the regular compute resource should be in the list
- await Assert.That(waitableNames).Contains("my-container");
- await Assert.That(waitableNames).DoesNotContain("my-container-rebuilder");
+ // Reproduce the inner wait loop from WaitForResourcesWithFailFastAsync:
+ // await notificationService.WaitForResourceHealthyAsync(name, cancellationToken);
+ // IResourceWithParent resources (like ProjectRebuilderResource) never signal healthy;
+ // regular IComputeResource resources (containers) signal healthy immediately here.
+ using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
+ try
+ {
+ await Task.WhenAll(waitableNames.Select(name =>
+ {
+ var resource = resourceLookup[name];
+ return resource is IResourceWithParent
+ ? Task.Delay(Timeout.Infinite, timeoutCts.Token) // rebuilder: never healthy
+ : Task.CompletedTask; // container: healthy immediately
+ }));
+ }
+ catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
+ {
+ // Mirror the TimeoutException thrown by WaitForResourcesWithFailFastAsync on timeout
+ var pending = waitableNames
+ .Where(n => resourceLookup[n] is IResourceWithParent)
+ .ToList();
+ throw new TimeoutException(
+ $"Resources not ready: [{string.Join(", ", pending.Select(n => $"'{n}'"))}]");
+ }
}
private sealed class HealthyFixture : AspireFixture
From cd9d5bbe2c655115c70b8361e1e75623bc2c3e44 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 1 Apr 2026 22:31:56 +0000
Subject: [PATCH 3/6] Add runtime IsHidden safety net and document Aspire API
research
Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/2d1165fa-18fc-40e5-8757-0fd9b8bb87e5
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
TUnit.Aspire/AspireFixture.cs | 42 +++++++++++++++++++++++++++++++++--
1 file changed, 40 insertions(+), 2 deletions(-)
diff --git a/TUnit.Aspire/AspireFixture.cs b/TUnit.Aspire/AspireFixture.cs
index d556666d02..086e66da9e 100644
--- a/TUnit.Aspire/AspireFixture.cs
+++ b/TUnit.Aspire/AspireFixture.cs
@@ -391,6 +391,30 @@ 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 IResourceWithParent filter.
+ List? hiddenNames = null;
+ for (var i = resourceNames.Count - 1; i >= 0; i--)
+ {
+ if (notificationService.TryGetCurrentState(resourceNames[i], out var ev) && ev.Snapshot.IsHidden)
+ {
+ hiddenNames ??= [];
+ hiddenNames.Add(resourceNames[i]);
+ resourceNames.RemoveAt(i);
+ }
+ }
+
+ if (hiddenNames is { Count: > 0 })
+ {
+ LogProgress($"Skipping {hiddenNames.Count} hidden resource(s) at runtime: [{string.Join(", ", hiddenNames)}]");
+ }
+
+ if (resourceNames.Count == 0)
+ {
+ return;
+ }
+
// Track which resources have become ready (for timeout reporting)
var readyResources = new ConcurrentBag();
@@ -525,9 +549,23 @@ private async Task CollectResourceLogsAsync(
// Opt-in: only wait for IComputeResource (containers, projects, executables).
// Non-compute resources (parameters, connection strings) never report healthy and would hang.
+ //
// Also skip resources that implement IResourceWithParent — these are internally-managed child
- // resources such as ProjectRebuilderResource (introduced in Aspire 13.2.0) that are orchestrated
- // by Aspire itself and are not user-visible resources that need to be awaited.
+ // resources orchestrated by Aspire itself (e.g. ProjectRebuilderResource, introduced in Aspire
+ // 13.2.0 for hot-reload). ProjectRebuilderResource is the only IComputeResource that also
+ // implements IResourceWithParent; it is internal, registered with IsHidden = true and
+ // ExplicitStartupAnnotation (meaning it never auto-starts or emits healthy/running state).
+ //
+ // Alternatives considered (see https://github.com/thomhurst/TUnit/pull/5335):
+ // • IResourceWithoutLifetime — no types implement it in Aspire 13.2.x, so it's unusable.
+ // • ExplicitStartupAnnotation — public, but could over-exclude user resources that are
+ // manually started then expected to become healthy.
+ // • WaitBehavior.StopOnResourceUnavailable — doesn't help; the rebuilder stays in
+ // NotStarted (not a terminal state), so WaitForResourceHealthyAsync still hangs.
+ // • ResourceNotificationService.WaitForDependenciesAsync — different use case (waits for
+ // annotated deps of a specific resource, not "all resources").
+ // • TryGetCurrentState + IsHidden — used as a runtime safety net in
+ // WaitForResourcesWithFailFastAsync (see below).
protected virtual List GetWaitableResourceNames(DistributedApplicationModel model)
{
var waitable = new List();
From 61e9758856bb3b7e01fa195efb00f305e29f69b0 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 1 Apr 2026 22:34:20 +0000
Subject: [PATCH 4/6] Use RemoveAll instead of reverse iteration for O(n)
hidden resource filtering
Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/2d1165fa-18fc-40e5-8757-0fd9b8bb87e5
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
TUnit.Aspire/AspireFixture.cs | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/TUnit.Aspire/AspireFixture.cs b/TUnit.Aspire/AspireFixture.cs
index 086e66da9e..dc84bab480 100644
--- a/TUnit.Aspire/AspireFixture.cs
+++ b/TUnit.Aspire/AspireFixture.cs
@@ -395,15 +395,17 @@ private async Task WaitForResourcesWithFailFastAsync(
// This catches hidden internal resources (e.g. ProjectRebuilderResource registered with
// IsHidden = true) even if they slip past the model-level IResourceWithParent filter.
List? hiddenNames = null;
- for (var i = resourceNames.Count - 1; i >= 0; i--)
+ resourceNames.RemoveAll(name =>
{
- if (notificationService.TryGetCurrentState(resourceNames[i], out var ev) && ev.Snapshot.IsHidden)
+ if (notificationService.TryGetCurrentState(name, out var ev) && ev.Snapshot.IsHidden)
{
hiddenNames ??= [];
- hiddenNames.Add(resourceNames[i]);
- resourceNames.RemoveAt(i);
+ hiddenNames.Add(name);
+ return true;
}
- }
+
+ return false;
+ });
if (hiddenNames is { Count: > 0 })
{
From 4279e5ff8791f0bad18cc5589a00f6f9f21b3f55 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 1 Apr 2026 23:08:40 +0000
Subject: [PATCH 5/6] Address PR review: replace GetWaitableResourceNames with
ShouldWaitForResource predicate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Replace `protected virtual GetWaitableResourceNames` (leaky abstraction for test
access) with `protected virtual bool ShouldWaitForResource(IResource)` — a
genuinely useful, composable extension point (review Option B).
- Make `GetWaitableResourceNames` private again (internal implementation detail).
- Stop mutating input list in `WaitForResourcesWithFailFastAsync` — create a
filtered `visibleNames` copy instead of `RemoveAll` on the parameter.
- Simplify tests: replace hand-rolled wait loop simulation with clean predicate
assertions. Three focused tests (includes compute, excludes IResourceWithParent,
excludes non-compute).
- Add XML doc example showing composability: `base.ShouldWaitForResource(r) && ...`
- Rename FakeParameterResource → FakeNonComputeResource for clarity.
Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/03f5b467-1129-4308-b804-8ea435202e0c
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
.../WaitForHealthyReproductionTests.cs | 74 +++++++--------
TUnit.Aspire/AspireFixture.cs | 90 +++++++++++--------
2 files changed, 85 insertions(+), 79 deletions(-)
diff --git a/TUnit.Aspire.Tests/WaitForHealthyReproductionTests.cs b/TUnit.Aspire.Tests/WaitForHealthyReproductionTests.cs
index ab0c57f6cb..28d8ac5f44 100644
--- a/TUnit.Aspire.Tests/WaitForHealthyReproductionTests.cs
+++ b/TUnit.Aspire.Tests/WaitForHealthyReproductionTests.cs
@@ -92,52 +92,35 @@ public async Task AspireFixture_AllHealthy_Succeeds_AfterFix(CancellationToken c
/// 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, GetWaitableResourceNames included it in the wait list,
- /// so WaitForResourcesWithFailFastAsync called WaitForResourceHealthyAsync on it. That call never
- /// completes (the rebuilder never emits a healthy state), producing:
- /// TimeoutException: Resources not ready: ['my-container-rebuilder']
- ///
- /// This test exercises the actual wait loop using the real GetWaitableResourceNames output:
- /// • WITHOUT the fix: the rebuilder is in the wait list → the wait times out →
- /// TimeoutException: Resources not ready: ['my-container-rebuilder']
- /// • WITH the fix: the rebuilder is excluded → the wait completes immediately → no exception
+ /// IResourceWithParent. Without the fix, ShouldWaitForResource returns true for it, causing
+ /// WaitForResourceHealthyAsync to hang (it never emits healthy/running state).
///
[Test]
- public async Task GetWaitableResourceNames_ExcludesIResourceWithParent_PreventingTimeout()
+ public async Task ShouldWaitForResource_IncludesComputeResource()
{
- var regularResource = new FakeContainerResource("my-container");
- var rebuilderResource = new FakeRebuilderResource("my-container-rebuilder", regularResource);
+ var fixture = new InspectableFixture();
+ var regular = new FakeContainerResource("my-container");
+
+ await Assert.That(fixture.TestShouldWaitForResource(regular)).IsTrue();
+ }
- var model = new DistributedApplicationModel([regularResource, rebuilderResource]);
- var resourceLookup = model.Resources.ToDictionary(r => r.Name);
+ [Test]
+ public async Task ShouldWaitForResource_ExcludesIResourceWithParent()
+ {
var fixture = new InspectableFixture();
+ var regular = new FakeContainerResource("my-container");
+ var rebuilder = new FakeRebuilderResource("my-container-rebuilder", regular);
- var waitableNames = fixture.GetWaitableNames(model);
+ await Assert.That(fixture.TestShouldWaitForResource(rebuilder)).IsFalse();
+ }
- // Reproduce the inner wait loop from WaitForResourcesWithFailFastAsync:
- // await notificationService.WaitForResourceHealthyAsync(name, cancellationToken);
- // IResourceWithParent resources (like ProjectRebuilderResource) never signal healthy;
- // regular IComputeResource resources (containers) signal healthy immediately here.
- using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
- try
- {
- await Task.WhenAll(waitableNames.Select(name =>
- {
- var resource = resourceLookup[name];
- return resource is IResourceWithParent
- ? Task.Delay(Timeout.Infinite, timeoutCts.Token) // rebuilder: never healthy
- : Task.CompletedTask; // container: healthy immediately
- }));
- }
- catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
- {
- // Mirror the TimeoutException thrown by WaitForResourcesWithFailFastAsync on timeout
- var pending = waitableNames
- .Where(n => resourceLookup[n] is IResourceWithParent)
- .ToList();
- throw new TimeoutException(
- $"Resources not ready: [{string.Join(", ", pending.Select(n => $"'{n}'"))}]");
- }
+ [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
@@ -146,12 +129,12 @@ private sealed class HealthyFixture : AspireFixture
- /// Exposes for unit testing.
+ /// Exposes for unit testing.
///
private sealed class InspectableFixture : AspireFixture
{
- public List GetWaitableNames(DistributedApplicationModel model)
- => GetWaitableResourceNames(model);
+ public bool TestShouldWaitForResource(IResource resource)
+ => ShouldWaitForResource(resource);
}
/// A plain IComputeResource with no parent.
@@ -161,6 +144,13 @@ private sealed class FakeContainerResource(string name) : IComputeResource
public ResourceAnnotationCollection Annotations { get; } = new ResourceAnnotationCollection();
}
+ /// A non-compute resource (e.g. ParameterResource, ConnectionStringResource).
+ private sealed class FakeNonComputeResource(string name) : IResource
+ {
+ public string Name => name;
+ public ResourceAnnotationCollection Annotations { get; } = new ResourceAnnotationCollection();
+ }
+
///
/// Simulates ProjectRebuilderResource from Aspire 13.2.0:
/// an IComputeResource that also implements IResourceWithParent.
diff --git a/TUnit.Aspire/AspireFixture.cs b/TUnit.Aspire/AspireFixture.cs
index dc84bab480..db9f8917ce 100644
--- a/TUnit.Aspire/AspireFixture.cs
+++ b/TUnit.Aspire/AspireFixture.cs
@@ -393,26 +393,29 @@ private async Task WaitForResourcesWithFailFastAsync(
// 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 IResourceWithParent filter.
+ // IsHidden = true) even if they slip past the model-level ShouldWaitForResource filter.
+ var visibleNames = new List(resourceNames.Count);
List? hiddenNames = null;
- resourceNames.RemoveAll(name =>
+
+ foreach (var name in resourceNames)
{
if (notificationService.TryGetCurrentState(name, out var ev) && ev.Snapshot.IsHidden)
{
hiddenNames ??= [];
hiddenNames.Add(name);
- return true;
}
-
- return false;
- });
+ else
+ {
+ visibleNames.Add(name);
+ }
+ }
if (hiddenNames is { Count: > 0 })
{
LogProgress($"Skipping {hiddenNames.Count} hidden resource(s) at runtime: [{string.Join(", ", hiddenNames)}]");
}
- if (resourceNames.Count == 0)
+ if (visibleNames.Count == 0)
{
return;
}
@@ -424,10 +427,10 @@ private async Task WaitForResourcesWithFailFastAsync(
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)
{
@@ -439,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;
@@ -478,7 +481,7 @@ private async Task WaitForResourcesWithFailFastAsync(
failureCts.Cancel();
var readySet = new HashSet(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: [");
@@ -549,46 +552,59 @@ private async Task 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.
- //
- // Also skip resources that implement IResourceWithParent — these are internally-managed child
- // resources orchestrated by Aspire itself (e.g. ProjectRebuilderResource, introduced in Aspire
- // 13.2.0 for hot-reload). ProjectRebuilderResource is the only IComputeResource that also
- // implements IResourceWithParent; it is internal, registered with IsHidden = true and
- // ExplicitStartupAnnotation (meaning it never auto-starts or emits healthy/running state).
- //
- // Alternatives considered (see https://github.com/thomhurst/TUnit/pull/5335):
- // • IResourceWithoutLifetime — no types implement it in Aspire 13.2.x, so it's unusable.
- // • ExplicitStartupAnnotation — public, but could over-exclude user resources that are
- // manually started then expected to become healthy.
- // • WaitBehavior.StopOnResourceUnavailable — doesn't help; the rebuilder stays in
- // NotStarted (not a terminal state), so WaitForResourceHealthyAsync still hangs.
- // • ResourceNotificationService.WaitForDependenciesAsync — different use case (waits for
- // annotated deps of a specific resource, not "all resources").
- // • TryGetCurrentState + IsHidden — used as a runtime safety net in
- // WaitForResourcesWithFailFastAsync (see below).
- protected virtual List GetWaitableResourceNames(DistributedApplicationModel model)
+ ///
+ /// Returns whether should be included in the wait list.
+ /// Override to add custom inclusion/exclusion logic.
+ ///
+ ///
+ ///
+ /// Default: waits for (containers, projects, executables) unless
+ /// the resource also implements . Child resources are assumed to
+ /// be internally managed by Aspire and should not be independently awaited.
+ ///
+ ///
+ /// For example, Aspire 13.2.0+ adds ProjectRebuilderResource (an
+ /// that implements ) for hot-reload support. It never emits a
+ /// healthy/running state and would cause a guaranteed timeout if waited on.
+ ///
+ ///
+ /// Non-compute resources (parameters, connection strings) are always excluded because they never
+ /// report healthy and would hang indefinitely.
+ ///
+ ///
+ /// Override to exclude a slow resource from the wait list:
+ ///
+ /// protected override bool ShouldWaitForResource(IResource resource)
+ /// => base.ShouldWaitForResource(resource) && resource.Name != "slow-service";
+ ///
+ ///
+ ///
+ /// The resource to evaluate.
+ /// if the resource should be waited for; otherwise .
+ protected virtual bool ShouldWaitForResource(IResource resource)
+ => resource is IComputeResource && resource is not IResourceWithParent;
+
+ private List GetWaitableResourceNames(DistributedApplicationModel model)
{
var waitable = new List();
List? skipped = null;
foreach (var r in model.Resources)
{
- if (r is not IComputeResource || r is IResourceWithParent)
+ 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-waitable resource(s) (non-compute or child resources): [{string.Join(", ", skipped)}]");
+ LogProgress($"Skipping {skipped.Count} non-waitable resource(s): [{string.Join(", ", skipped)}]");
}
return waitable;
From fd3ef34e2661d6dac471e2668d7b905e07ebaea1 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 2 Apr 2026 06:50:29 +0000
Subject: [PATCH 6/6] Move regression doc to correct test
(ShouldWaitForResource_ExcludesIResourceWithParent)
The XML doc comment attributing the regression test for issue #5260 was
on ShouldWaitForResource_IncludesComputeResource (the happy-path check)
instead of ShouldWaitForResource_ExcludesIResourceWithParent (the actual
regression scenario). Moved it to the correct test.
Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/56738805-bc03-41bf-8a58-7d028c186c34
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
.../WaitForHealthyReproductionTests.cs | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/TUnit.Aspire.Tests/WaitForHealthyReproductionTests.cs b/TUnit.Aspire.Tests/WaitForHealthyReproductionTests.cs
index 28d8ac5f44..4e5aae0e23 100644
--- a/TUnit.Aspire.Tests/WaitForHealthyReproductionTests.cs
+++ b/TUnit.Aspire.Tests/WaitForHealthyReproductionTests.cs
@@ -88,13 +88,6 @@ public async Task AspireFixture_AllHealthy_Succeeds_AfterFix(CancellationToken c
}
}
- ///
- /// 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).
- ///
[Test]
public async Task ShouldWaitForResource_IncludesComputeResource()
{
@@ -104,6 +97,13 @@ public async Task ShouldWaitForResource_IncludesComputeResource()
await Assert.That(fixture.TestShouldWaitForResource(regular)).IsTrue();
}
+ ///
+ /// 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).
+ ///
[Test]
public async Task ShouldWaitForResource_ExcludesIResourceWithParent()
{