Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -190,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;
}
Expand All @@ -200,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;
}
Expand All @@ -217,6 +230,40 @@ or FileLoadException
or BadImageFormatException
or ReflectionTypeLoadException;

/// <summary>
/// Determines whether a standalone <see cref="FileNotFoundException"/> represents an
/// assembly-bind failure (a missing dependency assembly) rather than ordinary missing-file IO.
/// </summary>
/// <remarks>
/// The CLR raises a <see cref="FileNotFoundException"/> whose <see cref="FileNotFoundException.FileName"/>
/// is an assembly display name (for example
/// <c>"Aspire.TypeSystem, Version=42.42.42.42, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51"</c>)
/// when it cannot bind a referenced assembly. A code generator that simply fails to open a data
/// file produces a path-like <c>FileName</c> 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.
/// </remarks>
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<CodeGenerationLoadedAssemblyInfo> Assemblies) CaptureLoadedAssemblies(
AssemblyLoader? assemblyLoader,
ILogger? logger)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,16 @@ public Dictionary<string, string> 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.Discovery.LoadFailures;
var loadFailure = loadFailures.Count > 0 ? loadFailures[0] : null;
throw new ArgumentException(BuildNoCodeGeneratorMessage(language), loadFailure);
}

var context = _atsContextFactory.GetContext();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ namespace Aspire.Hosting.RemoteHost.CodeGeneration;
/// </summary>
internal sealed class CodeGeneratorResolver
{
private readonly Lazy<Dictionary<string, ICodeGenerator>> _generators;
private readonly Lazy<DiscoveryResult> _discovery;
private readonly ILogger<CodeGeneratorResolver> _logger;

public CodeGeneratorResolver(
Expand All @@ -32,7 +32,7 @@ internal CodeGeneratorResolver(
ILogger<CodeGeneratorResolver> logger)
{
_logger = logger;
_generators = new Lazy<Dictionary<string, ICodeGenerator>>(
_discovery = new Lazy<DiscoveryResult>(
() => DiscoverGenerators(serviceProvider, assembliesProvider()));
}

Expand All @@ -43,7 +43,7 @@ internal CodeGeneratorResolver(
/// <returns>The code generator, or null if not found.</returns>
public ICodeGenerator? GetCodeGenerator(string language)
{
_generators.Value.TryGetValue(language, out var generator);
_discovery.Value.Generators.TryGetValue(language, out var generator);
return generator;
}

Expand All @@ -53,14 +53,20 @@ internal CodeGeneratorResolver(
/// <returns>The set of supported language identifiers.</returns>
public IReadOnlyCollection<string> GetSupportedLanguages()
{
return _generators.Value.Keys.ToArray();
return _discovery.Value.Generators.Keys.ToArray();
}

private Dictionary<string, ICodeGenerator> DiscoverGenerators(
/// <summary>
/// Gets the result of generator discovery, including any load failures swallowed during probing.
/// </summary>
internal DiscoveryResult Discovery => _discovery.Value;
Comment thread
Copilot marked this conversation as resolved.

private DiscoveryResult DiscoverGenerators(
IServiceProvider serviceProvider,
IReadOnlyList<Assembly> assemblies)
{
var generators = new Dictionary<string, ICodeGenerator>(StringComparer.OrdinalIgnoreCase);
var loadFailures = new List<ReflectionTypeLoadException>();

foreach (var assembly in assemblies)
{
Expand All @@ -74,6 +80,10 @@ private Dictionary<string, ICodeGenerator> 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 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
// assemblies loaded from disk (for example, when Aspire.TypeSystem versions
Expand Down Expand Up @@ -130,10 +140,14 @@ private Dictionary<string, ICodeGenerator> 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);

internal sealed record DiscoveryResult(
Dictionary<string, ICodeGenerator> Generators,
IReadOnlyList<ReflectionTypeLoadException> LoadFailures);
}
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,77 @@ 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<LocalRpcException>(result);
Assert.Equal(CodeGenerationErrorCodes.IncompatibleAspireSdk, localRpc.ErrorCode);
var diagnostic = Assert.IsType<CodeGenerationDiagnostic>(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<LocalRpcException>(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);
Comment thread
sebastienros marked this conversation as resolved.
Outdated
}

[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<LocalRpcException>(result);
Assert.Equal(CodeGenerationErrorCodes.IncompatibleAspireSdk, localRpc.ErrorCode);
}

[Fact]
public void BuildDiagnostic_CapturesRuntimeAspireHostingVersion()
{
Expand Down
41 changes: 41 additions & 0 deletions tests/Aspire.Hosting.RemoteHost.Tests/FakeAssembly.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// A configurable <see cref="Assembly"/> test double whose name and <see cref="GetTypes"/>
/// 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
/// <see cref="Assembly.GetTypes"/>, so throwing from it reproduces a real type-load failure).
/// </summary>
internal sealed class FakeAssembly : Assembly
{
private readonly AssemblyName _name;
private readonly Func<Type[]> _getTypes;

private FakeAssembly(string name, Func<Type[]> getTypes)
{
_name = new AssemblyName(name);
_getTypes = getTypes;
}

public override AssemblyName GetName() => _name;

public override Type[] GetTypes() => _getTypes();

/// <summary>
/// Creates a fake assembly whose <see cref="GetTypes"/> returns the supplied types.
/// </summary>
public static FakeAssembly WithTypes(string name, params Type[] types)
=> new(name, () => types);

/// <summary>
/// Creates a fake assembly whose <see cref="GetTypes"/> throws the supplied exception,
/// simulating an assembly that loads but cannot have its types enumerated.
/// </summary>
public static FakeAssembly ThrowingOnGetTypes(string name, Exception exception)
=> new(name, () => throw exception);
}
49 changes: 31 additions & 18 deletions tests/Aspire.Hosting.RemoteHost.Tests/ResolverDiagnosticsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public class ResolverDiagnosticsTests
public void CodeGeneratorResolver_LogsWarning_WhenAssemblyTypeLoadFails()
{
var logger = new RecordingLogger<CodeGeneratorResolver>();
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<Assembly>)[stub], logger);
Expand All @@ -41,7 +41,7 @@ public void CodeGeneratorResolver_LogsWarning_WhenAssemblyTypeLoadFails()
public void LanguageSupportResolver_LogsWarning_WhenAssemblyTypeLoadFails()
{
var logger = new RecordingLogger<LanguageSupportResolver>();
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<Assembly>)[stub], logger);
Expand All @@ -60,7 +60,7 @@ public void CodeGeneratorResolver_LogsWarning_WhenCodeGenerationAssemblyContribu
{
var logger = new RecordingLogger<CodeGeneratorResolver>();
// 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<Assembly>)[empty], logger);
Expand All @@ -76,36 +76,49 @@ public void CodeGeneratorResolver_LogsWarning_WhenCodeGenerationAssemblyContribu
public void CodeGeneratorResolver_DoesNotLogContributionWarning_ForArbitraryAssembly()
{
var logger = new RecordingLogger<CodeGeneratorResolver>();
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<Assembly>)[arbitrary], logger).GetCodeGenerator("anything");

Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("did not contribute"));
}

private sealed class TypeLoadFailingAssembly : Assembly
[Fact]
public void CodeGeneratorResolver_ExposesSwallowedLoadFailures()
{
private readonly AssemblyName _name;
var logger = new RecordingLogger<CodeGeneratorResolver>();
var stub = CreateTypeLoadFailingAssembly("Aspire.Hosting.CodeGeneration.TypeScript");

public TypeLoadFailingAssembly(string name) => _name = new AssemblyName(name);
using var services = new ServiceCollection().BuildServiceProvider();
var resolver = new CodeGeneratorResolver(services, () => (IReadOnlyList<Assembly>)[stub], logger);

public override AssemblyName GetName() => _name;
// 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"));

public override Type[] GetTypes()
=> throw new ReflectionTypeLoadException(
[null, typeof(string)],
[new FileLoadException("synthetic loader exception: simulated Aspire.TypeSystem mismatch")]);
var failure = Assert.Single(resolver.Discovery.LoadFailures);
Assert.Contains("synthetic loader exception", failure.LoaderExceptions[0]!.Message);
}

private sealed class EmptyNamedAssembly : Assembly
[Fact]
public void CodeGeneratorResolver_NoLoadFailures_ReturnsEmpty()
{
private readonly AssemblyName _name;

public EmptyNamedAssembly(string name) => _name = new AssemblyName(name);
var logger = new RecordingLogger<CodeGeneratorResolver>();

public override AssemblyName GetName() => _name;
using var services = new ServiceCollection().BuildServiceProvider();
var resolver = new CodeGeneratorResolver(services, Array.Empty<Assembly>, logger);

public override Type[] GetTypes() => [typeof(string)];
Assert.Null(resolver.GetCodeGenerator("TypeScript"));
Assert.Empty(resolver.Discovery.LoadFailures);
}

// 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")]));
}
Loading
Loading