| File: IBrowserHost.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 Microsoft.Extensions.Logging; namespace Aspire.Hosting; // A browser instance/process boundary that one or more tracked log sessions can share. A host either owns the // browser process (Owned) or is connected to a browser someone else launched (Adopted). This distinction drives // lifetime: an Owned host can terminate its process on disposal, an Adopted host must never close the user's real // browser. internal interface IBrowserHost : IAsyncDisposable { BrowserHostIdentity Identity { get; } BrowserHostOwnership Ownership { get; } // Browser-level WebSocket endpoint for attach/adoption hosts. Null for pipe-backed owned hosts, where CDP is only // available through the private host-owned transport. Uri? DebugEndpoint { get; } int? ProcessId { get; } // Browser identification surfaced in dashboard properties. e.g. "Microsoft Edge", "Google Chrome". string BrowserDisplayName { get; } // Completes when the host itself is no longer usable: the underlying process exited (Owned), the adopted // host was disposed, or recovery gave up. Transient CDP socket loss is intentionally not modeled as host // termination because sessions can reconnect and reattach to their targets. Task Termination { get; } // Opens a browser-level CDP connection for a page session. WebSocket-backed hosts create a new connection per // session; pipe-backed hosts return a shared/multiplexed connection over the private CDP pipe owned by this AppHost. Task<IBrowserLogsCdpConnection> CreateCdpConnectionAsync( Func<BrowserLogsCdpProtocolEvent, ValueTask> eventHandler, ILogger<BrowserLogsSessionManager> logger, CancellationToken cancellationToken); // Creates a page/tab owned by one tracked browser-log session. The returned session owns only that page target; // disposing it must never close the browser process. Host implementations hide CDP event fanout and recovery // so callers cannot accidentally share a page target or call Browser.close on an adopted browser. Task<IBrowserPageSession> CreatePageSessionAsync( string sessionId, Uri url, BrowserConnectionDiagnosticsLogger connectionDiagnostics, Func<BrowserLogsCdpProtocolEvent, ValueTask> eventHandler, CancellationToken cancellationToken); } internal interface IBrowserPageSession : IAsyncDisposable { string TargetId { get; } string TargetSessionId { get; } // Completes when this page target is no longer available: the tab was closed/crashed, CDP reported a detach, // or the host terminated. Host-level reconnects should reattach and preserve this session when possible. Task<BrowserPageSessionResult> Completion { get; } Task<BrowserLogsCaptureScreenshotResult> CaptureScreenshotAsync(CancellationToken cancellationToken); } // Normalized page-session completion signal consumed by BrowserLogsRunningSession so manager state is independent of // the exact CDP event or transport failure that ended the page. internal readonly record struct BrowserPageSessionResult(BrowserPageSessionCompletionKind CompletionKind, Exception? Error); // Small vocabulary for page lifecycle outcomes. The manager uses this to distinguish normal tab closes from crashes // or unrecoverable browser connection loss. internal enum BrowserPageSessionCompletionKind { Stopped, PageClosed, PageCrashed, BrowserExited, ConnectionLost } // Reference-counted registry handle returned to each running session. Disposing the lease is the only way a session // releases a shared host, which keeps owned/adopted browser lifetime centralized in BrowserHostRegistry. internal sealed class BrowserHostLease : IAsyncDisposable { // Lease release acquires the BrowserHostRegistry lock, which is held across CreateHostCoreAsync. Browser startup can // be slow, so the release timeout must be long enough to avoid a release-cancellation that strands the registry // reference count permanently incremented. We also swallow timeouts at the lease boundary so disposal of an owning // session never throws. private static readonly TimeSpan s_releaseTimeout = TimeSpan.FromSeconds(60); private readonly Func<CancellationToken, ValueTask> _releaseAsync; private int _disposed; public BrowserHostLease(IBrowserHost host, Func<CancellationToken, ValueTask> releaseAsync) { Host = host ?? throw new ArgumentNullException(nameof(host)); _releaseAsync = releaseAsync ?? throw new ArgumentNullException(nameof(releaseAsync)); } public IBrowserHost Host { get; } public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref _disposed, 1) != 0) { return; } using var releaseCts = new CancellationTokenSource(s_releaseTimeout); try { await _releaseAsync(releaseCts.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (releaseCts.IsCancellationRequested) { // Release contended for the registry lock past the timeout. The registry will eventually release on its // own DisposeAsync path; do not propagate to our caller (typically a session DisposeAsync) where it would // mask other cleanup failures. } } } // Stable identity used by the host registry to decide whether two requests can share a host. Two configurations that // produce the same identity must be safe to back with the same browser process. // // Keyed by (executable, user-data-root) only. Profile directory is intentionally NOT part of the identity: // Chromium's singleton is keyed by user-data-dir, so launches for different profiles under the same user data // root are forwarded into the same browser process. Profile selection is therefore a per-target concern, not a // per-host concern. // // Both paths are normalized in the constructor: rooted via Path.GetFullPath, trailing separators trimmed, and // (on Windows only) compared case-insensitively. This ensures paths that differ only in casing, slashes, or a // trailing separator collapse to the same identity, so the registry actually shares hosts in practice. internal readonly struct BrowserHostIdentity : IEquatable<BrowserHostIdentity> { private static readonly StringComparer s_pathComparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; public BrowserHostIdentity(string executablePath, string userDataRootPath) { ArgumentException.ThrowIfNullOrWhiteSpace(executablePath); ArgumentException.ThrowIfNullOrWhiteSpace(userDataRootPath); ExecutablePath = NormalizePath(executablePath); UserDataRootPath = NormalizePath(userDataRootPath); } public string ExecutablePath { get; } public string UserDataRootPath { get; } public bool Equals(BrowserHostIdentity other) => s_pathComparer.Equals(ExecutablePath, other.ExecutablePath) && s_pathComparer.Equals(UserDataRootPath, other.UserDataRootPath); public override bool Equals(object? obj) => obj is BrowserHostIdentity other && Equals(other); // Defensive against default(BrowserHostIdentity) which leaves the path strings null. StringComparer // throws on null, so coalesce to empty before hashing. A default-constructed identity is never a valid // registry key but should not crash if one accidentally ends up in a hash set. public override int GetHashCode() => HashCode.Combine( s_pathComparer.GetHashCode(ExecutablePath ?? string.Empty), s_pathComparer.GetHashCode(UserDataRootPath ?? string.Empty)); public override string ToString() => $"{ExecutablePath} ({UserDataRootPath})"; public static bool operator ==(BrowserHostIdentity left, BrowserHostIdentity right) => left.Equals(right); public static bool operator !=(BrowserHostIdentity left, BrowserHostIdentity right) => !left.Equals(right); private static string NormalizePath(string path) { var rooted = Path.GetFullPath(path); return Path.TrimEndingDirectorySeparator(rooted); } } // Describes who owns the browser process behind a host. Session disposal uses this to avoid closing a real user browser // when Aspire merely adopted an existing debug endpoint. internal enum BrowserHostOwnership { // We launched the browser process. Disposing the host kills the process and deletes our endpoint metadata. Owned, // We connected to a browser someone else launched. Disposing only closes our CDP connection and any tracked targets // we created. The browser keeps running. Adopted, }