File: TerminalHostControlListener.cs
Web Access
Project: src\src\Aspire.TerminalHost\Aspire.TerminalHost.csproj (Aspire.TerminalHost)
// 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.Shared.TerminalHost;
using Microsoft.Extensions.Logging;
using StreamJsonRpc;
 
namespace Aspire.TerminalHost;
 
/// <summary>
/// Listens on the control UDS and serves a <see cref="TerminalHostControlRpcTarget"/>
/// over StreamJsonRpc to each connecting client (typically the Aspire AppHost).
/// </summary>
internal sealed class TerminalHostControlListener : IAsyncDisposable
{
    private readonly string _socketPath;
    private readonly TerminalHostControlRpcTarget _target;
    private readonly ILogger _logger;
    private Socket? _socket;
    private Task? _acceptLoop;
    private readonly CancellationTokenSource _disposeCts = new();
    private readonly List<JsonRpc> _activeRpcs = new();
    private readonly object _gate = new();
    // Reservation counter for connections that have been accepted by the loop
    // but whose ServeClientAsync task has not yet run far enough to add its
    // JsonRpc instance to _activeRpcs. Without this, two fast back-to-back
    // accepts could both observe _activeRpcs.Count == 0 under the lock and
    // both be served, defeating the documented single-client invariant.
    // Counted under _gate alongside _activeRpcs.
    private int _pendingClients;
    private bool _disposed;
 
    public TerminalHostControlListener(
        string socketPath,
        TerminalHostControlRpcTarget target,
        ILogger logger)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(socketPath);
        ArgumentNullException.ThrowIfNull(target);
        ArgumentNullException.ThrowIfNull(logger);
 
