File: BrowserLogsCdpConnection.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.Collections.Concurrent;
using System.Net.WebSockets;
using System.Text.Json;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting;
 
// Browser-level CDP connection operations used by BrowserPageSession.
internal interface IBrowserLogsCdpConnection : IAsyncDisposable
{
    Task Completion { get; }
 
    Task<BrowserLogsCreateTargetResult> CreateTargetAsync(CancellationToken cancellationToken);
 
    Task<BrowserLogsGetTargetsResult> GetTargetsAsync(CancellationToken cancellationToken);
 
    Task<BrowserLogsAttachToTargetResult> AttachToTargetAsync(string targetId, CancellationToken cancellationToken);
 
    Task<BrowserLogsCommandAck> CloseTargetAsync(string targetId, CancellationToken cancellationToken);
 
    Task<BrowserLogsCommandAck> EnableTargetDiscoveryAsync(CancellationToken cancellationToken);
 
    Task EnablePageInstrumentationAsync(string sessionId, CancellationToken cancellationToken);
 
    Task<BrowserLogsCaptureScreenshotResult> CaptureScreenshotAsync(string sessionId, CancellationToken cancellationToken);
 
    Task<BrowserLogsCommandAck> NavigateAsync(string sessionId, Uri url, CancellationToken cancellationToken);
}
 
// Owns one browser-level CDP transport. Protocol parsing stays in BrowserLogsCdpProtocol, while page lifecycle and
// reconnection policy stay in BrowserPageSession.
internal sealed class BrowserLogsCdpConnection : IBrowserLogsCdpConnection
{
    // CDP commands should fail fast enough to surface a broken browser session in the dashboard. Close uses a shorter
    // budget because it runs during disposal, while the websocket keep-alive stays comfortably below common proxy idle
    // timers without sending frequent pings during normal local development.
    private static readonly TimeSpan s_closeTimeout = TimeSpan.FromSeconds(3);
    private static readonly TimeSpan s_commandTimeout = TimeSpan.FromSeconds(30);
    // Screenshot capture asks the browser to rasterize and encode the current surface. Real browsers can take longer
    // than lightweight lifecycle/enable commands, especially under CI or agent load, so keep a dedicated timeout even
    // though it currently matches the general command budget.
    private static readonly TimeSpan s_screenshotCommandTimeout = TimeSpan.FromSeconds(30);
    private static readonly TimeSpan s_keepAliveInterval = TimeSpan.FromSeconds(15);
 
    private readonly CancellationTokenSource _disposeCts = new();
    private readonly Func<BrowserLogsCdpProtocolEvent, ValueTask> _eventHandler;
    private readonly ILogger<BrowserLogsSessionManager> _logger;
    private readonly ConcurrentDictionary<long, IPendingCommand> _pendingCommands = new();
    private readonly Task _receiveLoop;
    private readonly SemaphoreSlim _sendLock = new(1, 1);
    private readonly IBrowserLogsCdpTransport _transport;
    private long _nextCommandId;
 
    private BrowserLogsCdpConnection(IBrowserLogsCdpTransport transport, Func<BrowserLogsCdpProtocolEvent, ValueTask> eventHandler, ILogger<BrowserLogsSessionManager> logger)
    {
        _eventHandler = eventHandler;
        _logger = logger;
        _transport = transport;
        _receiveLoop = Task.Run(ReceiveLoopAsync);
    }
 
    public Task Completion => _receiveLoop;
 
    public static async Task<BrowserLogsCdpConnection> ConnectAsync(
        Uri webSocketUri,
        Func<BrowserLogsCdpProtocolEvent, ValueTask> eventHandler,
        ILogger<BrowserLogsSessionManager> logger,
        CancellationToken cancellationToken)
    {
        return await ConnectAsync(
            webSocketUri,
            eventHandler,
            logger,
            cancellationToken,
            static () => new ClientWebSocketConnector()).ConfigureAwait(false);
    }
 
    internal static async Task<BrowserLogsCdpConnection> ConnectAsync(
        Uri webSocketUri,
        Func<BrowserLogsCdpProtocolEvent, ValueTask> eventHandler,
        ILogger<BrowserLogsSessionManager> logger,
        CancellationToken cancellationToken,
        Func<IClientWebSocketConnector> connectorFactory)
    {
        using var connector = connectorFactory();
        // Browser-log sessions can sit idle while the page is loading or the developer is reading the dashboard.
        // Keep-alives make transport failures show up in the receive loop instead of only on the next CDP command.
        connector.SetKeepAliveInterval(s_keepAliveInterval);
        await connector.ConnectAsync(webSocketUri, cancellationToken).ConfigureAwait(false);
        return Create(
            new BrowserLogsWebSocketCdpTransport(connector.DetachConnectedWebSocket(), s_closeTimeout),
            eventHandler,
            logger);
    }
 
