File: NativeProviderHost.cs
Web Access
Project: src\src\Aspire.Hosting.Native.Client\Aspire.Hosting.Native.Client.csproj (Aspire.Hosting.Native.Client)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Native.Client;
 
/// <summary>Runs an integration's generated provider contract using framework-owned activation coordinates.</summary>
/// <param name="configuration">Injected provider activation settings.</param>
/// <param name="clock">Provider authentication deadlines.</param>
/// <param name="logger">Classified provider diagnostics.</param>
public sealed class NativeProviderHost(IConfiguration configuration, TimeProvider clock, ILogger logger)
{
    /// <summary>Registers a generated contract and remains alive until the native peer disconnects.</summary>
    /// <param name="definition">The integration's offline-generated ATS contract.</param>
    /// <param name="cancellationToken">Stops provider activation and lifetime waits.</param>
    /// <returns>The provider lifetime.</returns>
    public async Task RunAsync(NativeProviderDefinition definition, CancellationToken cancellationToken)
    {
        EndPoint endpoint = configuration["ASPIRE_NATIVE_RPC_SOCKET_PATH"] is { } socketPath
            ? new UnixDomainSocketEndPoint(socketPath)
            : IPEndPoint.Parse(configuration["ASPIRE_NATIVE_RPC_ENDPOINT"]
                ?? throw new InvalidOperationException("Provider activation requires an AppHost endpoint."));
        var token = configuration["ASPIRE_NATIVE_RPC_TOKEN"]
            ?? throw new InvalidOperationException("Provider activation requires authentication.");
        var authority = configuration["ASPIRE_INTEGRATION_HOST_REGISTRATION_ID"]
            ?? throw new InvalidOperationException("Provider activation requires expected registration authority.");
        var connected = await NativeClient.ConnectAsync(endpoint, token, TimeSpan.FromSeconds(15), clock, cancellationToken).ConfigureAwait(false);
        await using var clientLifetime = connected.ConfigureAwait(false);
        var client = connected;
        await client.RegisterIntegrationHostAsync(authority, definition, cancellationToken).ConfigureAwait(false);
        logger.LogInformation("Native integration provider registered.");
        await client.WaitForDisconnectAsync(cancellationToken).ConfigureAwait(false);
    }
}
 
/// <summary>Configures independent provider authority cleanup and owned-process shutdown deadlines.</summary>
public sealed class NativeProviderActivationOptions
{
    /// <summary>Gets the independent deadline for abandoning failed activation authority.</summary>
    public TimeSpan CleanupTimeout { get; init; } = TimeSpan.FromSeconds(15);
    /// <summary>Gets the deadline for process termination and redirected output release.</summary>
    public TimeSpan ShutdownTimeout { get; init; } = TimeSpan.FromSeconds(15);
 
    internal void Validate()
    {
        foreach (var timeout in new[] { CleanupTimeout, ShutdownTimeout })
        {
            if (timeout <= TimeSpan.Zero || timeout.TotalMilliseconds > int.MaxValue)
            {
                throw new ArgumentOutOfRangeException(nameof(timeout), "Provider cleanup deadlines must be positive bounded durations.");
            }
        }
    }
}
 
/// <summary>Activates an explicitly selected provider process; application code never performs registration or handle plumbing.</summary>
public sealed class NativeProviderActivation : IAsyncDisposable
{
    private readonly Process _process;
    private readonly Task _output;
    private readonly CancellationTokenSource _outputCancellation = new();
    private readonly TimeProvider _clock;
    private readonly NativeProviderActivationOptions _options;
 
    /// <summary>Owns a started provider and its redirected output.</summary>
    internal NativeProviderActivation(Process process, NativeProviderActivationOptions options, TimeProvider clock)
    {
        _process = process;
        _clock = clock;
        _options = options;
        // Drain both streams concurrently. Only classifications are logged by the
        // framework; provider console output may include application secrets.
        _output = Task.WhenAll(
            process.StandardOutput.BaseStream.CopyToAsync(Stream.Null, _outputCancellation.Token),
            process.StandardError.BaseStream.CopyToAsync(Stream.Null, _outputCancellation.Token));
    }
 