        _socketPath = socketPath;
        _target = target;
        _logger = logger;
    }
 
    /// <summary>
    /// Binds the UDS and starts the background accept loop.
    /// </summary>
    public Task StartAsync()
    {
        var dir = Path.GetDirectoryName(_socketPath);
        if (!string.IsNullOrEmpty(dir))
        {
            Directory.CreateDirectory(dir);
        }
 
        if (File.Exists(_socketPath))
        {
            try
            {
                File.Delete(_socketPath);
            }
            catch (IOException)
            {
                // Best effort — fall through and let Bind report the error.
            }
        }
 
        var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
        try
        {
            socket.Bind(new UnixDomainSocketEndPoint(_socketPath));
 
            // Restrict the control socket to the owning user (0600). Without this, file
            // permissions are governed solely by the inherited umask — on developer
            // machines that's frequently 002/022, leaving the socket world- or
            // group-accessible. Any local user who can traverse to the path could then
            // dial and invoke ShutdownAsync (no auth) or GetSessionAsync (leaks peer
            // DisplayNames). Skipped on Windows (UDS is supported but SetUnixFileMode
            // is not, and Windows access control on the socket file follows ACLs from
            // the temp directory, which is per-user by default).
            if (!OperatingSystem.IsWindows())
            {
                File.SetUnixFileMode(_socketPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
            }
 
            // Backlog of 16 is well above the documented "single AppHost client"
            // contract. The AppHost only ever maintains one healthy session, but it
            // may briefly reconnect (e.g., on its own startup retry) and CI runners
            // under load occasionally see transient kernel queue pressure on UDS
            // listen sockets. A larger backlog absorbs those bursts without affecting
            // the steady-state single-client behaviour — the accept loop still
            // refuses additional sessions when one is already active.
            socket.Listen(backlog: 16);
        }
        catch
        {
            // If Bind/SetUnixFileMode/Listen fail (perm denied, EADDRINUSE that
            // survived the pre-delete, broken parent dir), the raw Socket would
            // otherwise leak as a kernel handle until GC. Also delete any
            // partially-bound socket file so a retry isn't fighting our own
            // residue. Best-effort - swallow secondary failures so the original
            // exception is the one the caller sees.
            try { socket.Dispose(); } catch { }
            try { File.Delete(_socketPath); } catch { }
            throw;
        }
        _socket = socket;
 
        _logger.LogInformation("Control listener bound to '{Path}'.", _socketPath);
 
        _acceptLoop = Task.Run(() => AcceptLoopAsync(_disposeCts.Token));
        return Task.CompletedTask;
    }
 
    private async Task AcceptLoopAsync(CancellationToken cancellationToken)
    {
        if (_socket is null)
        {
            return;
        }
 
        while (!cancellationToken.IsCancellationRequested)
        {
            Socket client;
            try
            {
                client = await _socket.AcceptAsync(cancellationToken).ConfigureAwait(false);
            }
            catch (OperationCanceledException)
            {
                return;
            }
            catch (ObjectDisposedException)
            {
                return;
            }
            catch (SocketException ex) when (IsTransientAcceptError(ex.SocketErrorCode))
            {
                // Linux can return EAGAIN / EINTR from accept() under load (kernel
                // accept-queue pressure or signal interruption) — the socket is still
                // healthy and the next Accept will succeed. Bailing here would silently
                // kill the control channel for the rest of the process lifetime, which
                // we observed in CI as ConcurrentControlConnectsAreRefusedDownToOne
                // failing with `Resource temporarily unavailable`.
                _logger.LogDebug(ex, "Transient accept error ({SocketError}); retrying.", ex.SocketErrorCode);
                continue;
            }
            catch (SocketException ex)
            {
                _logger.LogDebug(ex, "Control listener accept failed; stopping.");
                return;
            }
 
            // The control protocol is documented as a single AppHost client (lifecycle,
            // shutdown, stats). If we already have an active session — or one being set
            // up on a worker task that hasn't reached _activeRpcs.Add yet — the new
            // socket is either an accidental retry from the same AppHost or a hostile
            // second dialer; in either case it is safer to refuse it than to fan out
            // unbounded concurrent JsonRpc instances (each allocates a NetworkStream + a
            // header-delimited message handler and adds three RPC registrations). The
            // 0600 perm on the UDS already restricts to the owning user, but defence in
            // depth.
            //
            // The reservation is taken under _gate at the accept site so two fast
            // back-to-back connects can't both pass the empty-slot check before either
            // has reached its ServeClientAsync. ServeClientAsync converts the
            // reservation to an _activeRpcs entry under the same lock.
            bool reserved;
            lock (_gate)
            {
                if (_activeRpcs.Count + _pendingClients >= 1)
                {
                    _logger.LogWarning(
                        "Refusing additional control connection - {Active} active session(s), {Pending} pending.",
                        _activeRpcs.Count,
                        _pendingClients);
                    reserved = false;
                }
                else
                {
                    _pendingClients++;
                    reserved = true;
                }
            }
 
            if (!reserved)
            {
                try { client.Dispose(); } catch { }
                continue;
            }
 
            _ = Task.Run(() => ServeClientAsync(client, cancellationToken), cancellationToken);
        }
    }
 
    private async Task ServeClientAsync(Socket client, CancellationToken cancellationToken)
    {
        // Tracks whether we still owe a _pendingClients-- decrement. Set to false
        // as soon as the reservation is converted to an _activeRpcs entry (or the
        // listener is already disposed). The outer finally releases any leftover
        // reservation if anything between accept and the conversion lock throws.
        var reservationOwned = true;
        JsonRpc? rpc = null;
        try
        {
            await using var stream = new NetworkStream(client, ownsSocket: true);
 
            var formatter = new SystemTextJsonFormatter();
            var handler = new HeaderDelimitedMessageHandler(stream, stream, formatter);
 
            rpc = new JsonRpc(handler);
            rpc.AddLocalRpcMethod(
                TerminalHostControlProtocol.GetSessionMethod,
                _target.GetType().GetMethod(nameof(TerminalHostControlRpcTarget.GetSessionAsync))!,
                _target);
            rpc.AddLocalRpcMethod(
                TerminalHostControlProtocol.GetInfoMethod,
                _target.GetType().GetMethod(nameof(TerminalHostControlRpcTarget.GetInfoAsync))!,
                _target);
            rpc.AddLocalRpcMethod(
                TerminalHostControlProtocol.ShutdownMethod,
                _target.GetType().GetMethod(nameof(TerminalHostControlRpcTarget.ShutdownAsync))!,
                _target);
 
            // Convert the accept-site reservation to an _activeRpcs entry under
            // the same lock, or release the reservation if we're already
            // disposing. After this point the slot accounting moves to
            // _activeRpcs and the finally releases via _activeRpcs.Remove.
            lock (_gate)
            {
                _pendingClients--;
                reservationOwned = false;
                if (_disposed)
                {
                    return;
                }
                _activeRpcs.Add(rpc);
            }
 
            rpc.StartListening();
            await rpc.Completion.WaitAsync(cancellationToken).ConfigureAwait(false);
        }
        catch (OperationCanceledException)
        {
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Control RPC connection ended with an error.");
        }
        finally
        {
            // If we never reached the conversion lock (e.g. NetworkStream/JsonRpc
            // setup threw), the slot is still reserved as pending. Release it so
            // the listener can accept future connections.
            if (reservationOwned)
            {
                lock (_gate)
                {
                    _pendingClients--;
                }
            }
            else if (rpc is not null)
            {
                lock (_gate)
                {
                    _activeRpcs.Remove(rpc);
                }
            }
 
            rpc?.Dispose();
        }
    }
 
    public async ValueTask DisposeAsync()
    {
        lock (_gate)
        {
            if (_disposed)
            {
                return;
            }
            _disposed = true;
        }
 
        await _disposeCts.CancelAsync().ConfigureAwait(false);
 
        try
        {
            _socket?.Dispose();
        }
        catch
        {
            // Best effort.
        }
 
        if (_acceptLoop is not null)
        {
            try
            {
                await _acceptLoop.ConfigureAwait(false);
            }
            catch (OperationCanceledException)
            {
            }
        }
 
        List<JsonRpc> rpcs;
        lock (_gate)
        {
            rpcs = [.. _activeRpcs];
            _activeRpcs.Clear();
        }
        foreach (var rpc in rpcs)
        {
            rpc.Dispose();
        }
 
        _disposeCts.Dispose();
 
        try
        {
            File.Delete(_socketPath);
        }
        catch
        {
            // Best effort.
        }
    }
 
    private static bool IsTransientAcceptError(SocketError code) => code switch
    {
        // EAGAIN / EWOULDBLOCK — kernel accept queue empty or temporarily exhausted.
        // Observed on Linux CI under load even though AcceptAsync is awaiting.
        SocketError.TryAgain => true,
        SocketError.WouldBlock => true,
        // EINTR — accept interrupted by a signal.
        SocketError.Interrupted => true,
        _ => false
    };
}