File: Projects\GuestAppHostProject.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.Tool.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Certificates;
using Aspire.Cli.Configuration;
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.Packaging;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Semver;
 
namespace Aspire.Cli.Projects;
 
/// <summary>
/// Handler for guest (non-.NET) AppHost projects.
/// Supports any language registered via <see cref="ILanguageDiscovery"/>.
/// </summary>
internal sealed class GuestAppHostProject : IAppHostProject
{
    private const string GeneratedFolderName = ".modules";
 
    private readonly IInteractionService _interactionService;
    private readonly IAppHostCliBackchannel _backchannel;
    private readonly IAppHostServerProjectFactory _appHostServerProjectFactory;
    private readonly ICertificateService _certificateService;
    private readonly IDotNetCliRunner _runner;
    private readonly IPackagingService _packagingService;
    private readonly IConfiguration _configuration;
    private readonly IFeatures _features;
    private readonly ILanguageDiscovery _languageDiscovery;
    private readonly ILogger<GuestAppHostProject> _logger;
    private readonly TimeProvider _timeProvider;
    private readonly RunningInstanceManager _runningInstanceManager;
 
    // Language is always resolved via constructor
    private readonly LanguageInfo _resolvedLanguage;
    private GuestRuntime? _guestRuntime;
 
    public GuestAppHostProject(
        LanguageInfo language,
        IInteractionService interactionService,
        IAppHostCliBackchannel backchannel,
        IAppHostServerProjectFactory appHostServerProjectFactory,
        ICertificateService certificateService,
        IDotNetCliRunner runner,
        IPackagingService packagingService,
        IConfiguration configuration,
        IFeatures features,
        ILanguageDiscovery languageDiscovery,
        ILogger<GuestAppHostProject> logger,
        TimeProvider? timeProvider = null)
    {
        _resolvedLanguage = language;
        _interactionService = interactionService;
        _backchannel = backchannel;
        _appHostServerProjectFactory = appHostServerProjectFactory;
        _certificateService = certificateService;
        _runner = runner;
        _packagingService = packagingService;
        _configuration = configuration;
        _features = features;
        _languageDiscovery = languageDiscovery;
        _logger = logger;
        _timeProvider = timeProvider ?? TimeProvider.System;
        _runningInstanceManager = new RunningInstanceManager(_logger, _interactionService, _timeProvider);
    }
 
    // ═══════════════════════════════════════════════════════════════
    // IDENTITY (Always resolved via constructor)
    // ═══════════════════════════════════════════════════════════════
 
    /// <inheritdoc />
    public string LanguageId => _resolvedLanguage.LanguageId;
 
    /// <inheritdoc />
    public string DisplayName => _resolvedLanguage.DisplayName;
 
    /// <summary>
    /// Gets the effective SDK version from configuration (inherits from parent directories)
    /// or falls back to the default SDK version.
    /// </summary>
    private string GetEffectiveSdkVersion()
    {
        // IConfiguration merges settings from parent directories and global settings
        // The key "sdkVersion" is the flattened key from settings.json
        var configuredVersion = _configuration["sdkVersion"];
        if (!string.IsNullOrEmpty(configuredVersion))
        {
            _logger.LogDebug("Using SDK version from configuration: {Version}", configuredVersion);
            return configuredVersion;
        }
        
        _logger.LogDebug("Using default SDK version: {Version}", AppHostServerProject.DefaultSdkVersion);
        return AppHostServerProject.DefaultSdkVersion;
    }
 
    // ═══════════════════════════════════════════════════════════════
    // DETECTION
    // ═══════════════════════════════════════════════════════════════
 
    /// <inheritdoc />
    public Task<string[]> GetDetectionPatternsAsync(CancellationToken cancellationToken = default)
    {
        // Return the detection patterns for this specific language
        return Task.FromResult(_resolvedLanguage.DetectionPatterns);
    }
 
    /// <inheritdoc />
    public bool CanHandle(FileInfo appHostFile)
    {
        // Check if file matches this language's detection patterns
        return _resolvedLanguage.DetectionPatterns.Any(p => 
            appHostFile.Name.Equals(p, StringComparison.OrdinalIgnoreCase));
    }
 
    // ═══════════════════════════════════════════════════════════════
    // CREATION
    // ═══════════════════════════════════════════════════════════════
 
    /// <inheritdoc />
    public string? AppHostFileName => _resolvedLanguage.DetectionPatterns.FirstOrDefault();
 
    /// <summary>
    /// Creates project files and builds the AppHost server.
    /// </summary>
    private static async Task<(bool Success, OutputCollector Output, string? ChannelName)> BuildAppHostServerAsync(
        AppHostServerProject appHostServerProject,
        string sdkVersion,
        List<(string Name, string Version)> packages,
        CancellationToken cancellationToken)
    {
        var outputCollector = new OutputCollector();
 
        var (_, channelName) = await appHostServerProject.CreateProjectFilesAsync(sdkVersion, packages, cancellationToken);
        var (buildSuccess, buildOutput) = await appHostServerProject.BuildAsync(cancellationToken);
        if (!buildSuccess)
        {
            foreach (var (_, line) in buildOutput.GetLines())
            {
                outputCollector.AppendOutput(line);
            }
        }
 
        return (buildSuccess, outputCollector, channelName);
    }
 
