From c9366b571a96fa91e6537eeb5573602db7b67bf8 Mon Sep 17 00:00:00 2001 From: IEvangelist <7679720+IEvangelist@users.noreply.github.com> Date: Thu, 21 May 2026 08:08:27 -0500 Subject: [PATCH 1/4] Enrich AppHost codegen TypeLoadException diagnostics (#16709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the installed Aspire CLI ships an spire-managed server whose bundled Aspire.Hosting.dll is on a different build than the user-restored Aspire.Hosting.CodeGeneration.TypeScript / Aspire.TypeSystem DLLs, reflection-based codegen can throw an empty TypeLoadException that travels back to the CLI with no message and triggers a 60-second backchannel timeout. This change adds three coordinated improvements: 1. Server-side: wrap reflection-load exceptions (TypeLoadException, MissingMethodException, MissingFieldException, BadImageFormatException, FileLoadException, ReflectionTypeLoadException) in a LocalRpcException with a safe, language-agnostic Message and a structured ErrorData payload (TypeName, MemberName, loaded ATS assemblies + informational versions, runtime Aspire.Hosting version, original exception type) carried via JSON-RPC error code -32050. 2. CLI-side: tiered output — emit a yellow pre-flight warning on detected CLI/SDK skew; render only the safe summary + remediation hint by default; reveal the full .NET diagnostic payload under --debug; always log the full payload via LogDebug. Also fault the BackchannelCompletionSource immediately on codegen failure so users no longer wait through the 60s timeout. 3. CLI-side: prune leftover cli.sock.* files older than 24 hours from ~/.aspire/cli/runtime/sockets/ on startup so stale entries don't accumulate from previous crashed runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BackchannelJsonSerializerContext.cs | 4 + .../AppHostCodeGenerationDiagnostic.cs | 105 ++++++ .../AppHostCodeGenerationException.cs | 29 ++ src/Aspire.Cli/Projects/AppHostRpcClient.cs | 60 +++- .../Projects/GuestAppHostProject.cs | 155 ++++++++- .../Resources/ErrorStrings.Designer.cs | 36 +++ src/Aspire.Cli/Resources/ErrorStrings.resx | 14 + .../Resources/xlf/ErrorStrings.cs.xlf | 20 ++ .../Resources/xlf/ErrorStrings.de.xlf | 20 ++ .../Resources/xlf/ErrorStrings.es.xlf | 20 ++ .../Resources/xlf/ErrorStrings.fr.xlf | 20 ++ .../Resources/xlf/ErrorStrings.it.xlf | 20 ++ .../Resources/xlf/ErrorStrings.ja.xlf | 20 ++ .../Resources/xlf/ErrorStrings.ko.xlf | 20 ++ .../Resources/xlf/ErrorStrings.pl.xlf | 20 ++ .../Resources/xlf/ErrorStrings.pt-BR.xlf | 20 ++ .../Resources/xlf/ErrorStrings.ru.xlf | 20 ++ .../Resources/xlf/ErrorStrings.tr.xlf | 20 ++ .../Resources/xlf/ErrorStrings.zh-Hans.xlf | 20 ++ .../Resources/xlf/ErrorStrings.zh-Hant.xlf | 20 ++ src/Aspire.Cli/Utils/CliPathHelper.cs | 60 +++- .../AssemblyLoader.cs | 49 +++ .../CodeGenerationDiagnostic.cs | 299 ++++++++++++++++++ .../CodeGeneration/CodeGenerationService.cs | 13 + .../Projects/GuestAppHostProjectSkewTests.cs | 39 +++ .../Utils/CliPathHelperTests.cs | 84 +++++ .../CodeGenerationDiagnosticBuilderTests.cs | 111 +++++++ .../ServiceErrorMessageTests.cs | 4 +- 28 files changed, 1313 insertions(+), 9 deletions(-) create mode 100644 src/Aspire.Cli/Projects/AppHostCodeGenerationDiagnostic.cs create mode 100644 src/Aspire.Cli/Projects/AppHostCodeGenerationException.cs create mode 100644 src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationDiagnostic.cs create mode 100644 tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectSkewTests.cs create mode 100644 tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs 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/Projects/AppHostCodeGenerationDiagnostic.cs b/src/Aspire.Cli/Projects/AppHostCodeGenerationDiagnostic.cs new file mode 100644 index 00000000000..12a80bc4c75 --- /dev/null +++ b/src/Aspire.Cli/Projects/AppHostCodeGenerationDiagnostic.cs @@ -0,0 +1,105 @@ +// 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.Serialization; + +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). + /// + [JsonPropertyName("originalExceptionType")] + public string OriginalExceptionType { get; init; } = ""; + + /// + /// Gets the name of the type that failed to load, if known. + /// + [JsonPropertyName("typeName")] + public string? TypeName { get; init; } + + /// + /// Gets the name of the missing member, if the failure was a missing-method or + /// missing-field error. + /// + [JsonPropertyName("memberName")] + public string? MemberName { get; init; } + + /// + /// Gets the informational version of the bundled Aspire.Hosting assembly on the + /// server side, if it could be discovered. + /// + [JsonPropertyName("runtimeAspireHostingVersion")] + public string? RuntimeAspireHostingVersion { get; init; } + + /// + /// Gets the on-disk location of the bundled Aspire.Hosting assembly, if it could be + /// discovered. + /// + [JsonPropertyName("runtimeAspireHostingPath")] + public string? RuntimeAspireHostingPath { get; init; } + + /// + /// Gets the loaded integration assemblies probed by the AppHost server at the time of the + /// failure. + /// + [JsonPropertyName("loadedAssemblies")] + public List LoadedAssemblies { get; init; } = []; + + /// + /// Gets a short, language-agnostic remediation hint suitable for surfacing to AppHost + /// authors. + /// + [JsonPropertyName("remediationHint")] + 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). + /// + [JsonPropertyName("name")] + public string Name { get; init; } = ""; + + /// + /// Gets the informational version of the assembly, when present, otherwise the assembly + /// version. + /// + [JsonPropertyName("informationalVersion")] + public string? InformationalVersion { get; init; } + + /// + /// Gets the on-disk location of the assembly when available. + /// + [JsonPropertyName("location")] + 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..28687fa08b2 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,132 @@ 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); + _logger.LogDebug("Aspire CLI/SDK version skew detected (CLI={CliVersion}, SDK={SdkVersion})", cliVersion, configuredSdkVersion); + } + 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. + /// + 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); + } + + return cli.Major != sdk.Major || cli.Minor != sdk.Minor || cli.Patch != sdk.Patch; + } + + 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); + + 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.DisplayMessage(KnownEmojis.Microscope, $"[grey] Exception: {Markup.Escape(diagnostic.OriginalExceptionType)}[/]", allowMarkup: true); + } + if (!string.IsNullOrWhiteSpace(diagnostic.TypeName)) + { + _interactionService.DisplayMessage(KnownEmojis.Microscope, $"[grey] Type: {Markup.Escape(diagnostic.TypeName!)}[/]", allowMarkup: true); + } + if (!string.IsNullOrWhiteSpace(diagnostic.MemberName)) + { + _interactionService.DisplayMessage(KnownEmojis.Microscope, $"[grey] Member: {Markup.Escape(diagnostic.MemberName!)}[/]", allowMarkup: true); + } + if (!string.IsNullOrWhiteSpace(diagnostic.RuntimeAspireHostingVersion)) + { + _interactionService.DisplayMessage( + KnownEmojis.Microscope, + $"[grey] Runtime Aspire.Hosting: {Markup.Escape(diagnostic.RuntimeAspireHostingVersion!)}[/]", + allowMarkup: true); + } + foreach (var assembly in diagnostic.LoadedAssemblies) + { + var version = assembly.InformationalVersion ?? ""; + _interactionService.DisplayMessage( + KnownEmojis.Microscope, + $"[grey] • {Markup.Escape(assembly.Name)} {Markup.Escape(version)}[/]", + allowMarkup: true); + } + } + /// /// 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..72b1313912a 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.sock.* 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,62 @@ 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; + + foreach (var path in Directory.EnumerateFiles(socketDirectory, "cli.sock.*")) + { + 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..070ca21b08a --- /dev/null +++ b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationDiagnostic.cs @@ -0,0 +1,299 @@ +// 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(); + + var runtimeAspireHosting = typeof(global::Aspire.Hosting.RemoteHost.AssemblyLoader).Assembly; + 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); + } + else if (runtimeAspireHosting is not null) + { + runtimeVersion = GetInformationalVersion(runtimeAspireHosting); + runtimePath = TryGetLocation(runtimeAspireHosting); + } + + 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/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectSkewTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectSkewTests.cs new file mode 100644 index 00000000000..35a9f8e8153 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectSkewTests.cs @@ -0,0 +1,39 @@ +// 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)] + [InlineData("13.1.0-preview.1.26218.1", "13.1.0-preview.1.26227.1", false)] + [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_DetectsMajorMinorPatchChanges(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..e4dc7e6815b 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs @@ -3,6 +3,7 @@ using Aspire.Cli.Acquisition; using Aspire.Cli.Utils; +using Microsoft.Extensions.Time.Testing; namespace Aspire.Cli.Tests.Utils; @@ -296,4 +297,87 @@ private static string WriteBinaryWithSidecar(string binaryDir, string source) return binaryPath; } + + [Fact] + public void CleanupStaleCliSockets_DeletesFilesOlderThanThreshold() + { + var tempDir = Path.Combine(Path.GetTempPath(), "aspire-cli-sockets-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var staleFile = Path.Combine(tempDir, "cli.sock.stale"); + File.WriteAllText(staleFile, string.Empty); + var freshFile = Path.Combine(tempDir, "cli.sock.fresh"); + 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 deleted = CliPathHelper.CleanupStaleCliSockets(tempDir, TimeSpan.FromHours(24), fakeTime); + + Assert.Equal(1, deleted); + Assert.False(File.Exists(staleFile)); + Assert.True(File.Exists(freshFile)); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void CleanupStaleCliSockets_OnlyMatchesCliSockPrefix() + { + var tempDir = Path.Combine(Path.GetTempPath(), "aspire-cli-sockets-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var matching = Path.Combine(tempDir, "cli.sock.abc123"); + File.WriteAllText(matching, string.Empty); + var unrelated = Path.Combine(tempDir, "apphost.sock.xyz"); + 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 deleted = CliPathHelper.CleanupStaleCliSockets(tempDir, TimeSpan.FromHours(24), fakeTime); + + Assert.Equal(1, deleted); + Assert.False(File.Exists(matching)); + Assert.True(File.Exists(unrelated)); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void CleanupStaleCliSockets_MissingDirectoryIsNoOp() + { + var missingDir = Path.Combine(Path.GetTempPath(), "aspire-cli-sockets-missing-" + Guid.NewGuid().ToString("N")); + + var deleted = CliPathHelper.CleanupStaleCliSockets(missingDir, TimeSpan.FromHours(24)); + + Assert.Equal(0, deleted); + } + + [Fact] + public void CleanupStaleCliSockets_EmptyDirectoryReturnsZero() + { + var tempDir = Path.Combine(Path.GetTempPath(), "aspire-cli-sockets-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var deleted = CliPathHelper.CleanupStaleCliSockets(tempDir, TimeSpan.FromHours(24)); + + Assert.Equal(0, deleted); + } + finally + { + Directory.Delete(tempDir, 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..b2eca911997 --- /dev/null +++ b/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs @@ -0,0 +1,111 @@ +// 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)); + } +} 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. From 976050429fb6f9c7ee8981d35cbe0e0baa6797d4 Mon Sep 17 00:00:00 2001 From: IEvangelist <7679720+IEvangelist@users.noreply.github.com> Date: Thu, 21 May 2026 08:08:27 -0500 Subject: [PATCH 2/4] Address PR review feedback for #16709 - Drop [JsonPropertyName(camelCase)] from CLI diagnostic DTO so the source-generated context deserializes the server's default PascalCase payload. Add a wire-contract test that round-trips the on-the-wire shape and the BackchannelJsonSerializerContext options. - Use SemVersion.ComparePrecedence in IsKnownIncompatibleSkew so SemVer prerelease identifiers are compared (the #16709 case: 13.4.0-preview.1.26218.1 vs 13.4.0-preview.1.26227.1). Update skew tests to cover prerelease and build-metadata cases. - Resolve the runtime Aspire.Hosting version by walking AppDomain.CurrentDomain.GetAssemblies(); never fall back to Aspire.Hosting.RemoteHost (which is what typeof(AssemblyLoader) returned). Add a regression test. - Only the diagnostic-section header keeps the microscope emoji; the continuation lines (Exception, Type, Member, runtime version, loaded assemblies) render as plain text indented under the header. - Tests: use Directory.CreateTempSubdirectory() instead of manually combining Path.GetTempPath() + Guid for CliPathHelper janitor tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AppHostCodeGenerationDiagnostic.cs | 12 --- .../Projects/GuestAppHostProject.cs | 31 +++--- src/Aspire.Cli/Utils/CliPathHelper.cs | 2 +- .../CodeGenerationDiagnostic.cs | 10 +- ...deGenerationDiagnosticWireContractTests.cs | 99 +++++++++++++++++++ .../Projects/GuestAppHostProjectSkewTests.cs | 11 ++- .../Utils/CliPathHelperTests.cs | 34 +++---- .../CodeGenerationDiagnosticBuilderTests.cs | 20 ++++ 8 files changed, 168 insertions(+), 51 deletions(-) create mode 100644 tests/Aspire.Cli.Tests/Projects/AppHostCodeGenerationDiagnosticWireContractTests.cs diff --git a/src/Aspire.Cli/Projects/AppHostCodeGenerationDiagnostic.cs b/src/Aspire.Cli/Projects/AppHostCodeGenerationDiagnostic.cs index 12a80bc4c75..7c88ed4c560 100644 --- a/src/Aspire.Cli/Projects/AppHostCodeGenerationDiagnostic.cs +++ b/src/Aspire.Cli/Projects/AppHostCodeGenerationDiagnostic.cs @@ -1,8 +1,6 @@ // 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.Serialization; - namespace Aspire.Cli.Projects; /// @@ -33,48 +31,41 @@ internal sealed class AppHostCodeGenerationDiagnostic /// Gets the CLR type name of the original exception thrown by the AppHost server /// (e.g. System.TypeLoadException). /// - [JsonPropertyName("originalExceptionType")] public string OriginalExceptionType { get; init; } = ""; /// /// Gets the name of the type that failed to load, if known. /// - [JsonPropertyName("typeName")] public string? TypeName { get; init; } /// /// Gets the name of the missing member, if the failure was a missing-method or /// missing-field error. /// - [JsonPropertyName("memberName")] public string? MemberName { get; init; } /// /// Gets the informational version of the bundled Aspire.Hosting assembly on the /// server side, if it could be discovered. /// - [JsonPropertyName("runtimeAspireHostingVersion")] public string? RuntimeAspireHostingVersion { get; init; } /// /// Gets the on-disk location of the bundled Aspire.Hosting assembly, if it could be /// discovered. /// - [JsonPropertyName("runtimeAspireHostingPath")] public string? RuntimeAspireHostingPath { get; init; } /// /// Gets the loaded integration assemblies probed by the AppHost server at the time of the /// failure. /// - [JsonPropertyName("loadedAssemblies")] public List LoadedAssemblies { get; init; } = []; /// /// Gets a short, language-agnostic remediation hint suitable for surfacing to AppHost /// authors. /// - [JsonPropertyName("remediationHint")] public string? RemediationHint { get; init; } } @@ -87,19 +78,16 @@ internal sealed class AppHostLoadedAssemblyInfo /// /// Gets the simple name of the assembly (e.g. Aspire.Hosting.JavaScript). /// - [JsonPropertyName("name")] public string Name { get; init; } = ""; /// /// Gets the informational version of the assembly, when present, otherwise the assembly /// version. /// - [JsonPropertyName("informationalVersion")] public string? InformationalVersion { get; init; } /// /// Gets the on-disk location of the assembly when available. /// - [JsonPropertyName("location")] public string? Location { get; init; } } diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 28687fa08b2..81789efbba7 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -1633,7 +1633,6 @@ private void WarnIfCliSdkVersionSkew(string appPath) cliVersion, configuredSdkVersion); _interactionService.DisplayMessage(KnownEmojis.Warning, $"[yellow]{Markup.Escape(message)}[/]", allowMarkup: true); - _logger.LogDebug("Aspire CLI/SDK version skew detected (CLI={CliVersion}, SDK={SdkVersion})", cliVersion, configuredSdkVersion); } catch (Exception ex) { @@ -1647,6 +1646,15 @@ private void WarnIfCliSdkVersionSkew(string appPath) /// (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) || @@ -1655,7 +1663,10 @@ internal static bool IsKnownIncompatibleSkew(string cliVersion, string sdkVersio return !string.Equals(cliVersion, sdkVersion, StringComparison.OrdinalIgnoreCase); } - return cli.Major != sdk.Major || cli.Minor != sdk.Minor || cli.Patch != sdk.Patch; + // 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) @@ -1702,30 +1713,24 @@ private void RenderCodeGenerationFailure(AppHostCodeGenerationException exceptio var diagnostic = exception.Diagnostic; if (!string.IsNullOrWhiteSpace(diagnostic.OriginalExceptionType)) { - _interactionService.DisplayMessage(KnownEmojis.Microscope, $"[grey] Exception: {Markup.Escape(diagnostic.OriginalExceptionType)}[/]", allowMarkup: true); + _interactionService.DisplayPlainText($" Exception: {diagnostic.OriginalExceptionType}"); } if (!string.IsNullOrWhiteSpace(diagnostic.TypeName)) { - _interactionService.DisplayMessage(KnownEmojis.Microscope, $"[grey] Type: {Markup.Escape(diagnostic.TypeName!)}[/]", allowMarkup: true); + _interactionService.DisplayPlainText($" Type: {diagnostic.TypeName}"); } if (!string.IsNullOrWhiteSpace(diagnostic.MemberName)) { - _interactionService.DisplayMessage(KnownEmojis.Microscope, $"[grey] Member: {Markup.Escape(diagnostic.MemberName!)}[/]", allowMarkup: true); + _interactionService.DisplayPlainText($" Member: {diagnostic.MemberName}"); } if (!string.IsNullOrWhiteSpace(diagnostic.RuntimeAspireHostingVersion)) { - _interactionService.DisplayMessage( - KnownEmojis.Microscope, - $"[grey] Runtime Aspire.Hosting: {Markup.Escape(diagnostic.RuntimeAspireHostingVersion!)}[/]", - allowMarkup: true); + _interactionService.DisplayPlainText($" Runtime Aspire.Hosting: {diagnostic.RuntimeAspireHostingVersion}"); } foreach (var assembly in diagnostic.LoadedAssemblies) { var version = assembly.InformationalVersion ?? ""; - _interactionService.DisplayMessage( - KnownEmojis.Microscope, - $"[grey] • {Markup.Escape(assembly.Name)} {Markup.Escape(version)}[/]", - allowMarkup: true); + _interactionService.DisplayPlainText($" • {assembly.Name} {version}"); } } diff --git a/src/Aspire.Cli/Utils/CliPathHelper.cs b/src/Aspire.Cli/Utils/CliPathHelper.cs index 72b1313912a..9430f6de129 100644 --- a/src/Aspire.Cli/Utils/CliPathHelper.cs +++ b/src/Aspire.Cli/Utils/CliPathHelper.cs @@ -220,7 +220,7 @@ internal static string CreateGuestAppHostSocketPath(string socketPrefix) /// . /// /// - /// Unlike , + /// 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 diff --git a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationDiagnostic.cs b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationDiagnostic.cs index 070ca21b08a..fde1a9c7e98 100644 --- a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationDiagnostic.cs +++ b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationDiagnostic.cs @@ -225,7 +225,10 @@ private static (string? Version, string? Path, List(); - var runtimeAspireHosting = typeof(global::Aspire.Hosting.RemoteHost.AssemblyLoader).Assembly; + // 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)); @@ -235,11 +238,6 @@ private static (string? Version, string? Path, List +/// 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 index 35a9f8e8153..8e9af6d88e2 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectSkewTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectSkewTests.cs @@ -10,11 +10,18 @@ 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)] - [InlineData("13.1.0-preview.1.26218.1", "13.1.0-preview.1.26227.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_DetectsMajorMinorPatchChanges(string cli, string sdk, bool expected) + public void IsKnownIncompatibleSkew_DetectsMajorMinorPatchAndPrereleaseChanges(string cli, string sdk, bool expected) { var result = GuestAppHostProject.IsKnownIncompatibleSkew(cli, sdk); diff --git a/tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs b/tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs index e4dc7e6815b..a7c9b420fc3 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs @@ -301,20 +301,19 @@ private static string WriteBinaryWithSidecar(string binaryDir, string source) [Fact] public void CleanupStaleCliSockets_DeletesFilesOlderThanThreshold() { - var tempDir = Path.Combine(Path.GetTempPath(), "aspire-cli-sockets-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(tempDir); + var tempDir = Directory.CreateTempSubdirectory("aspire-cli-sockets-"); try { - var staleFile = Path.Combine(tempDir, "cli.sock.stale"); + var staleFile = Path.Combine(tempDir.FullName, "cli.sock.stale"); File.WriteAllText(staleFile, string.Empty); - var freshFile = Path.Combine(tempDir, "cli.sock.fresh"); + var freshFile = Path.Combine(tempDir.FullName, "cli.sock.fresh"); 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 deleted = CliPathHelper.CleanupStaleCliSockets(tempDir, TimeSpan.FromHours(24), fakeTime); + var deleted = CliPathHelper.CleanupStaleCliSockets(tempDir.FullName, TimeSpan.FromHours(24), fakeTime); Assert.Equal(1, deleted); Assert.False(File.Exists(staleFile)); @@ -322,27 +321,26 @@ public void CleanupStaleCliSockets_DeletesFilesOlderThanThreshold() } finally { - Directory.Delete(tempDir, recursive: true); + tempDir.Delete(recursive: true); } } [Fact] public void CleanupStaleCliSockets_OnlyMatchesCliSockPrefix() { - var tempDir = Path.Combine(Path.GetTempPath(), "aspire-cli-sockets-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(tempDir); + var tempDir = Directory.CreateTempSubdirectory("aspire-cli-sockets-"); try { - var matching = Path.Combine(tempDir, "cli.sock.abc123"); + var matching = Path.Combine(tempDir.FullName, "cli.sock.abc123"); File.WriteAllText(matching, string.Empty); - var unrelated = Path.Combine(tempDir, "apphost.sock.xyz"); + var unrelated = Path.Combine(tempDir.FullName, "apphost.sock.xyz"); 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 deleted = CliPathHelper.CleanupStaleCliSockets(tempDir, TimeSpan.FromHours(24), fakeTime); + var deleted = CliPathHelper.CleanupStaleCliSockets(tempDir.FullName, TimeSpan.FromHours(24), fakeTime); Assert.Equal(1, deleted); Assert.False(File.Exists(matching)); @@ -350,14 +348,17 @@ public void CleanupStaleCliSockets_OnlyMatchesCliSockPrefix() } finally { - Directory.Delete(tempDir, recursive: true); + tempDir.Delete(recursive: true); } } [Fact] public void CleanupStaleCliSockets_MissingDirectoryIsNoOp() { - var missingDir = Path.Combine(Path.GetTempPath(), "aspire-cli-sockets-missing-" + Guid.NewGuid().ToString("N")); + // 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)); @@ -367,17 +368,16 @@ public void CleanupStaleCliSockets_MissingDirectoryIsNoOp() [Fact] public void CleanupStaleCliSockets_EmptyDirectoryReturnsZero() { - var tempDir = Path.Combine(Path.GetTempPath(), "aspire-cli-sockets-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(tempDir); + var tempDir = Directory.CreateTempSubdirectory("aspire-cli-sockets-"); try { - var deleted = CliPathHelper.CleanupStaleCliSockets(tempDir, TimeSpan.FromHours(24)); + var deleted = CliPathHelper.CleanupStaleCliSockets(tempDir.FullName, TimeSpan.FromHours(24)); Assert.Equal(0, deleted); } finally { - Directory.Delete(tempDir, recursive: true); + tempDir.Delete(recursive: true); } } } diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs index b2eca911997..0e7630ef106 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs +++ b/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs @@ -108,4 +108,24 @@ public void BuildDiagnostic_CapturesRuntimeAspireHostingVersion() 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); + } } From b1e3bac459a29f929c84e336b302df6d72211d68 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 26 May 2026 16:03:53 -0700 Subject: [PATCH 3/4] Fix stale CLI socket cleanup matching Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Utils/CliPathHelper.cs | 5 ++-- src/Shared/BackchannelConstants.cs | 11 ++++++++ .../Utils/CliPathHelperTests.cs | 25 +++++++++++-------- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/Aspire.Cli/Utils/CliPathHelper.cs b/src/Aspire.Cli/Utils/CliPathHelper.cs index 9430f6de129..fdb2114f17a 100644 --- a/src/Aspire.Cli/Utils/CliPathHelper.cs +++ b/src/Aspire.Cli/Utils/CliPathHelper.cs @@ -11,7 +11,7 @@ internal static class CliPathHelper { internal const string AspireHomeEnvironmentVariable = "ASPIRE_HOME"; - // The maximum age before a leftover cli.sock.* file in the runtime sockets directory is + // 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); @@ -236,7 +236,8 @@ internal static int CleanupStaleCliSockets(string socketDirectory, TimeSpan maxA var now = (timeProvider ?? TimeProvider.System).GetUtcNow(); var deleted = 0; - foreach (var path in Directory.EnumerateFiles(socketDirectory, "cli.sock.*")) + var socketFileSearchPattern = BackchannelConstants.ComputeSocketFileSearchPattern("cli.sock"); + foreach (var path in Directory.EnumerateFiles(socketDirectory, socketFileSearchPattern)) { try { 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/Utils/CliPathHelperTests.cs b/tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs index a7c9b420fc3..b25d247fc27 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs @@ -3,6 +3,7 @@ using Aspire.Cli.Acquisition; using Aspire.Cli.Utils; +using Aspire.Hosting.Backchannel; using Microsoft.Extensions.Time.Testing; namespace Aspire.Cli.Tests.Utils; @@ -301,19 +302,21 @@ private static string WriteBinaryWithSidecar(string binaryDir, string source) [Fact] public void CleanupStaleCliSockets_DeletesFilesOlderThanThreshold() { - var tempDir = Directory.CreateTempSubdirectory("aspire-cli-sockets-"); + var tempRoot = Directory.CreateTempSubdirectory("aspire-cli-sockets-"); try { - var staleFile = Path.Combine(tempDir.FullName, "cli.sock.stale"); + var staleFile = BackchannelConstants.ComputeCliSocketPath(tempRoot.FullName, "cli.sock"); + Directory.CreateDirectory(Path.GetDirectoryName(staleFile)!); File.WriteAllText(staleFile, string.Empty); - var freshFile = Path.Combine(tempDir.FullName, "cli.sock.fresh"); + 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 deleted = CliPathHelper.CleanupStaleCliSockets(tempDir.FullName, TimeSpan.FromHours(24), fakeTime); + var socketDirectory = Path.GetDirectoryName(staleFile)!; + var deleted = CliPathHelper.CleanupStaleCliSockets(socketDirectory, TimeSpan.FromHours(24), fakeTime); Assert.Equal(1, deleted); Assert.False(File.Exists(staleFile)); @@ -321,26 +324,28 @@ public void CleanupStaleCliSockets_DeletesFilesOlderThanThreshold() } finally { - tempDir.Delete(recursive: true); + tempRoot.Delete(recursive: true); } } [Fact] public void CleanupStaleCliSockets_OnlyMatchesCliSockPrefix() { - var tempDir = Directory.CreateTempSubdirectory("aspire-cli-sockets-"); + var tempRoot = Directory.CreateTempSubdirectory("aspire-cli-sockets-"); try { - var matching = Path.Combine(tempDir.FullName, "cli.sock.abc123"); + var matching = BackchannelConstants.ComputeCliSocketPath(tempRoot.FullName, "cli.sock"); + Directory.CreateDirectory(Path.GetDirectoryName(matching)!); File.WriteAllText(matching, string.Empty); - var unrelated = Path.Combine(tempDir.FullName, "apphost.sock.xyz"); + 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 deleted = CliPathHelper.CleanupStaleCliSockets(tempDir.FullName, TimeSpan.FromHours(24), fakeTime); + var socketDirectory = Path.GetDirectoryName(matching)!; + var deleted = CliPathHelper.CleanupStaleCliSockets(socketDirectory, TimeSpan.FromHours(24), fakeTime); Assert.Equal(1, deleted); Assert.False(File.Exists(matching)); @@ -348,7 +353,7 @@ public void CleanupStaleCliSockets_OnlyMatchesCliSockPrefix() } finally { - tempDir.Delete(recursive: true); + tempRoot.Delete(recursive: true); } } From 0ee9003a538f373476f636fba1bc208b820ced02 Mon Sep 17 00:00:00 2001 From: IEvangelist <7679720+IEvangelist@users.noreply.github.com> Date: Wed, 27 May 2026 13:16:00 -0500 Subject: [PATCH 4/4] Address PR feedback on codegen diagnostics - Catch AppHostCodeGenerationException in sdk dump per-integration path so one failing integration does not abort the full Task.WhenAll batch. - Log the full serialized AppHostCodeGenerationDiagnostic payload in RenderCodeGenerationFailure so debug logs match the XML doc contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs | 2 +- src/Aspire.Cli/Projects/GuestAppHostProject.cs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) 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/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 81789efbba7..08c79b2bdb6 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -1702,6 +1702,11 @@ private void RenderCodeGenerationFailure(AppHostCodeGenerationException exceptio 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) {