Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<AppHostLoadedAssemblyInfo>))]
// V2 API request/response types
[JsonSerializable(typeof(GetCapabilitiesRequest))]
[JsonSerializable(typeof(BackchannelTraceContext))]
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ private async Task<IntegrationDumpResult> 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);
Expand Down
93 changes: 93 additions & 0 deletions src/Aspire.Cli/Projects/AppHostCodeGenerationDiagnostic.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Aspire.Cli.Projects;

/// <summary>
/// JSON-RPC error codes returned by the AppHost server for code-generation failures.
/// </summary>
/// <remarks>
/// Values mirror those defined server-side in <c>Aspire.Hosting.RemoteHost</c>.
/// </remarks>
internal static class AppHostCodeGenerationErrorCodes
{
/// <summary>
/// The AppHost server failed to load reflection-based code generation metadata.
/// Typically caused by an assembly-version mismatch between the bundled
/// <c>Aspire.Hosting</c> runtime and the user-restored integration assemblies.
/// </summary>
public const int IncompatibleAspireSdk = -32050;
}

/// <summary>
/// CLI-side representation of the structured diagnostic payload the AppHost server attaches to
/// code-generation failures (mirrors <c>CodeGenerationDiagnostic</c> in
/// <c>Aspire.Hosting.RemoteHost</c>). The JSON shape is contractual; updating one side without
/// the other will break the round-trip.
/// </summary>
internal sealed class AppHostCodeGenerationDiagnostic
{
/// <summary>
/// Gets the CLR type name of the original exception thrown by the AppHost server
/// (e.g. <c>System.TypeLoadException</c>).
/// </summary>
public string OriginalExceptionType { get; init; } = "";

/// <summary>
/// Gets the name of the type that failed to load, if known.
/// </summary>
public string? TypeName { get; init; }

/// <summary>
/// Gets the name of the missing member, if the failure was a missing-method or
/// missing-field error.
/// </summary>
public string? MemberName { get; init; }

/// <summary>
/// Gets the informational version of the bundled <c>Aspire.Hosting</c> assembly on the
/// server side, if it could be discovered.
/// </summary>
public string? RuntimeAspireHostingVersion { get; init; }

/// <summary>
/// Gets the on-disk location of the bundled <c>Aspire.Hosting</c> assembly, if it could be
/// discovered.
/// </summary>
public string? RuntimeAspireHostingPath { get; init; }

/// <summary>
/// Gets the loaded integration assemblies probed by the AppHost server at the time of the
/// failure.
/// </summary>
public List<AppHostLoadedAssemblyInfo> LoadedAssemblies { get; init; } = [];

/// <summary>
/// Gets a short, language-agnostic remediation hint suitable for surfacing to AppHost
/// authors.
/// </summary>
public string? RemediationHint { get; init; }
}