    /// <summary>
    /// Builds the AppHost server project and generates SDK code.
    /// </summary>
    private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, CancellationToken cancellationToken)
    {
        // Step 1: Load config - source of truth for SDK version and packages
        var effectiveSdkVersion = GetEffectiveSdkVersion();
        var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, effectiveSdkVersion);
        var packages = config.GetAllPackages().ToList();
 
        var appHostServerProject = _appHostServerProjectFactory.Create(directory.FullName);
        var socketPath = appHostServerProject.GetSocketPath();
 
        var (buildSuccess, buildOutput, _) = await BuildAppHostServerAsync(appHostServerProject, config.SdkVersion!, packages, cancellationToken);
        if (!buildSuccess)
        {
            _interactionService.DisplayLines(buildOutput.GetLines());
            _interactionService.DisplayError("Failed to build AppHost server.");
            return;
        }
 
        // Step 2: Start the AppHost server temporarily for code generation
        var currentPid = Environment.ProcessId;
        var (serverProcess, _) = appHostServerProject.Run(socketPath, currentPid, new Dictionary<string, string>());
 
        try
        {
            // Step 3: Connect to server
            await using var rpcClient = await AppHostRpcClient.ConnectAsync(socketPath, cancellationToken);
 
            // Step 4: Install dependencies using GuestRuntime
            var installResult = await InstallDependenciesAsync(directory, rpcClient, cancellationToken);
            if (installResult != 0)
            {
                return;
            }
 
            // Step 5: Generate SDK code via RPC
            await GenerateCodeViaRpcAsync(
                directory.FullName,
                rpcClient,
                packages,
                cancellationToken);
        }
        finally
        {
            // Step 6: Stop the server (we were just generating code)
            if (!serverProcess.HasExited)
            {
                try
                {
                    serverProcess.Kill(entireProcessTree: true);
                }
                catch (Exception ex)
                {
                    _logger.LogDebug(ex, "Error killing AppHost server process after code generation");
                }
            }
        }
    }
 
    // ═══════════════════════════════════════════════════════════════
    // EXECUTION
    // ═══════════════════════════════════════════════════════════════
 
    /// <inheritdoc />
    public Task<AppHostValidationResult> ValidateAppHostAsync(FileInfo appHostFile, CancellationToken cancellationToken)
    {
        // Check if the file exists
        if (!appHostFile.Exists)
        {
            _logger.LogDebug("AppHost file {File} does not exist", appHostFile.FullName);
            return Task.FromResult(new AppHostValidationResult(IsValid: false));
        }
 
        // Use the resolved language's detection patterns (set in constructor)
        var patterns = _resolvedLanguage.DetectionPatterns;
        if (!patterns.Any(p => appHostFile.Name.Equals(p, StringComparison.OrdinalIgnoreCase)))
        {
            _logger.LogDebug("AppHost file {File} does not match {Language} detection patterns: {Patterns}", 
                appHostFile.Name, _resolvedLanguage.DisplayName, string.Join(", ", patterns));
            return Task.FromResult(new AppHostValidationResult(IsValid: false));
        }
 
        // Guest languages don't have the "possibly unbuildable" concept
        // Detailed validation is delegated to the server-side language support
        _logger.LogDebug("Validated {Language} AppHost: {File}", _resolvedLanguage.DisplayName, appHostFile.FullName);
        return Task.FromResult(new AppHostValidationResult(IsValid: true));
    }
 
    /// <inheritdoc />
    public async Task<int> RunAsync(AppHostProjectContext context, CancellationToken cancellationToken)
    {
        var appHostFile = context.AppHostFile;
        var directory = appHostFile.Directory!;
 
        _logger.LogDebug("Running {Language} AppHost: {AppHostFile}", DisplayName, appHostFile.FullName);
 
        try
        {
            // Step 1: Ensure certificates are trusted
            try
            {
                await _certificateService.EnsureCertificatesTrustedAsync(_runner, cancellationToken);
            }
            catch
            {
                context.BuildCompletionSource?.TrySetResult(false);
                throw;
            }
 
            // Build phase: build AppHost server (dependency install happens after server starts)
            // Load config - source of truth for SDK version and packages
            var effectiveSdkVersion = GetEffectiveSdkVersion();
            var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, effectiveSdkVersion);
            var packages = config.GetAllPackages().ToList();
 
            var appHostServerProject = _appHostServerProjectFactory.Create(directory.FullName);
            var socketPath = appHostServerProject.GetSocketPath();
 
            var buildResult = await _interactionService.ShowStatusAsync(
                ":hammer_and_wrench:  Building app host...",
                async () =>
                {
                    // Build the AppHost server
                    var (buildSuccess, buildOutput, channelName) = await BuildAppHostServerAsync(appHostServerProject, config.SdkVersion!, packages, cancellationToken);
                    if (!buildSuccess)
                    {
                        return (Success: false, Output: buildOutput, Error: "Failed to build app host.", ChannelName: (string?)null, NeedsCodeGen: false);
                    }
 
                    return (Success: true, Output: buildOutput, Error: (string?)null, ChannelName: channelName, NeedsCodeGen: NeedsGeneration(directory.FullName, packages));
                });
 
            // Save the channel to settings.json if available (config already has SdkVersion)
            if (buildResult.ChannelName is not null)
            {
                config.Channel = buildResult.ChannelName;
                config.Save(directory.FullName);
            }
 
            if (!buildResult.Success)
            {
                // Set OutputCollector so RunCommand can display errors
                context.OutputCollector = buildResult.Output;
                context.BuildCompletionSource?.TrySetResult(false);
                return ExitCodeConstants.FailedToBuildArtifacts;
            }
 
            // Store output collector in context for exception handling by RunCommand
            // This must be set BEFORE signaling build completion to avoid a race condition
            context.OutputCollector = buildResult.Output;
 
            // Signal that build/preparation is complete
            context.BuildCompletionSource?.TrySetResult(true);
 
            // Read launchSettings.json if it exists, or create defaults
            var launchSettingsEnvVars = ReadLaunchSettingsEnvironmentVariables(directory) ?? new Dictionary<string, string>();
 
            // Generate a backchannel socket path for CLI to connect to AppHost server
            var backchannelSocketPath = GetBackchannelSocketPath();
 
            // Pass the backchannel socket path to AppHost server so it opens a server for CLI communication
            launchSettingsEnvVars[KnownConfigNames.UnixSocketPath] = backchannelSocketPath;
 
            // Check if hot reload (watch mode) is enabled
            var enableHotReload = _features.IsFeatureEnabled(KnownFeatures.DefaultWatchEnabled, defaultValue: false);
 
            // Start the AppHost server process
            var currentPid = Environment.ProcessId;
            var (appHostServerProcess, appHostServerOutputCollector) = appHostServerProject.Run(socketPath, currentPid, launchSettingsEnvVars, debug: context.Debug);
 
            // The backchannel completion source is the contract with RunCommand
            // We signal this when the backchannel is ready, RunCommand uses it for UX
            var backchannelCompletionSource = context.BackchannelCompletionSource ?? new TaskCompletionSource<IAppHostCliBackchannel>();
 
            // Start connecting to the backchannel (for dashboard URLs, logs, etc.)
            _ = StartBackchannelConnectionAsync(appHostServerProcess, backchannelSocketPath, backchannelCompletionSource, enableHotReload, cancellationToken);
 
            // Give the server a moment to start
            await Task.Delay(500, cancellationToken);
 
            if (appHostServerProcess.HasExited)
            {
                _interactionService.DisplayLines(appHostServerOutputCollector.GetLines());
                _interactionService.DisplayError("App host exited unexpectedly.");
                return ExitCodeConstants.FailedToDotnetRunAppHost;
            }
 
            // Step 5: Connect to server for RPC calls
            await using var rpcClient = await AppHostRpcClient.ConnectAsync(socketPath, cancellationToken);
 
            // Step 6: Install dependencies (using GuestRuntime)
            // The GuestRuntime will skip if the RuntimeSpec doesn't have InstallDependencies configured
            var installResult = await InstallDependenciesAsync(directory, rpcClient, cancellationToken);
            if (installResult != 0)
            {
                context.BackchannelCompletionSource?.TrySetException(
                    new InvalidOperationException($"Failed to install {DisplayName} dependencies."));
 
                if (!appHostServerProcess.HasExited)
                {
                    try
                    {
                        appHostServerProcess.Kill(entireProcessTree: true);
                    }
                    catch (Exception ex)
                    {
                        _logger.LogDebug(ex, "Error killing AppHost server process after dependency install failure");
                    }
                }
 
                return installResult;
            }
 
            // Step 7: Generate SDK code via RPC if needed
            if (buildResult.NeedsCodeGen)
            {
                await GenerateCodeViaRpcAsync(
                    directory.FullName,
                    rpcClient,
                    packages,
                    cancellationToken);
            }
 
            // Step 8: Execute the guest apphost
 
            // Pass the socket path to the guest process
            var environmentVariables = new Dictionary<string, string>(context.EnvironmentVariables)
            {
                ["REMOTE_APP_HOST_SOCKET_PATH"] = socketPath
            };
 
            // Start guest apphost - it will connect to AppHost server, define resources
            // When hot reload is enabled, use watch mode
            var (guestExitCode, guestOutput) = await ExecuteGuestAppHostAsync(
                appHostFile, directory, environmentVariables, enableHotReload, rpcClient, cancellationToken);
 
            if (guestExitCode != 0)
            {
                _logger.LogError("{Language} apphost exited with code {ExitCode}", DisplayName, guestExitCode);
 
                // Display the output (same pattern as DotNetCliRunner)
                _interactionService.DisplayLines(guestOutput.GetLines());
 
                // Signal failure to RunCommand so it doesn't hang waiting for the backchannel
                var error = new InvalidOperationException($"The {DisplayName} apphost failed.");
                context.BackchannelCompletionSource?.TrySetException(error);
 
                // Kill the AppHost server since the apphost failed
                if (!appHostServerProcess.HasExited)
                {
                    try
                    {
                        appHostServerProcess.Kill(entireProcessTree: true);
                    }
                    catch (Exception ex)
                    {
                        _logger.LogDebug(ex, "Error killing AppHost server process after {Language} failure", DisplayName);
                    }
                }
 
                return guestExitCode;
            }
 
            // In watch mode, wait for server to exit (Ctrl+C or orphan detection)
            // In non-watch mode, kill the server now that the apphost has exited
            if (!enableHotReload && !appHostServerProcess.HasExited)
            {
                try
                {
                    appHostServerProcess.Kill(entireProcessTree: true);
                }
                catch (Exception ex)
                {
                    _logger.LogDebug(ex, "Error killing AppHost server process");
                }
            }
 
            await appHostServerProcess.WaitForExitAsync(cancellationToken);
 
            return appHostServerProcess.ExitCode;
        }
        catch (OperationCanceledException)
        {
            // Signal that build/preparation failed so RunCommand doesn't hang waiting
            context.BuildCompletionSource?.TrySetResult(false);
            _interactionService.DisplayCancellationMessage();
            return ExitCodeConstants.Success;
        }
        catch (Exception ex)
        {
            // Signal that build/preparation failed so RunCommand doesn't hang waiting
            context.BuildCompletionSource?.TrySetResult(false);
            _logger.LogError(ex, "Failed to run {Language} AppHost", DisplayName);
            _interactionService.DisplayError($"Failed to run {DisplayName} AppHost: {ex.Message}");
            return ExitCodeConstants.FailedToDotnetRunAppHost;
        }
    }
 
    private Dictionary<string, string>? ReadLaunchSettingsEnvironmentVariables(DirectoryInfo directory)
    {
        // For guest apphosts, look for apphost.run.json
        // similar to how .NET single-file apphosts use apphost.run.json
        var apphostRunPath = Path.Combine(directory.FullName, "apphost.run.json");
        var launchSettingsPath = Path.Combine(directory.FullName, "Properties", "launchSettings.json");
 
        var configPath = File.Exists(apphostRunPath) ? apphostRunPath : launchSettingsPath;
 
        if (!File.Exists(configPath))
        {
            _logger.LogDebug("No apphost.run.json or launchSettings.json found in {Path}", directory.FullName);
            return null;
        }
 
        try
        {
            var json = File.ReadAllText(configPath);
            using var doc = JsonDocument.Parse(json);
 
            if (!doc.RootElement.TryGetProperty("profiles", out var profiles))
            {
                return null;
            }
 
            // Try to find the 'https' profile first, then fall back to the first profile
            JsonElement? profileElement = null;
            if (profiles.TryGetProperty("https", out var httpsProfile))
            {
                profileElement = httpsProfile;
            }
            else
            {
                // Use the first profile
                using var enumerator = profiles.EnumerateObject();
                if (enumerator.MoveNext())
                {
                    profileElement = enumerator.Current.Value;
                }
            }
 
            if (profileElement == null)
            {
                return null;
            }
 
            var result = new Dictionary<string, string>();
 
            // Read applicationUrl and convert to ASPNETCORE_URLS
            if (profileElement.Value.TryGetProperty("applicationUrl", out var appUrl) &&
                appUrl.ValueKind == JsonValueKind.String)
            {
                result["ASPNETCORE_URLS"] = appUrl.GetString()!;
            }
 
            // Read environment variables
            if (profileElement.Value.TryGetProperty("environmentVariables", out var envVars))
            {
                foreach (var prop in envVars.EnumerateObject())
                {
                    if (prop.Value.ValueKind == JsonValueKind.String)
                    {
                        result[prop.Name] = prop.Value.GetString()!;
                    }
                }
            }
 
            if (result.Count == 0)
            {
                return null;
            }
 
            _logger.LogDebug("Read {Count} environment variables from apphost.run.json", result.Count);
            return result;
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Failed to read launchSettings.json");
            return null;
        }
    }
 
    /// <inheritdoc />
    public async Task<int> PublishAsync(PublishContext context, CancellationToken cancellationToken)
    {
        var appHostFile = context.AppHostFile;
        var directory = appHostFile.Directory!;
 
        _logger.LogDebug("Publishing guest AppHost: {AppHostFile}", appHostFile.FullName);
 
        try
        {
            // Step 1: Load config - source of truth for SDK version and packages
            var effectiveSdkVersion = GetEffectiveSdkVersion();
            var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, effectiveSdkVersion);
            var packages = config.GetAllPackages().ToList();
 
            var appHostServerProject = _appHostServerProjectFactory.Create(directory.FullName);
            var jsonRpcSocketPath = appHostServerProject.GetSocketPath();
 
            // Build the AppHost server
            var (buildSuccess, buildOutput, _) = await BuildAppHostServerAsync(appHostServerProject, config.SdkVersion!, packages, cancellationToken);
            if (!buildSuccess)
            {
                // Set OutputCollector so PipelineCommandBase can display errors
                context.OutputCollector = buildOutput;
                // Signal the backchannel completion source so the caller doesn't wait forever
                context.BackchannelCompletionSource?.TrySetException(
                    new InvalidOperationException("The app host build failed."));
                return ExitCodeConstants.FailedToBuildArtifacts;
            }
 
            // Store output collector in context for exception handling
            context.OutputCollector = buildOutput;
 
            // Check if code generation is needed (we'll do it after server starts)
            var needsCodeGen = NeedsGeneration(directory.FullName, packages);
 
            // Read launchSettings.json if it exists
            var launchSettingsEnvVars = ReadLaunchSettingsEnvironmentVariables(directory) ?? new Dictionary<string, string>();
 
            // Generate a backchannel socket path for CLI to connect to AppHost server
            var backchannelSocketPath = GetBackchannelSocketPath();
 
            // Pass the backchannel socket path to AppHost server so it opens a server
            launchSettingsEnvVars[KnownConfigNames.UnixSocketPath] = backchannelSocketPath;
 
            // Step 2: Start the AppHost server process (it opens the backchannel for progress reporting)
            var currentPid = Environment.ProcessId;
            var (appHostServerProcess, appHostServerOutputCollector) = appHostServerProject.Run(jsonRpcSocketPath, currentPid, launchSettingsEnvVars, debug: context.Debug);
 
            // Start connecting to the backchannel
            if (context.BackchannelCompletionSource is not null)
            {
                _ = StartBackchannelConnectionAsync(appHostServerProcess, backchannelSocketPath, context.BackchannelCompletionSource, enableHotReload: false, cancellationToken);
            }
 
            // Give the server a moment to start
            await Task.Delay(500, cancellationToken);
 
            if (appHostServerProcess.HasExited)
            {
                _interactionService.DisplayLines(appHostServerOutputCollector.GetLines());
                _interactionService.DisplayError("App host exited unexpectedly.");
                return ExitCodeConstants.FailedToDotnetRunAppHost;
            }
 
            // Step 3: Connect to server for RPC calls
            await using var rpcClient = await AppHostRpcClient.ConnectAsync(jsonRpcSocketPath, cancellationToken);
 
            // Step 4: Install dependencies if needed (using GuestRuntime)
            // The GuestRuntime will skip if the RuntimeSpec doesn't have InstallDependencies configured
            var installResult = await InstallDependenciesAsync(directory, rpcClient, cancellationToken);
            if (installResult != 0)
            {
                context.BackchannelCompletionSource?.TrySetException(
                    new InvalidOperationException($"Failed to install {DisplayName} dependencies."));
 
                if (!appHostServerProcess.HasExited)
                {
                    try
                    {
                        appHostServerProcess.Kill(entireProcessTree: true);
                    }
                    catch (Exception ex)
                    {
                        _logger.LogDebug(ex, "Error killing AppHost server process after dependency install failure");
                    }
                }
 
                return installResult;
            }
 
            // Step 5: Generate code via RPC if needed
            if (needsCodeGen)
            {
                await GenerateCodeViaRpcAsync(
                    directory.FullName,
                    rpcClient,
                    packages,
                    cancellationToken);
            }
 
            // Pass the socket path to the guest process
            var environmentVariables = new Dictionary<string, string>(context.EnvironmentVariables)
            {
                ["REMOTE_APP_HOST_SOCKET_PATH"] = jsonRpcSocketPath
            };
 
            // Step 6: Execute the guest apphost for publishing
            // Pass the publish arguments (e.g., --operation publish --step deploy)
            var (guestExitCode, guestOutput) = await ExecuteGuestAppHostForPublishAsync(
                appHostFile, directory, environmentVariables, context.Arguments, rpcClient, cancellationToken);
 
            if (guestExitCode != 0)
            {
                _logger.LogError("{Language} apphost exited with code {ExitCode}", DisplayName, guestExitCode);
 
                // Display the output (same pattern as DotNetCliRunner)
                _interactionService.DisplayLines(guestOutput.GetLines());
 
                // Signal failure so callers don't hang waiting for the backchannel
                var error = new InvalidOperationException($"The {DisplayName} apphost failed.");
                context.BackchannelCompletionSource?.TrySetException(error);
 
                // Kill the AppHost server since the apphost failed
                if (!appHostServerProcess.HasExited)
                {
                    try
                    {
                        appHostServerProcess.Kill(entireProcessTree: true);
                    }
                    catch (Exception ex)
                    {
                        _logger.LogDebug(ex, "Error killing AppHost server process after {Language} failure", DisplayName);
                    }
                }
 
                return guestExitCode;
            }
 
            // Kill the server after the guest apphost exits
            if (!appHostServerProcess.HasExited)
            {
                try
                {
                    appHostServerProcess.Kill(entireProcessTree: true);
                }
                catch (Exception ex)
                {
                    _logger.LogDebug(ex, "Error killing AppHost server process");
                }
            }
 
            await appHostServerProcess.WaitForExitAsync(cancellationToken);
 
            return appHostServerProcess.ExitCode;
        }
        catch (OperationCanceledException)
        {
            _interactionService.DisplayCancellationMessage();
            return ExitCodeConstants.Success;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to publish {Language} AppHost", DisplayName);
            _interactionService.DisplayError($"Failed to publish {DisplayName} AppHost: {ex.Message}");
            return ExitCodeConstants.FailedToDotnetRunAppHost;
        }
    }
 
    /// <summary>
    /// Gets the backchannel socket path for CLI communication.
    /// </summary>
    private static string GetBackchannelSocketPath()
    {
        var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
        var aspireCliPath = Path.Combine(homeDirectory, ".aspire", "cli", "backchannels");
        Directory.CreateDirectory(aspireCliPath);
        var socketName = $"{Guid.NewGuid():N}.sock";
        return Path.Combine(aspireCliPath, socketName);
    }
 
    /// <summary>
    /// Starts connecting to the AppHost server's backchannel server.
    /// </summary>
    private async Task StartBackchannelConnectionAsync(
        Process process,
        string socketPath,
        TaskCompletionSource<IAppHostCliBackchannel> backchannelCompletionSource,
        bool enableHotReload,
        CancellationToken cancellationToken)
    {
        const int ConnectionTimeoutSeconds = 60;
 
        var startTime = DateTimeOffset.UtcNow;
        var connectionAttempts = 0;
 
        _logger.LogDebug("Starting backchannel connection to AppHost server at {SocketPath}", socketPath);
 
        while (!cancellationToken.IsCancellationRequested)
        {
            try
            {
                _logger.LogTrace("Attempting to connect to AppHost server backchannel at {SocketPath} (attempt {Attempt})", socketPath, ++connectionAttempts);
                // Pass enableHotReload as autoReconnect - the backchannel will handle reconnection internally
                await _backchannel.ConnectAsync(socketPath, autoReconnect: enableHotReload, cancellationToken).ConfigureAwait(false);
                backchannelCompletionSource.TrySetResult(_backchannel);
                _logger.LogDebug("Connected to AppHost server backchannel at {SocketPath}", socketPath);
                return;
            }
            catch (SocketException ex) when (process.HasExited && process.ExitCode != 0)
            {
                _logger.LogError("AppHost server process has exited. Unable to connect to backchannel at {SocketPath}", socketPath);
                var backchannelException = new FailedToConnectBackchannelConnection($"AppHost server process has exited unexpectedly.", process, ex);
                backchannelCompletionSource.TrySetException(backchannelException);
                return;
            }
            catch (SocketException)
            {
                var waitingFor = DateTimeOffset.UtcNow - startTime;
 
                // Timeout after ConnectionTimeoutSeconds - the AppHost server should have started by now
                if (waitingFor > TimeSpan.FromSeconds(ConnectionTimeoutSeconds))
                {
                    _logger.LogError("Timed out waiting for AppHost server to start after {Timeout} seconds", ConnectionTimeoutSeconds);
                    var timeoutException = new TimeoutException($"Timed out waiting for AppHost server to start after {ConnectionTimeoutSeconds} seconds. Check the debug logs for more details.");
                    backchannelCompletionSource.TrySetException(timeoutException);
                    return;
                }
 
                // Slow down polling after 10 seconds
                if (waitingFor > TimeSpan.FromSeconds(10))
                {
                    await Task.Delay(1000, cancellationToken).ConfigureAwait(false);
                }
                else
                {
                    await Task.Delay(50, cancellationToken).ConfigureAwait(false);
                }
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Failed to connect to AppHost server backchannel");
                backchannelCompletionSource.TrySetException(ex);
                return;
            }
        }
    }
 
    /// <inheritdoc />
    public async Task<bool> AddPackageAsync(AddPackageContext context, CancellationToken cancellationToken)
    {
        var directory = context.AppHostFile.Directory;
        if (directory is null)
        {
            return false;
        }
 
        // Load config - source of truth for SDK version and packages
        var effectiveSdkVersion = GetEffectiveSdkVersion();
        var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, effectiveSdkVersion);
 
        // Update .aspire/settings.json with the new package
        config.AddOrUpdatePackage(context.PackageId, context.PackageVersion);
        config.Save(directory.FullName);
 
        // Build and regenerate SDK code with the new package
        await BuildAndGenerateSdkAsync(directory, cancellationToken);
 
        return true;
    }
 
    /// <inheritdoc />
    public async Task<UpdatePackagesResult> UpdatePackagesAsync(UpdatePackagesContext context, CancellationToken cancellationToken)
    {
        var directory = context.AppHostFile.Directory;
        if (directory is null)
        {
            return new UpdatePackagesResult { UpdatesApplied = false };
        }
 
        // Load config - source of truth for SDK version and packages
        var effectiveSdkVersion = GetEffectiveSdkVersion();
        var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, effectiveSdkVersion);
        if (config.Packages is null || config.Packages.Count == 0)
        {
            _interactionService.DisplayMessage("check_mark", UpdateCommandStrings.ProjectUpToDateMessage);
            return new UpdatePackagesResult { UpdatesApplied = false };
        }
 
        // Find updates for each package
        var updates = await _interactionService.ShowStatusAsync(
            UpdateCommandStrings.AnalyzingProjectStatus,
            async () =>
            {
                var packageUpdates = new List<(string PackageId, string CurrentVersion, string NewVersion)>();
 
                foreach (var (packageId, currentVersion) in config.Packages)
                {
                    try
                    {
                        var packages = await context.Channel.GetPackagesAsync(packageId, directory, cancellationToken);
                        var latestPackage = packages
                            .Where(p => SemVersion.TryParse(p.Version, SemVersionStyles.Strict, out _))
                            .OrderByDescending(p => SemVersion.Parse(p.Version, SemVersionStyles.Strict), SemVersion.PrecedenceComparer)
                            .FirstOrDefault();
 
                        if (latestPackage is not null && latestPackage.Version != currentVersion)
                        {
                            packageUpdates.Add((packageId, currentVersion, latestPackage.Version));
                        }
                    }
                    catch (Exception ex)
                    {
                        _logger.LogWarning(ex, "Failed to check for updates to package {PackageId}", packageId);
                    }
                }
 
                return packageUpdates;
            });
 
        if (updates.Count == 0)
        {
            _interactionService.DisplayMessage("check_mark", UpdateCommandStrings.ProjectUpToDateMessage);
            return new UpdatePackagesResult { UpdatesApplied = false };
        }
 
        // Display pending updates
        _interactionService.DisplayEmptyLine();
        foreach (var (packageId, currentVersion, newVersion) in updates)
        {
            _interactionService.DisplayMessage("package", $"[bold yellow]{packageId}[/] [bold green]{currentVersion}[/] to [bold green]{newVersion}[/]");
        }
        _interactionService.DisplayEmptyLine();
 
        // Confirm with user
        if (!await _interactionService.ConfirmAsync(UpdateCommandStrings.PerformUpdatesPrompt, true, cancellationToken))
        {
            return new UpdatePackagesResult { UpdatesApplied = false };
        }
 
        // Apply updates to settings.json (config already has SdkVersion)
        foreach (var (packageId, _, newVersion) in updates)
        {
            config.AddOrUpdatePackage(packageId, newVersion);
        }
        config.Save(directory.FullName);
 
        // Rebuild and regenerate SDK code with updated packages
        _interactionService.DisplayEmptyLine();
        _interactionService.DisplaySubtleMessage("Regenerating SDK code with updated packages...");
        await BuildAndGenerateSdkAsync(directory, cancellationToken);
 
        _interactionService.DisplayEmptyLine();
        _interactionService.DisplaySuccess(UpdateCommandStrings.UpdateSuccessfulMessage);
 
        return new UpdatePackagesResult { UpdatesApplied = true };
    }
 
    /// <inheritdoc />
    public async Task<bool> CheckAndHandleRunningInstanceAsync(FileInfo appHostFile, DirectoryInfo homeDirectory, CancellationToken cancellationToken)
    {
        // For guest projects, we use the AppHost server's path to compute the socket path
        // The AppHost server is created in a subdirectory of the guest apphost directory
        var directory = appHostFile.Directory;
        if (directory is null)
        {
            return true; // No directory, nothing to check
        }
 
        var appHostServerProject = _appHostServerProjectFactory.Create(directory.FullName);
        var genericAppHostPath = appHostServerProject.GetProjectFilePath();
 
        // Compute socket path based on the AppHost server project path
        var auxiliarySocketPath = AppHostHelper.ComputeAuxiliarySocketPath(genericAppHostPath, homeDirectory.FullName);
 
        // Check if the socket file exists
        if (!File.Exists(auxiliarySocketPath))
        {
            return true; // No running instance, continue
        }
 
        // Stop the running instance
        return await _runningInstanceManager.StopRunningInstanceAsync(auxiliarySocketPath, cancellationToken);
    }
 
    /// <summary>
    /// Checks if code generation is needed based on the current state.
    /// </summary>
    private bool NeedsGeneration(string appPath, IEnumerable<(string PackageId, string Version)> packages)
    {
        // In dev mode (ASPIRE_REPO_ROOT set), always regenerate to pick up code changes
        if (!string.IsNullOrEmpty(_configuration["ASPIRE_REPO_ROOT"]))
        {
            _logger.LogDebug("Dev mode detected (ASPIRE_REPO_ROOT set), skipping generation cache");
            return true;
        }
 
        return CheckNeedsGeneration(appPath, packages.ToList());
    }
 
    /// <summary>
    /// Checks if code generation is needed by comparing the hash of current packages
    /// with the stored hash from previous generation.
    /// </summary>
    private static bool CheckNeedsGeneration(string appPath, List<(string PackageId, string Version)> packages)
    {
        var generatedPath = Path.Combine(appPath, GeneratedFolderName);
        var hashPath = Path.Combine(generatedPath, ".codegen-hash");
 
        // If hash file doesn't exist, generation is needed
        if (!File.Exists(hashPath))
        {
            return true;
        }
 
        // Compare stored hash with current packages hash
        var storedHash = File.ReadAllText(hashPath).Trim();
        var currentHash = ComputePackagesHash(packages);
 
        return !string.Equals(storedHash, currentHash, StringComparison.OrdinalIgnoreCase);
    }
 
    /// <summary>
    /// Generates SDK code by calling the AppHost server's generateCode RPC method.
    /// </summary>
    private async Task GenerateCodeViaRpcAsync(
        string appPath,
        IAppHostRpcClient rpcClient,
        IEnumerable<(string PackageId, string Version)> packages,
        CancellationToken cancellationToken)
    {
        var packagesList = packages.ToList();
 
        // Use CodeGenerator (e.g., "TypeScript") not LanguageId (e.g., "typescript/nodejs")
        // The code generator is registered by its Language property, not the runtime ID
        var codeGenerator = _resolvedLanguage.CodeGenerator;
 
        _logger.LogDebug("Generating {CodeGenerator} code via RPC for {Count} packages", codeGenerator, packagesList.Count);
 
        // Use the typed RPC method
        var files = await rpcClient.GenerateCodeAsync(codeGenerator, cancellationToken);
 
        // Write generated files to the output directory
        var outputPath = Path.Combine(appPath, GeneratedFolderName);
        Directory.CreateDirectory(outputPath);
 
        foreach (var (fileName, content) in files)
        {
            var filePath = Path.Combine(outputPath, fileName);
            var directory = Path.GetDirectoryName(filePath);
            if (!string.IsNullOrEmpty(directory))
            {
                Directory.CreateDirectory(directory);
            }
            await File.WriteAllTextAsync(filePath, content, cancellationToken);
        }
 
        // Write generation hash for caching
        SaveGenerationHash(outputPath, packagesList);
 
        _logger.LogInformation("Generated {Count} {CodeGenerator} files in {Path}",
            files.Count, codeGenerator, outputPath);
    }
 
    /// <summary>
    /// Saves a hash of the packages to avoid regenerating code unnecessarily.
    /// </summary>
    private static void SaveGenerationHash(string generatedPath, List<(string PackageId, string Version)> packages)
    {
        var hashPath = Path.Combine(generatedPath, ".codegen-hash");
        var hash = ComputePackagesHash(packages);
        File.WriteAllText(hashPath, hash);
    }
 
    /// <summary>
    /// Computes a hash of the package list for caching purposes.
    /// </summary>
    private static string ComputePackagesHash(List<(string PackageId, string Version)> packages)
    {
        var sb = new System.Text.StringBuilder();
        foreach (var (packageId, version) in packages.OrderBy(p => p.PackageId))
        {
            sb.Append(packageId);
            sb.Append(':');
            sb.Append(version);
            sb.Append(';');
        }
        var bytes = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(sb.ToString()));
        return Convert.ToHexString(bytes);
    }
 
    // ═══════════════════════════════════════════════════════════════
    // RUNTIME MANAGEMENT
    // ═══════════════════════════════════════════════════════════════
 
    /// <summary>
    /// Ensures the GuestRuntime is created.
    /// </summary>
    private async Task EnsureRuntimeCreatedAsync(
        IAppHostRpcClient rpcClient,
        CancellationToken cancellationToken)
    {
        if (_guestRuntime is null)
        {
            var runtimeSpec = await rpcClient.GetRuntimeSpecAsync(_resolvedLanguage.LanguageId, cancellationToken);
            _guestRuntime = new GuestRuntime(runtimeSpec, _logger);
 
            _logger.LogDebug("Created GuestRuntime for {Language}: Execute={Command} {Args}",
                _resolvedLanguage.LanguageId,
                runtimeSpec.Execute.Command,
                string.Join(" ", runtimeSpec.Execute.Args));
        }
    }
 
    // ═══════════════════════════════════════════════════════════════
    // GUEST RUNTIME HELPERS
    // ═══════════════════════════════════════════════════════════════
 
    /// <summary>
    /// Installs dependencies for the guest AppHost using GuestRuntime.
    /// </summary>
    private async Task<int> InstallDependenciesAsync(
        DirectoryInfo directory,
        IAppHostRpcClient rpcClient,
        CancellationToken cancellationToken)
    {
        await EnsureRuntimeCreatedAsync(rpcClient, cancellationToken);
 
        if (_guestRuntime is null)
        {
            _interactionService.DisplayError("GuestRuntime not initialized. This is a bug.");
            return ExitCodeConstants.FailedToBuildArtifacts;
        }
 
        var result = await _guestRuntime.InstallDependenciesAsync(directory, cancellationToken);
        if (result != 0)
        {
            _interactionService.DisplayError($"Failed to install {_resolvedLanguage?.DisplayName ?? "guest"} dependencies.");
        }
 
        return result;
    }
 
    /// <summary>
    /// Executes the guest AppHost using GuestRuntime.
    /// </summary>
    private async Task<(int ExitCode, OutputCollector Output)> ExecuteGuestAppHostAsync(
        FileInfo appHostFile,
        DirectoryInfo directory,
        IDictionary<string, string> environmentVariables,
        bool watchMode,
        IAppHostRpcClient rpcClient,
        CancellationToken cancellationToken)
    {
        await EnsureRuntimeCreatedAsync(rpcClient, cancellationToken);
 
        if (_guestRuntime is null)
        {
            _interactionService.DisplayError("GuestRuntime not initialized. This is a bug.");
            return (ExitCodeConstants.FailedToDotnetRunAppHost, new OutputCollector());
        }
 
        return await _guestRuntime.RunAsync(appHostFile, directory, environmentVariables, watchMode, cancellationToken);
    }
 
    /// <summary>
    /// Executes the guest AppHost for publishing using GuestRuntime.
    /// </summary>
    private async Task<(int ExitCode, OutputCollector Output)> ExecuteGuestAppHostForPublishAsync(
        FileInfo appHostFile,
        DirectoryInfo directory,
        IDictionary<string, string> environmentVariables,
        string[]? publishArgs,
        IAppHostRpcClient rpcClient,
        CancellationToken cancellationToken)
    {
        await EnsureRuntimeCreatedAsync(rpcClient, cancellationToken);
 
        if (_guestRuntime is null)
        {
            _interactionService.DisplayError("GuestRuntime not initialized. This is a bug.");
            return (ExitCodeConstants.FailedToDotnetRunAppHost, new OutputCollector());
        }
 
        return await _guestRuntime.PublishAsync(appHostFile, directory, environmentVariables, publishArgs, cancellationToken);
    }
}