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 @@ -214,6 +221,7 @@ is TypeLoadException
or MissingMethodException
or MissingFieldException
or FileLoadException
or FileNotFoundException
or BadImageFormatException
or ReflectionTypeLoadException;

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,27 @@ 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: the resolved generators and any
/// <see cref="ReflectionTypeLoadException"/>s swallowed while probing assemblies. A non-empty
/// <see cref="DiscoveryResult.LoadFailures"/> almost always means a code generator was silently
/// dropped because of a binary mismatch (typically a diverged <c>Aspire.TypeSystem</c> 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 <c>ResolverDiagnosticsTests</c>.
/// </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 +87,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 +147,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,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<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 BuildDiagnostic_CapturesRuntimeAspireHostingVersion()
{
Expand Down
29 changes: 29 additions & 0 deletions tests/Aspire.Hosting.RemoteHost.Tests/ResolverDiagnosticsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CodeGeneratorResolver>();
var stub = new TypeLoadFailingAssembly("Aspire.Hosting.CodeGeneration.TypeScript");

using var services = new ServiceCollection().BuildServiceProvider();
var resolver = new CodeGeneratorResolver(services, () => (IReadOnlyList<Assembly>)[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.Discovery.LoadFailures);
Assert.Contains("synthetic loader exception", failure.LoaderExceptions[0]!.Message);
}

[Fact]
public void CodeGeneratorResolver_NoLoadFailures_ReturnsEmpty()
{
var logger = new RecordingLogger<CodeGeneratorResolver>();

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

Assert.Null(resolver.GetCodeGenerator("TypeScript"));
Assert.Empty(resolver.Discovery.LoadFailures);
}

private sealed class TypeLoadFailingAssembly : Assembly
{
private readonly AssemblyName _name;
Expand Down
53 changes: 53 additions & 0 deletions tests/Aspire.Hosting.RemoteHost.Tests/ServiceErrorMessageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<LocalRpcException>(() => codeService.GenerateCode("TypeScript"));

Assert.Equal(CodeGenerationErrorCodes.IncompatibleAspireSdk, ex.ErrorCode);
var diagnostic = Assert.IsType<CodeGenerationDiagnostic>(ex.ErrorData);
Assert.False(string.IsNullOrWhiteSpace(diagnostic.RemediationHint));
}

private static (LanguageService Lang, CodeGenerationService Code) CreateServices()
{
var configuration = new ConfigurationBuilder()
Expand Down Expand Up @@ -133,10 +149,47 @@ private static CodeGenerationService CreateCodeGenerationServiceWithEmptyResolve
return new CodeGenerationService(auth, atsContextFactory, codeResolver, loader, NullLogger<CodeGenerationService>.Instance, telemetry);
}

private static CodeGenerationService CreateCodeGenerationServiceWithLoadFailingAssembly()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();

var telemetry = CreateTelemetry();
var loader = new AssemblyLoader(configuration, NullLogger<AssemblyLoader>.Instance, telemetry);
var services = new ServiceCollection().BuildServiceProvider();
var codeResolver = new CodeGeneratorResolver(
services,
() => (IReadOnlyList<Assembly>)[new LoadFailingAssembly("Aspire.Hosting.CodeGeneration.TypeScript")],
NullLogger<CodeGeneratorResolver>.Instance);

var auth = CreateAuthenticatedState();
var atsContextFactory = new AtsContextFactory(loader, NullLogger<AtsContextFactory>.Instance, telemetry);
return new CodeGenerationService(auth, atsContextFactory, codeResolver, loader, NullLogger<CodeGenerationService>.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")]);
}
}
Loading