Skip to content

Commit 9874fb4

Browse files
IEvangelistCopilot
andcommitted
Surface silent integration-assembly load failures (#16729)
When integration assembly discovery fails for any reason (build-pipeline version skew, stale local NuGet cache, transitive dependency mismatch, partially populated probe directory, etc.), the failure currently surfaces only as: No code generator found for language: TypeScript No language support found for: typescript/nodejs The underlying ReflectionTypeLoadException is swallowed at LogDebug, below the file logger's default Information threshold, and never reaches the CLI's on-disk log. This makes the user unable to self-diagnose. This change hardens the diagnostic chain at every layer without altering the success path: * CodeGeneratorResolver / LanguageSupportResolver now log ReflectionTypeLoadException at Warning level and include the LoaderExceptions text in the message. * When an assembly named Aspire.Hosting.CodeGeneration.* is loaded but contributes zero ICodeGenerator / ILanguageSupport types, log a Warning so the silent-failure case is visible. * AssemblyLoader runs an Aspire.TypeSystem version sanity check at startup against the libs directory and warns when the bundled and probed versions diverge. * LanguageService / CodeGenerationService error messages now list the available languages, or point at the apphost-server log + binary mismatch hint when zero have been discovered. * PrebuiltAppHostServer promotes apphost-server stdout/stderr capture from Trace to Debug/Information, so warnings emitted by the apphost server reach the default file log. Adds CodeGeneratorResolver.GetSupportedLanguages(), internal test-only constructors on both resolvers that take a synthetic assembly factory, and IntegrationLoadContext.GetSharedAssemblyNames(). Adds 8 new tests covering the resolver warning paths and the user-facing error messages. Closes #16729 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent fc1dad0 commit 9874fb4

10 files changed

Lines changed: 533 additions & 9 deletions

File tree

src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -856,15 +856,25 @@ private static string GetRestoreVersion(string packageName, string version, bool
856856
{
857857
if (e.Data is not null)
858858
{
859-
_logger.LogTrace("PrebuiltAppHostServer({ProcessId}) stdout: {Line}", process.Id, e.Data);
859+
// Promoted from LogTrace to LogDebug so that apphost-server stdout reaches the
860+
// CLI's on-disk log under the default file-logger filter (Debug). Previously
861+
// these lines were dropped entirely, which made apphost-side warnings
862+
// (for example, "LoaderExceptions" from the type-discovery path) invisible to
863+
// anyone diagnosing a "no code generator found" / "no language support found"
864+
// error. See https://github.com/microsoft/aspire/issues/16729.
865+
_logger.LogDebug("PrebuiltAppHostServer({ProcessId}) stdout: {Line}", process.Id, e.Data);
860866
outputCollector.AppendOutput(e.Data);
861867
}
862868
};
863869
process.ErrorDataReceived += (_, e) =>
864870
{
865871
if (e.Data is not null)
866872
{
867-
_logger.LogTrace("PrebuiltAppHostServer({ProcessId}) stderr: {Line}", process.Id, e.Data);
873+
// Promoted from LogTrace to LogInformation so that apphost-server stderr is
874+
// visible at the default console log level (Information). Stderr is reserved
875+
// for genuine problems in well-behaved server processes, so surfacing it
876+
// by default is appropriate. See https://github.com/microsoft/aspire/issues/16729.
877+
_logger.LogInformation("PrebuiltAppHostServer({ProcessId}) stderr: {Line}", process.Id, e.Data);
868878
outputCollector.AppendError(e.Data);
869879
}
870880
};

src/Aspire.Hosting.RemoteHost/AssemblyLoader.cs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ public AssemblyLoader(
4343
string.IsNullOrWhiteSpace(libsPath) ? "<none>" : libsPath,
4444
string.IsNullOrWhiteSpace(probeManifestPath) ? "<none>" : probeManifestPath);
4545

46+
WarnIfSharedAssemblyMismatch(libsPath, logger);
47+
4648
_assemblies = new Lazy<IReadOnlyList<Assembly>>(
4749
() => LoadAssemblies(_assemblyNamesToLoad.Value, _loadContext, logger));
4850
}
@@ -169,6 +171,80 @@ private static List<Assembly> LoadAssemblies(
169171
return assemblies;
170172
}
171173

174+
/// <summary>
175+
/// Warns when a shared assembly (one that <see cref="IntegrationLoadContext"/> intentionally
176+
/// resolves through the default <see cref="AssemblyLoadContext"/>) exists in the integration
177+
/// libs directory at a different identity than what the default context provides.
178+
/// </summary>
179+
/// <remarks>
180+
/// This is a defense against a real failure mode: when the bundled <c>Aspire.TypeSystem</c>
181+
/// (compiled into the apphost server's single-file executable) and the libs copy
182+
/// (restored alongside <c>Aspire.Hosting.*.dll</c>) report different assembly versions or MVIDs,
183+
/// integration assemblies that reference the libs copy will fail to bind their type
184+
/// references through the default context. The resulting <see cref="ReflectionTypeLoadException"/>
185+
/// would otherwise be swallowed silently and surface only as a downstream "no code generator
186+
/// found" / "no language support found" error with no actionable diagnostic.
187+
/// </remarks>
188+
private static void WarnIfSharedAssemblyMismatch(string? integrationLibsPath, ILogger logger)
189+
{
190+
if (string.IsNullOrWhiteSpace(integrationLibsPath) || !Directory.Exists(integrationLibsPath))
191+
{
192+
return;
193+
}
194+
195+
foreach (var sharedName in IntegrationLoadContext.GetSharedAssemblyNames())
196+
{
197+
var libsPath = Path.Combine(integrationLibsPath, sharedName + ".dll");
198+
if (!File.Exists(libsPath))
199+
{
200+
continue;
201+
}
202+
203+
AssemblyName? probedName;
204+
try
205+
{
206+
probedName = AssemblyName.GetAssemblyName(libsPath);
207+
}
208+
catch (Exception ex)
209+
{
210+
logger.LogDebug(ex, "Could not read assembly identity from {Path}", libsPath);
211+
continue;
212+
}
213+
214+
var defaultAsm = AssemblyLoadContext.Default.Assemblies.FirstOrDefault(
215+
assembly => string.Equals(assembly.GetName().Name, sharedName, StringComparison.OrdinalIgnoreCase));
216+
if (defaultAsm is null)
217+
{
218+
logger.LogDebug("Default context does not currently provide '{AssemblyName}'", sharedName);
219+
continue;
220+
}
221+
222+
var defaultName = defaultAsm.GetName();
223+
var defaultMvid = defaultAsm.ManifestModule.ModuleVersionId;
224+
225+
if (defaultName.Version != probedName.Version)
226+
{
227+
logger.LogWarning(
228+
"Shared assembly '{AssemblyName}' version mismatch: bundled={BundledVersion}, libs={LibsVersion} ({LibsPath}). " +
229+
"Integration assemblies referencing this assembly from the libs directory will fail to bind their type " +
230+
"references through the default load context, which causes integrations to be silently skipped during type discovery. " +
231+
"This typically indicates the apphost server bundle and the restored integration packages were produced by " +
232+
"different build configurations.",
233+
sharedName,
234+
defaultName.Version,
235+
probedName.Version,
236+
libsPath);
237+
continue;
238+
}
239+
240+
// Same version, but different MVID (compiled from different sources) is also a binary-incompatibility risk.
241+
// We can't read the probed MVID without loading the assembly, which we deliberately don't do here.
242+
// Logging the bundled MVID at Debug helps correlate with any subsequent ReflectionTypeLoadException.
243+
logger.LogDebug("Shared assembly '{AssemblyName}' identity matches: Version={Version}, BundledMvid={Mvid}",
244+
sharedName, defaultName.Version, defaultMvid);
245+
}
246+
}
247+
172248
private static Assembly LoadAssembly(IntegrationLoadContext loadContext, string name)
173249
{
174250
var assemblyName = new AssemblyName(name);

src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationService.cs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ public Dictionary<string, string> GenerateCode(string language, string? assembly
232232
var generator = _resolver.GetCodeGenerator(language);
233233
if (generator == null)
234234
{
235-
throw new ArgumentException($"No code generator found for language: {language}");
235+
throw new ArgumentException(BuildNoCodeGeneratorMessage(language));
236236
}
237237

238238
var context = _atsContextFactory.GetContext();
@@ -254,6 +254,26 @@ public Dictionary<string, string> GenerateCode(string language, string? assembly
254254
throw;
255255
}
256256
}
257+
258+
private string BuildNoCodeGeneratorMessage(string language)
259+
{
260+
var available = _resolver.GetSupportedLanguages()
261+
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
262+
.ToArray();
263+
264+
if (available.Length == 0)
265+
{
266+
// No generators discovered at all is almost always a binary-mismatch / type-load
267+
// failure (see CodeGeneratorResolver warnings). Point the user at the apphost
268+
// server log so they can see the underlying LoaderExceptions.
269+
return $"No code generator found for language: {language}. " +
270+
"No code generators were discovered in any loaded assembly. " +
271+
"This usually indicates a binary mismatch between the bundled apphost server and the integration assemblies on disk; " +
272+
"check the apphost server log for 'LoaderExceptions' Warnings.";
273+
}
274+
275+
return $"No code generator found for language: {language}. Available languages: {string.Join(", ", available)}.";
276+
}
257277
}
258278

259279
#region Response DTOs (Full Fidelity)

src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGeneratorResolver.cs

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,20 @@ public CodeGeneratorResolver(
2020
IServiceProvider serviceProvider,
2121
AssemblyLoader assemblyLoader,
2222
ILogger<CodeGeneratorResolver> logger)
23+
: this(serviceProvider, assemblyLoader.GetAssemblies, logger)
24+
{
25+
}
26+
27+
// Test-only seam: lets unit tests inject a synthetic assembly set without going
28+
// through the AssemblyLoader (which is sealed and probes the file system).
29+
internal CodeGeneratorResolver(
30+
IServiceProvider serviceProvider,
31+
Func<IReadOnlyList<Assembly>> assembliesProvider,
32+
ILogger<CodeGeneratorResolver> logger)
2333
{
2434
_logger = logger;
2535
_generators = new Lazy<Dictionary<string, ICodeGenerator>>(
26-
() => DiscoverGenerators(serviceProvider, assemblyLoader.GetAssemblies()));
36+
() => DiscoverGenerators(serviceProvider, assembliesProvider()));
2737
}
2838

