From ee9634a88a0e472d2763cf168b750b434fe7e586 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 28 Sep 2026 17:31:11 -0700 Subject: [PATCH 1/3] Prevent recursive MTP test entry point invocation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...DistributedApplicationEntryPointInvoker.cs | 25 ++++++++++++-- ...ibutedApplicationEntryPointInvokerTests.cs | 34 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 tests/Aspire.Hosting.Testing.Tests/DistributedApplicationEntryPointInvokerTests.cs diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationEntryPointInvoker.cs b/src/Aspire.Hosting.Testing/DistributedApplicationEntryPointInvoker.cs index d195a36a3ae..a470d30ac51 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationEntryPointInvoker.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationEntryPointInvoker.cs @@ -9,6 +9,8 @@ namespace Aspire.Hosting.Testing; internal static class DistributedApplicationEntryPointInvoker { + private const string MicrosoftTestingPlatformApplicationMetadataKey = "Microsoft.Testing.Platform.Application"; + // This helpers encapsulates all of the complex logic required to: // 1. Execute the entry point of the specified assembly in a different thread. // 2. Wait for the diagnostic source events to fire @@ -22,16 +24,26 @@ internal static class DistributedApplicationEntryPointInvoker Action? onBuilding = null, Action? entryPointCompleted = null) { - if (assembly.EntryPoint is null) + var entryPoint = assembly.EntryPoint; + if (entryPoint is null) { return null; } + if (IsMicrosoftTestingPlatformApplication(assembly)) + { + throw new InvalidOperationException( + $"The assembly '{assembly.GetName().Name}' is a Microsoft.Testing.Platform test application. " + + $"Invoking its entry point from {nameof(DistributedApplicationFactory)} would recursively run the test application. " + + "Ensure the entry point type belongs to the AppHost executable assembly, or use " + + $"{nameof(DistributedApplicationTestingBuilder)}.{nameof(DistributedApplicationTestingBuilder.Create)} to construct the application without invoking an entry point."); + } + return async (args, ct) => { var invoker = new EntryPointInvoker( args, - assembly.EntryPoint, + entryPoint, onConstructing, onConstructed, onBuilding, @@ -40,6 +52,15 @@ internal static class DistributedApplicationEntryPointInvoker }; } + private static bool IsMicrosoftTestingPlatformApplication(Assembly assembly) + { + // MTP identifies test application assemblies with: + // [assembly: AssemblyMetadata("Microsoft.Testing.Platform.Application", "true")] + return assembly.GetCustomAttributes().Any(static metadata => + string.Equals(metadata.Key, MicrosoftTestingPlatformApplicationMetadataKey, StringComparison.Ordinal) && + string.Equals(metadata.Value, bool.TrueString, StringComparison.OrdinalIgnoreCase)); + } + private sealed class EntryPointInvoker : IObserver { private static readonly AsyncLocal s_currentListener = new(); diff --git a/tests/Aspire.Hosting.Testing.Tests/DistributedApplicationEntryPointInvokerTests.cs b/tests/Aspire.Hosting.Testing.Tests/DistributedApplicationEntryPointInvokerTests.cs new file mode 100644 index 00000000000..ce0f2a62d23 --- /dev/null +++ b/tests/Aspire.Hosting.Testing.Tests/DistributedApplicationEntryPointInvokerTests.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Aspire.Hosting.Testing.Tests; + +public class DistributedApplicationEntryPointInvokerTests +{ + [Fact] + public void ResolveEntryPointThrowsForMicrosoftTestingPlatformApplication() + { + var assembly = typeof(DistributedApplicationEntryPointInvokerTests).Assembly; + + var exception = Assert.Throws( + () => DistributedApplicationEntryPointInvoker.ResolveEntryPoint(assembly)); + + Assert.Equal( + $"The assembly '{assembly.GetName().Name}' is a Microsoft.Testing.Platform test application. " + + $"Invoking its entry point from {nameof(DistributedApplicationFactory)} would recursively run the test application. " + + "Ensure the entry point type belongs to the AppHost executable assembly, or use " + + $"{nameof(DistributedApplicationTestingBuilder)}.{nameof(DistributedApplicationTestingBuilder.Create)} to construct the application without invoking an entry point.", + exception.Message); + } + + [Fact] + public void ResolveEntryPointAcceptsAppHostApplication() + { + var entryPoint = DistributedApplicationEntryPointInvoker.ResolveEntryPoint( + typeof(Projects.TestingAppHost1_AppHost).Assembly); + + Assert.NotNull(entryPoint); + } +} From 9635ce72708c72d491af931326153723c83d6e58 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 28 Sep 2026 17:38:01 -0700 Subject: [PATCH 2/3] Clarify MTP test project diagnostic Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../DistributedApplicationEntryPointInvoker.cs | 3 ++- .../DistributedApplicationEntryPointInvokerTests.cs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationEntryPointInvoker.cs b/src/Aspire.Hosting.Testing/DistributedApplicationEntryPointInvoker.cs index a470d30ac51..2e7c3dcd0bf 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationEntryPointInvoker.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationEntryPointInvoker.cs @@ -35,7 +35,8 @@ internal static class DistributedApplicationEntryPointInvoker throw new InvalidOperationException( $"The assembly '{assembly.GetName().Name}' is a Microsoft.Testing.Platform test application. " + $"Invoking its entry point from {nameof(DistributedApplicationFactory)} would recursively run the test application. " + - "Ensure the entry point type belongs to the AppHost executable assembly, or use " + + "Test projects should use Microsoft.NET.Sdk instead of Aspire.AppHost.Sdk and reference the AppHost project so the entry point type belongs to the AppHost executable assembly. " + + "Alternatively, use " + $"{nameof(DistributedApplicationTestingBuilder)}.{nameof(DistributedApplicationTestingBuilder.Create)} to construct the application without invoking an entry point."); } diff --git a/tests/Aspire.Hosting.Testing.Tests/DistributedApplicationEntryPointInvokerTests.cs b/tests/Aspire.Hosting.Testing.Tests/DistributedApplicationEntryPointInvokerTests.cs index ce0f2a62d23..c32c4325780 100644 --- a/tests/Aspire.Hosting.Testing.Tests/DistributedApplicationEntryPointInvokerTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/DistributedApplicationEntryPointInvokerTests.cs @@ -18,7 +18,8 @@ public void ResolveEntryPointThrowsForMicrosoftTestingPlatformApplication() Assert.Equal( $"The assembly '{assembly.GetName().Name}' is a Microsoft.Testing.Platform test application. " + $"Invoking its entry point from {nameof(DistributedApplicationFactory)} would recursively run the test application. " + - "Ensure the entry point type belongs to the AppHost executable assembly, or use " + + "Test projects should use Microsoft.NET.Sdk instead of Aspire.AppHost.Sdk and reference the AppHost project so the entry point type belongs to the AppHost executable assembly. " + + "Alternatively, use " + $"{nameof(DistributedApplicationTestingBuilder)}.{nameof(DistributedApplicationTestingBuilder.Create)} to construct the application without invoking an entry point.", exception.Message); } From d1e0e8d5d7620eb46ff931aed3ba4a81503191d6 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 28 Sep 2026 17:55:10 -0700 Subject: [PATCH 3/3] Improve recursive entry point diagnostic Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...DistributedApplicationEntryPointInvoker.cs | 29 ++++++++++++- .../DistributedApplicationFactory.cs | 2 +- ...ibutedApplicationEntryPointInvokerTests.cs | 42 +++++++++++++++++-- 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationEntryPointInvoker.cs b/src/Aspire.Hosting.Testing/DistributedApplicationEntryPointInvoker.cs index 2e7c3dcd0bf..23258f85b8f 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationEntryPointInvoker.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationEntryPointInvoker.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.CodeDom.Compiler; using System.Diagnostics; using System.Reflection; using Microsoft.Extensions.Hosting; @@ -9,6 +10,7 @@ namespace Aspire.Hosting.Testing; internal static class DistributedApplicationEntryPointInvoker { + private const string AspireHostingGeneratedCodeToolName = "Aspire.Hosting"; private const string MicrosoftTestingPlatformApplicationMetadataKey = "Microsoft.Testing.Platform.Application"; // This helpers encapsulates all of the complex logic required to: @@ -18,12 +20,13 @@ internal static class DistributedApplicationEntryPointInvoker // 4. Resolve the instance of the DistributedApplication // 5. Allow the caller to determine if the entry point has completed public static Func>? ResolveEntryPoint( - Assembly assembly, + Type entryPointType, Action? onConstructing = null, Action? onConstructed = null, Action? onBuilding = null, Action? entryPointCompleted = null) { + var assembly = entryPointType.Assembly; var entryPoint = assembly.EntryPoint; if (entryPoint is null) { @@ -32,6 +35,16 @@ internal static class DistributedApplicationEntryPointInvoker if (IsMicrosoftTestingPlatformApplication(assembly)) { + if (GetAspireProjectMetadata(entryPointType) is { } projectMetadata) + { + throw new InvalidOperationException( + $"The specified entry point type '{entryPointType.FullName}' is generated project metadata for '{projectMetadata.ProjectPath}', " + + $"but it was resolved from the Microsoft.Testing.Platform test application '{assembly.GetName().Name}'. " + + "This can happen when an Aspire.AppHost.Sdk test project generates a Projects.* type that shadows the AppHost's generated marker. " + + "Test projects should use Microsoft.NET.Sdk instead of Aspire.AppHost.Sdk and reference the AppHost project so the entry point type belongs to the AppHost executable assembly. " + + $"If the AppHost is discovered dynamically, load its assembly and pass a type from that assembly to {nameof(DistributedApplicationTestingBuilder)}.{nameof(DistributedApplicationTestingBuilder.CreateAsync)}(Type)."); + } + throw new InvalidOperationException( $"The assembly '{assembly.GetName().Name}' is a Microsoft.Testing.Platform test application. " + $"Invoking its entry point from {nameof(DistributedApplicationFactory)} would recursively run the test application. " + @@ -53,6 +66,20 @@ internal static class DistributedApplicationEntryPointInvoker }; } + private static IProjectMetadata? GetAspireProjectMetadata(Type type) + { + // Aspire generates referenced project markers as: + // [GeneratedCode("Aspire.Hosting", null)] + // public class MyProject : IProjectMetadata + if (!typeof(IProjectMetadata).IsAssignableFrom(type) || + type.GetCustomAttribute() is not { Tool: AspireHostingGeneratedCodeToolName }) + { + return null; + } + + return (IProjectMetadata)Activator.CreateInstance(type, nonPublic: true)!; + } + private static bool IsMicrosoftTestingPlatformApplication(Assembly assembly) { // MTP identifies test application assemblies with: diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationFactory.cs b/src/Aspire.Hosting.Testing/DistributedApplicationFactory.cs index 7af9dd22dd5..4848eff9067 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationFactory.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationFactory.cs @@ -381,7 +381,7 @@ private void EnsureEntryPointStarted() // This helper launches the target assembly's entry point and hooks into the lifecycle // so we can intercept execution at key stages. var factory = DistributedApplicationEntryPointInvoker.ResolveEntryPoint( - _entryPoint.Assembly, + _entryPoint, onConstructing: OnBuilderCreatingCore, onConstructed: OnBuilderCreatedCore, onBuilding: OnBuildingCore, diff --git a/tests/Aspire.Hosting.Testing.Tests/DistributedApplicationEntryPointInvokerTests.cs b/tests/Aspire.Hosting.Testing.Tests/DistributedApplicationEntryPointInvokerTests.cs index c32c4325780..de3b35a2d37 100644 --- a/tests/Aspire.Hosting.Testing.Tests/DistributedApplicationEntryPointInvokerTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/DistributedApplicationEntryPointInvokerTests.cs @@ -1,19 +1,40 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.CodeDom.Compiler; using Xunit; namespace Aspire.Hosting.Testing.Tests; public class DistributedApplicationEntryPointInvokerTests { + [Fact] + public void ResolveEntryPointThrowsForGeneratedProjectMetadataType() + { + var entryPointType = typeof(GeneratedAppHostProjectMetadata); + IProjectMetadata projectMetadata = new GeneratedAppHostProjectMetadata(); + var assembly = entryPointType.Assembly; + + var exception = Assert.Throws( + () => DistributedApplicationEntryPointInvoker.ResolveEntryPoint(entryPointType)); + + Assert.Equal( + $"The specified entry point type '{entryPointType.FullName}' is generated project metadata for '{projectMetadata.ProjectPath}', " + + $"but it was resolved from the Microsoft.Testing.Platform test application '{assembly.GetName().Name}'. " + + "This can happen when an Aspire.AppHost.Sdk test project generates a Projects.* type that shadows the AppHost's generated marker. " + + "Test projects should use Microsoft.NET.Sdk instead of Aspire.AppHost.Sdk and reference the AppHost project so the entry point type belongs to the AppHost executable assembly. " + + $"If the AppHost is discovered dynamically, load its assembly and pass a type from that assembly to {nameof(DistributedApplicationTestingBuilder)}.{nameof(DistributedApplicationTestingBuilder.CreateAsync)}(Type).", + exception.Message); + } + [Fact] public void ResolveEntryPointThrowsForMicrosoftTestingPlatformApplication() { - var assembly = typeof(DistributedApplicationEntryPointInvokerTests).Assembly; + var entryPointType = typeof(DistributedApplicationEntryPointInvokerTests); + var assembly = entryPointType.Assembly; var exception = Assert.Throws( - () => DistributedApplicationEntryPointInvoker.ResolveEntryPoint(assembly)); + () => DistributedApplicationEntryPointInvoker.ResolveEntryPoint(entryPointType)); Assert.Equal( $"The assembly '{assembly.GetName().Name}' is a Microsoft.Testing.Platform test application. " + @@ -28,8 +49,23 @@ public void ResolveEntryPointThrowsForMicrosoftTestingPlatformApplication() public void ResolveEntryPointAcceptsAppHostApplication() { var entryPoint = DistributedApplicationEntryPointInvoker.ResolveEntryPoint( - typeof(Projects.TestingAppHost1_AppHost).Assembly); + typeof(Projects.TestingAppHost1_AppHost)); Assert.NotNull(entryPoint); } + + [Fact] + public void ResolveEntryPointAcceptsProjectMetadataTypeFromAppHostApplication() + { + var entryPoint = DistributedApplicationEntryPointInvoker.ResolveEntryPoint( + typeof(Projects.TestingAppHost1_MyWorker)); + + Assert.NotNull(entryPoint); + } + + [GeneratedCode("Aspire.Hosting", null)] + private sealed class GeneratedAppHostProjectMetadata : IProjectMetadata + { + public string ProjectPath => "/path/to/Test.AppHost.csproj"; + } }