File: src\Aspire.Hosting.Browsers\BrowserPageSession.cs
Web Access
Project: src\tests\Aspire.Hosting.Browsers.Tests\Aspire.Hosting.Browsers.Tests.csproj (Aspire.Hosting.Browsers.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting;
 
// Factory for browser-level CDP connections.
internal delegate Task<IBrowserLogsCdpConnection> BrowserLogsCdpConnectionFactory(
    Func<BrowserLogsCdpProtocolEvent, ValueTask> eventHandler,
    ILogger<BrowserLogsSessionManager> logger,
    CancellationToken cancellationToken);
 
// Owns one browser page/tab for one browser-log session. CDP calls pages "targets", but this layer intentionally
// models the user-visible page session. The host may be shared by many sessions, while each BrowserPageSession has
// its own browser CDP connection, attached target session id, instrumentation setup, lifecycle monitoring, and
// reconnection loop.
internal sealed class BrowserPageSession : IBrowserPageSession
{
    // Keep reconnects quick and local to transient CDP connection loss. A 200 ms cadence gives the browser a few chances to
    // recover within the 5 s window without making the dashboard look healthy after the page is truly gone. Target close
    // uses a shorter 3 s budget because disposal should not block AppHost shutdown on an unresponsive browser.
    private static readonly TimeSpan s_connectionRecoveryDelay = TimeSpan.FromMilliseconds(200);
    private static readonly TimeSpan s_connectionRecoveryTimeout = TimeSpan.FromSeconds(5);
    private static readonly TimeSpan s_closeTargetTimeout = TimeSpan.FromSeconds(3);
 
    private readonly TaskCompletionSource<BrowserPageSessionResult> _completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
    private readonly BrowserConnectionDiagnosticsLogger _connectionDiagnostics;
    private readonly BrowserLogsCdpConnectionFactory _connectionFactory;
    private readonly Func<BrowserLogsCdpProtocolEvent, ValueTask> _eventHandler;
    private readonly IBrowserHost _host;
    // Serializes every operation that replaces, disposes, or sends a command through the current CDP connection.
    // Without this, screenshot capture can read a live connection reference while reconnect/dispose tears down that
    // same connection underneath it.
    private readonly SemaphoreSlim _connectionLock = new(1, 1);
    private readonly ILogger<BrowserLogsSessionManager> _logger;
    private readonly bool _reuseInitialBlankTarget;
    private readonly string _sessionId;
    private readonly CancellationTokenSource _stopCts = new();
    private readonly TimeProvider _timeProvider;
    private readonly Uri _url;
 
    private IBrowserLogsCdpConnection? _connection;
    private Task<BrowserPageSessionResult>? _monitorTask;
    private int _disposed;
    private string? _targetId;
    private string? _targetSessionId;
 
    private BrowserPageSession(
        IBrowserHost host,
        string sessionId,
        Uri url,
        BrowserConnectionDiagnosticsLogger connectionDiagnostics,
        BrowserLogsCdpConnectionFactory connectionFactory,
        Func<BrowserLogsCdpProtocolEvent, ValueTask> eventHandler,
        ILogger<BrowserLogsSessionManager> logger,
        TimeProvider timeProvider,
        bool reuseInitialBlankTarget)
    {
        _connectionDiagnostics = connectionDiagnostics;
        _connectionFactory = connectionFactory;
        _eventHandler = eventHandler;
        _host = host;
        _logger = logger;
        _reuseInitialBlankTarget = reuseInitialBlankTarget;
        _sessionId = sessionId;
        _timeProvider = timeProvider;
        _url = url;
    }
 
    public string TargetId => _targetId ?? throw new InvalidOperationException("Browser target id is not available before the target session starts.");
 
    public string TargetSessionId => _targetSessionId ?? throw new InvalidOperationException("Browser target session id is not available before the target session starts.");
 
    public Task<BrowserPageSessionResult> Completion => _monitorTask ?? throw new InvalidOperationException("Browser page session has not started.");
 
    public async Task<BrowserLogsCaptureScreenshotResult> CaptureScreenshotAsync(CancellationToken cancellationToken)
    {
        await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
 
        try
        {
            ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
 
            var connection = _connection ?? throw new InvalidOperationException("Tracked browser debug connection is not available.");
            var targetSessionId = _targetSessionId ?? throw new InvalidOperationException("Browser target session id is not available before the target session starts.");
 
            // Keep the lock for the whole CDP command. Capturing only the fields under the lock is not enough because
            // BrowserPageSession.DisposeAsync and reconnect both dispose the connection object itself.
            using var captureCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _stopCts.Token);
            return await connection.CaptureScreenshotAsync(targetSessionId, captureCts.Token).ConfigureAwait(false);
        }
        finally
        {
            _connectionLock.Release();
        }
    }
 
    internal static BrowserPageSessionResult? TryGetPageCompletion(BrowserLogsCdpProtocolEvent protocolEvent, string? targetId, string? targetSessionId)
    {
        return protocolEvent switch
        {
            BrowserLogsTargetDestroyedEvent targetDestroyed when string.Equals(targetDestroyed.TargetId, targetId, StringComparison.Ordinal) =>
                new BrowserPageSessionResult(BrowserPageSessionCompletionKind.PageClosed, Error: null),
 
            BrowserLogsTargetCrashedEvent targetCrashed when string.Equals(targetCrashed.TargetId, targetId, StringComparison.Ordinal) =>
                new BrowserPageSessionResult(
                    BrowserPageSessionCompletionKind.PageCrashed,
                    new InvalidOperationException($"Tracked browser page crashed with status '{targetCrashed.Parameters.Status}' and error code '{targetCrashed.Parameters.ErrorCode}'.")),
 
            BrowserLogsDetachedFromTargetEvent detached when
                string.Equals(detached.DetachedSessionId, targetSessionId, StringComparison.Ordinal) ||
                string.Equals(detached.TargetId, targetId, StringComparison.Ordinal) =>
                new BrowserPageSessionResult(BrowserPageSessionCompletionKind.PageClosed, Error: null),
 
            BrowserLogsInspectorDetachedEvent inspectorDetached when string.Equals(inspectorDetached.SessionId, targetSessionId, StringComparison.Ordinal) =>
                string.Equals(inspectorDetached.Reason, "target_closed", StringComparison.OrdinalIgnoreCase)
                    ? new BrowserPageSessionResult(BrowserPageSessionCompletionKind.PageClosed, Error: null)
                    : new BrowserPageSessionResult(
                        BrowserPageSessionCompletionKind.ConnectionLost,
                        new InvalidOperationException($"Tracked browser inspector detached: {inspectorDetached.Reason ?? "unknown reason"}.")),
 
            _ => null
        };
    }
 
    public static async Task<BrowserPageSession> StartAsync(
        IBrowserHost host,
        string sessionId,
        Uri url,
        BrowserConnectionDiagnosticsLogger connectionDiagnostics,
        Func<BrowserLogsCdpProtocolEvent, ValueTask> eventHandler,
        ILogger<BrowserLogsSessionManager> logger,
        TimeProvider timeProvider,
        bool reuseInitialBlankTarget,
        CancellationToken cancellationToken)
    {
        return await StartAsync(
            host,
            sessionId,
            url,
            connectionDiagnostics,
            host.CreateCdpConnectionAsync,
            eventHandler,
            logger,
            timeProvider,
            reuseInitialBlankTarget,
            cancellationToken).ConfigureAwait(false);
    }
 
    internal static async Task<BrowserPageSession> StartAsync(
        IBrowserHost host,
        string sessionId,
        Uri url,
        BrowserConnectionDiagnosticsLogger connectionDiagnostics,
        BrowserLogsCdpConnectionFactory connectionFactory,
        Func<BrowserLogsCdpProtocolEvent, ValueTask> eventHandler,
        ILogger<BrowserLogsSessionManager> logger,
        TimeProvider timeProvider,
        bool reuseInitialBlankTarget,
        CancellationToken cancellationToken)
    {
        var pageSession = new BrowserPageSession(host, sessionId, url, connectionDiagnostics, connectionFactory, eventHandler, logger, timeProvider, reuseInitialBlankTarget);
        try
        {
            await pageSession.ConnectAsync(createTarget: true, cancellationToken).ConfigureAwait(false);
            pageSession._monitorTask = pageSession.MonitorAsync();
            return pageSession;
        }
        catch
        {
            await pageSession.DisposeAsync().ConfigureAwait(false);
            throw;
        }
    }
 
    public async ValueTask DisposeAsync()
    {
        if (Interlocked.Exchange(ref _disposed, 1) != 0)
        {
            return;
        }
 
        _stopCts.Cancel();
 
        // Cancel first so any in-flight screenshot command holding _connectionLock is interrupted before disposal waits
        // for the lock. Once the lock is acquired, no new capture/reconnect can use the connection while the target is
        // being closed and the connection is being disposed.
        await _connectionLock.WaitAsync(CancellationToken.None).ConfigureAwait(false);
        try
        {
            var connection = _connection;
            // The ReferenceEquals check is technically redundant today (connection was just read from _connection
            // under the lock), but guards against future refactors that may read _connection earlier or release
            // and re-acquire the lock before reaching this point.
            if (connection is not null && ReferenceEquals(connection, _connection) && _targetId is not null)
            {
                try
                {
                    using var closeTargetCts = new CancellationTokenSource(s_closeTargetTimeout);
                    await connection.CloseTargetAsync(_targetId, closeTargetCts.Token).ConfigureAwait(false);
                }
                catch (Exception ex)
                {
                    _logger.LogDebug(ex, "Failed to close tracked browser target '{TargetId}' for session '{SessionId}'.", _targetId, _sessionId);
                }
            }
        }
        finally
        {
            _connectionLock.Release();
        }
 
        _completionSource.TrySetResult(new BrowserPageSessionResult(BrowserPageSessionCompletionKind.Stopped, Error: null));
 
        await DisposeConnectionAsync().ConfigureAwait(false);
 
        if (_monitorTask is not null)
        {
            try
            {
                await _monitorTask.ConfigureAwait(false);
            }
            catch
            {
            }
        }
 
        _stopCts.Dispose();
        _connectionLock.Dispose();
    }
 
    private async Task ConnectAsync(bool createTarget, CancellationToken cancellationToken)
    {
        // ConnectAsync is used for startup and reconnect. It swaps the current CDP connection, target attachment, and target
        // session id as one critical section so command callers never observe a half-attached page session.
        await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
 
        try
        {
            await DisposeConnectionCoreAsync().ConfigureAwait(false);
 
            _connection = await _connectionFactory(HandleEventAsync, _logger, cancellationToken).ConfigureAwait(false);
            // Target discovery must be re-enabled for every browser-level connection, including reconnects. The
            // subscription is attached to this connection, not to the browser process, and it is what makes Chromium emit
            // targetDestroyed/targetCrashed/detachedFromTarget events that tell us whether the tracked tab is gone.
            await _connection.EnableTargetDiscoveryAsync(cancellationToken).ConfigureAwait(false);
 
            if (createTarget)
            {
                _targetId = await CreateTargetAsync(cancellationToken).ConfigureAwait(false);
            }
 
            if (_targetId is null)
            {
                throw new InvalidOperationException("Tracked browser target id is not available.");
            }
 
            // Reconnects reuse the existing target id. A transient CDP connection drop does not necessarily close the browser
            // tab, so recovering should reattach to the same page instead of opening a duplicate tab in the user's browser.
            var attachToTargetResult = await _connection.AttachToTargetAsync(_targetId, cancellationToken).ConfigureAwait(false);
            _targetSessionId = attachToTargetResult.SessionId
                ?? throw new InvalidOperationException("Browser target attachment did not return a session id.");
 
            // Runtime/Log/Page/Network subscriptions are scoped to the attached target session. They have to be re-enabled
            // after every attach, including reconnects, or the page keeps running with no events flowing back to resource logs.
            await _connection.EnablePageInstrumentationAsync(_targetSessionId, cancellationToken).ConfigureAwait(false);
 
            if (createTarget)
            {
                await _connection.NavigateAsync(_targetSessionId, _url, cancellationToken).ConfigureAwait(false);
            }
        }
        finally
        {
            _connectionLock.Release();
        }
    }
 
    private async Task<string> CreateTargetAsync(CancellationToken cancellationToken)
    {
        if (_reuseInitialBlankTarget && _connection is not null)
        {
            var targets = await _connection.GetTargetsAsync(cancellationToken).ConfigureAwait(false);
            if (TrySelectReusableStartupPageTargetId(targets.TargetInfos) is { } targetId)
            {
                return targetId;
            }
        }
 
        // If no safe startup page target is available, create a fresh page target so we do not navigate an unrelated
        // page in a real browser window.
        var createTargetResult = await _connection!.CreateTargetAsync(cancellationToken).ConfigureAwait(false);
        return createTargetResult.TargetId
            ?? throw new InvalidOperationException("Browser target creation did not return a target id.");
    }
 
    internal static string? TrySelectReusableStartupPageTargetId(IReadOnlyList<BrowserLogsTargetInfo>? targetInfos)
    {
        if (targetInfos is null)
        {
            return null;
        }
 
        // Only owned browser hosts ask to reuse a startup target. Owned launches append about:blank to the Chromium
        // command line so the first tracked page session can navigate that visible empty tab instead of creating a
        // second tab. Adopted hosts disable this path so Aspire never navigates an arbitrary tab in a user's browser.
        var preferredTarget = targetInfos.FirstOrDefault(static targetInfo =>
            string.Equals(targetInfo.Type, "page", StringComparison.Ordinal) &&
            targetInfo.Attached != true &&
            string.Equals(targetInfo.Url, "about:blank", StringComparison.Ordinal));
 
        if (!string.IsNullOrWhiteSpace(preferredTarget?.TargetId))
        {
            return preferredTarget.TargetId;
        }
 
        // Chromium can report another unattached startup page first, especially with restored profile state. Reusing
        // that page is still preferable to opening an extra tab because this helper is never used for adopted browsers.
        return targetInfos.FirstOrDefault(static targetInfo =>
            string.Equals(targetInfo.Type, "page", StringComparison.Ordinal) &&
            targetInfo.Attached != true &&
            !string.IsNullOrWhiteSpace(targetInfo.TargetId))
            ?.TargetId;
    }
 
    private async Task<BrowserPageSessionResult> MonitorAsync()
    {
        try
        {
            while (true)
            {
                // Don't hold _connectionLock while awaiting the connection completion task: reconnect and disposal need
                // the same lock. Instead snapshot the stable completion task under the lock, then await the snapshot.
                var connectionSnapshot = await GetConnectionSnapshotAsync().ConfigureAwait(false);
                var completedTask = await Task.WhenAny(_host.Termination, connectionSnapshot.Completion, _completionSource.Task).ConfigureAwait(false);
 
                if (completedTask == _completionSource.Task)
                {
                    return await _completionSource.Task.ConfigureAwait(false);
                }
 
                if (completedTask == _host.Termination)
                {
                    var error = new InvalidOperationException($"Tracked browser host '{_host.Identity}' ended before the page session completed.");
                    _connectionDiagnostics.LogHostTerminated(error);
                    return new BrowserPageSessionResult(BrowserPageSessionCompletionKind.BrowserExited, error);
                }
 
                Exception? connectionError = null;
                try
                {
                    await connectionSnapshot.Completion.ConfigureAwait(false);
                }
                catch (Exception ex)
                {
                    connectionError = ex;
                }
 
                if (_stopCts.IsCancellationRequested)
                {
                    _logger.LogTrace("Stopping tracked browser page session '{SessionId}' after debug connection completed.", _sessionId);
                    return new BrowserPageSessionResult(BrowserPageSessionCompletionKind.Stopped, Error: null);
                }
 
                connectionError ??= new InvalidOperationException("The tracked browser debug connection closed without reporting a reason.");
                _connectionDiagnostics.LogConnectionLost(connectionError);
                if (await TryReconnectAsync(connectionError).ConfigureAwait(false))
                {
                    continue;
                }
 
                return new BrowserPageSessionResult(BrowserPageSessionCompletionKind.ConnectionLost, connectionError);
            }
        }
        finally
        {
            await DisposeConnectionAsync().ConfigureAwait(false);
        }
    }
 
    private async Task<bool> TryReconnectAsync(Exception connectionError)
    {
        await DisposeConnectionAsync().ConfigureAwait(false);
 
        // In a real browser the CDP connection can disappear briefly while the tab keeps running (for example during
        // browser hiccups or network stack resets). Give it a short recovery window so logs continue without opening
        // another tab, but fail fast enough that the dashboard does not look healthy after the target is truly gone.
        var reconnectDeadline = _timeProvider.GetUtcNow() + s_connectionRecoveryTimeout;
        var reconnectAttempt = 0;
        Exception? lastError = connectionError;
 
        while (!_stopCts.IsCancellationRequested && _timeProvider.GetUtcNow() < reconnectDeadline)
        {
            if (_host.Termination.IsCompleted)
            {
                _logger.LogTrace("Skipping tracked browser page session reconnect for '{SessionId}' because the browser host has terminated.", _sessionId);
                return false;
            }
 
            try
            {
                _logger.LogTrace("Attempting to reconnect tracked browser page session '{SessionId}' to target '{TargetId}'.", _sessionId, _targetId);
                await ConnectAsync(createTarget: false, _stopCts.Token).ConfigureAwait(false);
                _logger.LogTrace("Reconnected tracked browser page session '{SessionId}' to target '{TargetId}'.", _sessionId, _targetId);
                return true;
            }
            catch (OperationCanceledException) when (_stopCts.IsCancellationRequested)
            {
                return false;
            }
            catch (Exception ex)
            {
                lastError = ex;
                reconnectAttempt++;
                _connectionDiagnostics.LogReconnectAttemptFailed(reconnectAttempt, ex);
                await DisposeConnectionAsync().ConfigureAwait(false);
            }
 
            try
            {
                await Task.Delay(s_connectionRecoveryDelay, _timeProvider, _stopCts.Token).ConfigureAwait(false);
            }
            catch (OperationCanceledException) when (_stopCts.IsCancellationRequested)
            {
                return false;
            }
        }
 
        if (lastError is not null)
        {
            _connectionDiagnostics.LogReconnectFailed(lastError);
            _logger.LogDebug(lastError, "Timed out reconnecting tracked browser page session '{SessionId}'.", _sessionId);
        }
 
        return false;
    }
 
    private async ValueTask HandleEventAsync(BrowserLogsCdpProtocolEvent protocolEvent)
    {
        // Browser-level lifecycle events often are not stamped with the attached page session id, so check completion
        // first. Only after that should ordinary Runtime/Log/Network/Page events be filtered to this target session.
        if (TryGetPageCompletion(protocolEvent, _targetId, _targetSessionId) is { } pageCompletion)
        {
            _completionSource.TrySetResult(pageCompletion);
            return;
        }
 
        if (string.Equals(protocolEvent.SessionId, _targetSessionId, StringComparison.Ordinal))
        {
            await _eventHandler(protocolEvent).ConfigureAwait(false);
        }
    }
 
    private async Task<ConnectionSnapshot> GetConnectionSnapshotAsync()
    {
        await _connectionLock.WaitAsync(CancellationToken.None).ConfigureAwait(false);
        try
        {
            var connection = _connection ?? throw new InvalidOperationException("Tracked browser debug connection is not available.");
            return new ConnectionSnapshot(connection.Completion);
        }
        finally
        {
            _connectionLock.Release();
        }
    }
 
    private async Task DisposeConnectionAsync()
    {
        await _connectionLock.WaitAsync(CancellationToken.None).ConfigureAwait(false);
        try
        {
            await DisposeConnectionCoreAsync().ConfigureAwait(false);
        }
        finally
        {
            _connectionLock.Release();
        }
    }
 
    private async Task DisposeConnectionCoreAsync()
    {
        var connection = _connection;
        _connection = null;
 
        if (connection is not null)
        {
            await connection.DisposeAsync().ConfigureAwait(false);
        }
    }
 
    private sealed record ConnectionSnapshot(Task Completion);
}