File: src\Shared\BackchannelConstants.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.Tool.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.Globalization;
using System.Security.Cryptography;
using System.Text;
 
namespace Aspire.Hosting.Backchannel;
 
/// <summary>
/// Shared constants and helpers for backchannel socket communication between
/// AppHost and CLI. These MUST stay in sync between both components.
/// </summary>
/// <remarks>
/// <para>
/// <strong>Architecture Overview</strong>
/// </para>
/// <para>
/// The backchannel is a Unix domain socket that enables bidirectional communication:
/// </para>
/// <list type="bullet">
/// <item>CLI → AppHost: Commands (stop, get info, etc.)</item>
/// <item>AppHost → CLI: Status updates, events</item>
/// </list>
/// <para>
/// <strong>Socket File Location</strong>
/// </para>
/// <para>
/// Socket files are stored in: <c>~/.aspire/cli/backchannels/</c>
/// </para>
/// <para>
/// <strong>Socket Naming Format</strong>
/// </para>
/// <para>
/// New format: <c>auxi.sock.{hash}.{pid}</c>
/// </para>
/// <list type="bullet">
/// <item><c>auxi.sock</c> - Prefix (not "aux" because that's reserved on Windows)</item>
/// <item><c>{hash}</c> - SHA256(AppHost project path)[0:16] - identifies the AppHost project</item>
/// <item><c>{pid}</c> - Process ID of the AppHost - identifies the specific instance</item>
/// </list>
/// <para>
/// Old format (for backward compatibility): <c>auxi.sock.{hash}</c>
/// </para>
/// <para>
/// <strong>Why PID in the Filename?</strong>
/// </para>
/// <list type="bullet">
/// <item>Multiple instances of the same AppHost can run simultaneously</item>
/// <item>Orphan detection: if PID doesn't exist, socket is orphaned and can be deleted</item>
/// <item>Fast cleanup without needing to attempt connection</item>
/// </list>
/// <para>
/// <strong>Backward Compatibility</strong>
/// </para>
/// <para>
/// Old CLI versions use glob pattern <c>aux*.sock.*</c> which matches the new format.
/// Old CLIs will work with new AppHosts, they just won't benefit from PID-based orphan detection.
/// </para>
/// </remarks>
internal static class BackchannelConstants
{
    /// <summary>
    /// Directory path within ~/.aspire for backchannel sockets.
    /// </summary>
    public const string BackchannelsRelativePath = ".aspire/cli/backchannels";
 
    /// <summary>
    /// Prefix for auxiliary backchannel sockets.
    /// </summary>
    /// <remarks>
    /// Uses "auxi" instead of "aux" because "aux" is a reserved device name on Windows
    /// (from DOS days: CON, PRN, AUX, NUL, COM1-9, LPT1-9). Using "aux" causes
    /// "SocketException: A socket operation encountered a dead network" on Windows.
    /// </remarks>
    public const string SocketPrefix = "auxi.sock";
 
    /// <summary>
    /// Number of hex characters to use from the SHA256 hash.
    /// </summary>
    /// <remarks>
    /// Using 16 chars (64 bits) balances uniqueness against path length constraints.
    /// Unix socket paths are limited to ~104 characters on most systems.
    /// Full path example: ~/.aspire/cli/backchannels/auxi.sock.bc43b855b6848166.46730
    /// = ~65 characters, well under the limit.
    /// </remarks>
    public const int HashLength = 16;
 
    /// <summary>
    /// Gets the backchannels directory path for the given home directory.
    /// </summary>
    /// <param name="homeDirectory">The user's home directory.</param>
    /// <returns>The full path to the backchannels directory.</returns>
    public static string GetBackchannelsDirectory(string homeDirectory)
        => Path.Combine(homeDirectory, BackchannelsRelativePath);
 