    internal static BrowserLogsCdpConnection Create(
        IBrowserLogsCdpTransport transport,
        Func<BrowserLogsCdpProtocolEvent, ValueTask> eventHandler,
        ILogger<BrowserLogsSessionManager> logger)
    {
        return new BrowserLogsCdpConnection(transport, eventHandler, logger);
    }
 
    public Task<BrowserLogsCreateTargetResult> CreateTargetAsync(CancellationToken cancellationToken)
    {
        return SendCommandAsync(
            BrowserLogsCdpProtocol.TargetCreateTargetMethod,
            sessionId: null,
            static writer => writer.WriteString("url", "about:blank"),
            BrowserLogsCdpProtocol.ParseCreateTargetResponse,
            cancellationToken);
    }
 
    public Task<BrowserLogsGetTargetsResult> GetTargetsAsync(CancellationToken cancellationToken)
    {
        return SendCommandAsync(
            BrowserLogsCdpProtocol.TargetGetTargetsMethod,
            sessionId: null,
            writeParameters: null,
            BrowserLogsCdpProtocol.ParseGetTargetsResponse,
            cancellationToken);
    }
 
    public Task<BrowserLogsAttachToTargetResult> AttachToTargetAsync(string targetId, CancellationToken cancellationToken)
    {
        return SendCommandAsync(
            BrowserLogsCdpProtocol.TargetAttachToTargetMethod,
            sessionId: null,
            writer =>
            {
                writer.WriteString("targetId", targetId);
                writer.WriteBoolean("flatten", true);
            },
            BrowserLogsCdpProtocol.ParseAttachToTargetResponse,
            cancellationToken);
    }
 
    public Task<BrowserLogsCommandAck> CloseTargetAsync(string targetId, CancellationToken cancellationToken)
    {
        return SendCommandAsync(
            BrowserLogsCdpProtocol.TargetCloseTargetMethod,
            sessionId: null,
            writer => writer.WriteString("targetId", targetId),
            BrowserLogsCdpProtocol.ParseCommandAckResponse,
            cancellationToken);
    }
 
    public Task<BrowserLogsCommandAck> EnableTargetDiscoveryAsync(CancellationToken cancellationToken)
    {
        // Target discovery is a browser-level CDP subscription. Enabling it tells Chromium to publish lifecycle
        // events for page targets (created, destroyed, crashed, detached) on this browser websocket. We need those
        // events to decide whether a tracked tab ended normally, crashed, or only lost its CDP socket and can be
        // reattached. Target.getTargets is just a point-in-time snapshot; setDiscoverTargets is the ongoing signal.
        return SendCommandAsync(
            BrowserLogsCdpProtocol.TargetSetDiscoverTargetsMethod,
            sessionId: null,
            static writer => writer.WriteBoolean("discover", true),
            BrowserLogsCdpProtocol.ParseCommandAckResponse,
            cancellationToken);
    }
 
    public async Task EnablePageInstrumentationAsync(string sessionId, CancellationToken cancellationToken)
    {
        // These domains are per attached page session. In real browsers a successful browser-level websocket connection
        // is not enough; without these enables the page keeps running but console, exception, and network events stay
        // silent for this target.
        await SendCommandAsync(BrowserLogsCdpProtocol.RuntimeEnableMethod, sessionId, writeParameters: null, BrowserLogsCdpProtocol.ParseCommandAckResponse, cancellationToken).ConfigureAwait(false);
        await SendCommandAsync(BrowserLogsCdpProtocol.LogEnableMethod, sessionId, writeParameters: null, BrowserLogsCdpProtocol.ParseCommandAckResponse, cancellationToken).ConfigureAwait(false);
        await SendCommandAsync(BrowserLogsCdpProtocol.PageEnableMethod, sessionId, writeParameters: null, BrowserLogsCdpProtocol.ParseCommandAckResponse, cancellationToken).ConfigureAwait(false);
        await SendCommandAsync(BrowserLogsCdpProtocol.NetworkEnableMethod, sessionId, writeParameters: null, BrowserLogsCdpProtocol.ParseCommandAckResponse, cancellationToken).ConfigureAwait(false);
    }
 
