File: BrowserEndpointDiscovery.cs
Web Access
Project: src\src\Aspire.Hosting.Browsers\Aspire.Hosting.Browsers.csproj (Aspire.Hosting.Browsers)
// 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 Aspire.Hosting.Browsers.Resources;
using System.Globalization;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting;
 
// Supports WebSocket attach/adoption by persisting and validating the browser-level CDP endpoint for a shared user-data
// directory.
//
// Why this exists:
// - Chromium's singleton is keyed by the user data root, not by Aspire. If a WebSocket-based option already launched a
//   debug-enabled browser for that root, a later browser-log session can attach to it instead of starting another
//   process.
// - Chromium's DevToolsActivePort file is only a launch-time hand-off file and isn't enough for cross-session adoption.
//   This sidecar records the exact browser identity and endpoint proved during startup.
// - The sidecar is intentionally treated as a hint. Users can close the browser, edit/delete files, or reuse ports, so
//   every read revalidates schema, identity, PID liveness, endpoint reachability, and profile compatibility.
internal sealed class BrowserEndpointDiscovery(ILogger<BrowserLogsSessionManager> logger)
{
    private static readonly TimeSpan s_probeHttpClientTimeout = Timeout.InfiniteTimeSpan;
    // Endpoint adoption is on the command path, so fail quickly when stale metadata points at a dead or reused port.
    // Two seconds is long enough for a local /json/version response under load while keeping browser launch responsive.
    private static readonly TimeSpan s_probeTimeout = TimeSpan.FromSeconds(2);
    private static readonly HttpClient s_probeHttpClient = new()
    {
        // Keep the singleton client free of a global timeout. Each probe applies a linked CTS below so
        // endpoint-probe timeouts remain local while caller cancellation still propagates.
        Timeout = s_probeHttpClientTimeout
    };
    private static readonly BrowserEndpointJsonContext s_jsonContext = new(new JsonSerializerOptions(JsonSerializerDefaults.Web)
    {
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
        WriteIndented = true
    });
 
    private readonly ILogger<BrowserLogsSessionManager> _logger = logger;
 
    // Aspire sidecar file stored at the Chromium user data root next to browser singleton files such as
    // SingletonLock/lockfile and DevToolsActivePort. Keeping it under the user data root makes the adoption state
    // specific to the same browser singleton boundary that Chromium itself uses.
    public static string GetEndpointMetadataFilePath(string userDataDirectory) =>
        Path.Combine(userDataDirectory, "aspire-debug-endpoint.json");
 