2939
/// <summary>
@@ -37,6 +47,15 @@ public CodeGeneratorResolver(
3747
return generator;
3848
}
3949

50+
/// <summary>
51+
/// Gets the languages of all discovered code generators.
52+
/// </summary>
53+
/// <returns>The set of supported language identifiers.</returns>
54+
public IReadOnlyCollection<string> GetSupportedLanguages()
55+
{
56+
return _generators.Value.Keys.ToArray();
57+
}
58+
4059
private Dictionary<string, ICodeGenerator> DiscoverGenerators(
4160
IServiceProvider serviceProvider,
4261
IReadOnlyList<Assembly> assemblies)
@@ -46,17 +65,36 @@ private Dictionary<string, ICodeGenerator> DiscoverGenerators(
4665
foreach (var assembly in assemblies)
4766
{
4867
Type[] types;
68+
var assemblyName = assembly.GetName().Name;
69+
var hadTypeLoadFailure = false;
4970
try
5071
{
5172
types = assembly.GetTypes();
5273
}
5374
catch (ReflectionTypeLoadException ex)
5475
{
55-
_logger.LogDebug(ex, "Some types in assembly '{AssemblyName}' could not be loaded", assembly.GetName().Name);
76+
hadTypeLoadFailure = true;
77+
// Surface loader binding failures at Warning level. These typically indicate
78+
// a binary mismatch between the bundled runtime assemblies and the integration
79+
// assemblies loaded from disk (for example, when Aspire.TypeSystem versions
80+
// diverge). Including the LoaderExceptions in the log is essential for
81+
// diagnosing these failures, which previously disappeared into Debug-level
82+
// output that the apphost server never wrote to disk.
83+
var loaderMessages = ex.LoaderExceptions is { Length: > 0 } loaders
84+
? string.Join("; ", loaders.Where(e => e is not null).Select(e => e!.Message).Distinct())
85+
: "(no LoaderExceptions captured)";
86+
_logger.LogWarning(
87+
ex,
88+
"Some types in assembly '{AssemblyName}' could not be loaded; {LoadedCount} of {TotalCount} types are available. LoaderExceptions: {LoaderExceptions}",
89+
assemblyName,
90+
ex.Types.Count(t => t is not null),
91+
ex.Types.Length,
92+
loaderMessages);
5693
// Use the types that were successfully loaded
5794
types = ex.Types.Where(t => t is not null).ToArray()!;
5895
}
5996

97+
var discoveredInAssembly = 0;
6098
foreach (var type in types)
6199
{
62100
if (!type.IsAbstract &&
@@ -67,6 +105,7 @@ private Dictionary<string, ICodeGenerator> DiscoverGenerators(
67105
{
68106
var generator = (ICodeGenerator)ActivatorUtilities.CreateInstance(serviceProvider, type);
69107
generators[generator.Language] = generator;
108+
discoveredInAssembly++;
70109
_logger.LogDebug("Discovered code generator: {TypeName} for language '{Language}'", type.Name, generator.Language);
71110
}
72111
catch (Exception ex)
@@ -75,8 +114,26 @@ private Dictionary<string, ICodeGenerator> DiscoverGenerators(
75114
}
76115
}
77116
}
117+
118+
// If an assembly named like a code-generation contributor produced zero generators,
119+
// that is almost certainly a silent type-load failure rather than an intentional
120+
// design. Log a Warning so the user can see it.
121+
if (discoveredInAssembly == 0 && LooksLikeCodeGeneratorAssembly(assemblyName))
122+
{
123+
_logger.LogWarning(
124+
"Assembly '{AssemblyName}' was loaded but did not contribute any {Interface} implementations. {Hint}",
125+
assemblyName,
126+
nameof(ICodeGenerator),
127+
hadTypeLoadFailure
128+
? "This is likely caused by a binary mismatch between the bundled and probed assemblies (see preceding LoaderExceptions)."
129+
: "Verify the assembly contains a non-abstract type that implements " + typeof(ICodeGenerator).FullName + ".");
130+
}
78131
}
79132

80133
return generators;
81134
}
135+
136+
private static bool LooksLikeCodeGeneratorAssembly(string? assemblyName)
137+
=> assemblyName is not null
138+
&& assemblyName.StartsWith("Aspire.Hosting.CodeGeneration.", StringComparison.OrdinalIgnoreCase);
82139
}