    public Task<BrowserLogsCaptureScreenshotResult> CaptureScreenshotAsync(string sessionId, CancellationToken cancellationToken)
    {
        return SendCommandAsync(
            BrowserLogsCdpProtocol.PageCaptureScreenshotMethod,
            sessionId,
            static writer =>
            {
                writer.WriteString("format", "png");
                writer.WriteBoolean("fromSurface", true);
            },
            BrowserLogsCdpProtocol.ParseCaptureScreenshotResponse,
            cancellationToken,
            s_screenshotCommandTimeout);
    }
 
    public Task<BrowserLogsCommandAck> NavigateAsync(string sessionId, Uri url, CancellationToken cancellationToken)
    {
        return SendCommandAsync(
            BrowserLogsCdpProtocol.PageNavigateMethod,
            sessionId,
            writer => writer.WriteString("url", url.ToString()),
            BrowserLogsCdpProtocol.ParseCommandAckResponse,
            cancellationToken);
    }
 
    public async ValueTask DisposeAsync()
    {
        _disposeCts.Cancel();
 
        try
        {
            await _transport.DisposeAsync().ConfigureAwait(false);
        }
        catch
        {
        }
 
        try
        {
            await _receiveLoop.ConfigureAwait(false);
        }
        catch
        {
        }
 
        _disposeCts.Dispose();
        _sendLock.Dispose();
    }
 
    private async Task<TResult> SendCommandAsync<TResult>(
        string method,
        string? sessionId,
        Action<Utf8JsonWriter>? writeParameters,
        ResponseParser<TResult> parseResponse,
        CancellationToken cancellationToken,
        TimeSpan? commandTimeout = null)
    {
        var commandId = Interlocked.Increment(ref _nextCommandId);
        var pendingCommand = new PendingCommand<TResult>(parseResponse);
        _pendingCommands[commandId] = pendingCommand;
 
        try
        {
            using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disposeCts.Token);
            sendCts.CancelAfter(commandTimeout ?? s_commandTimeout);
 
            using var registration = sendCts.Token.Register(static state =>
            {
                ((IPendingCommand)state!).SetCanceled();
            }, pendingCommand);
 
            var payload = BrowserLogsCdpProtocol.CreateCommandFrame(commandId, method, sessionId, writeParameters);
            _logger.LogTrace("Tracked browser protocol -> {Frame}", BrowserLogsCdpProtocol.DescribeFrame(payload));
 
            await _sendLock.WaitAsync(sendCts.Token).ConfigureAwait(false);
            try
            {
                // Browser-level CDP transports are serialized so startup, reconnect, screenshot, and shutdown never
                // interleave command frames on the same connection.
                await _transport.SendAsync(payload, sendCts.Token).ConfigureAwait(false);
            }
            finally
            {
                _sendLock.Release();
            }
 
            return await pendingCommand.Task.ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && !_disposeCts.IsCancellationRequested)
        {
            throw new TimeoutException($"Timed out waiting for a tracked browser protocol response to '{method}'.");
        }
        finally
        {
            _pendingCommands.TryRemove(commandId, out _);
        }
    }
 
    private async Task ReceiveLoopAsync()
    {
        Exception? terminalException = null;
 
        try
        {
            while (!_disposeCts.IsCancellationRequested)
            {
                var frame = await _transport.ReceiveAsync(_disposeCts.Token).ConfigureAwait(false);
                _logger.LogTrace("Tracked browser protocol <- {Frame}", BrowserLogsCdpProtocol.DescribeFrame(frame));
 
                try
                {
                    await HandleFrameAsync(frame).ConfigureAwait(false);
                }
                catch (Exception ex)
                {
                    terminalException = new InvalidOperationException(
                        $"Tracked browser protocol receive loop failed while processing frame {BrowserLogsCdpProtocol.DescribeFrame(frame)}.",
                        ex);
                    break;
                }
            }
        }
        catch (OperationCanceledException) when (_disposeCts.IsCancellationRequested)
        {
        }
        catch (Exception ex)
        {
            terminalException = ex;
        }
        finally
        {
            terminalException ??= new InvalidOperationException("Browser debug connection closed.");
 
            // Any terminal transport failure must fault in-flight commands so callers can recover or shut down
            // instead of waiting forever on a response that will never arrive.
            foreach (var pendingCommand in _pendingCommands.Values)
            {
                pendingCommand.SetException(terminalException);
            }
        }
 
        if (!_disposeCts.IsCancellationRequested)
        {
            throw terminalException ?? new InvalidOperationException("Browser debug connection closed.");
        }
    }
 
    private async Task HandleFrameAsync(byte[] frame)
    {
        var header = BrowserLogsCdpProtocol.ParseMessageHeader(frame);
        // CDP responses are matched by id, while events are identified by method and may arrive between responses for
        // unrelated commands. Handle responses first so callers waiting on commands are unblocked even when the browser
        // is also streaming network or console events.
        if (header.Id is long commandId)
        {
            if (_pendingCommands.TryGetValue(commandId, out var pendingCommand))
            {
                pendingCommand.SetResult(frame);
            }
 
            return;
        }
 
        if (header.Method is not null && BrowserLogsCdpProtocol.ParseEvent(header, frame) is { } protocolEvent)
        {
            await _eventHandler(protocolEvent).ConfigureAwait(false);
        }
    }
 
    private interface IPendingCommand
    {
        void SetCanceled();
 
        void SetException(Exception exception);
 
        void SetResult(ReadOnlyMemory<byte> framePayload);
    }
 
    private delegate TResult ResponseParser<TResult>(ReadOnlySpan<byte> framePayload);
 
    private sealed class PendingCommand<TResult>(ResponseParser<TResult> parseResponse) : IPendingCommand
    {
        private readonly ResponseParser<TResult> _parseResponse = parseResponse;
        private readonly TaskCompletionSource<TResult> _taskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
 
        public Task<TResult> Task => _taskCompletionSource.Task;
 
        public void SetCanceled()
        {
            _taskCompletionSource.TrySetCanceled();
        }
 
        public void SetException(Exception exception)
        {
            _taskCompletionSource.TrySetException(exception);
        }
 
        public void SetResult(ReadOnlyMemory<byte> framePayload)
        {
            try
            {
                _taskCompletionSource.TrySetResult(_parseResponse(framePayload.Span));
            }
            catch (Exception ex)
            {
                _taskCompletionSource.TrySetException(ex);
            }
        }
    }
}
 
