File: Projects\GuestAppHostProject.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.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.IO.Hashing;
using System.Net.Sockets;
using System.Text.Json;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Certificates;
using Aspire.Cli.Commands;
using Aspire.Cli.Configuration;
using Aspire.Cli.Diagnostics;
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.Packaging;
using Aspire.Cli.Processes;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Aspire.Shared.UserSecrets;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Semver;
using Spectre.Console;
 
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, IGuestAppHostSdkGenerator
{
    private const string DevCertificateCacheDirectoryName = "dev-certs";
    private const string CertificateBundleCacheDirectoryName = "bundles";
 
    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 CliExecutionContext _executionContext;
    private readonly ILogger<GuestAppHostProject> _logger;
    private readonly FileLoggerProvider _fileLoggerProvider;
    private readonly TimeProvider _timeProvider;
    private readonly RunningInstanceManager _runningInstanceManager;
    private readonly ProfilingTelemetry _profilingTelemetry;
    private readonly IProcessTreeGracefulShutdownSignaler _gracefulShutdownSignaler;
    private readonly IGracefulShutdownWindow _shutdownService;
    private readonly IAppHostServerSessionFactory _serverSessionFactory;
    private readonly IEnvironment _environment;
 
    // Language is always resolved via constructor
    private readonly LanguageInfo _resolvedLanguage;
    private GuestRuntime? _guestRuntime;
 
    /// <summary>
    /// Set when the AppHost is Java, so the install path can clear staged dependencies the build tool
    /// will not prune itself. Null for every other language.
    /// </summary>
    private JavaAppHostToolchainResolution? _javaToolchainResolution;
 
    public GuestAppHostProject(
        LanguageInfo language,
        IInteractionService interactionService,
        IAppHostCliBackchannel backchannel,
        IAppHostServerProjectFactory appHostServerProjectFactory,
        ICertificateService certificateService,
        IDotNetCliRunner runner,
        IPackagingService packagingService,
        IConfiguration configuration,
        IFeatures features,
        ILanguageDiscovery languageDiscovery,
        CliExecutionContext executionContext,
        IEnvironment environment,
        ILogger<GuestAppHostProject> logger,
        FileLoggerProvider fileLoggerProvider,
        ProfilingTelemetry profilingTelemetry,
        IProcessTreeGracefulShutdownSignaler gracefulShutdownSignaler,
        IGracefulShutdownWindow shutdownService,
        IAppHostServerSessionFactory serverSessionFactory,
        TimeProvider timeProvider)
    {
        _resolvedLanguage = language;
        _interactionService = interactionService;
        _backchannel = backchannel;
        _appHostServerProjectFactory = appHostServerProjectFactory;
        _certificateService = certificateService;
        _runner = runner;
        _packagingService = packagingService;
        _configuration = configuration;
        _features = features;
        _languageDiscovery = languageDiscovery;
        _executionContext = executionContext;
        _environment = environment;
        _logger = logger;
        _fileLoggerProvider = fileLoggerProvider;
        _profilingTelemetry = profilingTelemetry;
        _timeProvider = timeProvider;
        _runningInstanceManager = new RunningInstanceManager(_logger, _interactionService, _timeProvider, _profilingTelemetry);
        _gracefulShutdownSignaler = gracefulShutdownSignaler;
        _shutdownService = shutdownService;
        _serverSessionFactory = serverSessionFactory;
    }
 
    // ═══════════════════════════════════════════════════════════════
    // IDENTITY (Always resolved via constructor)
    // ═══════════════════════════════════════════════════════════════
 
    /// <inheritdoc />
    public bool IsUnsupported { get; set; }
 
    /// <inheritdoc />
    public string LanguageId => _resolvedLanguage.LanguageId;
 
    /// <inheritdoc />
    public string DisplayName => _resolvedLanguage.DisplayName;
 
    /// <inheritdoc />
    public bool SupportsLaunchProfiles => false;
 
    /// <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.
        // Prefer the new nested sdk:version key and fall back to the legacy sdkVersion key.
        var configuredVersion = _configuration["sdk:version"] ?? _configuration["sdkVersion"];
        if (!string.IsNullOrEmpty(configuredVersion))
        {
            _logger.LogDebug("Using SDK version from configuration: {Version}", configuredVersion);
            return configuredVersion;
        }
 
        var defaultSdkVersion = _executionContext.IdentitySdkVersion;
        _logger.LogDebug("Using default SDK version: {Version}", defaultSdkVersion);
        return 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.AppHostFileName;
 
    /// <inheritdoc />
    public bool IsUsingProjectReferences(FileInfo appHostFile)
    {
        // When the CLI is emulating a different build via ASPIRE_CLI_* identity overrides (or the
        // install sidecar), it must behave like the installed CLI it is impersonating — and an
        // installed CLI never resolves Aspire packages through in-repo project references. Without
        // this guard a source (DEBUG) build run from inside the Aspire repo trips
        // AspireRepositoryDetector via its Environment.ProcessPath fallback (it walks up from the
        // CLI binary, not the apphost, and finds the repo's Aspire.slnx), so project-reference mode
        // is reported for an apphost that lives in an arbitrary directory. That short-circuits
        // channel resolution (see IntegrationPackageSearchService.GetConfiguredChannel) and an
        // emulated staging/daily apphost would silently resolve stable nuget.org packages instead of
        // its pinned channel's feed. See docs/specs/cli-identity-sidecar.md.
        if (_executionContext.IdentityOverridden)
        {
            return false;
        }
 
        return AspireRepositoryDetector.DetectRepositoryRoot(appHostFile.Directory?.FullName) is not null;
    }
 
    /// <summary>
    /// Gets all integration references including the code generation package for the current language.
    /// </summary>
    private async Task<List<IntegrationReference>> GetIntegrationReferencesAsync(
        AspireConfigFile config,
        DirectoryInfo directory,
        CancellationToken cancellationToken)
    {
        var defaultSdkVersion = GetEffectiveSdkVersion();
        var integrations = config.GetIntegrationReferences(defaultSdkVersion, directory.FullName).ToList();
        var codeGenPackage = await _languageDiscovery.GetPackageForLanguageAsync(_resolvedLanguage.LanguageId, cancellationToken);
 
        // The config can already declare the code generation integration itself, most often as a
        // project reference in this repo's own playgrounds. Adding the package on top of that puts
        // the same package identity in the closure twice, and when the two resolve to different
        // versions NuGet fails the restore with a package downgrade (NU1605). Package identities are
        // compared case-insensitively because NuGet treats them that way.
        var alreadyDeclared = codeGenPackage is not null
            && integrations.Any(i => string.Equals(i.Name, codeGenPackage, StringComparison.OrdinalIgnoreCase));
 
        if (codeGenPackage is not null && !alreadyDeclared)
        {
            var codeGenVersion = config.GetEffectiveSdkVersion(defaultSdkVersion);
            integrations.Add(IntegrationReference.FromPackage(codeGenPackage, codeGenVersion));
        }
 
        return integrations;
    }
 
    /// <summary>
    /// Resolves the directory containing the nearest aspire.config.json (or legacy settings file)
    /// by searching upward from <paramref name="appHostDirectory"/>.
    /// Falls back to <paramref name="appHostDirectory"/> when no config file is found.
    /// </summary>
    private static DirectoryInfo GetConfigDirectory(DirectoryInfo appHostDirectory)
        => ConfigurationHelper.GetConfigRootDirectory(appHostDirectory);
 
    private AspireConfigFile LoadConfiguration(DirectoryInfo directory)
    {
        var configDir = GetConfigDirectory(directory);
        try
        {
            var config = AspireConfigFile.LoadOrCreate(configDir.FullName, GetEffectiveSdkVersion());
            _logger.LogInformation("Loaded config from {Directory} (file exists: {Exists})", configDir.FullName, AspireConfigFile.Exists(configDir.FullName));
            return config;
        }
        catch (JsonException ex)
        {
            _logger.LogError(ex, "Failed to load configuration from {Directory}", configDir.FullName);
            throw;
        }
    }
 
    private static void SaveConfiguration(AspireConfigFile config, DirectoryInfo directory)
    {
        var configDir = GetConfigDirectory(directory);
        config.Save(configDir.FullName);
    }
 
    private string GetPrepareSdkVersion(AspireConfigFile config)
    {
        return config.GetEffectiveSdkVersion(GetEffectiveSdkVersion());
    }
 
    /// <inheritdoc />
    public Task<string?> GetAspireHostingVersionAsync(FileInfo appHostFile, CancellationToken cancellationToken)
    {
        var defaultSdkVersion = GetEffectiveSdkVersion();
 
        // Version inspection is read-only. Load an existing config from the same
        // inherited config root used by guest AppHost operations, but do not call
        // LoadOrCreate because merely checking the version must not write config files.
        var config = appHostFile.Directory is { } directory
            ? AspireConfigFile.Load(GetConfigDirectory(directory).FullName)
            : null;
 
        return Task.FromResult<string?>(config?.GetEffectiveSdkVersion(defaultSdkVersion) ?? defaultSdkVersion);
    }
 
    /// <summary>
    /// Prepares the AppHost server (creates files and builds for dev mode, restores packages for prebuilt mode).
    /// </summary>
    private static async Task<(bool Success, OutputCollector? Output, string? ChannelName, bool NeedsCodeGen)> PrepareAppHostServerAsync(
        IAppHostServerProject appHostServerProject,
        string sdkVersion,
        List<IntegrationReference> integrations,
        string? requestedChannel,
        string? packageSourceOverride = null,
        CancellationToken cancellationToken = default)
    {
        var result = await appHostServerProject.PrepareAsync(sdkVersion, integrations, requestedChannel, packageSourceOverride, cancellationToken);
        return (result.Success, result.Output, result.ChannelName, result.NeedsCodeGeneration);
    }
 
    /// <summary>
    /// Builds the AppHost server project and generates SDK code.
    /// </summary>
    /// <returns><see langword="true"/> if the code was generated successfully; otherwise, <see langword="false"/>.</returns>
    internal async Task<bool> BuildAndGenerateSdkAsync(DirectoryInfo directory, string? packageSourceOverride = null, CancellationToken cancellationToken = default)
    {
        var config = LoadConfiguration(directory);
        return await BuildAndGenerateSdkAsync(directory, config, packageSourceOverride, cancellationToken);
    }
 
    private async Task<bool> BuildAndGenerateSdkAsync(DirectoryInfo directory, AspireConfigFile config, string? packageSourceOverride = null, CancellationToken cancellationToken = default)
    {
        var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken);
 
        // Step 1: Use the supplied config as the source of truth. Update uses an
        // in-memory config here so a failed generation does not leave
        // aspire.config.json pinned to versions the current CLI cannot run.
        var integrations = await GetIntegrationReferencesAsync(config, directory, cancellationToken);
        var sdkVersion = GetPrepareSdkVersion(config);
 
        var (buildSuccess, buildOutput, _, _) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, integrations, config.Channel, packageSourceOverride, cancellationToken);
        if (!buildSuccess)
        {
            if (buildOutput is not null)
            {
                _interactionService.DisplayLines(buildOutput.GetLines());
            }
            _interactionService.DisplayError("Failed to prepare AppHost server.");
            return false;
        }
 
        // Step 2: Start the AppHost server temporarily for code generation
        await using var serverSession = _serverSessionFactory.Create(appHostServerProject, environmentVariables: null, debug: false, gracefulShutdownSignaler: null, shutdownService: null, isolateConsole: false, cancellationToken);
        // Short-lived RPC session: StartAsync() spawns the server. We never observe the
        // exit-code task because disposal flows the exit code through the activity scope and the only
        // failure mode we care about surfaces via the RPC call below.
        await serverSession.StartAsync();
 
        // Step 3: Connect to server
        var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken);
 
        // Step 4: Generate SDK code via RPC
        // This must happen before dependency installation because the generated
        // code directory (.aspire/modules) may not exist yet and dependency files reference it.
        await GenerateCodeViaRpcAsync(
            directory.FullName,
            appHostFile: null,
            rpcClient,
            integrations,
            targetSdkVersion: config.SdkVersion,
            cancellationToken);
 
        // Step 5: Install dependencies using GuestRuntime (best effort - don't block code generation)
        await InstallDependenciesAsync(directory, rpcClient, treatMissingJavaScriptToolAsWarning: true, cancellationToken: cancellationToken);
 
        return true;
    }
 
    Task<bool> IGuestAppHostSdkGenerator.BuildAndGenerateSdkAsync(DirectoryInfo directory, string? packageSourceOverride, CancellationToken cancellationToken)
    {
        return BuildAndGenerateSdkAsync(directory, packageSourceOverride, cancellationToken);
    }
 
    // ═══════════════════════════════════════════════════════════════
    // EXECUTION
    // ═══════════════════════════════════════════════════════════════
 
    /// <inheritdoc />
    public Task<AppHostValidationResult> ValidateAppHostAsync(FileInfo appHostFile, CancellationToken cancellationToken)
    {
        if (IsUnsupported)
        {
            return Task.FromResult(new AppHostValidationResult(IsValid: false, IsUnsupported: true));
        }
 
        // 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);
        var startProjectContext = Activity.Current?.Context ?? default;
 
        // Captures an exit code surfaced by an internal teardown trigger (currently only the
        // backchannel-fault continuation). Read by the outer OCE catch when the in-flight await
        // throws OCE because we cancelled appHostSystemCts ourselves. -1 = "no internal failure
        // recorded; fall back to Cancelled (130)". Declared at method scope so the catch can read
        // it. Written at most once by the single backchannel continuation, so a plain assignment
        // is sufficient; appHostSystemCts.Cancel() below provides the barrier for the catch's read.
        var internalFaultCode = -1;
 
        try
        {
            // Step 1: Ensure certificates are trusted
            Dictionary<string, string> certEnvVars;
            try
            {
                var certResult = await _certificateService.EnsureCertificatesTrustedAsync(cancellationToken);
                certEnvVars = new Dictionary<string, string>(certResult.EnvironmentVariables);
            }
            catch
            {
                context.BuildCompletionSource?.TrySetResult(false);
                throw;
            }
 
            // Step 2: Build/prepare the AppHost server (dependency install happens after server starts)
            var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken);
 
            // Load config - source of truth for SDK version and packages
            var config = LoadConfiguration(directory);
            var integrations = await GetIntegrationReferencesAsync(config, directory, cancellationToken);
            var sdkVersion = GetPrepareSdkVersion(config);
 
            var buildResult = await _interactionService.ShowStatusAsync(
                "Preparing Aspire server...",
                async () =>
                {
                    // Prepare the AppHost server (build for dev mode, restore for prebuilt)
                    var (prepareSuccess, prepareOutput, channelName, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, integrations, config.Channel, cancellationToken: cancellationToken);
                    if (!prepareSuccess)
                    {
                        return (Success: false, Output: prepareOutput, Error: "Failed to prepare app host.", ChannelName: (string?)null, NeedsCodeGen: false);
                    }
 
                    return (Success: true, Output: prepareOutput, Error: (string?)null, ChannelName: channelName, NeedsCodeGen: needsCodeGen);
                }, emoji: KnownEmojis.Gear);
 
            if (!buildResult.Success)
            {
                // Set OutputCollector so RunCommand can display errors
                context.OutputCollector = buildResult.Output;
                context.BuildCompletionSource?.TrySetResult(false);
                return CliExitCodes.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);
 
            // Step 3: Configure launch environment for the AppHost server
            // Read launch settings once and reuse them for both the temporary server and guest AppHost.
            var launchProfileEnvironmentVariables = ReadLaunchSettingsEnvironmentVariables(directory);
            var launchSettingsEnvVars = GetServerEnvironmentVariables(
                launchProfileEnvironmentVariables,
                defaultEnvironment: AppHostEnvironmentDefaults.DevelopmentEnvironmentName,
                args: context.UnmatchedTokens);
            launchSettingsEnvVars[KnownConfigNames.DcpWorkloadId] = AppHostWorkloadId.Create(appHostFile);
 
            // Apply certificate environment variables (e.g., SSL_CERT_DIR on Linux)
            foreach (var kvp in certEnvVars)
            {
                launchSettingsEnvVars[kvp.Key] = kvp.Value;
            }
 
            // 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;
 
            // Pass synthetic UserSecretsId so AppHost Server can read secrets set via 'aspire secret'
            launchSettingsEnvVars[KnownConfigNames.AspireUserSecretsId] = UserSecretsPathHelper.ComputeSyntheticUserSecretsId(appHostFile.FullName);
 
            // Check if hot reload (watch mode) is enabled
            var enableHotReload = _features.IsFeatureEnabled(KnownFeatures.DefaultWatchEnabled, defaultValue: false);
 
            // Step 4: Start the AppHost server process. The linked stop CTS is the only termination
            // trigger we hand to the session; cancelling it (here or via the outer cancellationToken)
            // is how we ask the session to kill its child process. The outer cancellationToken IS
            // CCM.Token (see Program.Main), so a user Ctrl+C lands here automatically.
            using var serverStopCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
            await using var serverSession = _serverSessionFactory.Create(
                appHostServerProject,
                launchSettingsEnvVars,
                context.Debug,
                _gracefulShutdownSignaler,
                _shutdownService,
                // Isolate the child console so a user Ctrl+C reaches the CLI (and drives the shared
                // shutdown ladder) rather than terminating the server child directly.
                isolateConsole: true,
                serverStopCts.Token);
            Task<int> serverCompletion;
            IAppHostRpcClient rpcClient;
            using (_profilingTelemetry.StartRunAppHostStartAppHostServer())
            {
                await serverSession.StartAsync();
                serverCompletion = serverSession.WaitForExitAsync();
 
                try
                {
                    // Step 5: Connect to server for RPC calls. The connection helper retries until
                    // the RPC socket is available and fails early if the server process exits — it
                    // races the connect against the server-exit signal, so a server that crashes on
                    // startup surfaces immediately (no fixed start-up delay to wait through).
                    rpcClient = await serverSession.GetRpcClientAsync(cancellationToken);
 
                    // Step 6: Generate SDK code via RPC if needed
                    // This must happen before dependency installation because the generated
                    // code directory (.aspire/modules) may not exist yet (e.g., freshly cloned project)
                    // and dependency files (pylock.toml, requirements.txt) reference it.
                    if (buildResult.NeedsCodeGen)
                    {
                        await GenerateCodeViaRpcAsync(
                            directory.FullName,
                            appHostFile,
                            rpcClient,
                            integrations,
                            cancellationToken: cancellationToken);
                    }
 
                    await EnsureRuntimeCreatedAsync(directory, rpcClient, cancellationToken);
                }
                catch (Exception) when (serverSession.HasServerExited == true && !cancellationToken.IsCancellationRequested)
                {
                    // If the helper server exits while we are connecting or making setup RPC calls,
                    // its captured output is the only place the real startup failure may be recorded.
                    // serverSession is in an `await using` scope, so returning here disposes it.
                    _interactionService.DisplayLines(serverSession.Output!.GetLines());
                    _interactionService.DisplayError("App host exited unexpectedly.");
                    return CliExitCodes.FailedToDotnetRunAppHost;
                }
            }
            var socketPath = serverSession.SocketPath!;
            var appHostServerOutputCollector = serverSession.Output!;
            var authenticationToken = serverSession.AuthenticationToken;
 
            // 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>();
 
            // Internal escalation CTS for the AppHost system. We cancel this when something fatal
            // happens to either the server or the guest (e.g. backchannel polling fails after the
            // configured timeout, the server exits unexpectedly) so the remaining process gets torn down
            // promptly. Without this, a hung guest can keep pendingRun alive forever after the CLI
            // has already given up on the backchannel, causing aspire run/start to hang instead of
            // surfacing the failure. Linked to the outer cancellationToken (which IS CCM.Token in
            // production wiring), so user Ctrl+C propagates here automatically.
            using var appHostSystemCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
            var appHostSystemToken = appHostSystemCts.Token;
 
            // Guard the backchannel continuation from touching the disposed appHostSystemCts after
            // RunAsync has already returned. The continuation can fault late, so the Cancel() call
            // is wrapped in a try/catch (ObjectDisposedException) — that catch is the authoritative
            // protection against the disposal race.
 
            // When the backchannel polling task gives up (timeout, server process exit, or other
            // fatal connection error), escalate to tearing down the whole AppHost system by
            // cancelling the local CTS. The disposable cleanup ladder (`await using serverSession`
            // / launcher) runs as the in-flight await unwinds, force-killing children if needed.
            // The BackchannelCompletionSource only signals readiness/connectivity — it never
            // causes the server or guest to be killed on its own, so we wire that here.
            _ = backchannelCompletionSource.Task.ContinueWith(
                t =>
                {
                    if (t.IsFaulted)
                    {
                        // The outer OCE catch reads this when the in-flight await throws because we
                        // cancelled below.
                        internalFaultCode = CliExitCodes.FailedToDotnetRunAppHost;
                        try
                        {
                            appHostSystemCts.Cancel();
                        }
                        catch (ObjectDisposedException)
                        {
                            // Continuation lost the race against `using var appHostSystemCts`
                            // disposal in the enclosing scope. The run already exited via another
                            // path, so nothing more to do here.
                        }
                    }
                },
                CancellationToken.None,
                TaskContinuationOptions.ExecuteSynchronously,
                TaskScheduler.Default);
 
            int guestExitCode;
            OutputCollector? guestOutput;
            IGuestProcessLauncher? launcher = null;
            using (var guestStartupActivity = _profilingTelemetry.StartRunAppHostStartGuestAppHost(_resolvedLanguage.LanguageId))
            {
                // Step 7: Install dependencies (using GuestRuntime)
                // The GuestRuntime will skip if the RuntimeSpec doesn't have InstallDependencies configured
                var installResult = await InstallDependenciesAsync(directory, rpcClient, treatMissingJavaScriptToolAsWarning: false, cancellationToken: cancellationToken);
                if (installResult != 0)
                {
                    context.BackchannelCompletionSource?.TrySetException(
                        new InvalidOperationException($"Failed to install {DisplayName} dependencies."));
 
                    // `await using serverSession` runs the per-process shutdown ladder as we unwind.
                    return installResult;
                }
 
                // Step 8: Execute the guest apphost
 
                // Pass the launch profile and certificate environment variables through to the guest AppHost
                // so it sees the same dashboard and resource service endpoints as the temporary .NET server.
                var environmentVariables = CreateGuestEnvironmentVariables(
                    context.EnvironmentVariables,
                    launchProfileEnvironmentVariables,
                    certEnvVars,
                    defaultEnvironment: AppHostEnvironmentDefaults.DevelopmentEnvironmentName,
                    args: context.UnmatchedTokens);
                environmentVariables["REMOTE_APP_HOST_SOCKET_PATH"] = socketPath;
                environmentVariables["ASPIRE_PROJECT_DIRECTORY"] = directory.FullName;
                environmentVariables["ASPIRE_APPHOST_FILEPATH"] = appHostFile.FullName;
                environmentVariables[KnownConfigNames.RemoteAppHostToken] = authenticationToken;
 
                if (_guestRuntime is null)
                {
                    _interactionService.DisplayError("GuestRuntime not initialized.");
                    return CliExitCodes.FailedToDotnetRunAppHost;
                }
 
                if (_guestRuntime.CertificateBundleEnvironmentVariable is { } certificateBundleEnvironmentVariable)
                {
                    var devCertPemPath = _certificateService.ExportDevCertificatePem(cancellationToken);
                    await ConfigureCertificateBundleEnvironmentAsync(
                        environmentVariables,
                        directory,
                        devCertPemPath,
                        certificateBundleEnvironmentVariable,
                        _guestRuntime.Language.Replace('/', '-'),
                        cancellationToken);
                }
 
                // Pass debug flag to the guest process
                if (context.Debug)
                {
                    environmentVariables["ASPIRE_DEBUG"] = "true";
                }
 
                // Check if the extension should launch the guest app host (for VS Code debugging).
                // This mirrors the pattern in DotNetCliRunner.ExecuteAsync for .NET app hosts.
                // The RuntimeSpec declares the required extension capability (e.g., "node" for TypeScript);
                // only use the extension launcher when the runtime requests it and the extension supports it.
                if (_guestRuntime.ExtensionLaunchCapability is { } requiredCapability
                    && ExtensionHelper.IsExtensionHost(_interactionService, out var extensionInteractionService, out var extensionBackchannel)
                    && await extensionBackchannel.HasCapabilityAsync(requiredCapability, cancellationToken))
                {
                    launcher = new ExtensionGuestLauncher(extensionInteractionService, appHostFile, context.StartDebugSession);
                }
                else
                {
                    launcher = _guestRuntime.CreateDefaultLauncher();
                }
 
                // Start guest apphost - it will connect to AppHost server, define resources.
                // If launcher is an ExtensionGuestLauncher, it delegates to the VS Code extension.
                Task StartBackchannelConnectionAfterGuestAppHostLaunchesAsync()
                {
                    // Guest runtimes can fail during dependency installation or pre-execute checks before
                    // the AppHost is invoked. Defer polling the server backchannel until the launcher has
                    // started or delegated the AppHost so those early failures don't leave the CLI waiting
                    // on an unused stream.
                    //
                    // Use the AppHost system token so that a guest-side failure (which faults the
                    // backchannel completion source and cancels appHostSystemCts) stops the polling loop
                    // promptly.
                    _ = StartBackchannelConnectionAsync(serverSession, backchannelSocketPath, backchannelCompletionSource, enableHotReload, startProjectContext, appHostSystemToken);
                    return Task.CompletedTask;
                }
 
                // Pass appHostSystemToken so a fatal backchannel failure (or user cancellation, since
                // appHostSystemCts is linked to cancellationToken AND CCM.Token) tears down the guest
                // process. The launcher will kill the guest's process tree when this token cancels.
                // Pass GuestLaunchOptions so the launcher uses an isolated console + graceful
                // shutdown on Windows, matching what the AppHost server already does.
                var guestLaunchOptions = new GuestLaunchOptions(
                    IsolateConsoleForGracefulShutdown: true,
                    GracefulShutdownSignaler: _gracefulShutdownSignaler,
                    ShutdownService: _shutdownService);
                (guestExitCode, guestOutput) = await ExecuteGuestAppHostAsync(
                    appHostFile, directory, environmentVariables, enableHotReload, context.NoBuild, rpcClient, launcher, StartBackchannelConnectionAfterGuestAppHostLaunchesAsync, guestLaunchOptions, appHostSystemToken);
            }
 
            // If the user cancelled (Ctrl+C), surface that as cancellation instead of a "guest failed"
            // run. ProcessGuestLauncher swallows the OperationCanceledException internally so the
            // process exit code can flow back, so we re-derive cancellation from the outer token here.
            if (cancellationToken.IsCancellationRequested)
            {
                cancellationToken.ThrowIfCancellationRequested();
            }
 
            // A non-zero exit code at this point means either:
            //  - The in-CLI portion of the guest run (e.g. a TypeScript PreExecute `tsc --noEmit` step)
            //    failed before the actual AppHost was launched.
            //  - The guest AppHost itself failed (syntax error, unhandled exception, etc).
            //  - The AppHost system escalation killed the guest because the server backchannel never
            //    came up (appHostSystemCts was cancelled by a backchannel failure). In this case the
            //    guest's own output may be empty - the relevant diagnostics are in the server output
            //    (e.g. DCP model validation errors).
            // Surface the failure regardless of launcher type, otherwise the extension flow would
            // silently hang awaiting serverCompletion for an apphost that was never started.
            if (guestExitCode != 0)
            {
                _logger.LogError("{Language} apphost exited with code {ExitCode}", DisplayName, guestExitCode);
 
                // Merge any captured AppHost server output into context.OutputCollector so RunCommand's
                // post-failure UX (DisplayRecentAppHostStartupOutput) surfaces it. This is especially
                // important when the run failed because of a server-side issue like a DCP model
                // validation error that the user would otherwise never see - the guest is just hung
                // waiting on the RPC at that point and produces no output of its own.
                MergeServerOutputIntoContextCollector(context, appHostServerOutputCollector);
 
                // Surface the captured output (e.g. tsc errors from a TypeScript PreExecute step)
                // so the user can see why the apphost failed. In the extension flow,
                // ExtensionInteractionService.DisplayLines routes these lines through the
                // backchannel without also writing them to the CLI's captured stdout/stderr.
                if (guestOutput is not null)
                {
                    _interactionService.DisplayLines(guestOutput.GetLines());
                }
 
                // Signal failure to RunCommand so it doesn't hang waiting for the backchannel.
                // RunCommand's startup catch path wraps the message with the localized
                // InteractionServiceStrings.UnexpectedErrorOccurred template before surfacing
                // it to the user, matching the pre-PR behavior where this exception fell
                // through to RunCommand's generic exception handler.
                var error = new InvalidOperationException($"The {DisplayName} apphost failed.");
                context.BackchannelCompletionSource?.TrySetException(error);
 
                // The backchannel exception above causes RunCommand's startup wait to throw,
                // tearing down the run via `await using serverSession`/launcher disposal.
                return guestExitCode;
            }
 
            if (launcher is ExtensionGuestLauncher)
            {
                // Extension manages the guest app host lifecycle via VS Code debug session.
                // Wait for the AppHost server to exit (Ctrl+C or extension termination).
                return await serverCompletion.WaitAsync(cancellationToken);
            }
 
            // In watch mode, wait for server to exit (Ctrl+C or orphan detection).
            // In non-watch mode the guest already finished cleanly; return success and let the
            // `await using serverSession` / launcher disposable cleanup tear down the server.
            if (!enableHotReload)
            {
                return 0;
            }
 
            return await serverCompletion.WaitAsync(cancellationToken);
        }
        catch (OperationCanceledException)
        {
            // Signal that build/preparation failed so RunCommand doesn't hang waiting
            context.BuildCompletionSource?.TrySetResult(false);
            // If an internal teardown trigger captured an exit code (currently only the
            // backchannel-fault continuation), surface it rather than the generic Cancelled.
            // Falls back to Cancelled (130) for ambient cancellation paths.
            return internalFaultCode != -1 ? internalFaultCode : 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
            context.BuildCompletionSource?.TrySetResult(false);
            _logger.LogError(ex, "Failed to run {Language} AppHost", DisplayName);
            _interactionService.DisplayError($"Failed to run {DisplayName} AppHost: {ex.Message}");
 
            return CliExitCodes.FailedToDotnetRunAppHost;
        }
    }
 
    internal Dictionary<string, string> GetServerEnvironmentVariables(
        DirectoryInfo directory,
        string? defaultEnvironment = AppHostEnvironmentDefaults.DevelopmentEnvironmentName,
        bool includeLaunchProfileEnvironmentVariables = true,
        string[]? args = null)
    {
        return GetServerEnvironmentVariables(
            ReadLaunchSettingsEnvironmentVariables(directory),
            defaultEnvironment,
            includeLaunchProfileEnvironmentVariables,
            args: args);
    }
 
    internal static Dictionary<string, string> GetServerEnvironmentVariables(
        IDictionary<string, string>? launchProfileEnvironmentVariables,
        string? defaultEnvironment = AppHostEnvironmentDefaults.DevelopmentEnvironmentName,
        bool includeLaunchProfileEnvironmentVariables = true,
        IReadOnlyDictionary<string, string?>? inheritedEnvironmentVariables = null,
        string[]? args = null)
    {
        var envVars = new Dictionary<string, string>();
        MergeLaunchProfileEnvironmentVariables(launchProfileEnvironmentVariables, envVars, includeLaunchProfileEnvironmentVariables);
        AppHostEnvironmentDefaults.ApplyEffectiveEnvironment(envVars, defaultEnvironment, inheritedEnvironmentVariables, args);
        return envVars;
    }
 
    internal Dictionary<string, string> CreateGuestEnvironmentVariables(
        DirectoryInfo directory,
        IDictionary<string, string> contextEnvironmentVariables,
        IDictionary<string, string>? additionalEnvironmentVariables = null,
        string? defaultEnvironment = null,
        bool includeLaunchProfileEnvironmentVariables = true,
        string[]? args = null)
    {
        return CreateGuestEnvironmentVariables(
            contextEnvironmentVariables,
            ReadLaunchSettingsEnvironmentVariables(directory),
            additionalEnvironmentVariables,
            defaultEnvironment,
            includeLaunchProfileEnvironmentVariables,
            args: args);
    }
 
    internal static Dictionary<string, string> CreateGuestEnvironmentVariables(
        IDictionary<string, string> contextEnvironmentVariables,
        IDictionary<string, string>? launchProfileEnvironmentVariables,
        IDictionary<string, string>? additionalEnvironmentVariables = null,
        string? defaultEnvironment = null,
        bool includeLaunchProfileEnvironmentVariables = true,
        IReadOnlyDictionary<string, string?>? inheritedEnvironmentVariables = null,
        string[]? args = null)
    {
        var environmentVariables = new Dictionary<string, string>(contextEnvironmentVariables);
 
        MergeLaunchProfileEnvironmentVariables(
            launchProfileEnvironmentVariables,
            environmentVariables,
            includeLaunchProfileEnvironmentVariables);
 
        if (additionalEnvironmentVariables is not null)
        {
            foreach (var (key, value) in additionalEnvironmentVariables)
            {
                environmentVariables[key] = value;
            }
        }
 
        AppHostEnvironmentDefaults.ApplyEffectiveEnvironment(environmentVariables, defaultEnvironment, inheritedEnvironmentVariables, args);
 
        ForwardAppHostArguments(environmentVariables, args);
 
        return environmentVariables;
    }
 
    /// <summary>
    /// Publishes the arguments the CLI also passes on the guest process command line into
    /// <c>ASPIRE_APPHOST_ARGS</c>.
    /// </summary>
    /// <remarks>
    /// Python, TypeScript and Rust AppHosts read the process arguments themselves
    /// (<c>sys.argv[1:]</c>, <c>process.argv.slice(2)</c>, <c>std::env::args()</c>), so a builder
    /// created without arguments still observes <c>--operation publish</c>. A JVM cannot do the
    /// same: <c>main(String[])</c> is the only place those arguments exist, and
    /// <c>ProcessHandle.current().info().arguments()</c> reports the JVM's own arguments (options
    /// and main class) rather than the application's. Without this, a Java AppHost that calls
    /// <c>CreateBuilder()</c> instead of <c>CreateBuilder(args)</c> silently runs the application
    /// when the user asked to publish.
    ///
    /// Newline is the separator because it is the one character an argument never contains in
    /// practice, whereas spaces are common in paths.
    /// </remarks>
    private static void ForwardAppHostArguments(IDictionary<string, string> environmentVariables, string[]? args)
    {
        if (args is not { Length: > 0 })
        {
            return;
        }
 
        environmentVariables["ASPIRE_APPHOST_ARGS"] = string.Join('\n', args);
    }
 
    private static void MergeLaunchProfileEnvironmentVariables(
        IDictionary<string, string>? launchProfileEnvironmentVariables,
        IDictionary<string, string> environmentVariables,
        bool includeLaunchProfileEnvironmentVariables = true)
    {
        if (launchProfileEnvironmentVariables is not null)
        {
            foreach (var (key, value) in launchProfileEnvironmentVariables)
            {
                if (!includeLaunchProfileEnvironmentVariables && AppHostEnvironmentDefaults.IsEnvironmentVariableName(key))
                {
                    continue;
                }
 
                environmentVariables[key] = value;
            }
        }
    }
 
    private Dictionary<string, string>? ReadLaunchSettingsEnvironmentVariables(DirectoryInfo directory)
    {
        // Check aspire.config.json first for launch profiles (may be in a parent directory)
        var configDir = GetConfigDirectory(directory);
        try
        {
            var aspireConfig = AspireConfigFile.Load(configDir.FullName);
            if (aspireConfig?.Profiles is { Count: > 0 })
            {
                return ReadProfileFromAspireConfig(aspireConfig);
            }
        }
        catch (JsonException ex)
        {
            _logger.LogWarning(ex, "Failed to load config for launch profiles from {Directory}", configDir.FullName);
        }
 
        // Fall back to apphost.run.json / launchSettings.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 aspire.config.json, apphost.run.json, or launchSettings.json found in {Path}", directory.FullName);
            return null;
        }
 
        try
        {
            _logger.LogDebug("Reading launch settings from {ConfigPath}", configPath);
            var json = File.ReadAllText(configPath);
            using var doc = JsonDocument.Parse(json, ConfigurationHelper.ParseOptions);
 
            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[KnownAspNetCoreConfigNames.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 {ConfigPath}", result.Count, configPath);
            return result;
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Failed to read {ConfigPath}", configPath);
            return null;
        }
    }
 
    private Dictionary<string, string>? ReadProfileFromAspireConfig(AspireConfigFile aspireConfig)
    {
        AspireConfigProfile? profile;
 
        // Prefer 'https' profile, then fall back to first
        if (aspireConfig.Profiles!.TryGetValue("https", out var httpsProfile))
        {
            profile = httpsProfile;
        }
        else
        {
            profile = aspireConfig.Profiles.Values.FirstOrDefault();
        }
 
        if (profile is null)
        {
            return null;
        }
 
        var result = new Dictionary<string, string>();
 
        if (!string.IsNullOrEmpty(profile.ApplicationUrl))
        {
            result[KnownAspNetCoreConfigNames.Urls] = profile.ApplicationUrl;
        }
 
        if (profile.EnvironmentVariables is not null)
        {
            foreach (var kvp in profile.EnvironmentVariables)
            {
                result[kvp.Key] = kvp.Value;
            }
        }
 
        if (result.Count == 0)
        {
            return null;
        }
 
        _logger.LogDebug("Read {Count} environment variables from aspire.config.json", result.Count);
        return result;
    }
 
    /// <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);
        var startProjectContext = Activity.Current?.Context ?? default;
 
        try
        {
            // Step 1: Load config - source of truth for SDK version and packages
            var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken);
            var config = LoadConfiguration(directory);
            var integrations = await GetIntegrationReferencesAsync(config, directory, cancellationToken);
            var sdkVersion = GetPrepareSdkVersion(config);
 
            // Prepare the AppHost server (build for dev mode, restore for prebuilt)
            var (prepareSuccess, prepareOutput, _, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, integrations, config.Channel, cancellationToken: cancellationToken);
            if (!prepareSuccess)
            {
                // Set OutputCollector so PipelineCommandBase can display errors
                context.OutputCollector = prepareOutput;
                // Signal the backchannel completion source so the caller doesn't wait forever
                context.BackchannelCompletionSource?.TrySetException(
                    new InvalidOperationException("The app host preparation failed."));
                return CliExitCodes.FailedToBuildArtifacts;
            }
 
            // Store output collector in context for exception handling
            context.OutputCollector = prepareOutput;
 
            // Read launch settings once and reuse them for both the temporary server and guest AppHost.
            var launchProfileEnvironmentVariables = ReadLaunchSettingsEnvironmentVariables(directory);
            var launchSettingsEnvVars = GetServerEnvironmentVariables(
                launchProfileEnvironmentVariables,
                defaultEnvironment: AppHostEnvironmentDefaults.ProductionEnvironmentName,
                includeLaunchProfileEnvironmentVariables: false,
                args: context.Arguments);
            launchSettingsEnvVars[KnownConfigNames.AspireHome] = _executionContext.AspireHomeDirectory.FullName;
 
            // 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;
 
            // Pass synthetic UserSecretsId so AppHost Server can read secrets set via 'aspire secret'
            launchSettingsEnvVars[KnownConfigNames.AspireUserSecretsId] = UserSecretsPathHelper.ComputeSyntheticUserSecretsId(appHostFile.FullName);
 
            // Step 2: Start the AppHost server process(it opens the backchannel for progress reporting)
            // Linked stop CTS is the only termination trigger we hand to the session.
            using var serverStopCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
            await using var serverSession = _serverSessionFactory.Create(
                appHostServerProject,
                launchSettingsEnvVars,
                context.Debug,
                gracefulShutdownSignaler: null,
                shutdownService: null,
                isolateConsole: false,
                serverStopCts.Token);
            Task<int> serverCompletion;
            IAppHostRpcClient rpcClient;
            using (_profilingTelemetry.StartRunAppHostStartAppHostServer())
            {
                await serverSession.StartAsync();
                serverCompletion = serverSession.WaitForExitAsync();
 
                try
                {
                    // Step 3: Connect to server for RPC calls. The connection helper retries until
                    // the RPC socket is available and fails early if the server process exits — it
                    // races the connect against the server-exit signal, so a server that crashes on
                    // startup surfaces immediately (no fixed start-up delay to wait through).
                    rpcClient = await serverSession.GetRpcClientAsync(cancellationToken);
 
                    // Step 4: Generate code via RPC if needed
                    // This must happen before dependency installation because the generated
                    // code directory (.aspire/modules) may not exist yet (e.g., freshly cloned project)
                    // and dependency files (pylock.toml, requirements.txt) reference it.
                    if (needsCodeGen)
                    {
                        await GenerateCodeViaRpcAsync(
                            directory.FullName,
                            appHostFile,
                            rpcClient,
                            integrations,
                            cancellationToken: cancellationToken);
                    }
 
                    await EnsureRuntimeCreatedAsync(directory, rpcClient, cancellationToken);
                }
                catch (Exception) when (serverSession.HasServerExited == true && !cancellationToken.IsCancellationRequested)
                {
                    // The publish pipeline waits on the AppHost backchannel independently from this
                    // setup path. If the helper server exits before the guest AppHost can launch,
                    // fault that waiter immediately instead of letting it burn the full connection timeout.
                    // serverSession is in an `await using` scope, so returning here disposes it.
                    context.BackchannelCompletionSource?.TrySetException(
                        new InvalidOperationException("The app host server exited unexpectedly."));
                    _interactionService.DisplayLines(serverSession.Output!.GetLines());
                    _interactionService.DisplayError("App host exited unexpectedly.");
                    return CliExitCodes.FailedToDotnetRunAppHost;
                }
                catch (Exception ex)
                {
                    // The backchannel connection is deferred until the guest AppHost launches
                    // (see StartBackchannelConnectionAfterGuestAppHostLaunchesAsync below), so on a
                    // setup failure here it was never started. Fault the completion source so the
                    // publish pipeline waiter doesn't burn the full connection timeout.
                    // The `await using` declaration above disposes the session on the rethrow.
                    context.BackchannelCompletionSource?.TrySetException(ex);
                    throw;
                }
            }
            var jsonRpcSocketPath = serverSession.SocketPath!;
            var appHostServerOutputCollector = serverSession.Output!;
            var authenticationToken = serverSession.AuthenticationToken;
 
            int guestExitCode;
            OutputCollector? guestOutput;
            using (var guestStartupActivity = _profilingTelemetry.StartRunAppHostStartGuestAppHost(_resolvedLanguage.LanguageId))
            {
                // Step 5: Install dependencies if needed (using GuestRuntime)
                // The GuestRuntime will skip if the RuntimeSpec doesn't have InstallDependencies configured
                var installResult = await InstallDependenciesAsync(directory, rpcClient, treatMissingJavaScriptToolAsWarning: false, cancellationToken: cancellationToken);
                if (installResult != 0)
                {
                    context.BackchannelCompletionSource?.TrySetException(
                        new InvalidOperationException($"Failed to install {DisplayName} dependencies."));
 
                    serverStopCts.Cancel();
 
                    return installResult;
                }
 
                // Pass the launch profile environment variables through to the guest AppHost so publish mode
                // uses the same dashboard and resource service endpoints as the temporary .NET server.
                var environmentVariables = CreateGuestEnvironmentVariables(
                    context.EnvironmentVariables,
                    launchProfileEnvironmentVariables,
                    defaultEnvironment: AppHostEnvironmentDefaults.ProductionEnvironmentName,
                    includeLaunchProfileEnvironmentVariables: false,
                    args: context.Arguments);
                environmentVariables[KnownConfigNames.AspireHome] = _executionContext.AspireHomeDirectory.FullName;
                environmentVariables["REMOTE_APP_HOST_SOCKET_PATH"] = jsonRpcSocketPath;
                environmentVariables["ASPIRE_PROJECT_DIRECTORY"] = directory.FullName;
                environmentVariables["ASPIRE_APPHOST_FILEPATH"] = appHostFile.FullName;
                environmentVariables[KnownConfigNames.RemoteAppHostToken] = authenticationToken;
 
                // Step 6: Execute the guest apphost for publishing
                // Pass the publish arguments (e.g., --operation publish --step deploy)
                Task StartBackchannelConnectionAfterGuestAppHostLaunchesAsync()
                {
                    if (context.BackchannelCompletionSource is not null)
                    {
                        _ = StartBackchannelConnectionAsync(serverSession, backchannelSocketPath, context.BackchannelCompletionSource, enableHotReload: false, startProjectContext, cancellationToken);
                    }
 
                    return Task.CompletedTask;
                }
 
                (guestExitCode, guestOutput) = await ExecuteGuestAppHostForPublishAsync(
                    appHostFile, directory, environmentVariables, context.Arguments, context.NoBuild, rpcClient, StartBackchannelConnectionAfterGuestAppHostLaunchesAsync, cancellationToken);
            }
 
            if (guestExitCode != 0)
            {
                _logger.LogError("{Language} apphost exited with code {ExitCode}", DisplayName, guestExitCode);
 
                // Display the output (same pattern as DotNetCliRunner)
                if (guestOutput is not null)
                {
                    _interactionService.DisplayLines(guestOutput.GetLines());
                }
 
                // Signal failure so callers don't hang waiting for the backchannel. The caller
                // (e.g. PipelineCommandBase) wraps the message with the localized
                // InteractionServiceStrings.UnexpectedErrorOccurred template before surfacing
                // it to the user.
                var error = new InvalidOperationException($"The {DisplayName} apphost failed.");
                context.BackchannelCompletionSource?.TrySetException(error);
 
                // Kill the AppHost server since the apphost failed
                serverStopCts.Cancel();
 
                return guestExitCode;
            }
 
            // Kill the server after the guest apphost exits
            serverStopCts.Cancel();
 
            await serverCompletion.WaitAsync(cancellationToken);
 
            // The guest apphost's publish result determines command success.
            // The helper server may be terminated by the CLI as part of normal cleanup,
            // which can yield a non-zero process exit code on Unix-like systems.
            return guestExitCode;
        }
        catch (OperationCanceledException)
        {
            return CliExitCodes.Cancelled;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to publish {Language} AppHost", DisplayName);
            _interactionService.DisplayError($"Failed to publish {DisplayName} AppHost: {ex.Message}");
            return CliExitCodes.FailedToDotnetRunAppHost;
        }
    }
 
    /// <summary>
    /// Gets the backchannel socket path for CLI communication.
    /// </summary>
    private static string GetBackchannelSocketPath()
    {
        return CliPathHelper.CreateUnixDomainSocketPath("cli.sock");
    }
 
    /// <summary>
    /// Copies the AppHost server's captured output into <see cref="AppHostProjectContext.OutputCollector"/>
    /// so RunCommand's post-failure UX (which only reads from context.OutputCollector) can surface
    /// server-side diagnostics like DCP model validation errors. The build/run collector lives on the
    /// context and is the canonical place for AppHost output; the server's collector is internal to
    /// AppHostServerSession and isn't otherwise visible to commands.
    /// </summary>
    private static void MergeServerOutputIntoContextCollector(AppHostProjectContext context, OutputCollector serverOutput)
    {
        if (context.OutputCollector is not { } target || ReferenceEquals(target, serverOutput))
        {
            return;
        }
 
        foreach (var (stream, line) in serverOutput.GetLines())
        {
            if (stream == OutputLineStream.StdErr)
            {
                target.AppendError(line);
            }
            else
            {
                target.AppendOutput(line);
            }
        }
    }
 
    /// <summary>
    /// Starts connecting to the AppHost server's backchannel server.
    /// </summary>
    private async Task StartBackchannelConnectionAsync(
        IAppHostServerSession serverSession,
        string socketPath,
        TaskCompletionSource<IAppHostCliBackchannel> backchannelCompletionSource,
        bool enableHotReload,
        ActivityContext parentContext,
        CancellationToken cancellationToken)
    {
        var connectionTimeout = AppHostStartupTimeout.GetBackchannelConnectionTimeout(_configuration);
 
        using var activity = _profilingTelemetry.StartBackchannelConnect(socketPath, parentContext, enableHotReload, retryCount: 0);
        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);
                if (connectionAttempts == 0 || connectionAttempts % 10 == 0)
                {
                    activity.AddBackchannelConnectAttemptEvent(connectionAttempts);
                }
                // Pass enableHotReload as autoReconnect - the backchannel will handle reconnection internally
                await _backchannel.ConnectAsync(socketPath, autoReconnect: enableHotReload, retryCount: connectionAttempts, cancellationToken).ConfigureAwait(false);
                activity.SetBackchannelRetryCount(connectionAttempts);
                activity.AddBackchannelConnectedEvent();
                backchannelCompletionSource.TrySetResult(_backchannel);
                _logger.LogDebug("Connected to AppHost server backchannel at {SocketPath}", socketPath);
                return;
            }
            // Route HasExited / ExitCode through the session so the isolated Windows spawn path
            // (which surfaces Process via Process.GetProcessById, whose status getters are
            // unreliable for processes the BCL did not itself start) goes through the
            // IsolatedProcess wrapper's GetExitCodeProcess-backed accessors instead.
            // See https://github.com/dotnet/runtime/issues/45003.
            catch (SocketException ex) when (serverSession.HasServerExited == true && !cancellationToken.IsCancellationRequested)
            {
                var exitCode = serverSession.TryGetServerExitCode();
                // Log at Debug level - this is expected when AppHost crashes during startup.
                // The real error is in the AppHost output, not this connection-level detail.
                if (exitCode is { } knownExitCode)
                {
                    _logger.LogDebug("AppHost server process has exited with code {ExitCode}. Unable to connect to backchannel at {SocketPath}", knownExitCode, socketPath);
                }
                else
                {
                    _logger.LogDebug("AppHost server process has exited before its exit code was available. Unable to connect to backchannel at {SocketPath}", socketPath);
                }
 
                var message = exitCode switch
                {
                    CliExitCodes.Success => "The AppHost server process exited",
                    { } nonZeroExitCode => $"The AppHost server process exited unexpectedly with exit code {nonZeroExitCode}",
                    null => "The AppHost server process exited unexpectedly"
                };
                var backchannelException = new FailedToConnectBackchannelConnection(message, ex);
                activity.SetError(backchannelException);
                backchannelCompletionSource.TrySetException(backchannelException);
                return;
            }
            catch (SocketException)
            {
                var waitingFor = DateTimeOffset.UtcNow - startTime;
 
                // The AppHost server cannot open this backchannel until the guest has executed its
                // builder. Use the outer startup budget so an extension-managed debugger can remain
                // paused before builder creation without the guest path tearing down the session first.
                if (waitingFor > connectionTimeout)
                {
                    _logger.LogError("Timed out waiting for AppHost server to start after {Timeout} seconds", connectionTimeout.TotalSeconds);
                    var timeoutException = new TimeoutException($"Timed out waiting for AppHost server to start after {connectionTimeout.TotalSeconds} seconds. Check the debug logs for more details.");
                    activity.SetError(timeoutException);
                    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 (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
            {
                // User cancellation (Ctrl+C) is tearing down the run; appHostSystemToken is linked
                // to it. Leave the backchannel completion source pending rather than faulting it:
                // faulting here would trip the IsFaulted continuation in RunAppHostAsync, which sets
                // internalFaultCode = FailedToDotnetRunAppHost and would surface a run *failure*
                // instead of the normal Cancelled (130). The outer run loop observes the same token
                // and returns cancellation on its own.
                _logger.LogDebug("Backchannel connection to AppHost server cancelled during shutdown.");
                return;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Failed to connect to AppHost server backchannel");
                activity.SetError(ex);
                backchannelCompletionSource.TrySetException(ex);
                return;
            }
            finally
            {
                connectionAttempts++;
            }
        }
    }
 
    /// <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 config = LoadConfiguration(directory);
 
        // Update configuration with the new package
        config.AddOrUpdatePackage(context.PackageId, context.PackageVersion);
 
        // Build and regenerate SDK code with the new package
        var regenerateSuccess = await BuildAndGenerateSdkAsync(directory, config, cancellationToken: cancellationToken);
        if (!regenerateSuccess)
        {
            return false;
        }
 
        SaveConfiguration(config, directory);
        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 config = LoadConfiguration(directory);
 
        // Find updates for SDK version and packages
        string? newSdkVersion = null;
        var updates = await _interactionService.ShowStatusAsync(
            UpdateCommandStrings.AnalyzingProjectStatus,
            async () =>
            {
                var packageUpdates = new List<(string PackageId, string CurrentVersion, string NewVersion)>();
 
                // Check for SDK version update (silently - it's an implementation detail)
                try
                {
                    var latestSdkPackage = await context.Channel.GetLatestGuestAppHostSdkPackageAsync(directory, cancellationToken);
 
                    if (latestSdkPackage is not null && latestSdkPackage.Version != config.SdkVersion)
                    {
                        newSdkVersion = latestSdkPackage.Version;
                    }
                }
                catch (Exception ex)
                {
                    _logger.LogWarning(ex, "Failed to check for SDK version updates");
                }
 
                // Check for package updates
                if (config.Packages is not null)
                {
                    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;
            });
 
        var explicitChannelName = context.Channel.ShouldPersistChannelName() ? context.Channel.Name : null;
        var explicitChannelChanged = explicitChannelName is not null && !string.Equals(config.Channel, explicitChannelName, StringComparisons.CliInputOrOutput);
 
        if (updates.Count == 0 && newSdkVersion is null)
        {
            if (explicitChannelChanged)
            {
                config.Channel = explicitChannelName;
                SaveConfiguration(config, directory);
            }
 
            _interactionService.DisplayMessage(KnownEmojis.CheckMarkButton, UpdateCommandStrings.ProjectUpToDateMessage);
            return new UpdatePackagesResult { UpdatesApplied = explicitChannelChanged };
        }
 
        // Display pending updates
        _interactionService.DisplayEmptyLine();
        if (newSdkVersion is not null)
        {
            _interactionService.DisplayMessage(KnownEmojis.Package, $"[bold yellow]Aspire SDK[/] [bold green]{config.SdkVersion.EscapeMarkup()}[/] to [bold green]{newSdkVersion.EscapeMarkup()}[/]", allowMarkup: true);
        }
        foreach (var (packageId, currentVersion, newVersion) in updates)
        {
            _interactionService.DisplayMessage(KnownEmojis.Package, $"[bold yellow]{packageId.EscapeMarkup()}[/] [bold green]{currentVersion.EscapeMarkup()}[/] to [bold green]{newVersion.EscapeMarkup()}[/]", allowMarkup: true);
        }
        _interactionService.DisplayEmptyLine();
 
        // Confirm with user
        if (!await _interactionService.PromptConfirmAsync(UpdateCommandStrings.PerformUpdatesPrompt, context.ConfirmBinding, cancellationToken: cancellationToken))
        {
            return new UpdatePackagesResult { UpdatesApplied = false };
        }
 
        // Apply updates to settings.json
        if (newSdkVersion is not null)
        {
            config.SdkVersion = newSdkVersion;
        }
        // Persist the channel when update resolved a non-stable explicit channel. That can
        // come from --channel, per-project/global config, prompt selection, or the
        // UpdateCommand identity-channel fallback for non-project-reference AppHosts. When
        // the resolved channel is Implicit or stable, leave the project's existing setting
        // untouched rather than pinning the default public-feed behavior.
        if (explicitChannelName is not null)
        {
            config.Channel = explicitChannelName;
        }
        foreach (var (packageId, _, newVersion) in updates)
        {
            config.AddOrUpdatePackage(packageId, newVersion);
        }
        // Rebuild and regenerate SDK code with updated packages
        _interactionService.DisplayEmptyLine();
        var regenerateResult = await _interactionService.ShowStatusAsync(
            UpdateCommandStrings.RegeneratingSdkCode,
            async () =>
            {
                var regenerateSuccess = await BuildAndGenerateSdkAsync(directory, config, cancellationToken: cancellationToken);
 
                if (!regenerateSuccess)
                {
                    return new UpdatePackagesResult { UpdatesApplied = false };
                }
 
                return new UpdatePackagesResult { UpdatesApplied = true };
            });
 
        if (!regenerateResult.UpdatesApplied)
        {
            return regenerateResult;
        }
 
        SaveConfiguration(config, directory);
 
        _interactionService.DisplayMessage(KnownEmojis.Package, UpdateCommandStrings.RegeneratedSdkCode);
 
        _interactionService.DisplayEmptyLine();
        _interactionService.DisplaySuccess(UpdateCommandStrings.UpdateSuccessfulMessage);
 
        return new UpdatePackagesResult { UpdatesApplied = true };
    }
 
    /// <inheritdoc />
    public async Task<RunningInstanceResult> FindAndStopRunningInstanceAsync(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 RunningInstanceResult.NoRunningInstance; // No directory, nothing to check
        }
 
        var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken);
        var genericAppHostPath = appHostServerProject.GetInstanceIdentifier();
 
        // Find matching sockets for this AppHost
        var matchingSockets = AppHostHelper.FindMatchingNonOrphanedSockets(
            genericAppHostPath,
            homeDirectory.FullName,
            Environment.ProcessId,
            _logger);
 
        // Check if any socket files exist
        if (matchingSockets.Length == 0)
        {
            return RunningInstanceResult.NoRunningInstance; // No running instance, continue
        }
 
        // Stop all running instances
        var stopTasks = matchingSockets.Select(socketPath =>
            _runningInstanceManager.StopRunningInstanceAsync(socketPath, cancellationToken));
        var results = await Task.WhenAll(stopTasks);
        return results.All(r => r) ? RunningInstanceResult.InstanceStopped : RunningInstanceResult.StopFailed;
    }
 
    /// <summary>
    /// Generates SDK code by calling the AppHost server's generateCode RPC method.
    /// </summary>
    private async Task GenerateCodeViaRpcAsync(
        string appPath,
        FileInfo? appHostFile,
        IAppHostRpcClient rpcClient,
        IEnumerable<IntegrationReference> integrations,
        string? targetSdkVersion = null,
        CancellationToken cancellationToken = default)
    {
        var integrationsList = integrations.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;
 
        WarnIfCliSdkVersionSkew(appPath, targetSdkVersion);
 
        _logger.LogDebug("Generating {CodeGenerator} code via RPC for {Count} packages", codeGenerator, integrationsList.Count);
 
        // Use the typed RPC method
        Dictionary<string, string> 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
        // generated `.mts/.mjs` outputs back to `.ts/.js` AND write them to the legacy
        // `.modules/` folder so the existing import paths resolve.
        if (ShouldEmitLegacyTypeScriptGeneratedFiles(appPath, appHostFile))
        {
            files = ConvertGeneratedFilesForLegacyTypeScriptAppHost(files);
            outputPath = Path.Combine(appPath, LanguageInfo.LegacyGeneratedFolderName);
 
            // Nudge the user toward the modern `apphost.mts` layout. The legacy layout keeps
            // working, so this is a single, non-blocking warning that points at `aspire update --migrate`.
            _interactionService.DisplayMessage(
                KnownEmojis.Warning,
                $"[yellow]{Markup.Escape(ErrorStrings.LegacyTypeScriptAppHostWarning)}[/]",
                allowMarkup: true);
        }
 
        // Write generated files to the output directory
        Directory.CreateDirectory(outputPath);
 
        var writtenCount = 0;
        foreach (var (fileName, content) in files)
        {
            var filePath = Path.Combine(outputPath, fileName);
            var directory = Path.GetDirectoryName(filePath);
            if (!string.IsNullOrEmpty(directory))
            {
                Directory.CreateDirectory(directory);
            }
 
            if (await WriteGeneratedFileAsync(filePath, content, _resolvedLanguage.PreserveUnchangedGeneratedFiles, cancellationToken))
            {
                writtenCount++;
            }
        }
 
        // Write generation hash for caching
        SaveGenerationHash(outputPath, integrationsList);
 
        await PruneObsoleteGeneratedFilesAsync(outputPath, files.Keys, cancellationToken);
 
        _logger.LogInformation("Generated {Count} {CodeGenerator} files in {Path} ({WrittenCount} changed)",
            files.Count, codeGenerator, outputPath, writtenCount);
    }
 
    /// <summary>
    /// Writes a generated file, skipping the write when <paramref name="preserveUnchangedFiles" /> is
    /// set and the content already on disk is identical.
    /// </summary>
    /// <remarks>
    /// The generated SDK is hundreds of files and is regenerated on every launch, but its content is
    /// identical from one launch to the next unless the app model changed. See
    /// <see cref="GeneratedFileWriter" /> for why leaving those timestamps alone matters.
    /// <para>
    /// Only languages that compile the generated sources in place opt in, via
    /// <see cref="LanguageInfo.PreserveUnchangedGeneratedFiles" />. A language that installs them into
    /// an environment first cannot: uv reuses its cached build of <c>.aspire/modules</c> when the
    /// sources have not changed, so leaving an unchanged file alone leaves the Python AppHost importing
    /// a stale install of the SDK.
    /// </para>
    /// </remarks>
    /// <returns><see langword="true" /> when the file was written.</returns>
    internal static async Task<bool> WriteGeneratedFileAsync(string filePath, string content, bool preserveUnchangedFiles, CancellationToken cancellationToken)
    {
        if (preserveUnchangedFiles)
        {
            return await GeneratedFileWriter.WriteIfChangedAsync(filePath, content, cancellationToken);
        }
 
        await File.WriteAllTextAsync(filePath, content, cancellationToken);
        return true;
    }
 
    internal static Dictionary<string, string> ConvertGeneratedFilesForLegacyTypeScriptAppHost(Dictionary<string, string> files)
    {
        var convertedFiles = new Dictionary<string, string>(StringComparer.Ordinal);
 
        foreach (var (fileName, content) in files)
        {
            var convertedFileName = fileName switch
            {
                "aspire.mts" => "aspire.ts",
                "base.mts" => "base.ts",
                "transport.mts" => "transport.ts",
                _ => fileName
            };
 
            convertedFiles[convertedFileName] = convertedFileName.EndsWith(".ts", StringComparison.Ordinal)
                ? content
                    .Replace(".mjs", ".js", StringComparison.Ordinal)
                    .Replace("aspire.mts", "aspire.ts", StringComparison.Ordinal)
                    .Replace("base.mts", "base.ts", StringComparison.Ordinal)
                    .Replace("transport.mts", "transport.ts", StringComparison.Ordinal)
                : content;
        }
 
        return convertedFiles;
    }
 
    private bool ShouldEmitLegacyTypeScriptGeneratedFiles(string appPath, FileInfo? appHostFile)
    {
        if (!TypeScriptAppHostToolchainResolver.IsTypeScriptLanguage(_resolvedLanguage))
        {
            return false;
        }
 
        return appHostFile is not null
            ? LegacyTypeScriptAppHost.IsLegacyAppHostFile(appHostFile)
            : LegacyTypeScriptAppHost.IsLegacyLayout(appPath);
    }
 
    /// <summary>
    /// Emits a single pre-flight warning when the installed CLI version doesn't match the SDK
    /// version pinned in <c>aspire.config.json</c>. 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.
    /// </summary>
    private void WarnIfCliSdkVersionSkew(string appPath, string? targetSdkVersion = null)
    {
        try
        {
            var cliVersion = _executionContext.IdentitySdkVersion;
 
            // When the caller is actively updating TO a version that matches the CLI,
            // the on-disk config is stale and about to be overwritten — skip the warning.
            if (targetSdkVersion is not null && !IsKnownIncompatibleSkew(cliVersion, targetSdkVersion))
            {
                return;
            }
 
            var configDir = ConfigurationHelper.GetConfigRootDirectory(new DirectoryInfo(appPath));
            var config = AspireConfigFile.Load(configDir.FullName);
            var configuredSdkVersion = config?.SdkVersion;
            if (string.IsNullOrWhiteSpace(configuredSdkVersion))
            {
                return;
            }
 
            if (!IsKnownIncompatibleSkew(cliVersion, configuredSdkVersion))
            {
                return;
            }
 
            var message = string.Format(
                System.Globalization.CultureInfo.CurrentCulture,
                ErrorStrings.CodegenVersionSkewWarning,
                cliVersion,
                configuredSdkVersion);
            _interactionService.DisplayMessage(KnownEmojis.Warning, $"[yellow]{Markup.Escape(message)}[/]", allowMarkup: true);
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Failed to evaluate CLI/SDK version skew prior to code generation.");
        }
    }
 
    /// <summary>
    /// Returns <see langword="true"/> 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.
    /// </summary>
    /// <summary>
    /// Returns <see langword="true"/> when the supplied CLI and SDK versions differ in a way that
    /// is known to produce ABI incompatibilities — specifically when they differ in
    /// <see cref="SemVersion.Major"/>, <see cref="SemVersion.Minor"/>, <see cref="SemVersion.Patch"/>,
    /// or in their prerelease identifiers (e.g. <c>13.4.0-preview.1.26218.1</c> vs
    /// <c>13.4.0-preview.1.26227.1</c>, which was the exact reproduction case in
    /// <see href="https://github.com/microsoft/aspire/issues/16709"/>). Build metadata
    /// (everything after <c>+</c>) is ignored per the SemVer spec.
    /// </summary>
    internal static bool IsKnownIncompatibleSkew(string cliVersion, string sdkVersion)
    {
        if (!SemVersion.TryParse(NormalizeVersion(cliVersion), SemVersionStyles.Any, out var cli) ||
            !SemVersion.TryParse(NormalizeVersion(sdkVersion), SemVersionStyles.Any, out var sdk))
        {
            return !string.Equals(cliVersion, sdkVersion, StringComparison.OrdinalIgnoreCase);
        }
 
        // Compare full precedence, which covers Major/Minor/Patch *and* prerelease identifiers
        // but (per the SemVer spec) ignores build metadata. NormalizeVersion already strips '+'
        // suffixes defensively for parsers that include them in precedence.
        return SemVersion.ComparePrecedence(cli, sdk) != 0;
    }
 
    internal static string NormalizeVersion(string version)
    {
        var plusIndex = version.IndexOf('+');
        return plusIndex > 0 ? version[..plusIndex] : version;
    }
 
    /// <summary>
    /// Renders a <see cref="AppHostCodeGenerationException"/> to the user with .NET-specific
    /// details tiered behind <c>--debug</c> 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 <c>LogDebug</c> call regardless of mode.
    /// </summary>
    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 ?? "<none>",
            exception.Diagnostic.MemberName ?? "<none>",
            exception.Diagnostic.RuntimeAspireHostingVersion ?? "<none>",
            exception.Diagnostic.LoadedAssemblies.Count);
        _logger.LogDebug(
            "Code generation diagnostic payload: {DiagnosticPayload}",
            JsonSerializer.Serialize(
                exception.Diagnostic,
                BackchannelJsonSerializerContext.Default.AppHostCodeGenerationDiagnostic));
 
        if (!_executionContext.DebugMode)
        {
            _interactionService.DisplayMessage(KnownEmojis.Information, $"[grey]{Markup.Escape(ErrorStrings.CodegenDebugHint)}[/]", allowMarkup: true);
            return;
        }
 
        _interactionService.DisplayMessage(KnownEmojis.Microscope, $"[grey]{Markup.Escape(ErrorStrings.CodegenDebugHeader)}[/]", allowMarkup: true);
        var diagnostic = exception.Diagnostic;
        if (!string.IsNullOrWhiteSpace(diagnostic.OriginalExceptionType))
        {
            _interactionService.DisplayPlainText($"   Exception: {diagnostic.OriginalExceptionType}");
        }
        if (!string.IsNullOrWhiteSpace(diagnostic.TypeName))
        {
            _interactionService.DisplayPlainText($"   Type: {diagnostic.TypeName}");
        }
        if (!string.IsNullOrWhiteSpace(diagnostic.MemberName))
        {
            _interactionService.DisplayPlainText($"   Member: {diagnostic.MemberName}");
        }
        if (!string.IsNullOrWhiteSpace(diagnostic.RuntimeAspireHostingVersion))
        {
            _interactionService.DisplayPlainText($"   Runtime Aspire.Hosting: {diagnostic.RuntimeAspireHostingVersion}");
        }
        foreach (var assembly in diagnostic.LoadedAssemblies)
        {
            var version = assembly.InformationalVersion ?? "<unknown>";
            _interactionService.DisplayPlainText($"{assembly.Name} {version}");
        }
    }
 
    /// <summary>
    /// Saves a hash of the integrations to avoid regenerating code unnecessarily.
    /// When project references are present, the hash is always unique to force regeneration
    /// since project outputs are mutable.
    /// </summary>
    /// <summary>
    /// Deletes generated files a previous generation wrote that the current one no longer produces,
    /// and records the current set for the next run.
    /// </summary>
    /// <remarks>
    /// Removing a package from <c>aspire.config.json</c>, or renaming a resource type, changes which
    /// files the generator emits. Without pruning the old ones stay on disk, and for languages that
    /// compile the generated sources in place that is not merely untidy: <c>javac</c> compiles
    /// everything under the source root, so a leftover file referencing a type that no longer exists
    /// fails the AppHost build outright, with an error pointing at generated code the user never wrote.
    /// <para>
    /// Only paths a previous run recorded in the manifest are eligible, so a file Aspire did not write
    /// is never deleted - including on the first run, when no manifest exists yet. A failure to delete
    /// or to write the manifest is not fatal: the worst case is the stale file surviving, which is the
    /// behaviour before this existed.
    /// </para>
    /// </remarks>
    internal static async Task PruneObsoleteGeneratedFilesAsync(
        string outputPath,
        IEnumerable<string> generatedRelativePaths,
        CancellationToken cancellationToken)
    {
        var manifestPath = Path.Combine(outputPath, GeneratedManifestFileName);
        var current = new HashSet<string>(generatedRelativePaths.Select(NormalizeManifestPath), StringComparer.Ordinal);
 
        if (File.Exists(manifestPath))
        {
            try
            {
                foreach (var line in await File.ReadAllLinesAsync(manifestPath, cancellationToken).ConfigureAwait(false))
                {
                    var recorded = line.Trim();
                    if (recorded.Length == 0 || current.Contains(NormalizeManifestPath(recorded)))
                    {
                        continue;
                    }
 
                    DeleteObsoleteGeneratedFile(outputPath, recorded);
                }
            }
            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
            {
                // An unreadable manifest only costs pruning for this run.
            }
        }
 
        try
        {
            Directory.CreateDirectory(outputPath);
            await File.WriteAllLinesAsync(manifestPath, current.Order(StringComparer.Ordinal), cancellationToken).ConfigureAwait(false);
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
            // Losing the manifest only means the next run cannot prune.
        }
    }
 
    /// <summary>
    /// Manifest of the files the last generation wrote, relative to the generated folder.
    /// </summary>
    private const string GeneratedManifestFileName = ".codegen-manifest";
 
    /// <summary>
    /// Stores manifest entries with forward slashes so a manifest written on Windows still prunes on
    /// Unix, and vice versa, when a repository is shared between them.
    /// </summary>
    private static string NormalizeManifestPath(string path) => path.Replace('\\', '/');
 
    private static void DeleteObsoleteGeneratedFile(string outputPath, string recordedRelativePath)
    {
        var fullPath = Path.GetFullPath(Path.Combine(outputPath, recordedRelativePath));
 
        // A manifest is written by Aspire, but it is a file on disk in the user's repository, so a
        // hand-edited or corrupted entry must not be able to reach outside the generated folder.
        var root = Path.GetFullPath(outputPath);
        if (!fullPath.StartsWith(root + Path.DirectorySeparatorChar, PathComparison))
        {
            return;
        }
 
        try
        {
            File.Delete(fullPath);
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
            // A file that cannot be deleted is left alone; that is the pre-existing behaviour.
        }
    }
 
    private static StringComparison PathComparison =>
        OperatingSystem.IsLinux() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;
 
    private static void SaveGenerationHash(string generatedPath, List<IntegrationReference> integrations)
    {
        var hashPath = Path.Combine(generatedPath, ".codegen-hash");
        var hash = ComputeIntegrationsHash(integrations);
        File.WriteAllText(hashPath, hash);
    }
 
    /// <summary>
    /// Computes a hash of the integration list for caching purposes.
    /// If any project references are present, includes a timestamp to force regeneration
    /// since project outputs can change between builds.
    /// </summary>
    private static string ComputeIntegrationsHash(List<IntegrationReference> integrations)
    {
        var sb = new System.Text.StringBuilder();
        foreach (var integration in integrations.OrderBy(p => p.Name))
        {
            sb.Append(integration.Name);
            sb.Append(':');
            sb.Append(integration.Version ?? integration.ProjectPath ?? "");
            sb.Append(';');
        }
 
        // Project references are mutable — always regenerate when they're present
        if (integrations.Any(i => i.IsProjectReference))
        {
            sb.Append("timestamp:");
            sb.Append(DateTime.UtcNow.Ticks);
        }
 
        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(
        DirectoryInfo directory,
        IAppHostRpcClient rpcClient,
        CancellationToken cancellationToken)
    {
        if (_guestRuntime is null)
        {
            var runtimeSpec = await rpcClient.GetRuntimeSpecAsync(_resolvedLanguage.LanguageId, cancellationToken);
            if (TypeScriptAppHostToolchainResolver.IsTypeScriptLanguage(_resolvedLanguage))
            {
                var toolchain = TypeScriptAppHostToolchainResolver.Resolve(directory, _environment, _logger);
                runtimeSpec = TypeScriptAppHostToolchainResolver.ApplyToRuntimeSpec(runtimeSpec, toolchain);
            }
            else if (JavaAppHostToolchainResolver.IsJavaLanguage(_resolvedLanguage))
            {
                var resolution = JavaAppHostToolchainResolver.Resolve(directory, _logger);
                await JavaAppHostToolchainResolver.EnsureToolchainFilesExistAsync(resolution, cancellationToken);
                runtimeSpec = JavaAppHostToolchainResolver.ApplyToRuntimeSpec(runtimeSpec, resolution, directory);
                _javaToolchainResolution = resolution;
            }
 
            _guestRuntime = new GuestRuntime(runtimeSpec, _logger, PathLookupHelper.FindFullPathFromPath, _environment, _profilingTelemetry, _fileLoggerProvider);
 
            _logger.LogDebug("Created GuestRuntime for {RuntimeDisplayName}: Execute={Command} {Args}",
                runtimeSpec.DisplayName,
                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,
        bool treatMissingJavaScriptToolAsWarning,
        CancellationToken cancellationToken)
    {
        await EnsureRuntimeCreatedAsync(directory, rpcClient, cancellationToken);
 
        if (_guestRuntime is null)
        {
            _interactionService.DisplayError("GuestRuntime not initialized. This is a bug.");
            return CliExitCodes.FailedToBuildArtifacts;
        }
 
        var (initResult, initOutput) = await _guestRuntime.InitializeAsync(directory, cancellationToken);
        if (initResult != 0)
        {
            var lines = initOutput.GetLines().ToArray();
            if (lines.Length > 0)
            {
                _interactionService.DisplayLines(lines);
            }
            else
            {
                _interactionService.DisplayError($"Failed to initialize {_resolvedLanguage?.DisplayName ?? "guest"} environment.");
            }
            return initResult;
        }
 
        if (_javaToolchainResolution is { } javaToolchain)
        {
            // Immediately before staging, so the AppHost can never be left with a cleared classpath.
            JavaAppHostToolchainResolver.ClearStagedDependencies(javaToolchain);
        }
 
        var (result, output) = await _guestRuntime.InstallDependenciesAsync(directory, cancellationToken);
        if (result != 0)
        {
            var lines = output.GetLines().ToArray();
            if (lines.Length > 0)
            {
                _interactionService.DisplayLines(lines);
            }
            else
            {
                _interactionService.DisplayError($"Failed to install {_resolvedLanguage?.DisplayName ?? "guest"} dependencies.");
            }
 
            if (treatMissingJavaScriptToolAsWarning && MissingJavaScriptToolWarning.IsMatch(lines))
            {
                _interactionService.DisplayMessage(KnownEmojis.Warning, MissingJavaScriptToolWarning.GetMessage(directory, _resolvedLanguage, _environment));
                return 0;
            }
        }
 
        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,
        bool noBuild,
        IAppHostRpcClient rpcClient,
        IGuestProcessLauncher launcher,
        Func<Task>? afterAppHostLaunchedAsync,
        GuestLaunchOptions? appHostLaunchOptions,
        CancellationToken cancellationToken)
    {
        await EnsureRuntimeCreatedAsync(directory, rpcClient, cancellationToken);
 
        if (_guestRuntime is null)
        {
            _interactionService.DisplayError("GuestRuntime not initialized. This is a bug.");
            return (CliExitCodes.FailedToDotnetRunAppHost, new OutputCollector());
        }
 
        return await _guestRuntime.RunAsync(appHostFile, directory, environmentVariables, watchMode, launcher, cancellationToken, noBuild: noBuild, afterAppHostLaunchedAsync: afterAppHostLaunchedAsync, appHostLaunchOptions: appHostLaunchOptions);
    }
 
    /// <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,
        bool noBuild,
        IAppHostRpcClient rpcClient,
        Func<Task>? afterAppHostLaunchedAsync,
        CancellationToken cancellationToken)
    {
        await EnsureRuntimeCreatedAsync(directory, rpcClient, cancellationToken);
 
        if (_guestRuntime is null)
        {
            _interactionService.DisplayError("GuestRuntime not initialized. This is a bug.");
            return (CliExitCodes.FailedToDotnetRunAppHost, new OutputCollector());
        }
 
        return await _guestRuntime.PublishAsync(appHostFile, directory, environmentVariables, publishArgs, _guestRuntime.CreateDefaultLauncher(), noBuild: noBuild, afterAppHostLaunchedAsync: afterAppHostLaunchedAsync, cancellationToken: cancellationToken);
    }
 
    /// <summary>
    /// Computes a deterministic synthetic UserSecretsId from the AppHost file path.
    /// </summary>
    public Task<string?> GetUserSecretsIdAsync(FileInfo appHostFile, bool autoInit, CancellationToken cancellationToken)
    {
        var id = UserSecretsPathHelper.ComputeSyntheticUserSecretsId(appHostFile.FullName);
        return Task.FromResult<string?>(id);
    }
 
    /// <summary>
    /// Configures a language runtime's certificate bundle to trust the ASP.NET Core development certificate.
    /// </summary>
    internal async Task ConfigureCertificateBundleEnvironmentAsync(
        IDictionary<string, string> environmentVariables,
        DirectoryInfo workingDirectory,
        string? devCertPemPath,
        string environmentVariableName,
        string cacheFilePrefix,
        CancellationToken cancellationToken)
    {
        if (devCertPemPath is null)
        {
            return;
        }
 
        if (string.IsNullOrWhiteSpace(environmentVariableName))
        {
            throw new InvalidOperationException("The certificate bundle environment variable name cannot be empty.");
        }
 
        if (string.IsNullOrWhiteSpace(cacheFilePrefix) ||
            cacheFilePrefix.Any(character => !char.IsAsciiLetterOrDigit(character) && character is not '-' and not '_'))
        {
            throw new InvalidOperationException("The certificate bundle cache file prefix contains invalid characters.");
        }
 
        // Explicit AppHost configuration takes precedence over the inherited environment.
        // Environment variable names are case-insensitive on Windows.
        var configuredKeys = _environment.IsWindows()
            ? environmentVariables.Keys
                .Where(key => string.Equals(key, environmentVariableName, StringComparison.OrdinalIgnoreCase))
                .ToArray()
            : environmentVariables.ContainsKey(environmentVariableName)
                ? [environmentVariableName]
                : [];
        var existingCertificateBundle = configuredKeys.LastOrDefault() is { } configuredKey
            ? environmentVariables[configuredKey]
            : _environment.GetEnvironmentVariable(environmentVariableName);
        var certificateBundlePath = devCertPemPath;
 
        if (!string.IsNullOrWhiteSpace(existingCertificateBundle))
        {
            try
            {
                var existingBundlePath = Path.GetFullPath(existingCertificateBundle, workingDirectory.FullName);
                var pathComparison = _environment.IsWindows()
                    ? StringComparison.OrdinalIgnoreCase
                    : StringComparison.Ordinal;
 
                if (!string.Equals(existingBundlePath, devCertPemPath, pathComparison))
                {
                    var devCertificateContents = await File.ReadAllBytesAsync(devCertPemPath, cancellationToken);
                    var existingBundleContents = await File.ReadAllBytesAsync(existingBundlePath, cancellationToken);
 
                    // Place the Aspire certificate first because OpenSSL may select the first matching self-signed certificate.
                    byte[] bundleContents = [.. devCertificateContents, (byte)'\n', .. existingBundleContents];
 
                    // Cache by the final contents so unchanged inputs reuse the same immutable bundle.
                    var bundleHash = Convert.ToHexString(XxHash128.Hash(bundleContents)).ToLowerInvariant();
                    var bundleDirectory = Path.Combine(
                        _executionContext.AspireHomeDirectory.FullName,
                        DevCertificateCacheDirectoryName,
                        CertificateBundleCacheDirectoryName);
                    var bundlePath = Path.Combine(bundleDirectory, $"{cacheFilePrefix}-{bundleHash}.pem");
 
                    if (!File.Exists(bundlePath))
                    {
                        CertificateCacheWriter.WriteFile(bundlePath, bundleContents, _logger);
                    }
 
                    certificateBundlePath = bundlePath;
                }
            }
            catch (Exception ex) when (ex is ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException)
            {
                _logger.LogWarning(ex, "Failed to combine {EnvironmentVariableName} bundle {ExistingBundlePath} with the Aspire development certificate", environmentVariableName, existingCertificateBundle);
                _interactionService.DisplayMessage(
                    KnownEmojis.Warning,
                    $"Unable to add the Aspire development certificate to {environmentVariableName} '{existingCertificateBundle}'. The existing certificate bundle will be used unchanged.");
                certificateBundlePath = existingCertificateBundle;
            }
        }
 
        SetCertificateBundleEnvironmentVariable(environmentVariables, configuredKeys, environmentVariableName, certificateBundlePath);
    }
 
    private static void SetCertificateBundleEnvironmentVariable(
        IDictionary<string, string> environmentVariables,
        IEnumerable<string> configuredKeys,
        string environmentVariableName,
        string value)
    {
        foreach (var configuredKey in configuredKeys)
        {
            if (!string.Equals(configuredKey, environmentVariableName, StringComparison.Ordinal))
            {
                environmentVariables.Remove(configuredKey);
            }
        }
 
        environmentVariables[environmentVariableName] = value;
    }
 
}