/// <summary>
/// Identity information for a single loaded assembly captured at the time of a code-generation
/// failure.
/// </summary>
internal sealed class AppHostLoadedAssemblyInfo
{
/// <summary>
/// Gets the simple name of the assembly (e.g. <c>Aspire.Hosting.JavaScript</c>).
/// </summary>
public string Name { get; init; } = "";

/// <summary>
/// Gets the informational version of the assembly, when present, otherwise the assembly
/// version.
/// </summary>
public string? InformationalVersion { get; init; }

/// <summary>
/// Gets the on-disk location of the assembly when available.
/// </summary>
public string? Location { get; init; }
}
29 changes: 29 additions & 0 deletions src/Aspire.Cli/Projects/AppHostCodeGenerationException.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Thrown by <see cref="AppHostRpcClient"/> when the AppHost server reports a reflection-based
/// code-generation failure. Carries a structured <see cref="AppHostCodeGenerationDiagnostic"/>
/// payload supplied by the server so the CLI can render an actionable, tiered diagnostic.
/// </summary>
/// <remarks>
/// The <see cref="Exception.Message"/> 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 <see cref="Diagnostic"/> and is only
/// rendered to the user when <c>--debug</c> is supplied.
/// </remarks>
internal sealed class AppHostCodeGenerationException : Exception
{
public AppHostCodeGenerationException(string message, AppHostCodeGenerationDiagnostic diagnostic, Exception? innerException = null)
: base(message, innerException)
{
Diagnostic = diagnostic;
}

/// <summary>
/// Gets the structured diagnostic payload that accompanied the RPC failure.
/// </summary>
public AppHostCodeGenerationDiagnostic Diagnostic { get; }
}
60 changes: 56 additions & 4 deletions src/Aspire.Cli/Projects/AppHostRpcClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -94,19 +95,19 @@ public Task<Dictionary<string, string>> ScaffoldAppHostAsync(

/// <inheritdoc />
public Task<Dictionary<string, string>> GenerateCodeAsync(string languageId, CancellationToken cancellationToken)
=> InvokeAsync<Dictionary<string, string>>("generateCode", [languageId, null], cancellationToken);
=> InvokeCodeGenerationAsync<Dictionary<string, string>>("generateCode", [languageId, null], cancellationToken);

/// <inheritdoc />
public Task<Dictionary<string, string>> GenerateCodeForAssemblyAsync(string languageId, string assemblyName, CancellationToken cancellationToken)
=> InvokeAsync<Dictionary<string, string>>("generateCode", [languageId, assemblyName], cancellationToken);
=> InvokeCodeGenerationAsync<Dictionary<string, string>>("generateCode", [languageId, assemblyName], cancellationToken);

/// <inheritdoc />
public Task<Commands.Sdk.CapabilitiesInfo> GetCapabilitiesAsync(CancellationToken cancellationToken)
=> InvokeAsync<Commands.Sdk.CapabilitiesInfo>("getCapabilities", [null], cancellationToken);
=> InvokeCodeGenerationAsync<Commands.Sdk.CapabilitiesInfo>("getCapabilities", [null], cancellationToken);

/// <inheritdoc />
public Task<Commands.Sdk.CapabilitiesInfo> GetCapabilitiesForAssembliesAsync(IReadOnlyList<string> assemblyNames, CancellationToken cancellationToken)
Comment thread
IEvangelist marked this conversation as resolved.
=> InvokeAsync<Commands.Sdk.CapabilitiesInfo>("getCapabilities", [assemblyNames], cancellationToken);
=> InvokeCodeGenerationAsync<Commands.Sdk.CapabilitiesInfo>("getCapabilities", [assemblyNames], cancellationToken);

/// <inheritdoc />
public Task<T> InvokeAsync<T>(string methodName, object?[] parameters, CancellationToken cancellationToken)
Expand All @@ -116,6 +117,57 @@ public Task<T> InvokeAsync<T>(string methodName, object?[] parameters, Cancellat
public Task InvokeAsync(string methodName, object?[] parameters, CancellationToken cancellationToken)
=> _jsonRpc.InvokeWithProfilingAsync(_profilingTelemetry, ConnectionName, methodName, parameters, cancellationToken);

/// <summary>
/// Invokes a code-generation RPC method and rethrows structured load/type failures as
/// <see cref="AppHostCodeGenerationException"/> so the CLI can render an actionable
/// diagnostic instead of an empty or .NET-specific error message.
/// </summary>
private async Task<T> InvokeCodeGenerationAsync<T>(string methodName, object?[] parameters, CancellationToken cancellationToken)
{
try
{
return await _jsonRpc.InvokeWithProfilingAsync<T>(_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);
}
}

/// <summary>
/// Extracts a <see cref="AppHostCodeGenerationDiagnostic"/> from a <see cref="RemoteInvocationException"/>'s
/// structured error data, returning <see langword="null"/> if the payload is missing or
/// can't be deserialized.
/// </summary>
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;
}

/// <inheritdoc />
public async ValueTask DisposeAsync()
{
Expand Down
Loading
Loading