// Test seam for websocket creation. Production code uses ClientWebSocketConnector; protocol/recovery tests can inject
// a connector that fails or returns a controlled socket without depending on a real browser.
internal interface IClientWebSocketConnector : IDisposable
{
    void SetKeepAliveInterval(TimeSpan interval);
 
    Task ConnectAsync(Uri webSocketUri, CancellationToken cancellationToken);
 
    WebSocket DetachConnectedWebSocket();
}
 
// Thin ownership wrapper around ClientWebSocket. It lets BrowserLogsCdpConnection transfer the connected socket into
// the receive/send pipeline while still disposing the socket on connection failures.
internal sealed class ClientWebSocketConnector : IClientWebSocketConnector
{
    private ClientWebSocket? _webSocket = new();
 
    public void SetKeepAliveInterval(TimeSpan interval)
    {
        GetWebSocket().Options.KeepAliveInterval = interval;
    }
 
    public Task ConnectAsync(Uri webSocketUri, CancellationToken cancellationToken)
    {
        return GetWebSocket().ConnectAsync(webSocketUri, cancellationToken);
    }
 
    public WebSocket DetachConnectedWebSocket()
    {
        var webSocket = GetWebSocket();
        _webSocket = null;
        return webSocket;
    }
 
    public void Dispose()
    {
        _webSocket?.Dispose();
        _webSocket = null;
    }
 
    private ClientWebSocket GetWebSocket()
    {
        var webSocket = _webSocket;
        ObjectDisposedException.ThrowIf(webSocket is null, this);
        return webSocket;
    }
}
 
// Transport abstraction for browser-level CDP frames. WebSocket uses complete text messages; Chromium pipe uses
// NUL-delimited JSON. Keeping framing here lets BrowserLogsCdpConnection own command correlation independent of launch
// transport.
internal interface IBrowserLogsCdpTransport : IAsyncDisposable
{
    Task SendAsync(ReadOnlyMemory<byte> frame, CancellationToken cancellationToken);
 
    Task<byte[]> ReceiveAsync(CancellationToken cancellationToken);
}
 
internal sealed class BrowserLogsWebSocketCdpTransport(WebSocket webSocket, TimeSpan closeTimeout) : IBrowserLogsCdpTransport
{
    private readonly TimeSpan _closeTimeout = closeTimeout;
    private readonly WebSocket _webSocket = webSocket;
 
