File: Backchannel\AuxiliaryBackchannelService.cs
Web Access
Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Net.Sockets;
using Aspire.Hosting.Diagnostics;
using Aspire.Hosting.Eventing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StreamJsonRpc;
 
namespace Aspire.Hosting.Backchannel;
 
/// <summary>
/// Background service that listens for multiple concurrent connections on a Unix socket and provides MCP-related RPC operations.
/// </summary>
internal sealed class AuxiliaryBackchannelService(
    ILogger<AuxiliaryBackchannelService> logger,
    IConfiguration configuration,
    IDistributedApplicationEventing eventing,
    IServiceProvider serviceProvider)
    : BackgroundService
{
    private AppHostSocketManager.AppHostSocketListener? _appHostSocket;
    private readonly TaskCompletionSource _listeningTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
 
    /// <summary>
    /// Gets the Unix socket path where the auxiliary backchannel is listening.
    /// </summary>
    public string? SocketPath { get; private set; }
 
    /// <summary>
    /// Gets a task that completes when the server socket is bound and listening for connections.
    /// </summary>
    /// <remarks>
    /// Used by tests to wait until the backchannel is ready before attempting to connect.
    /// </remarks>
    internal Task ListeningTask => _listeningTcs.Task;
 
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        try
        {
            _appHostSocket = AppHostSocketManager.CreateSocket(
                GetAppHostPath(configuration),
                Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
                Environment.ProcessId,
                logger);
            SocketPath = _appHostSocket.SocketPath;
 
            logger.LogDebug("Starting auxiliary backchannel service on socket path: {SocketPath}", SocketPath);
 
            logger.LogDebug("Auxiliary backchannel listening on {SocketPath}", SocketPath);
            _listeningTcs.TrySetResult();
 
            // Accept connections in a loop (supporting multiple concurrent connections)
            while (!stoppingToken.IsCancellationRequested)
            {
                try
                {
                    var clientSocket = await _appHostSocket.Socket.AcceptAsync(stoppingToken).ConfigureAwait(false);
 
                    // Handle each connection on a separate task
                    _ = Task.Run(async () => await HandleClientConnectionAsync(clientSocket, stoppingToken).ConfigureAwait(false), stoppingToken);
                }
                catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
                {
                    // Expected when shutting down
                    break;
                }
                catch (Exception ex)
                {
                    logger.LogError(ex, "Error accepting client connection on auxiliary backchannel.");
                }
            }
        }
        catch (TaskCanceledException ex)
        {
            logger.LogDebug("Auxiliary backchannel service was cancelled: {Message}", ex.Message);
            _listeningTcs.TrySetCanceled(stoppingToken);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Error in auxiliary backchannel service.");
            _listeningTcs.TrySetException(ex);
        }
        finally
        {
            // Creating the socket can fail (bind failure, an AF_UNIX path over the platform byte
            // limit, permissions) and the accept loop only completes the source once it is already
            // listening, so guarantee completion on every exit path. Waiters would otherwise block
            // until their own timeout instead of observing the failure.
            _listeningTcs.TrySetCanceled(stoppingToken);
 
            // Nothing outside tests awaits ListeningTask, so read the fault here to mark it observed
            // and keep it from resurfacing as an UnobservedTaskException when the task is finalized.
            _ = _listeningTcs.Task.Exception;
 
            _appHostSocket?.Dispose();
        }
    }
 
    private async Task HandleClientConnectionAsync(Socket clientSocket, CancellationToken stoppingToken)
    {
        try
        {
            logger.LogDebug("Client connected to auxiliary backchannel.");
 
            // Publish the connected event
            var connectedEvent = new AuxiliaryBackchannelConnectedEvent(serviceProvider, SocketPath!, clientSocket);
            await eventing.PublishAsync(
                connectedEvent,
                EventDispatchBehavior.NonBlockingConcurrent,
                stoppingToken).ConfigureAwait(false);
 
            // Create a new RPC target for this connection
            var rpcTarget = new AuxiliaryBackchannelRpcTarget(
                serviceProvider.GetRequiredService<ILogger<AuxiliaryBackchannelRpcTarget>>(),
                serviceProvider.GetRequiredService<IConfiguration>(),
                serviceProvider.GetRequiredService<ProfilingTelemetry>(),
                serviceProvider);
 
            // Set up JSON-RPC over the client socket
            using var stream = new NetworkStream(clientSocket, ownsSocket: true);
 
            // Create JSON-RPC connection with proper System.Text.Json formatter so it doesn't use Newtonsoft.Json
            // and handles correct MCP SDK type serialization
            // Configure to use camelCase naming to match CLI's MCP SDK options
            var formatter = new SystemTextJsonFormatter();
            formatter.JsonSerializerOptions = new System.Text.Json.JsonSerializerOptions
            {
                PropertyNameCaseInsensitive = true,
                PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
            };
 
            var handler = new HeaderDelimitedMessageHandler(stream, formatter);
            using var rpc = new JsonRpc(handler, rpcTarget)
            {
                ActivityTracingStrategy = new ActivityTracingStrategy()
            };
            rpc.StartListening();
 
            // Wait for the connection to be disposed (client disconnect or cancellation)
            await rpc.Completion.ConfigureAwait(false);
 
            logger.LogDebug("Client disconnected from auxiliary backchannel");
        }
        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
        {
            logger.LogDebug("Client connection handler was cancelled");
        }
        catch (IOException ex) when (ex.InnerException is SocketException { SocketErrorCode: SocketError.ConnectionReset })
        {
            // IOException wrapping a ConnectionReset SocketException is expected when the client
            // disconnects abruptly (e.g., process exit). This is a normal condition and not an error.
            logger.LogDebug(ex, "Client disconnected from auxiliary backchannel");
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Error handling client connection on auxiliary backchannel");
        }
    }
 
    private static string? GetAppHostPath(IConfiguration configuration) =>
        configuration["AppHost:FilePath"] ?? configuration["AppHost:Path"];
}