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() {