diff --git a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs index dd1c9472bab..a79a52c00dd 100644 --- a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs +++ b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs @@ -8,6 +8,7 @@ using System.Text.Json.Serialization.Metadata; using Aspire.Cli.Commands; using Aspire.Cli.Commands.Sdk; +using Aspire.Cli.Projects; using Aspire.TypeSystem; using Spectre.Console; using StreamJsonRpc; @@ -62,6 +63,9 @@ namespace Aspire.Cli.Backchannel; [JsonSerializable(typeof(JsonNode))] [JsonSerializable(typeof(CapabilitiesInfo))] [JsonSerializable(typeof(CommonErrorData))] +[JsonSerializable(typeof(AppHostCodeGenerationDiagnostic))] +[JsonSerializable(typeof(AppHostLoadedAssemblyInfo))] +[JsonSerializable(typeof(List))] // V2 API request/response types [JsonSerializable(typeof(GetCapabilitiesRequest))] [JsonSerializable(typeof(BackchannelTraceContext))] diff --git a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs index 86b554a262e..ef3fcb185d3 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs @@ -366,7 +366,7 @@ private async Task DumpIntegrationCapabilitiesAsync( return new IntegrationDumpResult(integration.Name, Success: true, HasErrors: capabilities.Diagnostics.Exists(d => d.Severity == "Error")); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException or RemoteInvocationException) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException or RemoteInvocationException or AppHostCodeGenerationException) { _logger.LogWarning(ex, "Failed to dump capabilities for integration {IntegrationName}", integration.Name); return new IntegrationDumpResult(integration.Name, Success: false, HasErrors: false, ex.Message); diff --git a/src/Aspire.Cli/Projects/AppHostCodeGenerationDiagnostic.cs b/src/Aspire.Cli/Projects/AppHostCodeGenerationDiagnostic.cs new file mode 100644 index 00000000000..7c88ed4c560 --- /dev/null +++ b/src/Aspire.Cli/Projects/AppHostCodeGenerationDiagnostic.cs @@ -0,0 +1,93 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Projects; + +/// +/// JSON-RPC error codes returned by the AppHost server for code-generation failures. +/// +/// +/// Values mirror those defined server-side in Aspire.Hosting.RemoteHost. +/// +internal static class AppHostCodeGenerationErrorCodes +{ + /// + /// The AppHost server failed to load reflection-based code generation metadata. + /// Typically caused by an assembly-version mismatch between the bundled + /// Aspire.Hosting runtime and the user-restored integration assemblies. + /// + public const int IncompatibleAspireSdk = -32050; +} + +/// +/// CLI-side representation of the structured diagnostic payload the AppHost server attaches to +/// code-generation failures (mirrors CodeGenerationDiagnostic in +/// Aspire.Hosting.RemoteHost). The JSON shape is contractual; updating one side without +/// the other will break the round-trip. +/// +internal sealed class AppHostCodeGenerationDiagnostic +{ + /// + /// Gets the CLR type name of the original exception thrown by the AppHost server + /// (e.g. System.TypeLoadException). + /// + public string OriginalExceptionType { get; init; } = ""; + + /// + /// Gets the name of the type that failed to load, if known. + /// + public string? TypeName { get; init; } + + /// + /// Gets the name of the missing member, if the failure was a missing-method or + /// missing-field error. + /// + public string? MemberName { get; init; } + + /// + /// Gets the informational version of the bundled Aspire.Hosting assembly on the + /// server side, if it could be discovered. + /// + public string? RuntimeAspireHostingVersion { get; init; } + + /// + /// Gets the on-disk location of the bundled Aspire.Hosting assembly, if it could be + /// discovered. + /// + public string? RuntimeAspireHostingPath { get; init; } + + /// + /// Gets the loaded integration assemblies probed by the AppHost server at the time of the + /// failure. + /// + public List LoadedAssemblies { get; init; } = []; + + /// + /// Gets a short, language-agnostic remediation hint suitable for surfacing to AppHost + /// authors. + /// + public string? RemediationHint { get; init; } +} + +/// +/// Identity information for a single loaded assembly captured at the time of a code-generation +/// failure. +/// +internal sealed class AppHostLoadedAssemblyInfo +{ + /// + /// Gets the simple name of the assembly (e.g. Aspire.Hosting.JavaScript). + /// + public string Name { get; init; } = ""; + + /// + /// Gets the informational version of the assembly, when present, otherwise the assembly + /// version. + /// + public string? InformationalVersion { get; init; } + + /// + /// Gets the on-disk location of the assembly when available. + /// + public string? Location { get; init; } +} diff --git a/src/Aspire.Cli/Projects/AppHostCodeGenerationException.cs b/src/Aspire.Cli/Projects/AppHostCodeGenerationException.cs new file mode 100644 index 00000000000..76d9ea71a38 --- /dev/null +++ b/src/Aspire.Cli/Projects/AppHostCodeGenerationException.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Projects; + +/// +/// Thrown by when the AppHost server reports a reflection-based +/// code-generation failure. Carries a structured +/// payload supplied by the server so the CLI can render an actionable, tiered diagnostic. +/// +/// +/// The always contains the short, language-agnostic message the +/// server produced; the full structured payload — including .NET-specific identifiers such as +/// type names and assembly identities — is exposed via and is only +/// rendered to the user when --debug is supplied. +/// +internal sealed class AppHostCodeGenerationException : Exception +{ + public AppHostCodeGenerationException(string message, AppHostCodeGenerationDiagnostic diagnostic, Exception? innerException = null) + : base(message, innerException) + { + Diagnostic = diagnostic; + } + + /// + /// Gets the structured diagnostic payload that accompanied the RPC failure. + /// + public AppHostCodeGenerationDiagnostic Diagnostic { get; } +} diff --git a/src/Aspire.Cli/Projects/AppHostRpcClient.cs b/src/Aspire.Cli/Projects/AppHostRpcClient.cs index 14e306703f2..5d7e0a81ea9 100644 --- a/src/Aspire.Cli/Projects/AppHostRpcClient.cs +++ b/src/Aspire.Cli/Projects/AppHostRpcClient.cs @@ -3,6 +3,7 @@ using System.IO.Pipes; using System.Net.Sockets; +using System.Text.Json; using Aspire.Cli.Backchannel; using Aspire.Cli.Telemetry; using Aspire.TypeSystem; @@ -94,19 +95,19 @@ public Task> ScaffoldAppHostAsync( /// public Task> GenerateCodeAsync(string languageId, CancellationToken cancellationToken) - => InvokeAsync>("generateCode", [languageId, null], cancellationToken); + => InvokeCodeGenerationAsync>("generateCode", [languageId, null], cancellationToken); /// public Task> GenerateCodeForAssemblyAsync(string languageId, string assemblyName, CancellationToken cancellationToken) - => InvokeAsync>("generateCode", [languageId, assemblyName], cancellationToken); + => InvokeCodeGenerationAsync>("generateCode", [languageId, assemblyName], cancellationToken); /// public Task GetCapabilitiesAsync(CancellationToken cancellationToken) - => InvokeAsync("getCapabilities", [null], cancellationToken); + => InvokeCodeGenerationAsync("getCapabilities", [null], cancellationToken); /// public Task GetCapabilitiesForAssembliesAsync(IReadOnlyList assemblyNames, CancellationToken cancellationToken) - => InvokeAsync("getCapabilities", [assemblyNames], cancellationToken); + => InvokeCodeGenerationAsync("getCapabilities", [assemblyNames], cancellationToken); /// public Task InvokeAsync(string methodName, object?[] parameters, CancellationToken cancellationToken) @@ -116,6 +117,57 @@ public Task InvokeAsync(string methodName, object?[] parameters, Cancellat public Task InvokeAsync(string methodName, object?[] parameters, CancellationToken cancellationToken) => _jsonRpc.InvokeWithProfilingAsync(_profilingTelemetry, ConnectionName, methodName, parameters, cancellationToken); + /// + /// Invokes a code-generation RPC method and rethrows structured load/type failures as + /// so the CLI can render an actionable + /// diagnostic instead of an empty or .NET-specific error message. + /// + private async Task InvokeCodeGenerationAsync(string methodName, object?[] parameters, CancellationToken cancellationToken) + { + try + { + return await _jsonRpc.InvokeWithProfilingAsync(_profilingTelemetry, ConnectionName, methodName, parameters, cancellationToken).ConfigureAwait(false); + } + catch (RemoteInvocationException ex) when (ex.ErrorCode == AppHostCodeGenerationErrorCodes.IncompatibleAspireSdk) + { + var diagnostic = TryReadDiagnostic(ex); + if (diagnostic is null) + { + throw; + } + + throw new AppHostCodeGenerationException(ex.Message, diagnostic, ex); + } + } + + /// + /// Extracts a from a 's + /// structured error data, returning if the payload is missing or + /// can't be deserialized. + /// + private static AppHostCodeGenerationDiagnostic? TryReadDiagnostic(RemoteInvocationException exception) + { + if (exception.DeserializedErrorData is AppHostCodeGenerationDiagnostic typed) + { + return typed; + } + + var payload = exception.DeserializedErrorData ?? exception.ErrorData; + if (payload is JsonElement element) + { + try + { + return element.Deserialize(BackchannelJsonSerializerContext.Default.AppHostCodeGenerationDiagnostic); + } + catch (JsonException) + { + return null; + } + } + + return null; + } + /// public async ValueTask DisposeAsync() { diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 80b633fa0c0..08c79b2bdb6 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -700,6 +700,14 @@ Task StartBackchannelConnectionAfterGuestAppHostLaunchesAsync() context.BuildCompletionSource?.TrySetResult(false); return CliExitCodes.Cancelled; } + catch (AppHostCodeGenerationException ex) + { + // We already rendered an actionable, tiered diagnostic in GenerateCodeViaRpcAsync. + // Avoid double-printing here — just log and return the standard failure exit code. + context.BuildCompletionSource?.TrySetResult(false); + _logger.LogError(ex, "Code generation failed for {Language} AppHost", DisplayName); + return CliExitCodes.FailedToDotnetRunAppHost; + } catch (Exception ex) { // Signal that build/preparation failed so RunCommand doesn't hang waiting @@ -1041,8 +1049,13 @@ await GenerateCodeViaRpcAsync( await EnsureRuntimeCreatedAsync(directory, rpcClient, cancellationToken); } - catch + catch (Exception ex) { + // The backchannel connection task was started before code generation + // (see StartBackchannelConnectionAsync above); fault it eagerly so the + // caller doesn't wait out the connection timeout when generateCode fails. + context.BackchannelCompletionSource?.TrySetException(ex); + // Once Start() succeeds we own the server process, so dispose it here when // post-start work fails - the `await using` below isn't in scope yet. await serverSession.DisposeAsync(); @@ -1503,10 +1516,22 @@ private async Task GenerateCodeViaRpcAsync( // The code generator is registered by its Language property, not the runtime ID var codeGenerator = _resolvedLanguage.CodeGenerator; + WarnIfCliSdkVersionSkew(appPath); + _logger.LogDebug("Generating {CodeGenerator} code via RPC for {Count} packages", codeGenerator, integrationsList.Count); // Use the typed RPC method - var files = await rpcClient.GenerateCodeAsync(codeGenerator, cancellationToken); + Dictionary files; + try + { + files = await rpcClient.GenerateCodeAsync(codeGenerator, cancellationToken); + } + catch (AppHostCodeGenerationException ex) + { + RenderCodeGenerationFailure(ex); + throw; + } + var outputPath = Path.Combine(appPath, LanguageInfo.GeneratedFolderName); // Legacy TypeScript AppHosts (`apphost.ts`) still import generated files from // `./.modules/aspire.js`. When that scaffold shape is detected, convert the @@ -1578,6 +1603,142 @@ private bool ShouldEmitLegacyTypeScriptGeneratedFiles(string appPath, FileInfo? !File.Exists(Path.Combine(appPath, TypeScriptMtsAppHostFileName)); } + /// + /// Emits a single pre-flight warning when the installed CLI version doesn't match the SDK + /// version pinned in aspire.config.json. This is a best-effort heuristic — we keep it + /// purely informational and let code-generation try first so that benign skew (e.g. a + /// daily-build CLI against a stable SDK) doesn't block valid scenarios. + /// + private void WarnIfCliSdkVersionSkew(string appPath) + { + try + { + var configDir = ConfigurationHelper.GetConfigRootDirectory(new DirectoryInfo(appPath)); + var config = AspireConfigFile.Load(configDir.FullName); + var configuredSdkVersion = config?.SdkVersion; + if (string.IsNullOrWhiteSpace(configuredSdkVersion)) + { + return; + } + + var cliVersion = VersionHelper.GetDefaultSdkVersion(); + if (!IsKnownIncompatibleSkew(cliVersion, configuredSdkVersion)) + { + return; + } + + var message = string.Format( + System.Globalization.CultureInfo.CurrentCulture, + ErrorStrings.CodegenVersionSkewWarning, + cliVersion, + configuredSdkVersion); + _interactionService.DisplayMessage(KnownEmojis.Warning, $"[yellow]{Markup.Escape(message)}[/]", allowMarkup: true); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to evaluate CLI/SDK version skew prior to code generation."); + } + } + + /// + /// Returns when the supplied CLI and SDK versions look mismatched in a + /// way that is worth warning about. We deliberately tolerate metadata-only differences + /// (build suffixes, +commit hashes) and only flag a skew when the parsed major/minor/patch + /// numbers disagree. + /// + /// + /// Returns when the supplied CLI and SDK versions differ in a way that + /// is known to produce ABI incompatibilities — specifically when they differ in + /// , , , + /// or in their prerelease identifiers (e.g. 13.4.0-preview.1.26218.1 vs + /// 13.4.0-preview.1.26227.1, which was the exact reproduction case in + /// ). Build metadata + /// (everything after +) is ignored per the SemVer spec. + /// + internal static bool IsKnownIncompatibleSkew(string cliVersion, string sdkVersion) + { + if (!SemVersion.TryParse(NormalizeVersion(cliVersion), SemVersionStyles.Any, out var cli) || + !SemVersion.TryParse(NormalizeVersion(sdkVersion), SemVersionStyles.Any, out var sdk)) + { + return !string.Equals(cliVersion, sdkVersion, StringComparison.OrdinalIgnoreCase); + } + + // Compare full precedence, which covers Major/Minor/Patch *and* prerelease identifiers + // but (per the SemVer spec) ignores build metadata. NormalizeVersion already strips '+' + // suffixes defensively for parsers that include them in precedence. + return SemVersion.ComparePrecedence(cli, sdk) != 0; + } + + internal static string NormalizeVersion(string version) + { + var plusIndex = version.IndexOf('+'); + return plusIndex > 0 ? version[..plusIndex] : version; + } + + /// + /// Renders a to the user with .NET-specific + /// details tiered behind --debug so that polyglot AppHost authors aren't confronted + /// with C#/CLR jargon by default. The full structured payload is always written to the debug + /// log file via the logger's LogDebug call regardless of mode. + /// + private void RenderCodeGenerationFailure(AppHostCodeGenerationException exception) + { + var summary = string.Format( + System.Globalization.CultureInfo.CurrentCulture, + ErrorStrings.CodegenIncompatibleSdkSummary, + DisplayName); + _interactionService.DisplayError(summary); + + var hint = exception.Diagnostic.RemediationHint; + if (!string.IsNullOrWhiteSpace(hint)) + { + _interactionService.DisplayMessage(KnownEmojis.Information, $"[grey]{Markup.Escape(hint!)}[/]", allowMarkup: true); + } + + _logger.LogDebug( + "Code generation failed. OriginalExceptionType={OriginalExceptionType}, TypeName={TypeName}, MemberName={MemberName}, RuntimeAspireHostingVersion={RuntimeVersion}, LoadedAssemblies={LoadedCount}", + exception.Diagnostic.OriginalExceptionType, + exception.Diagnostic.TypeName ?? "", + exception.Diagnostic.MemberName ?? "", + exception.Diagnostic.RuntimeAspireHostingVersion ?? "", + exception.Diagnostic.LoadedAssemblies.Count); + _logger.LogDebug( + "Code generation diagnostic payload: {DiagnosticPayload}", + JsonSerializer.Serialize( + exception.Diagnostic, + BackchannelJsonSerializerContext.Default.AppHostCodeGenerationDiagnostic)); + + if (!_executionContext.DebugMode) + { + _interactionService.DisplayMessage(KnownEmojis.Information, $"[grey]{Markup.Escape(ErrorStrings.CodegenDebugHint)}[/]", allowMarkup: true); + return; + } + + _interactionService.DisplayMessage(KnownEmojis.Microscope, $"[grey]{Markup.Escape(ErrorStrings.CodegenDebugHeader)}[/]", allowMarkup: true); + var diagnostic = exception.Diagnostic; + if (!string.IsNullOrWhiteSpace(diagnostic.OriginalExceptionType)) + { + _interactionService.DisplayPlainText($" Exception: {diagnostic.OriginalExceptionType}"); + } + if (!string.IsNullOrWhiteSpace(diagnostic.TypeName)) + { + _interactionService.DisplayPlainText($" Type: {diagnostic.TypeName}"); + } + if (!string.IsNullOrWhiteSpace(diagnostic.MemberName)) + { + _interactionService.DisplayPlainText($" Member: {diagnostic.MemberName}"); + } + if (!string.IsNullOrWhiteSpace(diagnostic.RuntimeAspireHostingVersion)) + { + _interactionService.DisplayPlainText($" Runtime Aspire.Hosting: {diagnostic.RuntimeAspireHostingVersion}"); + } + foreach (var assembly in diagnostic.LoadedAssemblies) + { + var version = assembly.InformationalVersion ?? ""; + _interactionService.DisplayPlainText($" • {assembly.Name} {version}"); + } + } + /// /// Saves a hash of the integrations to avoid regenerating code unnecessarily. /// When project references are present, the hash is always unique to force regeneration diff --git a/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs b/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs index cd7bbbd50b5..0a1e6964118 100644 --- a/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs @@ -416,5 +416,41 @@ public static string InvalidJsonInConfigFile { return ResourceManager.GetString("InvalidJsonInConfigFile", resourceCulture); } } + + /// + /// Looks up a localized string similar to The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them.. + /// + public static string CodegenVersionSkewWarning { + get { + return ResourceManager.GetString("CodegenVersionSkewWarning", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again.. + /// + public static string CodegenIncompatibleSdkSummary { + get { + return ResourceManager.GetString("CodegenIncompatibleSdkSummary", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Run with '--debug' for full diagnostic details.. + /// + public static string CodegenDebugHint { + get { + return ResourceManager.GetString("CodegenDebugHint", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Diagnostic details:. + /// + public static string CodegenDebugHeader { + get { + return ResourceManager.GetString("CodegenDebugHeader", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/ErrorStrings.resx b/src/Aspire.Cli/Resources/ErrorStrings.resx index 408361118bb..aba3b239f19 100644 --- a/src/Aspire.Cli/Resources/ErrorStrings.resx +++ b/src/Aspire.Cli/Resources/ErrorStrings.resx @@ -262,4 +262,18 @@ The configuration file '{0}' contains invalid JSON: {1} {0} is the file path, {1} is the exception message + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + Run with '--debug' for full diagnostic details. + + + Diagnostic details: + diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf index 0e9f7aa0537..7c2ddc27688 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf @@ -57,6 +57,26 @@ Developer certificates are only partially trusted. Trust them interactively by running 'aspire certs trust'. + + Diagnostic details: + Diagnostic details: + + + + Run with '--debug' for full diagnostic details. + Run with '--debug' for full diagnostic details. + + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + This command is not yet supported with single file AppHosts. Tento příkaz zatím není pro funkci AppHost pro jednosouborové scénáře podporován. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf index 1a1760c3d4d..7ee217e468a 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf @@ -57,6 +57,26 @@ Developer certificates are only partially trusted. Trust them interactively by running 'aspire certs trust'. + + Diagnostic details: + Diagnostic details: + + + + Run with '--debug' for full diagnostic details. + Run with '--debug' for full diagnostic details. + + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + This command is not yet supported with single file AppHosts. Dieser Befehl wird für AppHosts mit einer einzelnen Datei noch nicht unterstützt. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf index a5dc8ef08bc..00257b66305 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf @@ -57,6 +57,26 @@ Developer certificates are only partially trusted. Trust them interactively by running 'aspire certs trust'. + + Diagnostic details: + Diagnostic details: + + + + Run with '--debug' for full diagnostic details. + Run with '--debug' for full diagnostic details. + + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + This command is not yet supported with single file AppHosts. Este comando aún no es compatible con AppHosts de un solo archivo. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf index 7090a201033..280e4d050b7 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf @@ -57,6 +57,26 @@ Developer certificates are only partially trusted. Trust them interactively by running 'aspire certs trust'. + + Diagnostic details: + Diagnostic details: + + + + Run with '--debug' for full diagnostic details. + Run with '--debug' for full diagnostic details. + + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + This command is not yet supported with single file AppHosts. Cette commande n’est pas encore prise en charge avec les hôtes d’application à fichier unique. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf index e04f31c5d83..3f9864622c6 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf @@ -57,6 +57,26 @@ Developer certificates are only partially trusted. Trust them interactively by running 'aspire certs trust'. + + Diagnostic details: + Diagnostic details: + + + + Run with '--debug' for full diagnostic details. + Run with '--debug' for full diagnostic details. + + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + This command is not yet supported with single file AppHosts. Questo comando non è ancora supportato con AppHost a file singolo. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf index 3df3a1c272c..84cb684a628 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf @@ -57,6 +57,26 @@ Developer certificates are only partially trusted. Trust them interactively by running 'aspire certs trust'. + + Diagnostic details: + Diagnostic details: + + + + Run with '--debug' for full diagnostic details. + Run with '--debug' for full diagnostic details. + + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + This command is not yet supported with single file AppHosts. このコマンドは、単一ファイルの AppHost ではまだサポートされていません。 diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf index e4f6ee1e19f..1cdf0d2b9d3 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf @@ -57,6 +57,26 @@ Developer certificates are only partially trusted. Trust them interactively by running 'aspire certs trust'. + + Diagnostic details: + Diagnostic details: + + + + Run with '--debug' for full diagnostic details. + Run with '--debug' for full diagnostic details. + + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + This command is not yet supported with single file AppHosts. 이 명령은 단일 파일 AppHosts에서 아직 지원되지 않습니다. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf index a4eab89ac77..791f4f7644c 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf @@ -57,6 +57,26 @@ Developer certificates are only partially trusted. Trust them interactively by running 'aspire certs trust'. + + Diagnostic details: + Diagnostic details: + + + + Run with '--debug' for full diagnostic details. + Run with '--debug' for full diagnostic details. + + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + This command is not yet supported with single file AppHosts. To polecenie nie jest jeszcze obsługiwane w przypadku hostów AppHost z jednym plikiem. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf index 77bda0f40af..8c867c5648d 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf @@ -57,6 +57,26 @@ Developer certificates are only partially trusted. Trust them interactively by running 'aspire certs trust'. + + Diagnostic details: + Diagnostic details: + + + + Run with '--debug' for full diagnostic details. + Run with '--debug' for full diagnostic details. + + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + This command is not yet supported with single file AppHosts. Este comando ainda não tem suporte para AppHosts de arquivo único. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf index 96d40225090..77cc5494af3 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf @@ -57,6 +57,26 @@ Developer certificates are only partially trusted. Trust them interactively by running 'aspire certs trust'. + + Diagnostic details: + Diagnostic details: + + + + Run with '--debug' for full diagnostic details. + Run with '--debug' for full diagnostic details. + + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + This command is not yet supported with single file AppHosts. Эта команда пока не поддерживается для одиночных файлов AppHost. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf index 85492f3c874..2fb689d9b5a 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf @@ -57,6 +57,26 @@ Developer certificates are only partially trusted. Trust them interactively by running 'aspire certs trust'. + + Diagnostic details: + Diagnostic details: + + + + Run with '--debug' for full diagnostic details. + Run with '--debug' for full diagnostic details. + + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + This command is not yet supported with single file AppHosts. Bu komut, tek dosya Uygulama Ana İşlemlerinde henüz desteklenmemektedir. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf index 2bd6ff0b22a..f46af90c416 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf @@ -57,6 +57,26 @@ Developer certificates are only partially trusted. Trust them interactively by running 'aspire certs trust'. + + Diagnostic details: + Diagnostic details: + + + + Run with '--debug' for full diagnostic details. + Run with '--debug' for full diagnostic details. + + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + This command is not yet supported with single file AppHosts. 单文件应用主机尚不支持此命令。 diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf index 682a813559d..be6279bf20b 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf @@ -57,6 +57,26 @@ Developer certificates are only partially trusted. Trust them interactively by running 'aspire certs trust'. + + Diagnostic details: + Diagnostic details: + + + + Run with '--debug' for full diagnostic details. + Run with '--debug' for full diagnostic details. + + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + This command is not yet supported with single file AppHosts. 單一檔案 AppHost 尚不支援此命令。 diff --git a/src/Aspire.Cli/Utils/CliPathHelper.cs b/src/Aspire.Cli/Utils/CliPathHelper.cs index 1548d5b39ce..fdb2114f17a 100644 --- a/src/Aspire.Cli/Utils/CliPathHelper.cs +++ b/src/Aspire.Cli/Utils/CliPathHelper.cs @@ -11,6 +11,13 @@ internal static class CliPathHelper { internal const string AspireHomeEnvironmentVariable = "ASPIRE_HOME"; + // The maximum age before a leftover CLI socket file in the runtime sockets directory is + // pruned. 24 hours is comfortably longer than any legitimate Aspire CLI run and short enough + // that stale entries don't pile up indefinitely after crashes (see issue #16709). + internal static readonly TimeSpan s_staleSocketThreshold = TimeSpan.FromHours(24); + + private static int s_socketDirectorySwept; + internal static string GetAspireHomeDirectory(string? processPath = null, ILogger? logger = null) { var effectiveProcessPath = processPath ?? Environment.ProcessPath; @@ -205,11 +212,63 @@ internal static string CreateGuestAppHostSocketPath(string socketPrefix) ? CreateSocketName(socketPrefix) : CreateSocketPath(socketPrefix); + /// + /// Prunes leftover CLI socket files from ~/.aspire/cli/runtime/sockets/ whose last + /// modified timestamp is older than . Returns the number of files + /// that were deleted. Exceptions from individual file deletions are swallowed so a single + /// permission-denied or locked file can't break startup. Exposed for tests via + /// . + /// + /// + /// Unlike , + /// CLI sockets don't encode the process ID in their filename — they're created with a random + /// GUID-style suffix — so the only reliable signal we have for "this is stale" is the file's + /// mtime. We pick a generous default threshold so an in-flight long-running run never has its + /// socket pruned out from under it. + /// + internal static int CleanupStaleCliSockets(string socketDirectory, TimeSpan maxAge, TimeProvider? timeProvider = null) + { + if (!Directory.Exists(socketDirectory)) + { + return 0; + } + + var now = (timeProvider ?? TimeProvider.System).GetUtcNow(); + var deleted = 0; + + var socketFileSearchPattern = BackchannelConstants.ComputeSocketFileSearchPattern("cli.sock"); + foreach (var path in Directory.EnumerateFiles(socketDirectory, socketFileSearchPattern)) + { + try + { + var lastWrite = File.GetLastWriteTimeUtc(path); + if (now - lastWrite >= maxAge) + { + File.Delete(path); + deleted++; + } + } + catch + { + // Best-effort cleanup; one bad file should not block CLI startup. + } + } + + return deleted; + } + private static string CreateSocketPath(string socketPrefix) { var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var socketPath = BackchannelConstants.ComputeCliSocketPath(homeDirectory, socketPrefix); - Directory.CreateDirectory(Path.GetDirectoryName(socketPath)!); + var socketDirectory = Path.GetDirectoryName(socketPath)!; + Directory.CreateDirectory(socketDirectory); + + if (Interlocked.CompareExchange(ref s_socketDirectorySwept, 1, 0) == 0) + { + CleanupStaleCliSockets(socketDirectory, s_staleSocketThreshold); + } + return socketPath; } diff --git a/src/Aspire.Hosting.RemoteHost/AssemblyLoader.cs b/src/Aspire.Hosting.RemoteHost/AssemblyLoader.cs index ad80280e668..2150836b087 100644 --- a/src/Aspire.Hosting.RemoteHost/AssemblyLoader.cs +++ b/src/Aspire.Hosting.RemoteHost/AssemblyLoader.cs @@ -3,6 +3,7 @@ using System.Reflection; using System.Runtime.Loader; +using Aspire.Hosting.RemoteHost.CodeGeneration; using Aspire.Hosting.RemoteHost.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -68,6 +69,54 @@ public IReadOnlyList GetAssemblies() } } + /// + /// Snapshots the currently loaded ATS integration assemblies as + /// records suitable for inclusion in a + /// diagnostic payload. Returns an empty list when no assemblies have been loaded yet so the + /// caller can include the result unconditionally. + /// + /// + /// This intentionally avoids forcing to run if it hasn't already, + /// because we want to capture the actual state at the moment a failure occurred rather than + /// triggering the load (which may itself throw). + /// + public IReadOnlyList GetLoadedAssemblyDiagnostics() + { + var infos = new List(); + if (!_assemblies.IsValueCreated) + { + return infos; + } + + foreach (var assembly in _assemblies.Value) + { + infos.Add(CreateAssemblyInfo(assembly)); + } + + return infos; + } + + private static CodeGenerationLoadedAssemblyInfo CreateAssemblyInfo(Assembly assembly) + { + var name = assembly.GetName(); + string? location; + try + { + location = string.IsNullOrEmpty(assembly.Location) ? null : assembly.Location; + } + catch + { + location = null; + } + + return new CodeGenerationLoadedAssemblyInfo + { + Name = name.Name ?? assembly.FullName ?? "", + InformationalVersion = CodeGenerationDiagnosticBuilder.GetInformationalVersion(assembly), + Location = location + }; + } + internal static IReadOnlyList GetAssemblyNamesToLoad( IConfiguration configuration, string? integrationLibsPath, diff --git a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationDiagnostic.cs b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationDiagnostic.cs new file mode 100644 index 00000000000..fde1a9c7e98 --- /dev/null +++ b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationDiagnostic.cs @@ -0,0 +1,297 @@ +// 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 Microsoft.Extensions.Logging; +using StreamJsonRpc; + +namespace Aspire.Hosting.RemoteHost.CodeGeneration; + +/// +/// JSON-RPC error codes used by the AppHost server for code-generation failures. +/// +/// +/// Values are within the JSON-RPC reserved server-error range (-32000 to -32099). +/// +internal static class CodeGenerationErrorCodes +{ + /// + /// The AppHost server failed to load or JIT-compile reflection-based code generation + /// metadata. Typically caused by an assembly-version mismatch between the bundled + /// Aspire.Hosting runtime and the user-restored integration assemblies. + /// + public const int IncompatibleAspireSdk = -32050; +} + +/// +/// Structured payload describing a reflection-load failure encountered while servicing a +/// code-generation RPC method. +/// +/// +/// Carried as from the server to the CLI so that the +/// CLI can render an actionable diagnostic. The shape is intentionally flat and JSON-serializable +/// so it survives the StreamJsonRpc SystemTextJsonFormatter round-trip without requiring +/// shared types between the server and the CLI. +/// +internal sealed class CodeGenerationDiagnostic +{ + /// + /// Gets the CLR type name of the original exception (e.g. System.TypeLoadException). + /// + public string OriginalExceptionType { get; init; } = ""; + + /// + /// Gets the name of the type that failed to load, if known. Populated from + /// when available. + /// + public string? TypeName { get; init; } + + /// + /// Gets the name of the missing member, if the failure was a + /// or . + /// + public string? MemberName { get; init; } + + /// + /// Gets the value of the bundled + /// Aspire.Hosting assembly on the server side, if it could be discovered. + /// + public string? RuntimeAspireHostingVersion { get; init; } + + /// + /// Gets the on-disk location of the bundled Aspire.Hosting assembly, if it could be + /// discovered. + /// + public string? RuntimeAspireHostingPath { get; init; } + + /// + /// Gets the loaded Aspire.Hosting* integration assemblies that were probed by the + /// AppHost server at the time of the failure. + /// + public List LoadedAssemblies { get; init; } = []; + + /// + /// Gets a short, language-agnostic remediation hint suitable for surfacing to AppHost + /// authors (e.g. instructing them to run aspire update). + /// + public string? RemediationHint { get; init; } +} + +/// +/// Identity information for a single loaded assembly captured at the time of a code-generation +/// failure. +/// +internal sealed class CodeGenerationLoadedAssemblyInfo +{ + /// + /// Gets the simple name of the assembly (e.g. Aspire.Hosting.JavaScript). + /// + public string Name { get; init; } = ""; + + /// + /// Gets the value of the assembly's + /// when present, otherwise the assembly version. + /// + public string? InformationalVersion { get; init; } + + /// + /// Gets the on-disk location of the assembly when available. + /// + public string? Location { get; init; } +} + +/// +/// Builds payloads from caught reflection-load +/// exceptions and converts them into instances that StreamJsonRpc +/// will propagate to the CLI with structured error data. +/// +internal static class CodeGenerationDiagnosticBuilder +{ + private const string SafeMessage = + "Aspire SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured SDK version. Run 'aspire update' to align the CLI and SDK and try again."; + + private const string RemediationHint = + "Run 'aspire update' to align the installed Aspire CLI with the configured SDK version, then retry."; + + /// + /// Inspects the supplied exception (and any inner exceptions) and, if it looks like a + /// reflection/load failure, returns a carrying a + /// in its . + /// Returns for exceptions that are not reflection-load failures. + /// + public static LocalRpcException? TryCreateRpcException(Exception exception, AssemblyLoader? assemblyLoader, ILogger? logger = null) + { + var loadException = FindReflectionLoadException(exception); + if (loadException is null) + { + return null; + } + + var diagnostic = BuildDiagnostic(loadException, assemblyLoader, logger); + + return new LocalRpcException(SafeMessage) + { + ErrorCode = CodeGenerationErrorCodes.IncompatibleAspireSdk, + ErrorData = diagnostic + }; + } + + /// + /// Builds a from the supplied exception without + /// wrapping it in a . Exposed for testing. + /// + internal static CodeGenerationDiagnostic BuildDiagnostic(Exception exception, AssemblyLoader? assemblyLoader, ILogger? logger = null) + { + string? typeName = null; + string? memberName = null; + + switch (exception) + { + case TypeLoadException tle: + typeName = tle.TypeName; + break; + case MissingMethodException mme: + memberName = SanitizeMemberMessage(mme.Message); + break; + case MissingFieldException mfe: + memberName = SanitizeMemberMessage(mfe.Message); + break; + case FileLoadException fle: + typeName = fle.FileName; + break; + case BadImageFormatException bife: + typeName = bife.FileName; + break; + } + + var (runtimeVersion, runtimePath, loadedAssemblies) = CaptureLoadedAssemblies(assemblyLoader, logger); + + return new CodeGenerationDiagnostic + { + OriginalExceptionType = exception.GetType().FullName ?? exception.GetType().Name, + TypeName = typeName, + MemberName = memberName, + RuntimeAspireHostingVersion = runtimeVersion, + RuntimeAspireHostingPath = runtimePath, + LoadedAssemblies = loadedAssemblies, + RemediationHint = RemediationHint + }; + } + + /// + /// Walks the exception chain and returns the first inner exception that looks like a + /// reflection-load failure, or if none is found. + /// + internal static Exception? FindReflectionLoadException(Exception? exception) + { + for (var current = exception; current is not null; current = current.InnerException) + { + if (current is ReflectionTypeLoadException rtle) + { + foreach (var loaderException in rtle.LoaderExceptions) + { + if (loaderException is not null && IsReflectionLoadException(loaderException)) + { + return loaderException; + } + } + + // No specific loader exception matched, but the RTLE itself is a reflection-load + // failure — fall through and return it from the IsReflectionLoadException check below. + } + + if (IsReflectionLoadException(current)) + { + return current; + } + } + + return null; + } + + private static bool IsReflectionLoadException(Exception exception) => exception + is TypeLoadException + or MissingMethodException + or MissingFieldException + or FileLoadException + or BadImageFormatException + or ReflectionTypeLoadException; + + private static (string? Version, string? Path, List Assemblies) CaptureLoadedAssemblies( + AssemblyLoader? assemblyLoader, + ILogger? logger) + { + string? runtimeVersion = null; + string? runtimePath = null; + var loaded = new List(); + + // Locate the actually-loaded Aspire.Hosting assembly (the runtime that backed the failing + // codegen). We avoid `typeof(Aspire.Hosting.X).Assembly` because Aspire.Hosting.RemoteHost + // does not reference Aspire.Hosting; if for any reason it isn't in AppDomain.Assemblies we + // leave the version null rather than substituting a sibling like Aspire.Hosting.RemoteHost. + var aspireHostingAssembly = AppDomain.CurrentDomain + .GetAssemblies() + .FirstOrDefault(a => string.Equals(a.GetName().Name, "Aspire.Hosting", StringComparison.OrdinalIgnoreCase)); + + if (aspireHostingAssembly is not null) + { + runtimeVersion = GetInformationalVersion(aspireHostingAssembly); + runtimePath = TryGetLocation(aspireHostingAssembly); + } + + if (assemblyLoader is null) + { + return (runtimeVersion, runtimePath, loaded); + } + + try + { + foreach (var info in assemblyLoader.GetLoadedAssemblyDiagnostics()) + { + loaded.Add(info); + } + } + catch (Exception ex) + { + logger?.LogDebug(ex, "Failed to capture loaded assembly diagnostics while building code-generation diagnostic."); + } + + return (runtimeVersion, runtimePath, loaded); + } + + internal static string? GetInformationalVersion(Assembly assembly) + { + var informational = assembly.GetCustomAttribute()?.InformationalVersion; + if (!string.IsNullOrWhiteSpace(informational)) + { + return informational; + } + + return assembly.GetName().Version?.ToString(); + } + + private static string? TryGetLocation(Assembly assembly) + { + try + { + return string.IsNullOrEmpty(assembly.Location) ? null : assembly.Location; + } + catch + { + return null; + } + } + + private static string? SanitizeMemberMessage(string? message) + { + if (string.IsNullOrWhiteSpace(message)) + { + return null; + } + + // The CLR's Missing*Exception messages are typically of the form + // "Method not found: 'System.Void Aspire.Hosting.X.Y.Z(Aspire.Hosting.Foo)'." + // We keep them verbatim; the CLI controls whether they are surfaced to the user. + return message; + } +} diff --git a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationService.cs b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationService.cs index dad2198a3f5..fd9ff421ad3 100644 --- a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationService.cs +++ b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationService.cs @@ -19,6 +19,7 @@ internal sealed class CodeGenerationService private readonly JsonRpcAuthenticationState _authenticationState; private readonly AtsContextFactory _atsContextFactory; private readonly CodeGeneratorResolver _resolver; + private readonly AssemblyLoader _assemblyLoader; private readonly ILogger _logger; private readonly RemoteHostProfilingTelemetry _profilingTelemetry; @@ -26,12 +27,14 @@ public CodeGenerationService( JsonRpcAuthenticationState authenticationState, AtsContextFactory atsContextFactory, CodeGeneratorResolver resolver, + AssemblyLoader assemblyLoader, ILogger logger, RemoteHostProfilingTelemetry profilingTelemetry) { _authenticationState = authenticationState; _atsContextFactory = atsContextFactory; _resolver = resolver; + _assemblyLoader = assemblyLoader; _logger = logger; _profilingTelemetry = profilingTelemetry; } @@ -85,6 +88,11 @@ public CapabilitiesResponse GetCapabilities(string[]? assemblyNames = null) { activity.SetError(ex); _logger.LogError(ex, "<< getCapabilities() failed"); + var wrapped = CodeGenerationDiagnosticBuilder.TryCreateRpcException(ex, _assemblyLoader, _logger); + if (wrapped is not null) + { + throw wrapped; + } throw; } } @@ -251,6 +259,11 @@ public Dictionary GenerateCode(string language, string? assembly { activity.SetError(ex); _logger.LogError(ex, "<< generateCode({Language}) failed", language); + var wrapped = CodeGenerationDiagnosticBuilder.TryCreateRpcException(ex, _assemblyLoader, _logger); + if (wrapped is not null) + { + throw wrapped; + } throw; } } diff --git a/src/Shared/BackchannelConstants.cs b/src/Shared/BackchannelConstants.cs index 031cc750296..8ec6bcfbe4c 100644 --- a/src/Shared/BackchannelConstants.cs +++ b/src/Shared/BackchannelConstants.cs @@ -285,6 +285,17 @@ public static string ComputeSocketFileName(string socketPrefix) return $"{GetCompactCliSocketPrefix(socketPrefix)}{CreateRandomBase64UrlIdentifier()}"; } + /// + /// Computes the search pattern for randomized socket files created with the specified logical + /// socket prefix. + /// + public static string ComputeSocketFileSearchPattern(string socketPrefix) + { + ArgumentException.ThrowIfNullOrEmpty(socketPrefix); + + return $"{GetCompactCliSocketPrefix(socketPrefix)}{new string('?', CompactInstanceIdLength)}"; + } + /// /// Computes the socket path prefix for finding compact sockets. /// diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostCodeGenerationDiagnosticWireContractTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostCodeGenerationDiagnosticWireContractTests.cs new file mode 100644 index 00000000000..2ef2f3f1b3a --- /dev/null +++ b/tests/Aspire.Cli.Tests/Projects/AppHostCodeGenerationDiagnosticWireContractTests.cs @@ -0,0 +1,99 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Projects; + +namespace Aspire.Cli.Tests.Projects; + +/// +/// Guards the wire contract between the AppHost server's reflection-based +/// SystemTextJsonFormatter (default , no naming policy, +/// PascalCase on the wire) and the CLI source-generated +/// . If either side adopts a naming policy or +/// renames a property, this test must catch it before the diagnostic silently degrades to +/// "all null" on the CLI as in issue #16709. +/// +public class AppHostCodeGenerationDiagnosticWireContractTests +{ + [Fact] + public void Deserialize_PascalCaseServerPayload_PopulatesAllFields() + { + const string serverJson = + """ + { + "OriginalExceptionType": "System.TypeLoadException", + "TypeName": "Aspire.Hosting.SomeType", + "MemberName": "Void Foo()", + "RuntimeAspireHostingVersion": "13.4.0-preview.1.26218.1", + "RuntimeAspireHostingPath": "C:\\aspire\\Aspire.Hosting.dll", + "LoadedAssemblies": [ + { + "Name": "Aspire.Hosting.CodeGeneration.TypeScript", + "InformationalVersion": "13.4.0-preview.1.26227.1", + "Location": "C:\\aspire\\Aspire.Hosting.CodeGeneration.TypeScript.dll" + } + ], + "RemediationHint": "Run 'aspire update'." + } + """; + + var diagnostic = JsonSerializer.Deserialize( + serverJson, + BackchannelJsonSerializerContext.Default.AppHostCodeGenerationDiagnostic); + + Assert.NotNull(diagnostic); + Assert.Equal("System.TypeLoadException", diagnostic.OriginalExceptionType); + Assert.Equal("Aspire.Hosting.SomeType", diagnostic.TypeName); + Assert.Equal("Void Foo()", diagnostic.MemberName); + Assert.Equal("13.4.0-preview.1.26218.1", diagnostic.RuntimeAspireHostingVersion); + Assert.Equal("C:\\aspire\\Aspire.Hosting.dll", diagnostic.RuntimeAspireHostingPath); + Assert.Equal("Run 'aspire update'.", diagnostic.RemediationHint); + var loaded = Assert.Single(diagnostic.LoadedAssemblies); + Assert.Equal("Aspire.Hosting.CodeGeneration.TypeScript", loaded.Name); + Assert.Equal("13.4.0-preview.1.26227.1", loaded.InformationalVersion); + Assert.Equal("C:\\aspire\\Aspire.Hosting.CodeGeneration.TypeScript.dll", loaded.Location); + } + + [Fact] + public void Roundtrip_UsingRpcFormatterOptions_PreservesAllFields() + { + // Use the exact same JsonSerializerOptions the StreamJsonRpc formatter uses on the CLI + // side. This catches the case where the wire-level deserializer differs from the typed + // JsonTypeInfo deserializer used by TryReadDiagnostic. + var options = BackchannelJsonSerializerContext.CreateJsonSerializerOptions(); + + var source = new AppHostCodeGenerationDiagnostic + { + OriginalExceptionType = "System.TypeLoadException", + TypeName = "Aspire.Hosting.SomeType", + MemberName = "Void Foo()", + RuntimeAspireHostingVersion = "13.4.0-preview.1.26218.1", + RuntimeAspireHostingPath = "/aspire/Aspire.Hosting.dll", + LoadedAssemblies = + [ + new AppHostLoadedAssemblyInfo + { + Name = "Aspire.Hosting.CodeGeneration.TypeScript", + InformationalVersion = "13.4.0-preview.1.26227.1", + Location = "/aspire/Aspire.Hosting.CodeGeneration.TypeScript.dll" + } + ], + RemediationHint = "Run 'aspire update'." + }; + + var json = JsonSerializer.Serialize(source, typeof(AppHostCodeGenerationDiagnostic), options); + var roundtripped = (AppHostCodeGenerationDiagnostic?)JsonSerializer.Deserialize(json, typeof(AppHostCodeGenerationDiagnostic), options); + + Assert.NotNull(roundtripped); + Assert.Equal(source.OriginalExceptionType, roundtripped.OriginalExceptionType); + Assert.Equal(source.TypeName, roundtripped.TypeName); + Assert.Equal(source.MemberName, roundtripped.MemberName); + Assert.Equal(source.RuntimeAspireHostingVersion, roundtripped.RuntimeAspireHostingVersion); + Assert.Equal(source.RemediationHint, roundtripped.RemediationHint); + var loaded = Assert.Single(roundtripped.LoadedAssemblies); + Assert.Equal(source.LoadedAssemblies[0].Name, loaded.Name); + Assert.Equal(source.LoadedAssemblies[0].InformationalVersion, loaded.InformationalVersion); + } +} diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectSkewTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectSkewTests.cs new file mode 100644 index 00000000000..8e9af6d88e2 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectSkewTests.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Projects; + +namespace Aspire.Cli.Tests.Projects; + +public class GuestAppHostProjectSkewTests +{ + [Theory] + [InlineData("13.1.0", "13.1.0", false)] + [InlineData("13.1.0-preview.1.26218.1", "13.1.0-preview.1.26218.1", false)] + // Build metadata (everything after '+') is SemVer-spec ignored for precedence. + [InlineData("13.1.0-preview.1.26218.1+abc", "13.1.0-preview.1.26218.1+def", false)] + // Issue #16709 reproduction: same M.M.P prerelease tag with different daily build numbers + // is detected as skew (this was the exact failure case). + [InlineData("13.1.0-preview.1.26218.1", "13.1.0-preview.1.26227.1", true)] + // Release vs prerelease of the same M.M.P is skew. + [InlineData("13.1.0", "13.1.0-preview.1", true)] + [InlineData("13.1.0-preview.1", "13.1.0", true)] + [InlineData("13.1.0", "13.2.0", true)] + [InlineData("13.1.0", "14.0.0", true)] + [InlineData("13.1.0", "13.1.1", true)] + public void IsKnownIncompatibleSkew_DetectsMajorMinorPatchAndPrereleaseChanges(string cli, string sdk, bool expected) + { + var result = GuestAppHostProject.IsKnownIncompatibleSkew(cli, sdk); + + Assert.Equal(expected, result); + } + + [Fact] + public void IsKnownIncompatibleSkew_FallsBackToStringCompareForUnparseable() + { + Assert.True(GuestAppHostProject.IsKnownIncompatibleSkew("not-a-version", "also-not-a-version-but-different")); + Assert.False(GuestAppHostProject.IsKnownIncompatibleSkew("identical", "identical")); + } + + [Theory] + [InlineData("13.1.0+build.5", "13.1.0")] + [InlineData("13.1.0-preview.1+sha.abc123", "13.1.0-preview.1")] + [InlineData("13.1.0", "13.1.0")] + public void NormalizeVersion_StripsBuildSuffix(string input, string expected) + { + Assert.Equal(expected, GuestAppHostProject.NormalizeVersion(input)); + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs b/tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs index f6b3cd19ab3..b25d247fc27 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs @@ -3,6 +3,8 @@ using Aspire.Cli.Acquisition; using Aspire.Cli.Utils; +using Aspire.Hosting.Backchannel; +using Microsoft.Extensions.Time.Testing; namespace Aspire.Cli.Tests.Utils; @@ -296,4 +298,91 @@ private static string WriteBinaryWithSidecar(string binaryDir, string source) return binaryPath; } + + [Fact] + public void CleanupStaleCliSockets_DeletesFilesOlderThanThreshold() + { + var tempRoot = Directory.CreateTempSubdirectory("aspire-cli-sockets-"); + try + { + var staleFile = BackchannelConstants.ComputeCliSocketPath(tempRoot.FullName, "cli.sock"); + Directory.CreateDirectory(Path.GetDirectoryName(staleFile)!); + File.WriteAllText(staleFile, string.Empty); + var freshFile = BackchannelConstants.ComputeCliSocketPath(tempRoot.FullName, "cli.sock"); + File.WriteAllText(freshFile, string.Empty); + + var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow); + File.SetLastWriteTimeUtc(staleFile, fakeTime.GetUtcNow().UtcDateTime - TimeSpan.FromHours(48)); + File.SetLastWriteTimeUtc(freshFile, fakeTime.GetUtcNow().UtcDateTime - TimeSpan.FromMinutes(5)); + + var socketDirectory = Path.GetDirectoryName(staleFile)!; + var deleted = CliPathHelper.CleanupStaleCliSockets(socketDirectory, TimeSpan.FromHours(24), fakeTime); + + Assert.Equal(1, deleted); + Assert.False(File.Exists(staleFile)); + Assert.True(File.Exists(freshFile)); + } + finally + { + tempRoot.Delete(recursive: true); + } + } + + [Fact] + public void CleanupStaleCliSockets_OnlyMatchesCliSockPrefix() + { + var tempRoot = Directory.CreateTempSubdirectory("aspire-cli-sockets-"); + try + { + var matching = BackchannelConstants.ComputeCliSocketPath(tempRoot.FullName, "cli.sock"); + Directory.CreateDirectory(Path.GetDirectoryName(matching)!); + File.WriteAllText(matching, string.Empty); + var unrelated = BackchannelConstants.ComputeCliSocketPath(tempRoot.FullName, "apphost.sock"); + File.WriteAllText(unrelated, string.Empty); + + var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow); + File.SetLastWriteTimeUtc(matching, fakeTime.GetUtcNow().UtcDateTime - TimeSpan.FromHours(48)); + File.SetLastWriteTimeUtc(unrelated, fakeTime.GetUtcNow().UtcDateTime - TimeSpan.FromHours(48)); + + var socketDirectory = Path.GetDirectoryName(matching)!; + var deleted = CliPathHelper.CleanupStaleCliSockets(socketDirectory, TimeSpan.FromHours(24), fakeTime); + + Assert.Equal(1, deleted); + Assert.False(File.Exists(matching)); + Assert.True(File.Exists(unrelated)); + } + finally + { + tempRoot.Delete(recursive: true); + } + } + + [Fact] + public void CleanupStaleCliSockets_MissingDirectoryIsNoOp() + { + // Create-then-delete to guarantee a unique path we know doesn't exist on disk. + var probe = Directory.CreateTempSubdirectory("aspire-cli-sockets-missing-"); + var missingDir = probe.FullName; + probe.Delete(); + + var deleted = CliPathHelper.CleanupStaleCliSockets(missingDir, TimeSpan.FromHours(24)); + + Assert.Equal(0, deleted); + } + + [Fact] + public void CleanupStaleCliSockets_EmptyDirectoryReturnsZero() + { + var tempDir = Directory.CreateTempSubdirectory("aspire-cli-sockets-"); + try + { + var deleted = CliPathHelper.CleanupStaleCliSockets(tempDir.FullName, TimeSpan.FromHours(24)); + + Assert.Equal(0, deleted); + } + finally + { + tempDir.Delete(recursive: true); + } + } } diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs new file mode 100644 index 00000000000..0e7630ef106 --- /dev/null +++ b/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs @@ -0,0 +1,131 @@ +// 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 StreamJsonRpc; +using Xunit; + +namespace Aspire.Hosting.RemoteHost.Tests; + +public class CodeGenerationDiagnosticBuilderTests +{ + [Fact] + public void TryCreateRpcException_NonReflectionFailure_ReturnsNull() + { + var result = CodeGenerationDiagnosticBuilder.TryCreateRpcException( + new InvalidOperationException("plain failure"), + assemblyLoader: null); + + Assert.Null(result); + } + + [Fact] + public void TryCreateRpcException_TypeLoadException_ReturnsLocalRpcExceptionWithDiagnostic() + { + var typeLoad = new TypeLoadException("type not found") + { + // TypeName/Message can be empty when the JIT throws — exercise the empty path here + // separately; for this test we want to confirm the wrapping path itself works. + }; + + var result = CodeGenerationDiagnosticBuilder.TryCreateRpcException(typeLoad, assemblyLoader: null); + + var localRpc = Assert.IsType(result); + Assert.Equal(CodeGenerationErrorCodes.IncompatibleAspireSdk, localRpc.ErrorCode); + var diagnostic = Assert.IsType(localRpc.ErrorData); + Assert.Equal(typeof(TypeLoadException).FullName, diagnostic.OriginalExceptionType); + Assert.False(string.IsNullOrWhiteSpace(diagnostic.RemediationHint)); + Assert.False(string.IsNullOrWhiteSpace(localRpc.Message)); + // The default message must NOT leak the .NET-specific type name. + Assert.DoesNotContain("TypeLoadException", localRpc.Message, StringComparison.Ordinal); + } + + [Fact] + public void TryCreateRpcException_TypeLoadExceptionWithEmptyMessage_ReturnsStructuredDiagnostic() + { + // Repro of issue #16709: JIT-thrown TypeLoadException with no Message — we must still + // produce a non-empty, actionable Message on the wire. + var typeLoad = new TypeLoadException(); + + var result = CodeGenerationDiagnosticBuilder.TryCreateRpcException(typeLoad, assemblyLoader: null); + + var localRpc = Assert.IsType(result); + Assert.False(string.IsNullOrWhiteSpace(localRpc.Message)); + var diagnostic = Assert.IsType(localRpc.ErrorData); + Assert.Equal(typeof(TypeLoadException).FullName, diagnostic.OriginalExceptionType); + } + + [Fact] + public void TryCreateRpcException_MissingMethodException_PopulatesMemberName() + { + var missing = new MissingMethodException("System.Void Aspire.Hosting.Foo.Bar()"); + + var result = CodeGenerationDiagnosticBuilder.TryCreateRpcException(missing, assemblyLoader: null); + + var localRpc = Assert.IsType(result); + var diagnostic = Assert.IsType(localRpc.ErrorData); + Assert.Equal(typeof(MissingMethodException).FullName, diagnostic.OriginalExceptionType); + Assert.False(string.IsNullOrWhiteSpace(diagnostic.MemberName)); + } + + [Fact] + public void TryCreateRpcException_WrappedTypeLoadException_FindsInnerCause() + { + var inner = new TypeLoadException("nested"); + var outer = new InvalidOperationException("wrapper", inner); + + var result = CodeGenerationDiagnosticBuilder.TryCreateRpcException(outer, assemblyLoader: null); + + var localRpc = Assert.IsType(result); + var diagnostic = Assert.IsType(localRpc.ErrorData); + Assert.Equal(typeof(TypeLoadException).FullName, diagnostic.OriginalExceptionType); + } + + [Fact] + public void TryCreateRpcException_ReflectionTypeLoadException_FindsLoaderException() + { + var loader = new TypeLoadException("missing type"); + var rtle = new ReflectionTypeLoadException([null], [loader]); + + var result = CodeGenerationDiagnosticBuilder.TryCreateRpcException(rtle, assemblyLoader: null); + + var localRpc = Assert.IsType(result); + var diagnostic = Assert.IsType(localRpc.ErrorData); + Assert.Equal(typeof(TypeLoadException).FullName, diagnostic.OriginalExceptionType); + } + + [Fact] + public void BuildDiagnostic_CapturesRuntimeAspireHostingVersion() + { + // BuildDiagnostic looks for the loaded Aspire.Hosting assembly via AppDomain. Calling + // any Aspire.Hosting type forces its assembly to be loaded so the search succeeds. + _ = typeof(global::Aspire.Hosting.DistributedApplication); + + var diagnostic = CodeGenerationDiagnosticBuilder.BuildDiagnostic( + new TypeLoadException(), + assemblyLoader: null); + + Assert.False(string.IsNullOrWhiteSpace(diagnostic.RuntimeAspireHostingVersion)); + } + + [Fact] + public void BuildDiagnostic_RuntimeAspireHostingVersion_DoesNotFallBackToRemoteHostAssembly() + { + _ = typeof(global::Aspire.Hosting.DistributedApplication); + + var diagnostic = CodeGenerationDiagnosticBuilder.BuildDiagnostic( + new TypeLoadException(), + assemblyLoader: null); + + var aspireHosting = AppDomain.CurrentDomain.GetAssemblies() + .First(a => string.Equals(a.GetName().Name, "Aspire.Hosting", StringComparison.OrdinalIgnoreCase)); + var aspireHostingVersion = aspireHosting + .GetCustomAttribute()?.InformationalVersion + ?? aspireHosting.GetName().Version?.ToString(); + + // Guards #16709 finding #3: prior code fell back to typeof(AssemblyLoader).Assembly which is + // Aspire.Hosting.RemoteHost - a sibling, not the runtime that backed the failing codegen. + Assert.Equal(aspireHostingVersion, diagnostic.RuntimeAspireHostingVersion); + } +} diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/ServiceErrorMessageTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/ServiceErrorMessageTests.cs index 133b3ab28e7..9b26bc03ae4 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/ServiceErrorMessageTests.cs +++ b/tests/Aspire.Hosting.RemoteHost.Tests/ServiceErrorMessageTests.cs @@ -95,7 +95,7 @@ private static (LanguageService Lang, CodeGenerationService Code) CreateServices 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); + var code = new CodeGenerationService(auth, atsContextFactory, codeResolver, loader, NullLogger.Instance, telemetry); return (lang, code); } @@ -130,7 +130,7 @@ private static CodeGenerationService CreateCodeGenerationServiceWithEmptyResolve var auth = CreateAuthenticatedState(); var atsContextFactory = new AtsContextFactory(loader, NullLogger.Instance, telemetry); - return new CodeGenerationService(auth, atsContextFactory, codeResolver, NullLogger.Instance, telemetry); + return new CodeGenerationService(auth, atsContextFactory, codeResolver, loader, NullLogger.Instance, telemetry); } // The default state is "authenticated" when no JsonRpcAuthToken is present in configuration.