File: NativeServerHostedService.cs
Web Access
Project: src\src\Aspire.Hosting.Native.Server\Aspire.Hosting.Native.Server.csproj (Aspire.Hosting.Native.Server)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Hosting.Native.Rpc;
using Aspire.Hosting.Native.Api;
using Aspire.Hosting.Native.Dcp;
using Aspire.Hosting.Native.Dashboard;
using Aspire.Hosting.Native.Cli;
using Aspire.Hosting.Native.Runtime;
using Aspire.Hosting.Native.Auxiliary;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Native.Server;
 
/// <summary>Runs native transport and controller services under the generic host lifetime.</summary>
internal sealed class NativeServerHostedService(NativeServerConfiguration settings, string[] args,
    ILoggerFactory loggerFactory, IHostApplicationLifetime applicationLifetime) : BackgroundService
{
    private NativeApplicationLifetime? _applicationStop;
 
    public override async Task StopAsync(CancellationToken cancellationToken)
    {
        try
        {
            if (_applicationStop is { } lifetime)
            {
                // CLI shutdown can arrive as an OS signal rather than an RPC. Drain the
                // guest's connection-owned lifetime before cancelling its transport.
                await lifetime.RequestStopAsync(settings.Options.CleanupTimeout, settings.Options.TimeProvider,
                    () => loggerFactory.CreateLogger<NativeServerHostedService>().LogWarning(
                        "The application guest did not finish shutdown before the cleanup deadline; stopping the native server."),
                    cancellationToken).ConfigureAwait(false);
            }
        }
        finally
        {
            await base.StopAsync(cancellationToken).ConfigureAwait(false);
        }
    }
 
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // Unix helpers cannot rely on an OS kill-on-parent-exit job. Keep the
        // launcher identity alive through teardown so abrupt CLI exit follows
        // the same bounded controller/workload cleanup as ordinary shutdown.
        var parentMonitor = settings.CliProcess.MonitorParent(() =>
        {
            loggerFactory.CreateLogger<NativeServerHostedService>()
                .LogInformation("The launching CLI has exited; stopping AppHost Server v2.");
            applicationLifetime.StopApplication();
        }, settings.Options.TimeProvider);
        try
        {
            await RunServerAsync(stoppingToken).ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
        {
        }
        catch
        {
            Environment.ExitCode = 1;
            throw;
        }
        finally
        {
            try
            {
                if (parentMonitor is not null)
                {
                    await parentMonitor.DisposeAsync().ConfigureAwait(false);
                }
            }
            finally
            {
                applicationLifetime.StopApplication();
            }
        }
    }
 
    private async Task RunServerAsync(CancellationToken stoppingToken)
    {
        var logger = loggerFactory.CreateLogger("Aspire.Hosting.Native.Server");
        var options = settings.Options;
        var token = settings.RpcToken;
        var writer = new NativeDiagnosticWriter(settings.TraceOperations, loggerFactory.CreateLogger<NativeDiagnosticWriter>());
        await using var diagnostics = new NativeDiagnosticPump(writer.Write, DiagnosticStreamFailed, options).ConfigureAwait(false);
        var endPoint = NativeStartupArguments.Parse(args, settings.SocketPath);
        using var shutdown = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
        var dcp = new DcpApiClient(options, loggerFactory.CreateLogger<DcpApiClient>());
        await using var dcpLifetime = dcp.ConfigureAwait(false);
        var dcpExecutable = settings.IsPublishing ? null : settings.DcpPath;
        var server = new NativeApplicationServer(UnavailableWorkloadExecutor.Instance, options.Runtime);
        using var startup = new TimeProviderCancellation(options.ControllerStartupTimeout, options.TimeProvider, shutdown.Token);
        var dcpReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        if (dcpExecutable is not null)
        {
            // Publish the RPC socket independently of DCP startup. The CLI must be able
            // to authenticate and fetch its SDK while the controller host is warming up;
            // workload launches still await successful DCP readiness.
            server = new NativeApplicationServer(new DcpWorkloadExecutor(dcp, dcpReady.Task, options,
                loggerFactory.CreateLogger<DcpWorkloadExecutor>()), options.Runtime);
        }
        using var applicationStopLifetime = server.ApplicationLifetime;
        _applicationStop = applicationStopLifetime;
        using var listener = new NativeRpcListener(endPoint, token, loggerFactory.CreateLogger<NativeRpcListener>(), server,
            NativeLanguageCatalog.LoadEmbedded(settings.GeneratedSdkDirectory), options);
        NativeDashboardHost? dashboard = null;
        Task? dashboardCleanup = null;
        Task? controllerCleanup = null;
        var dashboardPath = settings.IsPublishing ? null : settings.DashboardPath;
        var dashboardRequested = dashboardPath is not null;
        var dashboardState = new DcpDashboard(loggerFactory.CreateLogger<DcpDashboard>());
        var dashboardReady = new TaskCompletionSource<NativeCliDashboardUrls>(TaskCreationOptions.RunContinuationsAsynchronously);
        if (!dashboardRequested)
        {
            dashboardReady.SetResult(new());
        }
        var logs = new NativeCliLogBuffer(options.RetainedAppHostLogEntries);
        await using var cliLogging = new NativeDiagnosticPump(logs.Append, DiagnosticStreamFailed, options).ConfigureAwait(false);
        INativeBackchannelRpc backchannelRpc = new NativeBackchannelRpc(options,
            loggerFactory.CreateLogger<NativeBackchannelRpc>());
        using var backchannel = settings.BackchannelPath is { } backchannelPath
            ? new NativeCliProtocol(backchannelPath, server, applicationLifetime.StopApplication,
                dashboardReady.Task.WaitAsync, logs, options, backchannelRpc)
            : null;
        var appHostPath = settings.AppHostPath;
        using var auxiliary = new NativeAuxiliaryBackchannel(appHostPath,
            Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), server, options,
            exitCode =>
            {
                Environment.ExitCode = exitCode ?? 0;
                applicationLifetime.StopApplication();
            },
            backchannel is not null ? backchannel.WaitForAppHostReadyAsync : WaitForNativeAppHostReadyAsync,
            dashboardReady.Task.WaitAsync, () => dashboardState.ApiToken, logs, settings.CliProcess, backchannelRpc,
            loggerFactory.CreateLogger<NativeAuxiliaryBackchannel>());
        try
        {
            var dashboardPort = settings.IsPublishing ? null : settings.DashboardPort;
            var dashboardApiKey = settings.DashboardCredentials.ResourceServiceApiKey;
            if (dashboardPort is not null || dashboardRequested)
            {
                dashboard = await NativeDashboardHost.StartAsync(server,
                    settings.ApplicationName,
                    dashboardApiKey,
                    dashboardPort ?? 0, options, shutdown.Token).ConfigureAwait(false);
                // Direct-launch clients discover endpoints from these stdout announcements.
                // Component diagnostics go through ILogger and its root-configured stderr provider.
                Console.WriteLine($"Native Dashboard resource service listening on {dashboard.Address}.");
            }
            Console.WriteLine($"Native AppHost server listening on {listener.EndPoint}.");
            var listening = Task.WhenAll(listener.RunAsync(shutdown.Token),
                backchannel?.RunAsync(shutdown.Token) ?? Task.CompletedTask,
                auxiliary.RunAsync(shutdown.Token));
            try
            {
                try
                {
                    if (dcpExecutable is not null)
                    {
                        await dcp.StartAsync(dcpExecutable, startup.Token).ConfigureAwait(false);
                    }
                    if (dashboardPath is not null)
                    {
                        if (dcpExecutable is null || dashboard is null)
                        {
                            throw new InvalidOperationException("Dashboard launch requires DCP and its resource-service adapter.");
                        }
                        await dashboardState.StartAsync(dcp, dashboardPath, dashboard.Address,
                            settings.DashboardCredentials, options, startup.Token).ConfigureAwait(false);
                        dashboardReady.TrySetResult(dashboardState.Urls);
                        dashboardCleanup = StopDashboardOnShutdownAsync();
                    }
                    dcpReady.SetResult();
                    startup.StopTimeout();
                    if (dcpExecutable is not null)
                    {
                        controllerCleanup = StopControllerOnShutdownAsync();
                    }
                }
                catch (OperationCanceledException) when (shutdown.IsCancellationRequested)
                {
                    dcpReady.TrySetCanceled(shutdown.Token);
                    dashboardReady.TrySetCanceled(shutdown.Token);
                }
                catch (Exception exception)
                {
                    dcpReady.SetException(exception);
                    dashboardReady.TrySetException(exception);
                    _ = dashboardReady.Task.Exception;
                    // Observe the shared readiness failure even if no integration has
                    // launched a workload yet; the server still surfaces the original.
                    _ = dcpReady.Task.Exception;
                    throw;
                }
                await listening.ConfigureAwait(false);
            }
            finally
            {
                await shutdown.CancelAsync().ConfigureAwait(false);
                await listening.ConfigureAwait(false);
            }
        }
        finally
        {
            try
            {
                if (dashboard is not null)
                {
                    dashboard.BeginShutdown();
                    // Watches were quiesced before requesting Dashboard deletion. Remove its DCP
                    // process before disposing the resource-service host so reconnects cannot keep
                    // either side alive through the CLI's graceful shutdown budget.
                    using var cleanup = new CancellationTokenSource(options.CleanupTimeout, options.TimeProvider);
                    if (dashboardCleanup is null)
                    {
                        await dashboardState.StopAsync(dcp, cleanup.Token).ConfigureAwait(false);
                    }
                }
                await Task.WhenAll(controllerCleanup ?? Task.CompletedTask,
                    dashboardCleanup ?? Task.CompletedTask).ConfigureAwait(false);
            }
            finally
            {
                if (dashboard is not null)
                {
                    await dashboard.DisposeAsync().ConfigureAwait(false);
                }
            }
        }
 
        async Task WaitForNativeAppHostReadyAsync(CancellationToken cancellationToken)
        {
            await dcpReady.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
            while (server.GetApplicationObserver() is null)
            {
                await Task.Delay(options.RetryInterval, options.TimeProvider, cancellationToken).ConfigureAwait(false);
            }
        }
 
        async Task StopDashboardOnShutdownAsync()
        {
            try
            {
                await Task.Delay(Timeout.InfiniteTimeSpan, options.TimeProvider, shutdown.Token).ConfigureAwait(false);
            }
            catch (OperationCanceledException) when (shutdown.IsCancellationRequested)
            {
            }
            // Dashboard and application workloads are independent DCP resources. Start
            // their cleanup together rather than accumulating graceful-stop budgets.
            dashboard?.BeginShutdown();
            using var cleanup = new CancellationTokenSource(options.CleanupTimeout, options.TimeProvider);
            await dashboardState.StopAsync(dcp, cleanup.Token).ConfigureAwait(false);
        }
 
        async Task StopControllerOnShutdownAsync()
        {
            try
            {
                await Task.Delay(Timeout.InfiniteTimeSpan, options.TimeProvider, shutdown.Token).ConfigureAwait(false);
            }
            catch (OperationCanceledException) when (shutdown.IsCancellationRequested)
            {
            }
            // DCP owns dependency-aware process and container termination. Initiate it with
            // owner retirement, rather than waiting for every individual API deletion before
            // beginning controller cleanup within the CLI's shared graceful-stop window.
            dashboard?.BeginShutdown();
            using var cleanup = new CancellationTokenSource(options.CleanupTimeout, options.TimeProvider);
            await dcp.CleanupResourcesAsync(cleanup.Token).ConfigureAwait(false);
        }
 
        void DiagnosticStreamFailed(Exception error) =>
            logger.LogError("Native diagnostic stream failed: {ErrorType}.", error.GetType().Name);
    }
}