Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -856,15 +856,25 @@ 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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

having history type comments like this are awkward IMO. "Promoted from LogTrace to LogDebug" won't really mean anything in a few weeks. We should word this so it explains why we are doing what we are doing.

// 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);
}
};
process.ErrorDataReceived += (_, e) =>
{
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);
}
};
Expand Down
76 changes: 76 additions & 0 deletions src/Aspire.Hosting.RemoteHost/AssemblyLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ public AssemblyLoader(
string.IsNullOrWhiteSpace(libsPath) ? "<none>" : libsPath,
string.IsNullOrWhiteSpace(probeManifestPath) ? "<none>" : probeManifestPath);

WarnIfSharedAssemblyMismatch(libsPath, logger);

_assemblies = new Lazy<IReadOnlyList<Assembly>>(
() => LoadAssemblies(_assemblyNamesToLoad.Value, _loadContext, logger));
}
Expand Down Expand Up @@ -169,6 +171,80 @@ private static List<Assembly> LoadAssemblies(
return assemblies;
}

/// <summary>
/// Warns when a shared assembly (one that <see cref="IntegrationLoadContext"/> intentionally
/// resolves through the default <see cref="AssemblyLoadContext"/>) exists in the integration
/// libs directory at a different identity than what the default context provides.
/// </summary>
/// <remarks>
/// This is a defense against a real failure mode: when the bundled <c>Aspire.TypeSystem</c>
/// (compiled into the apphost server's single-file executable) and the libs copy
/// (restored alongside <c>Aspire.Hosting.*.dll</c>) 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 <see cref="ReflectionTypeLoadException"/>
/// would otherwise be swallowed silently and surface only as a downstream "no code generator
/// found" / "no language support found" error with no actionable diagnostic.
/// </remarks>
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ public Dictionary<string, string> 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();
Expand All @@ -254,6 +254,26 @@ public Dictionary<string, string> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,20 @@ public CodeGeneratorResolver(
IServiceProvider serviceProvider,
AssemblyLoader assemblyLoader,
ILogger<CodeGeneratorResolver> 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<IReadOnlyList<Assembly>> assembliesProvider,
ILogger<CodeGeneratorResolver> logger)
{
_logger = logger;
_generators = new Lazy<Dictionary<string, ICodeGenerator>>(
() => DiscoverGenerators(serviceProvider, assemblyLoader.GetAssemblies()));
() => DiscoverGenerators(serviceProvider, assembliesProvider()));
}

/// <summary>
Expand All @@ -37,6 +47,15 @@ public CodeGeneratorResolver(
return generator;
}

/// <summary>
/// Gets the languages of all discovered code generators.
/// </summary>
/// <returns>The set of supported language identifiers.</returns>
public IReadOnlyCollection<string> GetSupportedLanguages()
{
return _generators.Value.Keys.ToArray();
}

private Dictionary<string, ICodeGenerator> DiscoverGenerators(
IServiceProvider serviceProvider,
IReadOnlyList<Assembly> assemblies)
Expand All @@ -46,17 +65,36 @@ private Dictionary<string, ICodeGenerator> 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 &&
Expand All @@ -67,6 +105,7 @@ private Dictionary<string, ICodeGenerator> 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)
Expand All @@ -75,8 +114,26 @@ private Dictionary<string, ICodeGenerator> 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);
}
6 changes: 6 additions & 0 deletions src/Aspire.Hosting.RemoteHost/IntegrationLoadContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ internal sealed class IntegrationLoadContext : AssemblyLoadContext
{
private const string SharedAssemblyName = "Aspire.TypeSystem";

/// <summary>
/// Gets the assembly names that this load context shares with the default
/// <see cref="AssemblyLoadContext"/> (resolution always defers to the default ALC).
/// </summary>
internal static IReadOnlyList<string> GetSharedAssemblyNames() => [SharedAssemblyName];

private readonly string[] _probeDirectories;
private readonly IntegrationPackageProbeManifest _packageProbeManifest;
private readonly ILogger _logger;
Expand Down
25 changes: 23 additions & 2 deletions src/Aspire.Hosting.RemoteHost/Language/LanguageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public Dictionary<string, string> 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
Expand Down Expand Up @@ -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();
Expand All @@ -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)}.";
}
}
Loading
Loading