From 8dc117409d2703ae90794fe452ab9d043aa5ac48 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 11 Jun 2026 08:26:02 -0700 Subject: [PATCH 1/7] Surface actionable diagnostic when codegen generator dropped by load failure A TypeScript AppHost whose configured SDK version diverges from the installed CLI fails with a cryptic "No code generator found for language: TypeScript". The real cause is a binary mismatch: the code-generation assembly references an Aspire.TypeSystem version that is absent on disk, so type discovery raises a ReflectionTypeLoadException that CodeGeneratorResolver swallows. The generator is silently dropped, and GenerateCode then throws a plain ArgumentException that the diagnostic builder does not classify, so the actionable IncompatibleAspireSdk ("run aspire update") diagnostic the CLI knows how to render is never emitted. - CodeGeneratorResolver now retains the swallowed ReflectionTypeLoadExceptions and exposes them via GetDiscoveryLoadFailures(). - GenerateCode chains the captured load failure as the inner exception when no generator is found, so the existing catch path produces the IncompatibleAspireSdk diagnostic with the version-alignment remediation hint. - CodeGenerationDiagnosticBuilder recognizes FileNotFoundException (the actual loader exception for a missing dependency assembly) and captures its file name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CodeGenerationDiagnostic.cs | 8 +++ .../CodeGeneration/CodeGenerationService.cs | 11 +++- .../CodeGeneration/CodeGeneratorResolver.cs | 33 +++++++++--- .../CodeGenerationDiagnosticBuilderTests.cs | 38 +++++++++++++ .../ResolverDiagnosticsTests.cs | 29 ++++++++++ .../ServiceErrorMessageTests.cs | 53 +++++++++++++++++++ 6 files changed, 165 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationDiagnostic.cs b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationDiagnostic.cs index fde1a9c7e98..612fdea7974 100644 --- a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationDiagnostic.cs +++ b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationDiagnostic.cs @@ -159,6 +159,13 @@ internal static CodeGenerationDiagnostic BuildDiagnostic(Exception exception, As case FileLoadException fle: typeName = fle.FileName; break; + case FileNotFoundException fnfe: + // The CLR reports a missing dependency assembly (e.g. a diverged Aspire.TypeSystem) + // as "Could not load file or assembly '...'. The system cannot find the file + // specified.", which surfaces as a FileNotFoundException in the LoaderExceptions of + // a ReflectionTypeLoadException. Capture the offending assembly name for diagnostics. + typeName = fnfe.FileName; + break; case BadImageFormatException bife: typeName = bife.FileName; break; @@ -214,6 +221,7 @@ is TypeLoadException or MissingMethodException or MissingFieldException or FileLoadException + or FileNotFoundException or BadImageFormatException or ReflectionTypeLoadException; diff --git a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationService.cs b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationService.cs index fd9ff421ad3..a85ac04bca1 100644 --- a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationService.cs +++ b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationService.cs @@ -240,7 +240,16 @@ public Dictionary GenerateCode(string language, string? assembly var generator = _resolver.GetCodeGenerator(language); if (generator == null) { - throw new ArgumentException(BuildNoCodeGeneratorMessage(language)); + // A missing generator is frequently the visible symptom of an integration assembly + // that failed to load (typically an Aspire.TypeSystem binary mismatch between the + // bundled apphost server and the restored SDK packages). The resolver swallows that + // ReflectionTypeLoadException during discovery, so surface it here as the inner + // exception. This lets CodeGenerationDiagnosticBuilder classify the failure as an + // incompatible SDK and return the actionable "run aspire update" diagnostic instead + // of the opaque "no code generator found" message reaching the user verbatim. + var loadFailures = _resolver.GetDiscoveryLoadFailures(); + var loadFailure = loadFailures.Count > 0 ? loadFailures[0] : null; + throw new ArgumentException(BuildNoCodeGeneratorMessage(language), loadFailure); } var context = _atsContextFactory.GetContext(); diff --git a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGeneratorResolver.cs b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGeneratorResolver.cs index ea4fcc818c5..88dc38eb56f 100644 --- a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGeneratorResolver.cs +++ b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGeneratorResolver.cs @@ -13,7 +13,7 @@ namespace Aspire.Hosting.RemoteHost.CodeGeneration; /// internal sealed class CodeGeneratorResolver { - private readonly Lazy> _generators; + private readonly Lazy _discovery; private readonly ILogger _logger; public CodeGeneratorResolver( @@ -32,7 +32,7 @@ internal CodeGeneratorResolver( ILogger logger) { _logger = logger; - _generators = new Lazy>( + _discovery = new Lazy( () => DiscoverGenerators(serviceProvider, assembliesProvider())); } @@ -43,7 +43,7 @@ internal CodeGeneratorResolver( /// The code generator, or null if not found. public ICodeGenerator? GetCodeGenerator(string language) { - _generators.Value.TryGetValue(language, out var generator); + _discovery.Value.Generators.TryGetValue(language, out var generator); return generator; } @@ -53,14 +53,27 @@ internal CodeGeneratorResolver( /// The set of supported language identifiers. public IReadOnlyCollection GetSupportedLanguages() { - return _generators.Value.Keys.ToArray(); + return _discovery.Value.Generators.Keys.ToArray(); } - private Dictionary DiscoverGenerators( + /// + /// Gets the s that were swallowed while discovering + /// generators. A non-empty result almost always means a code generator was silently dropped + /// because of a binary mismatch (typically a diverged Aspire.TypeSystem version between + /// the bundled apphost server and the restored SDK assemblies). Callers use this to turn an + /// otherwise-opaque "no generator found" failure into an actionable incompatible-SDK diagnostic. + /// + public IReadOnlyList GetDiscoveryLoadFailures() + { + return _discovery.Value.LoadFailures; + } + + private DiscoveryResult DiscoverGenerators( IServiceProvider serviceProvider, IReadOnlyList assemblies) { var generators = new Dictionary(StringComparer.OrdinalIgnoreCase); + var loadFailures = new List(); foreach (var assembly in assemblies) { @@ -74,6 +87,10 @@ private Dictionary DiscoverGenerators( catch (ReflectionTypeLoadException ex) { hadTypeLoadFailure = true; + // Remember the load failure so a downstream "no generator found" error can be + // re-cast as an actionable incompatible-SDK diagnostic instead of a cryptic + // ArgumentException (see GetDiscoveryLoadFailures). + loadFailures.Add(ex); // Surface loader binding failures at Warning level. These typically indicate // a binary mismatch between the bundled runtime assemblies and the integration // assemblies loaded from disk (for example, when Aspire.TypeSystem versions @@ -130,10 +147,14 @@ private Dictionary DiscoverGenerators( } } - return generators; + return new DiscoveryResult(generators, loadFailures); } private static bool LooksLikeCodeGeneratorAssembly(string? assemblyName) => assemblyName is not null && assemblyName.StartsWith("Aspire.Hosting.CodeGeneration.", StringComparison.OrdinalIgnoreCase); + + private sealed record DiscoveryResult( + Dictionary Generators, + IReadOnlyList LoadFailures); } diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs index 0e7630ef106..d526193763f 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs +++ b/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs @@ -95,6 +95,44 @@ public void TryCreateRpcException_ReflectionTypeLoadException_FindsLoaderExcepti Assert.Equal(typeof(TypeLoadException).FullName, diagnostic.OriginalExceptionType); } + [Fact] + public void TryCreateRpcException_FileNotFoundLoaderException_CapturesMissingAssemblyName() + { + // Mirrors the real failure: a code-generation assembly references an Aspire.TypeSystem + // version that is absent on disk, so the CLR raises a FileNotFoundException inside the + // ReflectionTypeLoadException's LoaderExceptions. + var loader = new FileNotFoundException( + "Could not load file or assembly 'Aspire.TypeSystem, Version=42.42.42.42'. The system cannot find the file specified.", + "Aspire.TypeSystem, Version=42.42.42.42, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51"); + var rtle = new ReflectionTypeLoadException([null], [loader]); + + var result = CodeGenerationDiagnosticBuilder.TryCreateRpcException(rtle, assemblyLoader: null); + + var localRpc = Assert.IsType(result); + Assert.Equal(CodeGenerationErrorCodes.IncompatibleAspireSdk, localRpc.ErrorCode); + var diagnostic = Assert.IsType(localRpc.ErrorData); + Assert.Equal(typeof(FileNotFoundException).FullName, diagnostic.OriginalExceptionType); + Assert.Contains("Aspire.TypeSystem", diagnostic.TypeName); + } + + [Fact] + public void TryCreateRpcException_ArgumentExceptionWrappingReflectionLoad_FindsInnerCause() + { + // Repro of the TypeScript "no code generator found" path: GenerateCode throws an + // ArgumentException whose inner exception is the swallowed ReflectionTypeLoadException. + var loader = new FileNotFoundException("missing", "Aspire.TypeSystem, Version=42.42.42.42"); + var rtle = new ReflectionTypeLoadException([null], [loader]); + var argument = new ArgumentException("No code generator found for language: TypeScript.", rtle); + + var result = CodeGenerationDiagnosticBuilder.TryCreateRpcException(argument, assemblyLoader: null); + + var localRpc = Assert.IsType(result); + Assert.Equal(CodeGenerationErrorCodes.IncompatibleAspireSdk, localRpc.ErrorCode); + Assert.False(string.IsNullOrWhiteSpace(localRpc.Message)); + // The actionable message must not leak the raw "no code generator found" text. + Assert.DoesNotContain("no code generator found", localRpc.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void BuildDiagnostic_CapturesRuntimeAspireHostingVersion() { diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/ResolverDiagnosticsTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/ResolverDiagnosticsTests.cs index 9c2e5677344..742e39e4d2b 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/ResolverDiagnosticsTests.cs +++ b/tests/Aspire.Hosting.RemoteHost.Tests/ResolverDiagnosticsTests.cs @@ -84,6 +84,35 @@ public void CodeGeneratorResolver_DoesNotLogContributionWarning_ForArbitraryAsse Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("did not contribute")); } + [Fact] + public void CodeGeneratorResolver_ExposesSwallowedLoadFailures() + { + var logger = new RecordingLogger(); + var stub = new TypeLoadFailingAssembly("Aspire.Hosting.CodeGeneration.TypeScript"); + + using var services = new ServiceCollection().BuildServiceProvider(); + var resolver = new CodeGeneratorResolver(services, () => (IReadOnlyList)[stub], logger); + + // The generator is silently dropped, but the underlying load failure must be retained so + // callers can turn it into an actionable incompatible-SDK diagnostic. + Assert.Null(resolver.GetCodeGenerator("TypeScript")); + + var failure = Assert.Single(resolver.GetDiscoveryLoadFailures()); + Assert.Contains("synthetic loader exception", failure.LoaderExceptions[0]!.Message); + } + + [Fact] + public void CodeGeneratorResolver_NoLoadFailures_ReturnsEmpty() + { + var logger = new RecordingLogger(); + + using var services = new ServiceCollection().BuildServiceProvider(); + var resolver = new CodeGeneratorResolver(services, Array.Empty, logger); + + Assert.Null(resolver.GetCodeGenerator("TypeScript")); + Assert.Empty(resolver.GetDiscoveryLoadFailures()); + } + private sealed class TypeLoadFailingAssembly : Assembly { private readonly AssemblyName _name; diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/ServiceErrorMessageTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/ServiceErrorMessageTests.cs index 9b26bc03ae4..45685609ccb 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/ServiceErrorMessageTests.cs +++ b/tests/Aspire.Hosting.RemoteHost.Tests/ServiceErrorMessageTests.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; +using StreamJsonRpc; using Xunit; namespace Aspire.Hosting.RemoteHost.Tests; @@ -67,6 +68,21 @@ public void GenerateCode_NoGeneratorsDiscovered_PointsAtBundleMismatch() Assert.Contains("binary mismatch", ex.Message); } + [Fact] + public void GenerateCode_GeneratorDroppedByLoadFailure_ThrowsIncompatibleSdkDiagnostic() + { + // Reproduces the TypeScript AppHost failure: the TypeScript generator is silently dropped + // because Aspire.TypeSystem failed to load. Instead of a cryptic ArgumentException, the + // service must emit the actionable incompatible-SDK RPC error the CLI knows how to render. + var codeService = CreateCodeGenerationServiceWithLoadFailingAssembly(); + + var ex = Assert.Throws(() => codeService.GenerateCode("TypeScript")); + + Assert.Equal(CodeGenerationErrorCodes.IncompatibleAspireSdk, ex.ErrorCode); + var diagnostic = Assert.IsType(ex.ErrorData); + Assert.False(string.IsNullOrWhiteSpace(diagnostic.RemediationHint)); + } + private static (LanguageService Lang, CodeGenerationService Code) CreateServices() { var configuration = new ConfigurationBuilder() @@ -133,10 +149,47 @@ private static CodeGenerationService CreateCodeGenerationServiceWithEmptyResolve return new CodeGenerationService(auth, atsContextFactory, codeResolver, loader, NullLogger.Instance, telemetry); } + private static CodeGenerationService CreateCodeGenerationServiceWithLoadFailingAssembly() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary()) + .Build(); + + var telemetry = CreateTelemetry(); + var loader = new AssemblyLoader(configuration, NullLogger.Instance, telemetry); + var services = new ServiceCollection().BuildServiceProvider(); + var codeResolver = new CodeGeneratorResolver( + services, + () => (IReadOnlyList)[new LoadFailingAssembly("Aspire.Hosting.CodeGeneration.TypeScript")], + NullLogger.Instance); + + var auth = CreateAuthenticatedState(); + var atsContextFactory = new AtsContextFactory(loader, NullLogger.Instance, telemetry); + return new CodeGenerationService(auth, atsContextFactory, codeResolver, loader, NullLogger.Instance, telemetry); + } + // The default state is "authenticated" when no JsonRpcAuthToken is present in configuration. private static JsonRpcAuthenticationState CreateAuthenticatedState() => new(new ConfigurationBuilder().Build()); private static RemoteHostProfilingTelemetry CreateTelemetry() => new(new ConfigurationBuilder().Build()); + + // Simulates a code-generation assembly whose type discovery fails because a dependency + // (Aspire.TypeSystem) cannot be loaded — the exact shape of the reported TypeScript failure. + private sealed class LoadFailingAssembly : Assembly + { + private readonly AssemblyName _name; + + public LoadFailingAssembly(string name) => _name = new AssemblyName(name); + + public override AssemblyName GetName() => _name; + + public override Type[] GetTypes() + => throw new ReflectionTypeLoadException( + [null, typeof(string)], + [new FileNotFoundException( + "Could not load file or assembly 'Aspire.TypeSystem, Version=42.42.42.42'. The system cannot find the file specified.", + "Aspire.TypeSystem, Version=42.42.42.42, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51")]); + } } From bb86ab92a0a5f8fac5d5b986cafd1e4135a66d4a Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 11 Jun 2026 12:38:04 -0700 Subject: [PATCH 2/7] Document test-only consumption of GetDiscoveryLoadFailures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CodeGeneration/CodeGeneratorResolver.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGeneratorResolver.cs b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGeneratorResolver.cs index 88dc38eb56f..ec8b4fe813a 100644 --- a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGeneratorResolver.cs +++ b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGeneratorResolver.cs @@ -63,6 +63,10 @@ public IReadOnlyCollection GetSupportedLanguages() /// the bundled apphost server and the restored SDK assemblies). Callers use this to turn an /// otherwise-opaque "no generator found" failure into an actionable incompatible-SDK diagnostic. /// + /// + /// This is also the only way unit tests can observe the swallowed load failures, since discovery + /// otherwise hides them; see ResolverDiagnosticsTests. + /// public IReadOnlyList GetDiscoveryLoadFailures() { return _discovery.Value.LoadFailures; From e67fe67bd6c1a3da70ba6db1fd2d72df40a61c41 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 11 Jun 2026 12:42:52 -0700 Subject: [PATCH 3/7] Expose discovery result instead of dedicated load-failures accessor Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CodeGeneration/CodeGenerationService.cs | 2 +- .../CodeGeneration/CodeGeneratorResolver.cs | 24 ++++++++----------- .../ResolverDiagnosticsTests.cs | 4 ++-- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationService.cs b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationService.cs index a85ac04bca1..39c1eb9f16d 100644 --- a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationService.cs +++ b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationService.cs @@ -247,7 +247,7 @@ public Dictionary GenerateCode(string language, string? assembly // exception. This lets CodeGenerationDiagnosticBuilder classify the failure as an // incompatible SDK and return the actionable "run aspire update" diagnostic instead // of the opaque "no code generator found" message reaching the user verbatim. - var loadFailures = _resolver.GetDiscoveryLoadFailures(); + var loadFailures = _resolver.Discovery.LoadFailures; var loadFailure = loadFailures.Count > 0 ? loadFailures[0] : null; throw new ArgumentException(BuildNoCodeGeneratorMessage(language), loadFailure); } diff --git a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGeneratorResolver.cs b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGeneratorResolver.cs index ec8b4fe813a..590ed7ebe17 100644 --- a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGeneratorResolver.cs +++ b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGeneratorResolver.cs @@ -57,20 +57,16 @@ public IReadOnlyCollection GetSupportedLanguages() } /// - /// Gets the s that were swallowed while discovering - /// generators. A non-empty result almost always means a code generator was silently dropped - /// because of a binary mismatch (typically a diverged Aspire.TypeSystem version between - /// the bundled apphost server and the restored SDK assemblies). Callers use this to turn an + /// Gets the result of generator discovery: the resolved generators and any + /// s swallowed while probing assemblies. A non-empty + /// almost always means a code generator was silently + /// dropped because of a binary mismatch (typically a diverged Aspire.TypeSystem version + /// between the bundled apphost server and the restored SDK assemblies); callers use it to turn an /// otherwise-opaque "no generator found" failure into an actionable incompatible-SDK diagnostic. + /// Exposing the whole result (rather than a dedicated accessor) also lets unit tests observe the + /// swallowed failures, which discovery otherwise hides; see ResolverDiagnosticsTests. /// - /// - /// This is also the only way unit tests can observe the swallowed load failures, since discovery - /// otherwise hides them; see ResolverDiagnosticsTests. - /// - public IReadOnlyList GetDiscoveryLoadFailures() - { - return _discovery.Value.LoadFailures; - } + internal DiscoveryResult Discovery => _discovery.Value; private DiscoveryResult DiscoverGenerators( IServiceProvider serviceProvider, @@ -93,7 +89,7 @@ private DiscoveryResult DiscoverGenerators( hadTypeLoadFailure = true; // Remember the load failure so a downstream "no generator found" error can be // re-cast as an actionable incompatible-SDK diagnostic instead of a cryptic - // ArgumentException (see GetDiscoveryLoadFailures). + // ArgumentException (see Discovery). loadFailures.Add(ex); // Surface loader binding failures at Warning level. These typically indicate // a binary mismatch between the bundled runtime assemblies and the integration @@ -158,7 +154,7 @@ private static bool LooksLikeCodeGeneratorAssembly(string? assemblyName) => assemblyName is not null && assemblyName.StartsWith("Aspire.Hosting.CodeGeneration.", StringComparison.OrdinalIgnoreCase); - private sealed record DiscoveryResult( + internal sealed record DiscoveryResult( Dictionary Generators, IReadOnlyList LoadFailures); } diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/ResolverDiagnosticsTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/ResolverDiagnosticsTests.cs index 742e39e4d2b..b2756290466 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/ResolverDiagnosticsTests.cs +++ b/tests/Aspire.Hosting.RemoteHost.Tests/ResolverDiagnosticsTests.cs @@ -97,7 +97,7 @@ public void CodeGeneratorResolver_ExposesSwallowedLoadFailures() // callers can turn it into an actionable incompatible-SDK diagnostic. Assert.Null(resolver.GetCodeGenerator("TypeScript")); - var failure = Assert.Single(resolver.GetDiscoveryLoadFailures()); + var failure = Assert.Single(resolver.Discovery.LoadFailures); Assert.Contains("synthetic loader exception", failure.LoaderExceptions[0]!.Message); } @@ -110,7 +110,7 @@ public void CodeGeneratorResolver_NoLoadFailures_ReturnsEmpty() var resolver = new CodeGeneratorResolver(services, Array.Empty, logger); Assert.Null(resolver.GetCodeGenerator("TypeScript")); - Assert.Empty(resolver.GetDiscoveryLoadFailures()); + Assert.Empty(resolver.Discovery.LoadFailures); } private sealed class TypeLoadFailingAssembly : Assembly From 2d4ff0380394136917ac5b7f3f980cf1abde82d1 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 11 Jun 2026 12:49:45 -0700 Subject: [PATCH 4/7] Replace local test assembly fakes with reusable configurable FakeAssembly Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FakeAssembly.cs | 41 +++++++++++++++++++ .../ResolverDiagnosticsTests.cs | 40 ++++++------------ .../ServiceErrorMessageTests.cs | 19 +++------ 3 files changed, 59 insertions(+), 41 deletions(-) create mode 100644 tests/Aspire.Hosting.RemoteHost.Tests/FakeAssembly.cs diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/FakeAssembly.cs b/tests/Aspire.Hosting.RemoteHost.Tests/FakeAssembly.cs new file mode 100644 index 00000000000..dd1bb0d8a79 --- /dev/null +++ b/tests/Aspire.Hosting.RemoteHost.Tests/FakeAssembly.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; + +namespace Aspire.Hosting.RemoteHost.Tests; + +/// +/// A configurable test double whose name and +/// behavior are supplied by the caller. Use the factory methods to either return a fixed set of +/// types or to simulate a loader failure (the discovery resolvers probe assemblies via +/// , so throwing from it reproduces a real type-load failure). +/// +internal sealed class FakeAssembly : Assembly +{ + private readonly AssemblyName _name; + private readonly Func _getTypes; + + private FakeAssembly(string name, Func getTypes) + { + _name = new AssemblyName(name); + _getTypes = getTypes; + } + + public override AssemblyName GetName() => _name; + + public override Type[] GetTypes() => _getTypes(); + + /// + /// Creates a fake assembly whose returns the supplied types. + /// + public static FakeAssembly WithTypes(string name, params Type[] types) + => new(name, () => types); + + /// + /// Creates a fake assembly whose throws the supplied exception, + /// simulating an assembly that loads but cannot have its types enumerated. + /// + public static FakeAssembly ThrowingOnGetTypes(string name, Exception exception) + => new(name, () => throw exception); +} diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/ResolverDiagnosticsTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/ResolverDiagnosticsTests.cs index b2756290466..7ccd40326cf 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/ResolverDiagnosticsTests.cs +++ b/tests/Aspire.Hosting.RemoteHost.Tests/ResolverDiagnosticsTests.cs @@ -23,7 +23,7 @@ public class ResolverDiagnosticsTests public void CodeGeneratorResolver_LogsWarning_WhenAssemblyTypeLoadFails() { var logger = new RecordingLogger(); - var stub = new TypeLoadFailingAssembly("Aspire.Hosting.CodeGeneration.Synthetic"); + var stub = CreateTypeLoadFailingAssembly("Aspire.Hosting.CodeGeneration.Synthetic"); using var services = new ServiceCollection().BuildServiceProvider(); var resolver = new CodeGeneratorResolver(services, () => (IReadOnlyList)[stub], logger); @@ -41,7 +41,7 @@ public void CodeGeneratorResolver_LogsWarning_WhenAssemblyTypeLoadFails() public void LanguageSupportResolver_LogsWarning_WhenAssemblyTypeLoadFails() { var logger = new RecordingLogger(); - var stub = new TypeLoadFailingAssembly("Aspire.Hosting.CodeGeneration.Synthetic"); + var stub = CreateTypeLoadFailingAssembly("Aspire.Hosting.CodeGeneration.Synthetic"); using var services = new ServiceCollection().BuildServiceProvider(); var resolver = new LanguageSupportResolver(services, () => (IReadOnlyList)[stub], logger); @@ -60,7 +60,7 @@ public void CodeGeneratorResolver_LogsWarning_WhenCodeGenerationAssemblyContribu { var logger = new RecordingLogger(); // The marker name is what triggers the "did not contribute any" diagnostic. - var empty = new EmptyNamedAssembly("Aspire.Hosting.CodeGeneration.Synthetic"); + var empty = FakeAssembly.WithTypes("Aspire.Hosting.CodeGeneration.Synthetic", typeof(string)); using var services = new ServiceCollection().BuildServiceProvider(); var resolver = new CodeGeneratorResolver(services, () => (IReadOnlyList)[empty], logger); @@ -76,7 +76,7 @@ public void CodeGeneratorResolver_LogsWarning_WhenCodeGenerationAssemblyContribu public void CodeGeneratorResolver_DoesNotLogContributionWarning_ForArbitraryAssembly() { var logger = new RecordingLogger(); - var arbitrary = new EmptyNamedAssembly("My.Custom.Integration"); + var arbitrary = FakeAssembly.WithTypes("My.Custom.Integration", typeof(string)); using var services = new ServiceCollection().BuildServiceProvider(); _ = new CodeGeneratorResolver(services, () => (IReadOnlyList)[arbitrary], logger).GetCodeGenerator("anything"); @@ -88,7 +88,7 @@ public void CodeGeneratorResolver_DoesNotLogContributionWarning_ForArbitraryAsse public void CodeGeneratorResolver_ExposesSwallowedLoadFailures() { var logger = new RecordingLogger(); - var stub = new TypeLoadFailingAssembly("Aspire.Hosting.CodeGeneration.TypeScript"); + var stub = CreateTypeLoadFailingAssembly("Aspire.Hosting.CodeGeneration.TypeScript"); using var services = new ServiceCollection().BuildServiceProvider(); var resolver = new CodeGeneratorResolver(services, () => (IReadOnlyList)[stub], logger); @@ -113,28 +113,12 @@ public void CodeGeneratorResolver_NoLoadFailures_ReturnsEmpty() Assert.Empty(resolver.Discovery.LoadFailures); } - private sealed class TypeLoadFailingAssembly : Assembly - { - private readonly AssemblyName _name; - - public TypeLoadFailingAssembly(string name) => _name = new AssemblyName(name); - - public override AssemblyName GetName() => _name; - - public override Type[] GetTypes() - => throw new ReflectionTypeLoadException( + // Simulates a code-generation assembly that loads but whose type enumeration fails with a + // ReflectionTypeLoadException, mirroring a real Aspire.TypeSystem binary mismatch. + private static FakeAssembly CreateTypeLoadFailingAssembly(string name) + => FakeAssembly.ThrowingOnGetTypes( + name, + new ReflectionTypeLoadException( [null, typeof(string)], - [new FileLoadException("synthetic loader exception: simulated Aspire.TypeSystem mismatch")]); - } - - private sealed class EmptyNamedAssembly : Assembly - { - private readonly AssemblyName _name; - - public EmptyNamedAssembly(string name) => _name = new AssemblyName(name); - - public override AssemblyName GetName() => _name; - - public override Type[] GetTypes() => [typeof(string)]; - } + [new FileLoadException("synthetic loader exception: simulated Aspire.TypeSystem mismatch")])); } diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/ServiceErrorMessageTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/ServiceErrorMessageTests.cs index 45685609ccb..d30a1b10d4d 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/ServiceErrorMessageTests.cs +++ b/tests/Aspire.Hosting.RemoteHost.Tests/ServiceErrorMessageTests.cs @@ -160,7 +160,7 @@ private static CodeGenerationService CreateCodeGenerationServiceWithLoadFailingA var services = new ServiceCollection().BuildServiceProvider(); var codeResolver = new CodeGeneratorResolver( services, - () => (IReadOnlyList)[new LoadFailingAssembly("Aspire.Hosting.CodeGeneration.TypeScript")], + () => (IReadOnlyList)[CreateLoadFailingAssembly("Aspire.Hosting.CodeGeneration.TypeScript")], NullLogger.Instance); var auth = CreateAuthenticatedState(); @@ -177,19 +177,12 @@ private static RemoteHostProfilingTelemetry CreateTelemetry() // Simulates a code-generation assembly whose type discovery fails because a dependency // (Aspire.TypeSystem) cannot be loaded — the exact shape of the reported TypeScript failure. - private sealed class LoadFailingAssembly : Assembly - { - private readonly AssemblyName _name; - - public LoadFailingAssembly(string name) => _name = new AssemblyName(name); - - public override AssemblyName GetName() => _name; - - public override Type[] GetTypes() - => throw new ReflectionTypeLoadException( + private static FakeAssembly CreateLoadFailingAssembly(string name) + => FakeAssembly.ThrowingOnGetTypes( + name, + new ReflectionTypeLoadException( [null, typeof(string)], [new FileNotFoundException( "Could not load file or assembly 'Aspire.TypeSystem, Version=42.42.42.42'. The system cannot find the file specified.", - "Aspire.TypeSystem, Version=42.42.42.42, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51")]); - } + "Aspire.TypeSystem, Version=42.42.42.42, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51")])); } From 24541e1d84ae8327207c2cce0eb1740f89811464 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 11 Jun 2026 12:52:48 -0700 Subject: [PATCH 5/7] Gate standalone FileNotFoundException on assembly-name shape Only treat a bare FileNotFoundException as an incompatible-SDK signal when its FileName is an assembly display name, so a genuine missing-file IO error during code generation is not misreported as a version mismatch. FNFEs inside an RTLE's loader exceptions are still always treated as assembly-bind failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CodeGenerationDiagnostic.cs | 45 +++++++++++++++++-- .../CodeGenerationDiagnosticBuilderTests.cs | 33 ++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationDiagnostic.cs b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationDiagnostic.cs index 612fdea7974..5170b1eba85 100644 --- a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationDiagnostic.cs +++ b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationDiagnostic.cs @@ -197,7 +197,12 @@ internal static CodeGenerationDiagnostic BuildDiagnostic(Exception exception, As { foreach (var loaderException in rtle.LoaderExceptions) { - if (loaderException is not null && IsReflectionLoadException(loaderException)) + // A FileNotFoundException surfaced as an RTLE loader exception is always an + // assembly-bind failure (the CLR could not load a referenced assembly while + // enumerating types), so accept it here without the assembly-name shape check + // applied to standalone exceptions below. + if (loaderException is not null && + (IsReflectionLoadException(loaderException) || loaderException is FileNotFoundException)) { return loaderException; } @@ -207,7 +212,8 @@ internal static CodeGenerationDiagnostic BuildDiagnostic(Exception exception, As // failure — fall through and return it from the IsReflectionLoadException check below. } - if (IsReflectionLoadException(current)) + if (IsReflectionLoadException(current) || + (current is FileNotFoundException fnfe && IsAssemblyBindFailure(fnfe))) { return current; } @@ -221,10 +227,43 @@ is TypeLoadException or MissingMethodException or MissingFieldException or FileLoadException - or FileNotFoundException or BadImageFormatException or ReflectionTypeLoadException; + /// + /// Determines whether a standalone represents an + /// assembly-bind failure (a missing dependency assembly) rather than ordinary missing-file IO. + /// + /// + /// The CLR raises a whose + /// is an assembly display name (for example + /// "Aspire.TypeSystem, Version=42.42.42.42, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51") + /// when it cannot bind a referenced assembly. A code generator that simply fails to open a data + /// file produces a path-like FileName instead. We only want to classify the former as an + /// incompatible-SDK failure, otherwise a genuine missing-file error would be misreported as a + /// version mismatch with a "run aspire update" hint that cannot help. + /// + private static bool IsAssemblyBindFailure(FileNotFoundException exception) + { + if (exception.FileName is not { Length: > 0 } fileName) + { + return false; + } + + try + { + // Require an identity component beyond the simple name (Version or PublicKeyToken) so a + // plain filename such as "data.json" - which AssemblyName happily parses as a simple + // name - is not mistaken for an assembly reference. + var name = new AssemblyName(fileName); + return name.Version is not null || name.GetPublicKeyToken() is { Length: > 0 }; + } + catch (Exception ex) when (ex is FileLoadException or ArgumentException) + { + return false; + } + } + private static (string? Version, string? Path, List Assemblies) CaptureLoadedAssemblies( AssemblyLoader? assemblyLoader, ILogger? logger) diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs index d526193763f..6313f5fb592 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs +++ b/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs @@ -133,6 +133,39 @@ public void TryCreateRpcException_ArgumentExceptionWrappingReflectionLoad_FindsI Assert.DoesNotContain("no code generator found", localRpc.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void TryCreateRpcException_StandaloneFileNotFound_PlainFilePath_IsNotClassified() + { + // A code generator (or ATS context build) that fails to open a genuine data file raises a + // FileNotFoundException with a path-like FileName. This must NOT be reported as an + // incompatible SDK, otherwise the user gets a misleading "run aspire update" hint for an + // unrelated missing-file error. + var ioFailure = new FileNotFoundException( + "Could not find file '/tmp/codegen/template.json'.", + "/tmp/codegen/template.json"); + + var result = CodeGenerationDiagnosticBuilder.TryCreateRpcException(ioFailure, assemblyLoader: null); + + Assert.Null(result); + } + + [Fact] + public void TryCreateRpcException_StandaloneFileNotFound_AssemblyDisplayName_IsClassified() + { + // A direct assembly-bind failure (for example a JIT-time bind during generation) surfaces a + // FileNotFoundException whose FileName is a full assembly display name. That IS an + // incompatible-SDK signal and should be classified even though it is not wrapped in a + // ReflectionTypeLoadException. + var bindFailure = new FileNotFoundException( + "Could not load file or assembly 'Aspire.TypeSystem, Version=42.42.42.42'. The system cannot find the file specified.", + "Aspire.TypeSystem, Version=42.42.42.42, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51"); + + var result = CodeGenerationDiagnosticBuilder.TryCreateRpcException(bindFailure, assemblyLoader: null); + + var localRpc = Assert.IsType(result); + Assert.Equal(CodeGenerationErrorCodes.IncompatibleAspireSdk, localRpc.ErrorCode); + } + [Fact] public void BuildDiagnostic_CapturesRuntimeAspireHostingVersion() { From 4aa3bcaea614daee84f6a026c90676b4ee19e7ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= Date: Thu, 11 Jun 2026 12:57:50 -0700 Subject: [PATCH 6/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../CodeGeneration/CodeGeneratorResolver.cs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGeneratorResolver.cs b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGeneratorResolver.cs index 590ed7ebe17..d2d16c40bdf 100644 --- a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGeneratorResolver.cs +++ b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGeneratorResolver.cs @@ -57,14 +57,7 @@ public IReadOnlyCollection GetSupportedLanguages() } /// - /// Gets the result of generator discovery: the resolved generators and any - /// s swallowed while probing assemblies. A non-empty - /// almost always means a code generator was silently - /// dropped because of a binary mismatch (typically a diverged Aspire.TypeSystem version - /// between the bundled apphost server and the restored SDK assemblies); callers use it to turn an - /// otherwise-opaque "no generator found" failure into an actionable incompatible-SDK diagnostic. - /// Exposing the whole result (rather than a dedicated accessor) also lets unit tests observe the - /// swallowed failures, which discovery otherwise hides; see ResolverDiagnosticsTests. + /// Gets the result of generator discovery, including any load failures swallowed during probing. /// internal DiscoveryResult Discovery => _discovery.Value; From b01e4f078c6b049cf4318a3bf08cd6e3254c029f Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Fri, 12 Jun 2026 07:52:52 -0700 Subject: [PATCH 7/7] Assert exact incompatible-SDK message in diagnostic test Replace the negative/substring assertions with a single Assert.Equal against the full SafeMessage so the test pins the actionable message verbatim. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CodeGenerationDiagnosticBuilderTests.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs index 6313f5fb592..1b1bb6ad336 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs +++ b/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs @@ -128,9 +128,10 @@ public void TryCreateRpcException_ArgumentExceptionWrappingReflectionLoad_FindsI var localRpc = Assert.IsType(result); Assert.Equal(CodeGenerationErrorCodes.IncompatibleAspireSdk, localRpc.ErrorCode); - Assert.False(string.IsNullOrWhiteSpace(localRpc.Message)); - // The actionable message must not leak the raw "no code generator found" text. - Assert.DoesNotContain("no code generator found", localRpc.Message, StringComparison.OrdinalIgnoreCase); + // The cryptic ArgumentException is replaced with the actionable incompatible-SDK guidance. + Assert.Equal( + "Aspire SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured SDK version. Run 'aspire update' to align the CLI and SDK and try again.", + localRpc.Message); } [Fact]