    /// <summary>
    /// Computes the hash portion of the socket name from an AppHost path.
    /// </summary>
    /// <remarks>
    /// The hash is case-sensitive. On case-insensitive file systems (Windows, macOS with default settings),
    /// "C:\App\MyApp.csproj" and "C:\app\myapp.csproj" will produce different hashes even though they
    /// reference the same file. This is acceptable because the CLI typically uses the exact path provided
    /// by MSBuild or the user, which should be consistent within a session.
    /// </remarks>
    /// <param name="appHostPath">The full path to the AppHost project file.</param>
    /// <returns>A 16-character lowercase hex string.</returns>
    public static string ComputeHash(string appHostPath)
    {
        var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(appHostPath));
        return Convert.ToHexString(hashBytes)[..HashLength].ToLowerInvariant();
    }
 
    /// <summary>
    /// Computes the full socket path for an AppHost instance.
    /// </summary>
    /// <remarks>
    /// Called by AppHost when creating the socket. Includes the PID to ensure
    /// uniqueness across multiple instances of the same AppHost.
    /// </remarks>
    /// <param name="appHostPath">The full path to the AppHost project file.</param>
    /// <param name="homeDirectory">The user's home directory.</param>
    /// <param name="processId">The process ID of the AppHost.</param>
    /// <returns>The full socket path including PID.</returns>
    public static string ComputeSocketPath(string appHostPath, string homeDirectory, int processId)
    {
        var dir = GetBackchannelsDirectory(homeDirectory);
        var hash = ComputeHash(appHostPath);
        return Path.Combine(dir, $"{SocketPrefix}.{hash}.{processId}");
    }
 
    /// <summary>
    /// Computes the socket path prefix for finding sockets (without PID).
    /// </summary>
    /// <remarks>
    /// Called by CLI when searching for sockets. Since the CLI doesn't know the
    /// AppHost's PID, it uses this prefix with a glob pattern to find matching sockets.
    /// </remarks>
    /// <param name="appHostPath">The full path to the AppHost project file.</param>
    /// <param name="homeDirectory">The user's home directory.</param>
    /// <returns>The socket path prefix (without PID suffix).</returns>
    public static string ComputeSocketPrefix(string appHostPath, string homeDirectory)
    {
        var dir = GetBackchannelsDirectory(homeDirectory);
        var hash = ComputeHash(appHostPath);
        return Path.Combine(dir, $"{SocketPrefix}.{hash}");
    }
 
    /// <summary>
    /// Finds all socket files matching the given AppHost path.
    /// </summary>
    /// <remarks>
    /// Returns all socket files for an AppHost, regardless of PID. This includes
    /// both old format (<c>auxi.sock.{hash}</c>) and new format (<c>auxi.sock.{hash}.{pid}</c>).
    /// </remarks>
    /// <param name="appHostPath">The full path to the AppHost project file.</param>
    /// <param name="homeDirectory">The user's home directory.</param>
    /// <returns>An array of socket file paths, or empty if none found.</returns>
    public static string[] FindMatchingSockets(string appHostPath, string homeDirectory)
    {
        var prefix = ComputeSocketPrefix(appHostPath, homeDirectory);
        var dir = Path.GetDirectoryName(prefix);
        var prefixFileName = Path.GetFileName(prefix);
 
        if (dir is null || !Directory.Exists(dir))
        {
            return [];
        }
 
        // Match both old format (auxi.sock.{hash}) and new format (auxi.sock.{hash}.{pid})
        // Use pattern with "*" to match optional PID suffix
        var allMatches = Directory.GetFiles(dir, prefixFileName + "*");
        
        // Filter to only include exact match (old format) or .{pid} suffix (new format)
        // This avoids matching auxi.sock.{hash}abc (different hash that starts with same chars)
        // and also avoids matching files like auxi.sock.{hash}.12345.bak
        return allMatches.Where(f =>
        {
            var fileName = Path.GetFileName(f);
            if (fileName == prefixFileName)
            {
                return true; // Old format: exact match
            }
            if (fileName.StartsWith(prefixFileName + ".", StringComparison.Ordinal) &&
                int.TryParse(fileName.AsSpan(prefixFileName.Length + 1), NumberStyles.None, CultureInfo.InvariantCulture, out _))
            {
                return true; // New format: prefix followed by dot and integer PID
            }
            return false;
        }).ToArray();
    }
 
    /// <summary>
    /// Extracts the hash from a socket filename.
    /// </summary>
    /// <remarks>
    /// Works with both old format (<c>auxi.sock.{hash}</c>) and new format (<c>auxi.sock.{hash}.{pid}</c>).
    /// </remarks>
    /// <param name="socketPath">The full socket path or filename.</param>
    /// <returns>The hash portion, or <c>null</c> if the format is unrecognized.</returns>
    public static string? ExtractHash(string socketPath)
    {
        var fileName = Path.GetFileName(socketPath);
 
        // Handle new format: auxi.sock.{hash}.{pid}
        // Handle old format: auxi.sock.{hash}
        if (fileName.StartsWith($"{SocketPrefix}.", StringComparison.Ordinal))
        {
            var afterPrefix = fileName[($"{SocketPrefix}.".Length)..];
            // If there's another dot, it's new format - return just the hash part
            var dotIndex = afterPrefix.IndexOf('.');
            return dotIndex > 0 ? afterPrefix[..dotIndex] : afterPrefix;
        }
 
        // Handle legacy format: aux.sock.{hash}
        if (fileName.StartsWith("aux.sock.", StringComparison.Ordinal))
        {
            var afterPrefix = fileName["aux.sock.".Length..];
            var dotIndex = afterPrefix.IndexOf('.');
            return dotIndex > 0 ? afterPrefix[..dotIndex] : afterPrefix;
        }
 
        return null;
    }
 
    /// <summary>
    /// Extracts the PID from a socket filename (new format only).
    /// </summary>
    /// <param name="socketPath">The full socket path or filename.</param>
    /// <returns>The PID if present and valid, or <c>null</c> for old format sockets.</returns>
    public static int? ExtractPid(string socketPath)
    {
        var fileName = Path.GetFileName(socketPath);
        var lastDot = fileName.LastIndexOf('.');
        if (lastDot > 0 && int.TryParse(fileName[(lastDot + 1)..], NumberStyles.None, CultureInfo.InvariantCulture, out var pid))
        {
            return pid;
        }
        return null;
    }
 
    /// <summary>
    /// Checks if a process with the given PID exists and is running.
    /// </summary>
    /// <remarks>
    /// Used for orphan detection. If the PID from a socket filename doesn't correspond
    /// to a running process, the socket is orphaned and can be safely deleted.
    /// </remarks>
    /// <param name="pid">The process ID to check.</param>
    /// <returns><c>true</c> if the process exists and is running; otherwise, <c>false</c>.</returns>
    public static bool ProcessExists(int pid)
    {
        try
        {
            using var process = Process.GetProcessById(pid);
            return !process.HasExited;
        }
        catch (ArgumentException)
        {
            // Process doesn't exist
            return false;
        }
        catch (InvalidOperationException)
        {
            // Process has exited
            return false;
        }
    }
 
    /// <summary>
    /// Cleans up orphaned socket files for a specific AppHost hash.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Called by AppHost on startup to clean up sockets from previous crashed instances.
    /// This ensures orphan cleanup happens even if the user has an old CLI that doesn't
    /// support PID-based orphan detection.
    /// </para>
    /// <para>
    /// <strong>Limitation:</strong> This method only cleans up new format sockets (<c>auxi.sock.{hash}.{pid}</c>)
    /// because old format sockets (<c>auxi.sock.{hash}</c>) don't have a PID for orphan detection.
    /// Old format sockets are cleaned up via connection-based detection in the CLI.
    /// </para>
    /// </remarks>
    /// <param name="backchannelsDirectory">The backchannels directory path.</param>
    /// <param name="hash">The AppHost hash to match.</param>
    /// <param name="currentPid">The current process ID (to avoid deleting own socket).</param>
    /// <returns>The number of orphaned sockets deleted.</returns>
    public static int CleanupOrphanedSockets(string backchannelsDirectory, string hash, int currentPid)
    {
        var deleted = 0;
 
        if (!Directory.Exists(backchannelsDirectory))
        {
            return deleted;
        }
 
        // Find all sockets for this hash (both old and new format)
        var pattern = $"{SocketPrefix}.{hash}*";
        foreach (var socketPath in Directory.GetFiles(backchannelsDirectory, pattern))
        {
            var pid = ExtractPid(socketPath);
            if (pid.HasValue && pid.Value != currentPid && !ProcessExists(pid.Value))
            {
                try
                {
                    // Double-check before delete to minimize TOCTOU race window
                    // (A new process could theoretically start with the same PID between our checks)
                    if (!ProcessExists(pid.Value))
                    {
                        File.Delete(socketPath);
                        deleted++;
                    }
                }
                catch
                {
                    // Ignore deletion failures
                }
            }
        }
 
        return deleted;
    }
}