    public async Task<BrowserDebugEndpointMetadata?> TryReadAndValidateAsync(BrowserHostIdentity identity, string? profileDirectoryName, CancellationToken cancellationToken)
    {
        var metadataPath = GetEndpointMetadataFilePath(identity.UserDataRootPath);
        BrowserDebugEndpointMetadata? metadata;
 
        try
        {
            if (!File.Exists(metadataPath))
            {
                return null;
            }
 
            // This file is intentionally durable so adoption can survive an AppHost restart, but real browsers can leave
            // it behind when the process is closed externally. Treat unreadable or invalid metadata as stale and delete it
            // so future starts take the normal owned-browser path.
            // Aspire endpoint metadata shape:
            // {
            //   "schemaVersion": 1,
            //   "endpoint": "ws://127.0.0.1:50981/devtools/browser/<id>",
            //   "processId": 12345,
            //   "executablePath": "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
            //   "userDataRootPath": "/Users/me/Library/Application Support/Microsoft Edge",
            //   "profileDirectoryName": "Profile 1",
            //   "createdAt": "2026-04-25T19:37:25Z"
            // }
            using var stream = File.OpenRead(metadataPath);
                metadata = await JsonSerializer.DeserializeAsync(stream, s_jsonContext.BrowserDebugEndpointMetadata, cancellationToken).ConfigureAwait(false);
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
        {
            _logger.LogDebug(ex, "Unable to read tracked browser endpoint metadata '{MetadataPath}'. Treating it as stale.", metadataPath);
            TryDelete(metadataPath);
            return null;
        }
 
        var metadataExecutablePath = TryNormalizePath(metadata?.ExecutablePath);
        var metadataUserDataRootPath = TryNormalizePath(metadata?.UserDataRootPath);
 
        // Cheap structural checks come first so clearly stale files are removed before any process or network probe.
        // Executable and user-data paths are normalized before comparison because the sidecar may have been written by a
        // previous AppHost process with different slash/casing/trailing-separator spelling.
        if (metadata is null ||
            metadata.SchemaVersion != BrowserDebugEndpointMetadata.CurrentSchemaVersion ||
            metadata.ProcessId <= 0 ||
            string.IsNullOrWhiteSpace(metadata.Endpoint) ||
            !Uri.TryCreate(metadata.Endpoint, UriKind.Absolute, out var endpoint) ||
            metadataExecutablePath is null ||
            metadataUserDataRootPath is null ||
            !string.Equals(metadataExecutablePath, identity.ExecutablePath, GetPathComparison()) ||
            !string.Equals(metadataUserDataRootPath, identity.UserDataRootPath, GetPathComparison()))
        {
            TryDelete(metadataPath);
            return null;
        }
 
        if (!IsProcessAlive(metadata.ProcessId))
        {
            _logger.LogDebug("Tracked browser endpoint metadata '{MetadataPath}' points to process {ProcessId}, but that process is not running.", metadataPath, metadata.ProcessId);
            TryDelete(metadataPath);
            return null;
        }
 
        // Even a live process id is not enough: the browser may be shutting down, the port may now belong to another
        // process, or the endpoint may no longer be accepting CDP traffic. The /json/version probe is the observable
        // proof that the browser-level websocket is usable.
        bool endpointResponded;
        try
        {
            endpointResponded = await ProbeBrowserEndpointAsync(endpoint, cancellationToken).ConfigureAwait(false);
        }
        catch (Exception ex) when (!cancellationToken.IsCancellationRequested && ex is HttpRequestException or IOException or JsonException or OperationCanceledException)
        {
            _logger.LogDebug(ex, "Tracked browser endpoint metadata '{MetadataPath}' points to endpoint '{Endpoint}', but probing /json/version failed.", metadataPath, endpoint);
            endpointResponded = false;
        }
 
        if (!endpointResponded)
        {
            _logger.LogDebug("Tracked browser endpoint metadata '{MetadataPath}' points to endpoint '{Endpoint}', but it did not respond to /json/version.", metadataPath, endpoint);
            TryDelete(metadataPath);
            return null;
        }
 
        // At this point the sidecar points at a live Aspire-launched browser for the same user-data root. A profile
        // mismatch is therefore a real conflict, not stale metadata, and should be reported to the caller.
        if (!string.Equals(metadata.ProfileDirectoryName, profileDirectoryName, StringComparison.OrdinalIgnoreCase))
        {
            throw new InvalidOperationException(
                string.Format(
                    CultureInfo.CurrentCulture,
                    BrowserMessageStrings.BrowserLogsTrackedBrowserProfileConflict,
                    identity.UserDataRootPath,
                    metadata.ProfileDirectoryName ?? BrowserMessageStrings.BrowserLogsDefaultProfileName,
                    profileDirectoryName ?? BrowserMessageStrings.BrowserLogsDefaultProfileName));
        }
 
        return metadata with { Endpoint = endpoint.ToString() };
    }
 
    public static async Task WriteAsync(BrowserHostIdentity identity, string? profileDirectoryName, Uri endpoint, int processId, CancellationToken cancellationToken)
    {
        var metadataPath = GetEndpointMetadataFilePath(identity.UserDataRootPath);
        var tempPath = $"{metadataPath}.{Guid.NewGuid():N}.tmp";
        // The sidecar captures the identity that was used to launch the owned browser, not just the endpoint URL. That
        // lets a future AppHost reject metadata from a different browser executable or user-data root before connecting.
        //
        // Example:
        // {
        //   "schemaVersion": 1,
        //   "endpoint": "ws://127.0.0.1:50981/devtools/browser/<id>",
        //   "processId": 12345,
        //   "executablePath": "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
        //   "userDataRootPath": "/Users/me/Library/Application Support/Microsoft Edge",
        //   "profileDirectoryName": "Profile 1",
        //   "createdAt": "2026-04-25T19:37:25Z"
        // }
        var metadata = new BrowserDebugEndpointMetadata
        {
            SchemaVersion = BrowserDebugEndpointMetadata.CurrentSchemaVersion,
            Endpoint = endpoint.ToString(),
            ProcessId = processId,
            ExecutablePath = identity.ExecutablePath,
            UserDataRootPath = identity.UserDataRootPath,
            ProfileDirectoryName = profileDirectoryName,
            CreatedAt = DateTimeOffset.UtcNow
        };
 
        try
        {
            // Write through a temp file and then replace so readers never see a partially-written JSON document. A
            // malformed document is handled as stale, but atomic replacement avoids unnecessary delete/restart cycles.
            using (var stream = File.Create(tempPath))
            {
                await JsonSerializer.SerializeAsync(stream, metadata, s_jsonContext.BrowserDebugEndpointMetadata, cancellationToken).ConfigureAwait(false);
            }
 
            File.Move(tempPath, metadataPath, overwrite: true);
        }
        finally
        {
            TryDelete(tempPath);
        }
    }
 
    public static void DeleteEndpointMetadata(string userDataDirectory) =>
        TryDelete(GetEndpointMetadataFilePath(userDataDirectory));
 
    private static async Task<bool> ProbeBrowserEndpointAsync(Uri browserEndpoint, CancellationToken cancellationToken)
    {
        // BrowserHost.DebugEndpoint is a websocket URL. Chromium exposes the matching HTTP endpoint by swapping
        // ws/wss -> http/https and reading /json/version from the same authority.
        var versionEndpoint = new UriBuilder(browserEndpoint)
        {
            Scheme = browserEndpoint.Scheme == Uri.UriSchemeWss ? Uri.UriSchemeHttps : Uri.UriSchemeHttp,
            Path = "/json/version",
            Query = null
        }.Uri;
 
        using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        probeCts.CancelAfter(s_probeTimeout);
 
        using var response = await s_probeHttpClient.GetAsync(versionEndpoint, probeCts.Token).ConfigureAwait(false);
        if (!response.IsSuccessStatusCode)
        {
            return false;
        }
 
        // Chromium /json/version shape includes the browser-level websocket URL used for future CDP connections:
        // {
        //   "Browser": "Chrome/...",
        //   "Protocol-Version": "1.3",
        //   "webSocketDebuggerUrl": "ws://127.0.0.1:50981/devtools/browser/<id>"
        // }
        using var stream = await response.Content.ReadAsStreamAsync(probeCts.Token).ConfigureAwait(false);
        var version = await JsonSerializer.DeserializeAsync(stream, s_jsonContext.BrowserJsonVersionResponse, probeCts.Token).ConfigureAwait(false);
        return Uri.TryCreate(version?.WebSocketDebuggerUrl, UriKind.Absolute, out _);
    }
 
    private static bool IsProcessAlive(int processId)
    {
        try
        {
            using var process = Process.GetProcessById(processId);
            return !process.HasExited;
        }
        catch (ArgumentException)
        {
            return false;
        }
        catch (InvalidOperationException)
        {
            return false;
        }
    }
 
    private static string NormalizePath(string path) => Path.TrimEndingDirectorySeparator(Path.GetFullPath(path));
 
    private static string? TryNormalizePath(string? path)
    {
        if (string.IsNullOrWhiteSpace(path))
        {
            return null;
        }
 
        try
        {
            return NormalizePath(path);
        }
        catch (ArgumentException)
        {
            return null;
        }
        catch (IOException)
        {
            return null;
        }
        catch (NotSupportedException)
        {
            return null;
        }
    }
 
    private static StringComparison GetPathComparison() =>
        OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
 
    private static void TryDelete(string path)
    {
        try
        {
            File.Delete(path);
        }
        catch (IOException)
        {
        }
        catch (UnauthorizedAccessException)
        {
        }
    }
}
 
// On-disk adoption hint for WebSocket-backed hosts. A matching file never proves adoption is safe by itself; it must be
// validated against the requested identity, profile, process, and /json/version endpoint first.
internal sealed record BrowserDebugEndpointMetadata
{
    public const int CurrentSchemaVersion = 1;
 
    public int SchemaVersion { get; init; }
 
    public required string Endpoint { get; init; }
 
    public required int ProcessId { get; init; }
 
    public required string ExecutablePath { get; init; }
 
    public required string UserDataRootPath { get; init; }
 
    public string? ProfileDirectoryName { get; init; }
 
    public DateTimeOffset CreatedAt { get; init; }
}
 
// Minimal shape of Chromium's /json/version response. The documented browser-target discovery format includes fields
// such as "Browser", "Protocol-Version", and "webSocketDebuggerUrl"; only the browser WebSocket endpoint is required
// here to prove the probed HTTP endpoint is a DevTools endpoint.
// See https://chromedevtools.github.io/devtools-protocol/#how-do-i-access-the-browser-target
// Example: { "webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/browser/<id>" }
internal sealed record BrowserJsonVersionResponse
{
    public string? WebSocketDebuggerUrl { get; init; }
}
 
// Source-generated JSON context for the small metadata file exchanged between WebSocket attach/adoption paths and the
// Chromium /json/version probe response.
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(BrowserDebugEndpointMetadata))]
[JsonSerializable(typeof(BrowserJsonVersionResponse))]
internal sealed partial class BrowserEndpointJsonContext : JsonSerializerContext;