    /// <summary>Launches one explicitly selected provider and awaits server-verified registration.</summary>
    public static async Task<NativeProviderActivation> StartAsync(NativeClient client, string packageName,
        string executable, IReadOnlyList<string> arguments, NativeProviderActivationOptions options,
        TimeProvider clock, ILogger logger, CancellationToken cancellationToken)
    {
        options.Validate();
        if (client.Endpoint is not IPEndPoint and not UnixDomainSocketEndPoint)
        {
            throw new NotSupportedException("Provider activation requires a local AppHost endpoint.");
        }
        var start = new ProcessStartInfo(executable) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
        // Kernel-enforced parent ownership survives abrupt host termination.
        // .NET does not support this option on macOS; explicit disposal still applies.
        // https://learn.microsoft.com/dotnet/api/system.diagnostics.processstartinfo.killonparentexit
        if (OperatingSystem.IsLinux() || OperatingSystem.IsWindows())
        {
            start.KillOnParentExit = true;
        }
        foreach (var argument in arguments)
        {
            start.ArgumentList.Add(argument);
        }
        start.Environment.Remove("ASPIRE_NATIVE_RPC_ENDPOINT");
        start.Environment.Remove("ASPIRE_NATIVE_RPC_SOCKET_PATH");
        if (client.Endpoint is UnixDomainSocketEndPoint socket)
        {
            start.Environment["ASPIRE_NATIVE_RPC_SOCKET_PATH"] = socket.ToString();
        }
        else
        {
            start.Environment["ASPIRE_NATIVE_RPC_ENDPOINT"] = client.Endpoint.ToString();
        }
        start.Environment["ASPIRE_NATIVE_RPC_TOKEN"] = client.AuthenticationToken;
        var authority = await client.ExpectIntegrationHostAsync(packageName, cancellationToken).ConfigureAwait(false);
        NativeProviderActivation? activation = null;
        try
        {
            start.Environment["ASPIRE_INTEGRATION_HOST_REGISTRATION_ID"] = authority;
            activation = new NativeProviderActivation(Process.Start(start) ?? throw new InvalidOperationException("The provider process did not start."), options, clock);
            await client.WaitIntegrationHostRegistrationAsync(authority, cancellationToken).ConfigureAwait(false);
            if (activation._process.HasExited)
            {
                throw new InvalidOperationException("The provider disconnected during activation.");
            }
            logger.LogInformation("Native integration provider activation completed.");
 
            return activation;
        }
        catch (Exception activationError)
        {
            List<Exception> failures = [activationError];
            try
            {
                // The caller token may already be cancelled. An independent deadline
                // lets cleanup run, but a stalled peer cannot prevent process disposal.
                using var cleanup = new CancellationTokenSource(options.CleanupTimeout, clock);
                await client.AbandonIntegrationHostRegistrationAsync(authority, cleanup.Token).ConfigureAwait(false);
            }
            catch (Exception cleanupError)
            {
                failures.Add(cleanupError);
            }
            if (activation is not null)
            {
                try
                {
                    await activation.DisposeAsync().ConfigureAwait(false);
                }
                catch (Exception cleanupError)
                {
                    failures.Add(cleanupError);
                }
            }
            if (failures.Count > 1)
            {
                throw new AggregateException("Provider activation and cleanup failed.", failures);
            }
            throw;
        }
    }
 
    /// <summary>Stops only the provider process owned by this activation and drains its output.</summary>
    public async ValueTask DisposeAsync()
    {
        using var processLifetime = _process;
        using var outputLifetime = _outputCancellation;
        var completion = _output;
        try
        {
            if (!_process.HasExited)
            {
                try
                {
                    _process.Kill(entireProcessTree: true);
                }
                catch (InvalidOperationException) when (_process.HasExited)
                {
                    // Exiting between the check and Kill is normal shutdown.
                }
            }
            // A descendant can retain the pipes after the root exits. Both exit
            // and EOF must fit within the same shutdown budget.
            completion = Task.WhenAll(_process.WaitForExitStatusAsync(), _output);
            await completion.WaitAsync(_options.ShutdownTimeout, _clock).ConfigureAwait(false);
        }
        finally
        {
            // Cancellation and reader disposal release pending reads even when
            // process-tree termination cannot reach a pipe-owning descendant.
            _outputCancellation.Cancel();
            _process.StandardOutput.Dispose();
            _process.StandardError.Dispose();
            // A timed-out drain can fault after its caller has already observed
            // the shutdown failure. Observe that late fault without awaiting EOF.
            _ = completion.ContinueWith(static task => _ = task.Exception, CancellationToken.None,
                TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
        }
    }
}