    public async Task SendAsync(ReadOnlyMemory<byte> frame, CancellationToken cancellationToken)
    {
        await _webSocket.SendAsync(frame, WebSocketMessageType.Text, endOfMessage: true, cancellationToken).ConfigureAwait(false);
    }
 
    public async Task<byte[]> ReceiveAsync(CancellationToken cancellationToken)
    {
        var buffer = new byte[16 * 1024];
        using var messageBuffer = new MemoryStream();
 
        while (true)
        {
            var result = await _webSocket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false);
            if (result.MessageType == WebSocketMessageType.Close)
            {
                throw CreateUnexpectedConnectionClosureException(result);
            }
 
            // Large CDP events can span multiple websocket frames. Buffer until EndOfMessage so protocol parsing
            // always sees one complete JSON message, matching the frames observed from a real browser.
            messageBuffer.Write(buffer, 0, result.Count);
            if (result.EndOfMessage)
            {
                return messageBuffer.ToArray();
            }
        }
    }
 
    public async ValueTask DisposeAsync()
    {
        try
        {
            if (_webSocket.State is WebSocketState.Open or WebSocketState.CloseReceived)
            {
                using var closeCts = new CancellationTokenSource(_closeTimeout);
                await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Disposed", closeCts.Token).ConfigureAwait(false);
            }
        }
        catch
        {
            _webSocket.Abort();
        }
        finally
        {
            _webSocket.Dispose();
        }
    }
 
    private static InvalidOperationException CreateUnexpectedConnectionClosureException(WebSocketReceiveResult result)
    {
        // Preserve the remote close details; they become the reconnect/resource-log diagnostics when CDP drops.
        if (result.CloseStatus is { } closeStatus)
        {
            if (!string.IsNullOrWhiteSpace(result.CloseStatusDescription))
            {
                return new InvalidOperationException($"Browser debug connection closed by the remote endpoint with status '{closeStatus}' ({(int)closeStatus}): {result.CloseStatusDescription}");
            }
 
            return new InvalidOperationException($"Browser debug connection closed by the remote endpoint with status '{closeStatus}' ({(int)closeStatus}).");
        }
 
        return new InvalidOperationException("Browser debug connection closed by the remote endpoint without a close status.");
    }
}
 
internal sealed class BrowserLogsPipeCdpTransport(Stream readStream, Stream writeStream) : IBrowserLogsCdpTransport
{
    private const byte FrameTerminator = 0;
    private const int ReadBufferSize = 16 * 1024;
    private static readonly byte[] s_frameTerminator = [FrameTerminator];
 
    private readonly byte[] _readBuffer = new byte[ReadBufferSize];
    private readonly Stream _readStream = readStream;
    private readonly Stream _writeStream = writeStream;
    private int _readBufferCount;
    private int _readBufferOffset;
 
    public async Task SendAsync(ReadOnlyMemory<byte> frame, CancellationToken cancellationToken)
    {
        await _writeStream.WriteAsync(frame, cancellationToken).ConfigureAwait(false);
        await _writeStream.WriteAsync(s_frameTerminator, cancellationToken).ConfigureAwait(false);
        await _writeStream.FlushAsync(cancellationToken).ConfigureAwait(false);
    }
 
    public async Task<byte[]> ReceiveAsync(CancellationToken cancellationToken)
    {
        using var messageBuffer = new MemoryStream();
 
        while (true)
        {
            if (_readBufferOffset == _readBufferCount)
            {
                _readBufferOffset = 0;
                _readBufferCount = await _readStream.ReadAsync(_readBuffer, cancellationToken).ConfigureAwait(false);
                if (_readBufferCount == 0)
                {
                    throw new EndOfStreamException("Browser debug pipe closed.");
                }
            }
 
            var terminatorIndex = Array.IndexOf(_readBuffer, FrameTerminator, _readBufferOffset, _readBufferCount - _readBufferOffset);
            if (terminatorIndex >= 0)
            {
                messageBuffer.Write(_readBuffer, _readBufferOffset, terminatorIndex - _readBufferOffset);
                _readBufferOffset = terminatorIndex + 1;
                return messageBuffer.ToArray();
            }
 
            messageBuffer.Write(_readBuffer, _readBufferOffset, _readBufferCount - _readBufferOffset);
            _readBufferOffset = _readBufferCount;
        }
    }
 
    public async ValueTask DisposeAsync()
    {
        try
        {
            await _writeStream.DisposeAsync().ConfigureAwait(false);
        }
        finally
        {
            await _readStream.DisposeAsync().ConfigureAwait(false);
        }
    }
}