diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 9f1cdc08ae1..df2b5507c5f 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -856,7 +856,13 @@ private static string GetRestoreVersion(string packageName, string version, bool { if (e.Data is not null) { - _logger.LogTrace("PrebuiltAppHostServer({ProcessId}) stdout: {Line}", process.Id, e.Data); + // Promoted from LogTrace to LogDebug so that apphost-server stdout reaches the + // CLI's on-disk log under the default file-logger filter (Debug). Previously + // these lines were dropped entirely, which made apphost-side warnings + // (for example, "LoaderExceptions" from the type-discovery path) invisible to + // anyone diagnosing a "no code generator found" / "no language support found" + // error. See https://github.com/microsoft/aspire/issues/16729. + _logger.LogDebug("PrebuiltAppHostServer({ProcessId}) stdout: {Line}", process.Id, e.Data); outputCollector.AppendOutput(e.Data); } }; @@ -864,7 +870,11 @@ private static string GetRestoreVersion(string packageName, string version, bool { if (e.Data is not null) { - _logger.LogTrace("PrebuiltAppHostServer({ProcessId}) stderr: {Line}", process.Id, e.Data); + // Promoted from LogTrace to LogInformation so that apphost-server stderr is + // visible at the default console log level (Information). Stderr is reserved + // for genuine problems in well-behaved server processes, so surfacing it + // by default is appropriate. See https://github.com/microsoft/aspire/issues/16729. + _logger.LogInformation("PrebuiltAppHostServer({ProcessId}) stderr: {Line}", process.Id, e.Data); outputCollector.AppendError(e.Data); } }; diff --git a/src/Aspire.Hosting.RemoteHost/AssemblyLoader.cs b/src/Aspire.Hosting.RemoteHost/AssemblyLoader.cs index 8fc7c57f125..ad80280e668 100644 --- a/src/Aspire.Hosting.RemoteHost/AssemblyLoader.cs +++ b/src/Aspire.Hosting.RemoteHost/AssemblyLoader.cs @@ -43,6 +43,8 @@ public AssemblyLoader( string.IsNullOrWhiteSpace(libsPath) ? "" : libsPath, string.IsNullOrWhiteSpace(probeManifestPath) ? "" : probeManifestPath); + WarnIfSharedAssemblyMismatch(libsPath, logger); + _assemblies = new Lazy>( () => LoadAssemblies(_assemblyNamesToLoad.Value, _loadContext, logger)); } @@ -169,6 +171,80 @@ private static List LoadAssemblies( return assemblies; } + /// + /// Warns when a shared assembly (one that intentionally + /// resolves through the default ) exists in the integration + /// libs directory at a different identity than what the default context provides. + /// + /// + /// This is a defense against a real failure mode: when the bundled Aspire.TypeSystem + /// (compiled into the apphost server's single-file executable) and the libs copy + /// (restored alongside Aspire.Hosting.*.dll) report different assembly versions or MVIDs, + /// integration assemblies that reference the libs copy will fail to bind their type + /// references through the default context. The resulting + /// would otherwise be swallowed silently and surface only as a downstream "no code generator + /// found" / "no language support found" error with no actionable diagnostic. + /// + private static void WarnIfSharedAssemblyMismatch(string? integrationLibsPath, ILogger logger) + { + if (string.IsNullOrWhiteSpace(integrationLibsPath) || !Directory.Exists(integrationLibsPath)) + { + return; + } + + foreach (var sharedName in IntegrationLoadContext.GetSharedAssemblyNames()) + { + var libsPath = Path.Combine(integrationLibsPath, sharedName + ".dll"); + if (!File.Exists(libsPath)) + { + continue; + } + + AssemblyName? probedName; + try + { + probedName = AssemblyName.GetAssemblyName(libsPath); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Could not read assembly identity from {Path}", libsPath); + continue; + } + + var defaultAsm = AssemblyLoadContext.Default.Assemblies.FirstOrDefault( + assembly => string.Equals(assembly.GetName().Name, sharedName, StringComparison.OrdinalIgnoreCase)); + if (defaultAsm is null) + { + logger.LogDebug("Default context does not currently provide '{AssemblyName}'", sharedName); + continue; + } + + var defaultName = defaultAsm.GetName(); + var defaultMvid = defaultAsm.ManifestModule.ModuleVersionId; + + if (defaultName.Version != probedName.Version) + { + logger.LogWarning( + "Shared assembly '{AssemblyName}' version mismatch: bundled={BundledVersion}, libs={LibsVersion} ({LibsPath}). " + + "Integration assemblies referencing this assembly from the libs directory will fail to bind their type " + + "references through the default load context, which causes integrations to be silently skipped during type discovery. " + + "This typically indicates the apphost server bundle and the restored integration packages were produced by " + + "different build configurations.", + sharedName, + defaultName.Version, + probedName.Version, + libsPath); + continue; + } + + // Same version, but different MVID (compiled from different sources) is also a binary-incompatibility risk. + // We can't read the probed MVID without loading the assembly, which we deliberately don't do here. + // Logging the bundled MVID at Debug helps correlate with any subsequent ReflectionTypeLoadException. + logger.LogDebug("Shared assembly '{AssemblyName}' identity matches: Version={Version}, BundledMvid={Mvid}", + sharedName, defaultName.Version, defaultMvid); + } + } + private static Assembly LoadAssembly(IntegrationLoadContext loadContext, string name) { var assemblyName = new AssemblyName(name); diff --git a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationService.cs b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationService.cs index 1badb37b324..dad2198a3f5 100644 --- a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationService.cs +++ b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationService.cs @@ -232,7 +232,7 @@ public Dictionary GenerateCode(string language, string? assembly var generator = _resolver.GetCodeGenerator(language); if (generator == null) { - throw new ArgumentException($"No code generator found for language: {language}"); + throw new ArgumentException(BuildNoCodeGeneratorMessage(language)); } var context = _atsContextFactory.GetContext(); @@ -254,6 +254,26 @@ public Dictionary GenerateCode(string language, string? assembly throw; } } + + private string BuildNoCodeGeneratorMessage(string language) + { + var available = _resolver.GetSupportedLanguages() + .OrderBy(s => s, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (available.Length == 0) + { + // No generators discovered at all is almost always a binary-mismatch / type-load + // failure (see CodeGeneratorResolver warnings). Point the user at the apphost + // server log so they can see the underlying LoaderExceptions. + return $"No code generator found for language: {language}. " + + "No code generators were discovered in any loaded assembly. " + + "This usually indicates a binary mismatch between the bundled apphost server and the integration assemblies on disk; " + + "check the apphost server log for 'LoaderExceptions' Warnings."; + } + + return $"No code generator found for language: {language}. Available languages: {string.Join(", ", available)}."; + } } #region Response DTOs (Full Fidelity) diff --git a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGeneratorResolver.cs b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGeneratorResolver.cs index 2cd1028f9b0..ea4fcc818c5 100644 --- a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGeneratorResolver.cs +++ b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGeneratorResolver.cs @@ -20,10 +20,20 @@ public CodeGeneratorResolver( IServiceProvider serviceProvider, AssemblyLoader assemblyLoader, ILogger logger) + : this(serviceProvider, assemblyLoader.GetAssemblies, logger) + { + } + + // Test-only seam: lets unit tests inject a synthetic assembly set without going + // through the AssemblyLoader (which is sealed and probes the file system). + internal CodeGeneratorResolver( + IServiceProvider serviceProvider, + Func> assembliesProvider, + ILogger logger) { _logger = logger; _generators = new Lazy>( - () => DiscoverGenerators(serviceProvider, assemblyLoader.GetAssemblies())); + () => DiscoverGenerators(serviceProvider, assembliesProvider())); } /// @@ -37,6 +47,15 @@ public CodeGeneratorResolver( return generator; } + /// + /// Gets the languages of all discovered code generators. + /// + /// The set of supported language identifiers. + public IReadOnlyCollection GetSupportedLanguages() + { + return _generators.Value.Keys.ToArray(); + } + private Dictionary DiscoverGenerators( IServiceProvider serviceProvider, IReadOnlyList assemblies) @@ -46,17 +65,36 @@ private Dictionary DiscoverGenerators( foreach (var assembly in assemblies) { Type[] types; + var assemblyName = assembly.GetName().Name; + var hadTypeLoadFailure = false; try { types = assembly.GetTypes(); } catch (ReflectionTypeLoadException ex) { - _logger.LogDebug(ex, "Some types in assembly '{AssemblyName}' could not be loaded", assembly.GetName().Name); + hadTypeLoadFailure = true; + // 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 + // diverge). Including the LoaderExceptions in the log is essential for + // diagnosing these failures, which previously disappeared into Debug-level + // output that the apphost server never wrote to disk. + var loaderMessages = ex.LoaderExceptions is { Length: > 0 } loaders + ? string.Join("; ", loaders.Where(e => e is not null).Select(e => e!.Message).Distinct()) + : "(no LoaderExceptions captured)"; + _logger.LogWarning( + ex, + "Some types in assembly '{AssemblyName}' could not be loaded; {LoadedCount} of {TotalCount} types are available. LoaderExceptions: {LoaderExceptions}", + assemblyName, + ex.Types.Count(t => t is not null), + ex.Types.Length, + loaderMessages); // Use the types that were successfully loaded types = ex.Types.Where(t => t is not null).ToArray()!; } + var discoveredInAssembly = 0; foreach (var type in types) { if (!type.IsAbstract && @@ -67,6 +105,7 @@ private Dictionary DiscoverGenerators( { var generator = (ICodeGenerator)ActivatorUtilities.CreateInstance(serviceProvider, type); generators[generator.Language] = generator; + discoveredInAssembly++; _logger.LogDebug("Discovered code generator: {TypeName} for language '{Language}'", type.Name, generator.Language); } catch (Exception ex) @@ -75,8 +114,26 @@ private Dictionary DiscoverGenerators( } } } + + // If an assembly named like a code-generation contributor produced zero generators, + // that is almost certainly a silent type-load failure rather than an intentional + // design. Log a Warning so the user can see it. + if (discoveredInAssembly == 0 && LooksLikeCodeGeneratorAssembly(assemblyName)) + { + _logger.LogWarning( + "Assembly '{AssemblyName}' was loaded but did not contribute any {Interface} implementations. {Hint}", + assemblyName, + nameof(ICodeGenerator), + hadTypeLoadFailure + ? "This is likely caused by a binary mismatch between the bundled and probed assemblies (see preceding LoaderExceptions)." + : "Verify the assembly contains a non-abstract type that implements " + typeof(ICodeGenerator).FullName + "."); + } } return generators; } + + private static bool LooksLikeCodeGeneratorAssembly(string? assemblyName) + => assemblyName is not null + && assemblyName.StartsWith("Aspire.Hosting.CodeGeneration.", StringComparison.OrdinalIgnoreCase); } diff --git a/src/Aspire.Hosting.RemoteHost/IntegrationLoadContext.cs b/src/Aspire.Hosting.RemoteHost/IntegrationLoadContext.cs index 11c6b6d85be..3c0ac45c050 100644 --- a/src/Aspire.Hosting.RemoteHost/IntegrationLoadContext.cs +++ b/src/Aspire.Hosting.RemoteHost/IntegrationLoadContext.cs @@ -16,6 +16,12 @@ internal sealed class IntegrationLoadContext : AssemblyLoadContext { private const string SharedAssemblyName = "Aspire.TypeSystem"; + /// + /// Gets the assembly names that this load context shares with the default + /// (resolution always defers to the default ALC). + /// + internal static IReadOnlyList GetSharedAssemblyNames() => [SharedAssemblyName]; + private readonly string[] _probeDirectories; private readonly IntegrationPackageProbeManifest _packageProbeManifest; private readonly ILogger _logger; diff --git a/src/Aspire.Hosting.RemoteHost/Language/LanguageService.cs b/src/Aspire.Hosting.RemoteHost/Language/LanguageService.cs index 9ce7088d434..c613adebc8b 100644 --- a/src/Aspire.Hosting.RemoteHost/Language/LanguageService.cs +++ b/src/Aspire.Hosting.RemoteHost/Language/LanguageService.cs @@ -55,7 +55,7 @@ public Dictionary ScaffoldAppHost(string language, string target var languageSupport = _resolver.GetLanguageSupport(language); if (languageSupport == null) { - throw new ArgumentException($"No language support found for: {language}"); + throw new ArgumentException(BuildNoLanguageSupportMessage(language)); } var request = new ScaffoldRequest @@ -135,7 +135,7 @@ public RuntimeSpec GetRuntimeSpec(string language) var languageSupport = _resolver.GetLanguageSupport(language); if (languageSupport == null) { - throw new ArgumentException($"No language support found for: {language}"); + throw new ArgumentException(BuildNoLanguageSupportMessage(language)); } var spec = languageSupport.GetRuntimeSpec(); @@ -150,4 +150,25 @@ public RuntimeSpec GetRuntimeSpec(string language) throw; } } + + private string BuildNoLanguageSupportMessage(string language) + { + var available = _resolver.GetAllLanguages() + .Select(l => l.Language) + .OrderBy(s => s, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (available.Length == 0) + { + // No language support discovered at all is almost always a binary-mismatch / + // type-load failure (see LanguageSupportResolver warnings). Point the user at + // the apphost server log so they can see the underlying LoaderExceptions. + return $"No language support found for: {language}. " + + "No language support implementations were discovered in any loaded assembly. " + + "This usually indicates a binary mismatch between the bundled apphost server and the integration assemblies on disk; " + + "check the apphost server log for 'LoaderExceptions' Warnings."; + } + + return $"No language support found for: {language}. Available languages: {string.Join(", ", available)}."; + } } diff --git a/src/Aspire.Hosting.RemoteHost/Language/LanguageSupportResolver.cs b/src/Aspire.Hosting.RemoteHost/Language/LanguageSupportResolver.cs index 4e71e9f08a3..6fd332402c8 100644 --- a/src/Aspire.Hosting.RemoteHost/Language/LanguageSupportResolver.cs +++ b/src/Aspire.Hosting.RemoteHost/Language/LanguageSupportResolver.cs @@ -20,10 +20,20 @@ public LanguageSupportResolver( IServiceProvider serviceProvider, AssemblyLoader assemblyLoader, ILogger logger) + : this(serviceProvider, assemblyLoader.GetAssemblies, logger) + { + } + + // Test-only seam: lets unit tests inject a synthetic assembly set without going + // through the AssemblyLoader (which is sealed and probes the file system). + internal LanguageSupportResolver( + IServiceProvider serviceProvider, + Func> assembliesProvider, + ILogger logger) { _logger = logger; _languages = new Lazy>( - () => DiscoverLanguages(serviceProvider, assemblyLoader.GetAssemblies())); + () => DiscoverLanguages(serviceProvider, assembliesProvider())); } /// @@ -55,17 +65,36 @@ private Dictionary DiscoverLanguages( foreach (var assembly in assemblies) { Type[] types; + var assemblyName = assembly.GetName().Name; + var hadTypeLoadFailure = false; try { types = assembly.GetTypes(); } catch (ReflectionTypeLoadException ex) { - _logger.LogDebug(ex, "Some types in assembly '{AssemblyName}' could not be loaded", assembly.GetName().Name); + hadTypeLoadFailure = true; + // 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 + // diverge). Including the LoaderExceptions in the log is essential for + // diagnosing these failures, which previously disappeared into Debug-level + // output that the apphost server never wrote to disk. + var loaderMessages = ex.LoaderExceptions is { Length: > 0 } loaders + ? string.Join("; ", loaders.Where(e => e is not null).Select(e => e!.Message).Distinct()) + : "(no LoaderExceptions captured)"; + _logger.LogWarning( + ex, + "Some types in assembly '{AssemblyName}' could not be loaded; {LoadedCount} of {TotalCount} types are available. LoaderExceptions: {LoaderExceptions}", + assemblyName, + ex.Types.Count(t => t is not null), + ex.Types.Length, + loaderMessages); // Use the types that were successfully loaded types = ex.Types.Where(t => t is not null).ToArray()!; } + var discoveredInAssembly = 0; foreach (var type in types) { if (!type.IsAbstract && @@ -76,6 +105,7 @@ private Dictionary DiscoverLanguages( { var language = (ILanguageSupport)ActivatorUtilities.CreateInstance(serviceProvider, type); languages[language.Language] = language; + discoveredInAssembly++; _logger.LogDebug("Discovered language support: {TypeName} for language '{Language}'", type.Name, language.Language); } catch (Exception ex) @@ -84,8 +114,26 @@ private Dictionary DiscoverLanguages( } } } + + // If an assembly named like a code-generation / language-support contributor + // produced zero implementations, that is almost certainly a silent type-load + // failure rather than an intentional design. Log a Warning so the user can see it. + if (discoveredInAssembly == 0 && LooksLikeLanguageSupportAssembly(assemblyName)) + { + _logger.LogWarning( + "Assembly '{AssemblyName}' was loaded but did not contribute any {Interface} implementations. {Hint}", + assemblyName, + nameof(ILanguageSupport), + hadTypeLoadFailure + ? "This is likely caused by a binary mismatch between the bundled and probed assemblies (see preceding LoaderExceptions)." + : "Verify the assembly contains a non-abstract type that implements " + typeof(ILanguageSupport).FullName + "."); + } } return languages; } + + private static bool LooksLikeLanguageSupportAssembly(string? assemblyName) + => assemblyName is not null + && assemblyName.StartsWith("Aspire.Hosting.CodeGeneration.", StringComparison.OrdinalIgnoreCase); } diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/RecordingLogger.cs b/tests/Aspire.Hosting.RemoteHost.Tests/RecordingLogger.cs new file mode 100644 index 00000000000..ac8cb80e586 --- /dev/null +++ b/tests/Aspire.Hosting.RemoteHost.Tests/RecordingLogger.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.RemoteHost.Tests; + +/// +/// Minimal in-memory logger used by tests that need to inspect emitted log entries. +/// Thread-safe; entries are appended in the order Log is called. +/// +internal sealed class RecordingLogger : ILogger +{ + public ConcurrentQueue Entries { get; } = new(); + + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + Entries.Enqueue(new LogEntry(logLevel, eventId, formatter(state, exception), exception)); + } + + internal sealed record LogEntry(LogLevel Level, EventId EventId, string Message, Exception? Exception); + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + public void Dispose() { } + } +} diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/ResolverDiagnosticsTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/ResolverDiagnosticsTests.cs new file mode 100644 index 00000000000..9c2e5677344 --- /dev/null +++ b/tests/Aspire.Hosting.RemoteHost.Tests/ResolverDiagnosticsTests.cs @@ -0,0 +1,111 @@ +// 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; +using Aspire.Hosting.RemoteHost.CodeGeneration; +using Aspire.Hosting.RemoteHost.Language; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Aspire.Hosting.RemoteHost.Tests; + +/// +/// Coverage for the diagnostics surfaced by the discovery resolvers when type loading fails or +/// when a "code generation" assembly is loaded but contributes no implementations. These tests +/// guard the user-facing fix for https://github.com/microsoft/aspire/issues/16729 — a previously +/// silent ReflectionTypeLoadException that produced a downstream "no code generator found" / +/// "no language support found" error with no actionable diagnostic. +/// +public class ResolverDiagnosticsTests +{ + [Fact] + public void CodeGeneratorResolver_LogsWarning_WhenAssemblyTypeLoadFails() + { + var logger = new RecordingLogger(); + var stub = new TypeLoadFailingAssembly("Aspire.Hosting.CodeGeneration.Synthetic"); + + using var services = new ServiceCollection().BuildServiceProvider(); + var resolver = new CodeGeneratorResolver(services, () => (IReadOnlyList)[stub], logger); + + Assert.Null(resolver.GetCodeGenerator("anything")); + + var warning = logger.Entries.SingleOrDefault(e => e.Level == LogLevel.Warning && e.Message.Contains("could not be loaded")); + Assert.NotNull(warning); + Assert.Contains("LoaderExceptions", warning!.Message); + Assert.Contains(stub.GetName().Name!, warning.Message); + Assert.Contains("synthetic loader exception", warning.Message); + } + + [Fact] + public void LanguageSupportResolver_LogsWarning_WhenAssemblyTypeLoadFails() + { + var logger = new RecordingLogger(); + var stub = new TypeLoadFailingAssembly("Aspire.Hosting.CodeGeneration.Synthetic"); + + using var services = new ServiceCollection().BuildServiceProvider(); + var resolver = new LanguageSupportResolver(services, () => (IReadOnlyList)[stub], logger); + + Assert.Null(resolver.GetLanguageSupport("anything")); + + var warning = logger.Entries.SingleOrDefault(e => e.Level == LogLevel.Warning && e.Message.Contains("could not be loaded")); + Assert.NotNull(warning); + Assert.Contains("LoaderExceptions", warning!.Message); + Assert.Contains(stub.GetName().Name!, warning.Message); + Assert.Contains("synthetic loader exception", warning.Message); + } + + [Fact] + public void CodeGeneratorResolver_LogsWarning_WhenCodeGenerationAssemblyContributesNothing() + { + var logger = new RecordingLogger(); + // The marker name is what triggers the "did not contribute any" diagnostic. + var empty = new EmptyNamedAssembly("Aspire.Hosting.CodeGeneration.Synthetic"); + + using var services = new ServiceCollection().BuildServiceProvider(); + var resolver = new CodeGeneratorResolver(services, () => (IReadOnlyList)[empty], logger); + + Assert.Null(resolver.GetCodeGenerator("anything")); + + var warning = logger.Entries.SingleOrDefault(e => e.Level == LogLevel.Warning && e.Message.Contains("did not contribute")); + Assert.NotNull(warning); + Assert.Contains("ICodeGenerator", warning!.Message); + } + + [Fact] + public void CodeGeneratorResolver_DoesNotLogContributionWarning_ForArbitraryAssembly() + { + var logger = new RecordingLogger(); + var arbitrary = new EmptyNamedAssembly("My.Custom.Integration"); + + using var services = new ServiceCollection().BuildServiceProvider(); + _ = new CodeGeneratorResolver(services, () => (IReadOnlyList)[arbitrary], logger).GetCodeGenerator("anything"); + + Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("did not contribute")); + } + + 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( + [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)]; + } +} diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/ServiceErrorMessageTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/ServiceErrorMessageTests.cs new file mode 100644 index 00000000000..133b3ab28e7 --- /dev/null +++ b/tests/Aspire.Hosting.RemoteHost.Tests/ServiceErrorMessageTests.cs @@ -0,0 +1,142 @@ +// 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; +using Aspire.Hosting.RemoteHost.CodeGeneration; +using Aspire.Hosting.RemoteHost.Diagnostics; +using Aspire.Hosting.RemoteHost.Language; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Aspire.Hosting.RemoteHost.Tests; + +/// +/// Verifies the user-facing error messages on the RPC-exposed services include actionable +/// information instead of just "No language support found for: X". See +/// https://github.com/microsoft/aspire/issues/16729 for the background. +/// +public class ServiceErrorMessageTests +{ + [Fact] + public void ScaffoldAppHost_UnknownLanguage_ListsAvailableLanguages() + { + var (langService, _) = CreateServices(); + + var ex = Assert.Throws(() => langService.ScaffoldAppHost("klingon", "/tmp/whatever")); + + Assert.Contains("No language support found for: klingon", ex.Message); + Assert.Contains("Available languages:", ex.Message); + Assert.Contains("typescript/nodejs", ex.Message); + } + + [Fact] + public void GenerateCode_UnknownLanguage_ListsAvailableLanguages() + { + var (_, codeService) = CreateServices(); + + var ex = Assert.Throws(() => codeService.GenerateCode("klingon")); + + Assert.Contains("No code generator found for language: klingon", ex.Message); + Assert.Contains("Available languages:", ex.Message); + Assert.Contains("TypeScript", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ScaffoldAppHost_NoLanguagesDiscovered_PointsAtBundleMismatch() + { + var langService = CreateLanguageServiceWithEmptyResolver(); + + var ex = Assert.Throws(() => langService.ScaffoldAppHost("typescript/nodejs", "/tmp/whatever")); + + Assert.Contains("No language support found for: typescript/nodejs", ex.Message); + Assert.Contains("LoaderExceptions", ex.Message); + Assert.Contains("binary mismatch", ex.Message); + } + + [Fact] + public void GenerateCode_NoGeneratorsDiscovered_PointsAtBundleMismatch() + { + var codeService = CreateCodeGenerationServiceWithEmptyResolver(); + + var ex = Assert.Throws(() => codeService.GenerateCode("TypeScript")); + + Assert.Contains("No code generator found for language: TypeScript", ex.Message); + Assert.Contains("LoaderExceptions", ex.Message); + Assert.Contains("binary mismatch", ex.Message); + } + + private static (LanguageService Lang, CodeGenerationService Code) CreateServices() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["AtsAssemblies:0"] = "Aspire.Hosting.CodeGeneration.Go", + ["AtsAssemblies:1"] = "Aspire.Hosting.CodeGeneration.Java", + ["AtsAssemblies:2"] = "Aspire.Hosting.CodeGeneration.Python", + ["AtsAssemblies:3"] = "Aspire.Hosting.CodeGeneration.Rust", + ["AtsAssemblies:4"] = "Aspire.Hosting.CodeGeneration.TypeScript" + }) + .Build(); + + var telemetry = CreateTelemetry(); + var loader = new AssemblyLoader(configuration, NullLogger.Instance, telemetry); + // Note: do NOT dispose the ServiceProvider here. The resolvers lazily instantiate + // language-support / code-generator types via ActivatorUtilities, which would fail + // if the provider had already been disposed. + var services = new ServiceCollection().BuildServiceProvider(); + + var langResolver = new LanguageSupportResolver(services, loader, NullLogger.Instance); + var codeResolver = new CodeGeneratorResolver(services, loader, NullLogger.Instance); + + var auth = CreateAuthenticatedState(); + + var atsContextFactory = new AtsContextFactory(loader, NullLogger.Instance, telemetry); + + var lang = new LanguageService(auth, langResolver, NullLogger.Instance, telemetry); + var code = new CodeGenerationService(auth, atsContextFactory, codeResolver, NullLogger.Instance, telemetry); + return (lang, code); + } + + private static LanguageService CreateLanguageServiceWithEmptyResolver() + { + var services = new ServiceCollection().BuildServiceProvider(); + // Use the test-only seam to inject an empty assembly list so the resolver hits the + // "no implementations discovered" branch deterministically, regardless of what + // AssemblyLoader probing finds in the test runtime directory. + var langResolver = new LanguageSupportResolver( + services, + Array.Empty, + NullLogger.Instance); + + var auth = CreateAuthenticatedState(); + return new LanguageService(auth, langResolver, NullLogger.Instance, CreateTelemetry()); + } + + private static CodeGenerationService CreateCodeGenerationServiceWithEmptyResolver() + { + 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, + Array.Empty, + NullLogger.Instance); + + var auth = CreateAuthenticatedState(); + var atsContextFactory = new AtsContextFactory(loader, NullLogger.Instance, telemetry); + return new CodeGenerationService(auth, atsContextFactory, codeResolver, 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()); +}