src/Aspire.Hosting.RemoteHost/IntegrationLoadContext.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ internal sealed class IntegrationLoadContext : AssemblyLoadContext
1616
{
1717
private const string SharedAssemblyName = "Aspire.TypeSystem";
1818

19+
/// <summary>
20+
/// Gets the assembly names that this load context shares with the default
21+
/// <see cref="AssemblyLoadContext"/> (resolution always defers to the default ALC).
22+
/// </summary>
23+
internal static IReadOnlyList<string> GetSharedAssemblyNames() => [SharedAssemblyName];
24+
1925
private readonly string[] _probeDirectories;
2026
private readonly IntegrationPackageProbeManifest _packageProbeManifest;
2127
private readonly ILogger _logger;

src/Aspire.Hosting.RemoteHost/Language/LanguageService.cs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ public Dictionary<string, string> ScaffoldAppHost(string language, string target
5555
var languageSupport = _resolver.GetLanguageSupport(language);
5656
if (languageSupport == null)
5757
{
58-
throw new ArgumentException($"No language support found for: {language}");
58+
throw new ArgumentException(BuildNoLanguageSupportMessage(language));
5959
}
6060

6161
var request = new ScaffoldRequest
@@ -135,7 +135,7 @@ public RuntimeSpec GetRuntimeSpec(string language)
135135
var languageSupport = _resolver.GetLanguageSupport(language);
136136
if (languageSupport == null)
137137
{
138-
throw new ArgumentException($"No language support found for: {language}");
138+
throw new ArgumentException(BuildNoLanguageSupportMessage(language));
139139
}
140140

141141
var spec = languageSupport.GetRuntimeSpec();
@@ -150,4 +150,25 @@ public RuntimeSpec GetRuntimeSpec(string language)
150150
throw;
151151
}
152152
}
153+
154+
private string BuildNoLanguageSupportMessage(string language)
155+
{
156+
var available = _resolver.GetAllLanguages()
157+
.Select(l => l.Language)
158+
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
159+
.ToArray();
160+
161+
if (available.Length == 0)
162+
{
163+
// No language support discovered at all is almost always a binary-mismatch /
164+
// type-load failure (see LanguageSupportResolver warnings). Point the user at
165+
// the apphost server log so they can see the underlying LoaderExceptions.
166+
return $"No language support found for: {language}. " +
167+
"No language support implementations were discovered in any loaded assembly. " +
168+
"This usually indicates a binary mismatch between the bundled apphost server and the integration assemblies on disk; " +
169+
"check the apphost server log for 'LoaderExceptions' Warnings.";
170+
}
171+
172+
return $"No language support found for: {language}. Available languages: {string.Join(", ", available)}.";
173+
}
153174
}

0 commit comments

Comments
 (0)