From 016dbfb5f48e2e35a2d125b708548606f7d7ff9c Mon Sep 17 00:00:00 2001 From: David Fowler Date: Fri, 24 Apr 2026 21:47:13 -0700 Subject: [PATCH 01/36] Add BrowserUserDataMode to separate user data dir from profile Introduce a BrowserUserDataMode enum (Shared/Isolated) on the tracked browser launch path so callers can choose between launching against the browser's real user data directory (like clicking the browser icon) or a temporary, isolated user data directory. - WithBrowserLogs(...) gains a userDataMode parameter - Configuration key: Aspire:Hosting:BrowserLogs[:Resource]:UserDataMode - Default is Isolated to preserve current behavior - Setting Profile while UserDataMode is Isolated is rejected - Shared + omitted profile lets Chromium pick its default profile - Shared + profile resolves the profile inside the real user data dir - Surface effective user data mode as a resource property Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogsBuilderExtensions.cs | 81 +++++++-- .../BrowserLogs/BrowserLogsResource.cs | 29 +++- .../BrowserLogs/BrowserLogsRunningSession.cs | 164 ++++++++++++++++-- .../BrowserLogsBuilderExtensionsTests.cs | 98 ++++++++++- .../BrowserLogsSessionManagerTests.cs | 93 +++++++++- 5 files changed, 426 insertions(+), 39 deletions(-) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs index 3c2c5ad38ed..da30fd49564 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs @@ -23,6 +23,9 @@ public static class BrowserLogsBuilderExtensions internal const string BrowserExecutablePropertyName = "Browser executable"; internal const string ProfileConfigurationKey = "Profile"; internal const string ProfilePropertyName = "Profile"; + internal const string UserDataModeConfigurationKey = "UserDataMode"; + internal const string UserDataModePropertyName = "User data mode"; + internal const BrowserUserDataMode DefaultUserDataMode = BrowserUserDataMode.Isolated; internal const string TargetUrlPropertyName = "Target URL"; internal const string ActiveSessionsPropertyName = "Active sessions"; internal const string BrowserSessionsPropertyName = "Browser sessions"; @@ -44,8 +47,16 @@ public static class BrowserLogsBuilderExtensions /// browser names such as "msedge" and "chrome", or an explicit browser executable path. /// /// - /// Optional Chromium profile directory name to use. When not specified, the tracked browser uses the configured - /// value from Aspire:Hosting:BrowserLogs if present. + /// Optional Chromium profile name or directory name to use. Only valid when the effective user data mode + /// is . When not specified, the tracked browser uses the + /// configured value from Aspire:Hosting:BrowserLogs if present. + /// + /// + /// Optional that selects whether the tracked browser launches against + /// the browser's real user data directory () or a temporary + /// user data directory (). When not specified, the tracked + /// browser uses the configured value from Aspire:Hosting:BrowserLogs and otherwise defaults to + /// . /// /// A reference to the original for further chaining. /// @@ -64,10 +75,13 @@ public static class BrowserLogsBuilderExtensions /// endpoints when selecting the browser target URL. /// /// - /// Browser and profile settings can also be supplied from configuration using - /// Aspire:Hosting:BrowserLogs:Browser and Aspire:Hosting:BrowserLogs:Profile, or scoped to a - /// specific resource with Aspire:Hosting:BrowserLogs:{ResourceName}:Browser and - /// Aspire:Hosting:BrowserLogs:{ResourceName}:Profile. Explicit method arguments override configuration. + /// Browser, profile, and user data mode settings can also be supplied from configuration using + /// Aspire:Hosting:BrowserLogs:Browser, Aspire:Hosting:BrowserLogs:Profile, and + /// Aspire:Hosting:BrowserLogs:UserDataMode, or scoped to a specific resource with + /// Aspire:Hosting:BrowserLogs:{ResourceName}:Browser, + /// Aspire:Hosting:BrowserLogs:{ResourceName}:Profile, and + /// Aspire:Hosting:BrowserLogs:{ResourceName}:UserDataMode. Explicit method arguments override + /// configuration. /// /// /// @@ -82,7 +96,11 @@ public static class BrowserLogsBuilderExtensions /// [Experimental("ASPIREBROWSERLOGS001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] [AspireExport(Description = "Adds a child browser logs resource that opens tracked browser sessions and captures browser logs.")] - public static IResourceBuilder WithBrowserLogs(this IResourceBuilder builder, string? browser = null, string? profile = null) + public static IResourceBuilder WithBrowserLogs( + this IResourceBuilder builder, + string? browser = null, + string? profile = null, + BrowserUserDataMode? userDataMode = null) where T : IResourceWithEndpoints { ArgumentNullException.ThrowIfNull(builder); @@ -92,8 +110,14 @@ public static IResourceBuilder WithBrowserLogs(this IResourceBuilder bu builder.ApplicationBuilder.Services.TryAddSingleton(); var parentResource = builder.Resource; - var settings = ResolveSettings(builder.ApplicationBuilder.Configuration, parentResource.Name, browser, profile); - var browserLogsResource = new BrowserLogsResource($"{parentResource.Name}-browser-logs", parentResource, settings, browser, profile); + var settings = ResolveSettings(builder.ApplicationBuilder.Configuration, parentResource.Name, browser, profile, userDataMode); + var browserLogsResource = new BrowserLogsResource( + $"{parentResource.Name}-browser-logs", + parentResource, + settings, + browser, + profile, + userDataMode); browserLogsResource.Annotations.Add(NameValidationPolicyAnnotation.None); builder.ApplicationBuilder.AddResource(browserLogsResource) @@ -171,7 +195,8 @@ static ImmutableArray CreateInitialProperties(string r List properties = [ new(CustomResourceKnownProperties.Source, resourceName), - new(BrowserPropertyName, settings.Browser) + new(BrowserPropertyName, settings.Browser), + new(UserDataModePropertyName, settings.UserDataMode.ToString()) ]; if (settings.Profile is { } profile) @@ -222,7 +247,12 @@ static void ThrowIfBlankWhenSpecified(string? value, string paramName) } } - internal static BrowserLogsSettings ResolveSettings(IConfiguration configuration, string resourceName, string? browser, string? profile) + internal static BrowserLogsSettings ResolveSettings( + IConfiguration configuration, + string resourceName, + string? browser, + string? profile, + BrowserUserDataMode? userDataMode) { var browserLogsSection = configuration.GetSection(BrowserLogsConfigurationSectionName); var resourceSection = browserLogsSection.GetSection(resourceName); @@ -234,6 +264,10 @@ internal static BrowserLogsSettings ResolveSettings(IConfiguration configuration var resolvedProfile = profile ?? resourceSection[ProfileConfigurationKey] ?? browserLogsSection[ProfileConfigurationKey]; + var resolvedUserDataMode = userDataMode + ?? ParseUserDataMode(resourceSection[UserDataModeConfigurationKey]) + ?? ParseUserDataMode(browserLogsSection[UserDataModeConfigurationKey]) + ?? DefaultUserDataMode; if (string.IsNullOrWhiteSpace(resolvedBrowser)) { @@ -245,7 +279,30 @@ internal static BrowserLogsSettings ResolveSettings(IConfiguration configuration throw new InvalidOperationException("Tracked browser configuration resolved an empty profile value."); } - return new BrowserLogsSettings(resolvedBrowser, resolvedProfile); + if (resolvedUserDataMode == BrowserUserDataMode.Isolated && resolvedProfile is not null) + { + throw new InvalidOperationException( + $"Tracked browser configuration set '{ProfileConfigurationKey}' to '{resolvedProfile}' while '{UserDataModeConfigurationKey}' is '{BrowserUserDataMode.Isolated}'. " + + $"Profiles can only be selected when '{UserDataModeConfigurationKey}' is '{BrowserUserDataMode.Shared}'."); + } + + return new BrowserLogsSettings(resolvedBrowser, resolvedProfile, resolvedUserDataMode); + } + + private static BrowserUserDataMode? ParseUserDataMode(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + if (Enum.TryParse(value, ignoreCase: true, out var parsed)) + { + return parsed; + } + + throw new InvalidOperationException( + $"Tracked browser configuration value '{value}' is not a valid '{UserDataModeConfigurationKey}'. Expected '{BrowserUserDataMode.Shared}' or '{BrowserUserDataMode.Isolated}'."); } internal static string GetDefaultBrowser(Func resolveBrowserExecutable) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsResource.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsResource.cs index 1de6b16b1bf..d301b42aa50 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsResource.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsResource.cs @@ -6,14 +6,33 @@ namespace Aspire.Hosting; -internal readonly record struct BrowserLogsSettings(string Browser, string? Profile); +/// +/// Selects the Chromium user data directory used by tracked browser sessions. +/// +public enum BrowserUserDataMode +{ + /// + /// Use the browser's real user data directory so the tracked session reuses real cookies, sessions, + /// extensions, and profile selection. Behaves like clicking the browser icon. + /// + Shared, + + /// + /// Launch the tracked browser against a temporary user data directory so the session starts from clean + /// state and does not affect the user's normal browser profiles. + /// + Isolated, +} + +internal readonly record struct BrowserLogsSettings(string Browser, string? Profile, BrowserUserDataMode UserDataMode); internal sealed class BrowserLogsResource( string name, IResourceWithEndpoints parentResource, BrowserLogsSettings initialSettings, string? browserOverride, - string? profileOverride) + string? profileOverride, + BrowserUserDataMode? userDataModeOverride) : Resource(name) { public IResourceWithEndpoints ParentResource { get; } = parentResource; @@ -22,10 +41,14 @@ internal sealed class BrowserLogsResource( public string? Profile { get; } = initialSettings.Profile; + public BrowserUserDataMode UserDataMode { get; } = initialSettings.UserDataMode; + public string? BrowserOverride { get; } = browserOverride; public string? ProfileOverride { get; } = profileOverride; + public BrowserUserDataMode? UserDataModeOverride { get; } = userDataModeOverride; + public BrowserLogsSettings ResolveCurrentSettings(IConfiguration configuration) => - BrowserLogsBuilderExtensions.ResolveSettings(configuration, ParentResource.Name, BrowserOverride, ProfileOverride); + BrowserLogsBuilderExtensions.ResolveSettings(configuration, ParentResource.Name, BrowserOverride, ProfileOverride, UserDataModeOverride); } diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs index f2296e76204..b06869c05e5 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs @@ -5,6 +5,7 @@ using System.Globalization; using System.Text; +using System.Text.Json; using Aspire.Hosting.Dcp.Process; using Microsoft.Extensions.Logging; @@ -233,14 +234,26 @@ private async Task InitializeAsync(CancellationToken cancellationToken) throw new InvalidOperationException($"Unable to locate browser '{_settings.Browser}'. Specify an installed Chromium-based browser or an explicit executable path."); } - _userDataDirectory = CreateUserDataDirectory(_settings.Browser, _browserExecutable, _settings.Profile); + _userDataDirectory = CreateUserDataDirectory(_settings, _browserExecutable); var devToolsActivePortFilePath = GetDevToolsActivePortFilePath(); var previousBrowserEndpointWriteTimeUtc = PrepareBrowserEndpointFile(devToolsActivePortFilePath); await StartBrowserProcessAsync(cancellationToken).ConfigureAwait(false); _resourceLogger.LogInformation("[{SessionId}] Started tracked browser process '{BrowserExecutable}'.", _sessionId, _browserExecutable); if (_settings.Profile is not null) { - _resourceLogger.LogInformation("[{SessionId}] Using tracked browser profile '{Profile}'.", _sessionId, _settings.Profile); + if (_userDataDirectory?.ProfileDirectoryName is { } profileDirectoryName && + !string.Equals(profileDirectoryName, _settings.Profile, StringComparison.OrdinalIgnoreCase)) + { + _resourceLogger.LogInformation( + "[{SessionId}] Using tracked browser profile '{Profile}' (directory '{ProfileDirectoryName}').", + _sessionId, + _settings.Profile, + profileDirectoryName); + } + else + { + _resourceLogger.LogInformation("[{SessionId}] Using tracked browser profile '{Profile}'.", _sessionId, _settings.Profile); + } } _resourceLogger.LogInformation("[{SessionId}] Waiting for tracked browser debug endpoint metadata in '{DevToolsActivePortFilePath}'.", _sessionId, devToolsActivePortFilePath); @@ -308,9 +321,9 @@ private string BuildBrowserArguments() "--allow-insecure-localhost" ]; - if (_settings.Profile is not null) + if (userDataDirectory.ProfileDirectoryName is { } profileDirectoryName) { - arguments.Add($"--profile-directory={_settings.Profile}"); + arguments.Add($"--profile-directory={profileDirectoryName}"); } arguments.Add("about:blank"); @@ -671,28 +684,25 @@ private string GetDevToolsActivePortFilePath() return Path.Combine(userDataDirectory.Path, "DevToolsActivePort"); } - private BrowserLogsUserDataDirectory CreateUserDataDirectory(string browser, string browserExecutable, string? profile) + private BrowserLogsUserDataDirectory CreateUserDataDirectory(BrowserLogsSettings settings, string browserExecutable) { - if (profile is null) + if (settings.UserDataMode == BrowserUserDataMode.Isolated) { return BrowserLogsUserDataDirectory.CreateTemporary(_fileSystemService.TempDirectory.CreateTempSubdirectory("aspire-browser-logs")); } - var userDataDirectory = TryResolveBrowserUserDataDirectory(browser, browserExecutable) - ?? throw new InvalidOperationException($"Unable to resolve the user data directory for browser '{browser}'. Specify a known browser such as 'msedge' or 'chrome' when using a browser profile."); + var userDataDirectory = TryResolveBrowserUserDataDirectory(settings.Browser, browserExecutable) + ?? throw new InvalidOperationException($"Unable to resolve the user data directory for browser '{settings.Browser}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode."); if (!Directory.Exists(userDataDirectory)) { - throw new InvalidOperationException($"Browser user data directory '{userDataDirectory}' was not found for browser '{browser}'."); + throw new InvalidOperationException($"Browser user data directory '{userDataDirectory}' was not found for browser '{settings.Browser}'."); } - var profileDirectory = Path.Combine(userDataDirectory, profile); - if (!Directory.Exists(profileDirectory)) - { - throw new InvalidOperationException($"Browser profile '{profile}' was not found under '{userDataDirectory}'."); - } - - return BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory); + var profileDirectoryName = settings.Profile is { } profile + ? ResolveBrowserProfileDirectory(userDataDirectory, profile) + : null; + return BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory, profileDirectoryName); } internal static string? TryResolveBrowserExecutable(string browser) @@ -762,6 +772,91 @@ private BrowserLogsUserDataDirectory CreateUserDataDirectory(string browser, str }; } + internal static string ResolveBrowserProfileDirectory(string userDataDirectory, string profile) + { + ArgumentException.ThrowIfNullOrWhiteSpace(userDataDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(profile); + + if (!Directory.Exists(userDataDirectory)) + { + throw new InvalidOperationException($"Browser user data directory '{userDataDirectory}' was not found."); + } + + if (TryResolveBrowserProfileDirectoryFromDirectoryEntries(userDataDirectory, profile) is { } directMatch) + { + return directMatch; + } + + var localStatePath = Path.Combine(userDataDirectory, "Local State"); + if (File.Exists(localStatePath)) + { + try + { + using var localStateStream = File.OpenRead(localStatePath); + using var localStateDocument = JsonDocument.Parse(localStateStream); + if (TryResolveBrowserProfileDirectory(localStateDocument.RootElement, userDataDirectory, profile) is { } profileDirectory) + { + return profileDirectory; + } + } + catch (IOException ex) + { + throw new InvalidOperationException( + $"Unable to read Chromium profile metadata from '{localStatePath}' while resolving browser profile '{profile}'.", + ex); + } + catch (UnauthorizedAccessException ex) + { + throw new InvalidOperationException( + $"Unable to read Chromium profile metadata from '{localStatePath}' while resolving browser profile '{profile}'.", + ex); + } + catch (JsonException ex) + { + throw new InvalidOperationException( + $"Chromium profile metadata in '{localStatePath}' is invalid while resolving browser profile '{profile}'.", + ex); + } + } + + throw new InvalidOperationException( + $"Browser profile '{profile}' was not found under '{userDataDirectory}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata."); + } + + internal static string? TryResolveBrowserProfileDirectory(JsonElement localStateRoot, string userDataDirectory, string profile) + { + ArgumentException.ThrowIfNullOrWhiteSpace(userDataDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(profile); + + if (!localStateRoot.TryGetProperty("profile", out var profileElement) || + !profileElement.TryGetProperty("info_cache", out var infoCacheElement) || + infoCacheElement.ValueKind != JsonValueKind.Object) + { + return null; + } + + string? match = null; + + foreach (var profileEntry in infoCacheElement.EnumerateObject()) + { + if (!Directory.Exists(Path.Combine(userDataDirectory, profileEntry.Name)) || + !MatchesBrowserProfile(profileEntry, profile)) + { + continue; + } + + if (match is not null && !string.Equals(match, profileEntry.Name, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Browser profile '{profile}' matched multiple Chromium profiles under '{userDataDirectory}'. Specify the profile directory name instead."); + } + + match = profileEntry.Name; + } + + return match; + } + private static IEnumerable GetBrowserCandidates(string browser) { if (OperatingSystem.IsMacOS()) @@ -814,6 +909,20 @@ private static IEnumerable GetBrowserCandidates(string browser) }; } + private static string? TryResolveBrowserProfileDirectoryFromDirectoryEntries(string userDataDirectory, string profile) + { + foreach (var directoryPath in Directory.EnumerateDirectories(userDataDirectory)) + { + var directoryName = Path.GetFileName(directoryPath); + if (string.Equals(directoryName, profile, StringComparison.OrdinalIgnoreCase)) + { + return directoryName; + } + } + + return null; + } + private static BrowserKind GetBrowserKind(string browser, string browserExecutable) { if (MatchesBrowser(browser, browserExecutable, "msedge", "edge", "microsoft-edge")) @@ -871,6 +980,20 @@ private static bool MatchesBrowser(string browser, string browserExecutable, par return false; } + private static bool MatchesBrowserProfile(JsonProperty profileEntry, string profile) + { + return string.Equals(profileEntry.Name, profile, StringComparison.OrdinalIgnoreCase) || + MatchesBrowserProfileProperty(profileEntry.Value, "name", profile) || + MatchesBrowserProfileProperty(profileEntry.Value, "shortcut_name", profile); + } + + private static bool MatchesBrowserProfileProperty(JsonElement profileElement, string propertyName, string profile) + { + return profileElement.TryGetProperty(propertyName, out var propertyElement) && + propertyElement.ValueKind == JsonValueKind.String && + string.Equals(propertyElement.GetString(), profile, StringComparison.OrdinalIgnoreCase); + } + private static string BuildCommandLine(IReadOnlyList arguments) { var builder = new StringBuilder(); @@ -956,17 +1079,20 @@ private sealed class BrowserLogsUserDataDirectory : IDisposable { private readonly TempDirectory? _temporaryDirectory; - private BrowserLogsUserDataDirectory(string path, TempDirectory? temporaryDirectory) + private BrowserLogsUserDataDirectory(string path, string? profileDirectoryName, TempDirectory? temporaryDirectory) { Path = path; + ProfileDirectoryName = profileDirectoryName; _temporaryDirectory = temporaryDirectory; } public string Path { get; } - public static BrowserLogsUserDataDirectory CreatePersistent(string path) => new(path, temporaryDirectory: null); + public string? ProfileDirectoryName { get; } + + public static BrowserLogsUserDataDirectory CreatePersistent(string path, string? profileDirectoryName) => new(path, profileDirectoryName, temporaryDirectory: null); - public static BrowserLogsUserDataDirectory CreateTemporary(TempDirectory temporaryDirectory) => new(temporaryDirectory.Path, temporaryDirectory); + public static BrowserLogsUserDataDirectory CreateTemporary(TempDirectory temporaryDirectory) => new(temporaryDirectory.Path, profileDirectoryName: null, temporaryDirectory); public void Dispose() => _temporaryDirectory?.Dispose(); } diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs index a7fd8425329..3677117fd5d 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs @@ -73,6 +73,7 @@ public void WithBrowserLogs_UsesResourceSpecificConfigurationWhenArgumentsAreOmi using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:{BrowserLogsBuilderExtensions.BrowserConfigurationKey}"] = "msedge"; builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:{BrowserLogsBuilderExtensions.ProfileConfigurationKey}"] = "Default"; + builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:{BrowserLogsBuilderExtensions.UserDataModeConfigurationKey}"] = nameof(BrowserUserDataMode.Shared); builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:web:{BrowserLogsBuilderExtensions.BrowserConfigurationKey}"] = "chrome"; builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:web:{BrowserLogsBuilderExtensions.ProfileConfigurationKey}"] = "Profile 1"; @@ -164,6 +165,7 @@ public void WithBrowserLogs_ExplicitArgumentsOverrideConfiguration() using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:{BrowserLogsBuilderExtensions.BrowserConfigurationKey}"] = "chrome"; builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:{BrowserLogsBuilderExtensions.ProfileConfigurationKey}"] = "Profile 1"; + builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:{BrowserLogsBuilderExtensions.UserDataModeConfigurationKey}"] = nameof(BrowserUserDataMode.Shared); var web = builder.AddResource(new TestHttpResource("web")) .WithHttpEndpoint(targetPort: 8080) @@ -175,13 +177,104 @@ public void WithBrowserLogs_ExplicitArgumentsOverrideConfiguration() Properties = [] }); - web.WithBrowserLogs(browser: "msedge", profile: "Default"); + web.WithBrowserLogs(browser: "msedge", profile: "Default", userDataMode: BrowserUserDataMode.Shared); using var app = builder.Build(); var browserLogsResource = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); Assert.Equal("msedge", browserLogsResource.Browser); Assert.Equal("Default", browserLogsResource.Profile); + Assert.Equal(BrowserUserDataMode.Shared, browserLogsResource.UserDataMode); + } + + [Fact] + public void WithBrowserLogs_DefaultsToIsolatedUserDataMode() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var web = builder.AddResource(new TestHttpResource("web")) + .WithHttpEndpoint(targetPort: 8080) + .WithEndpoint("http", endpoint => endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", 8080)) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "TestHttp", + State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success), + Properties = [] + }); + + web.WithBrowserLogs(); + + using var app = builder.Build(); + var browserLogsResource = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + + Assert.Equal(BrowserUserDataMode.Isolated, browserLogsResource.UserDataMode); + var snapshot = browserLogsResource.Annotations.OfType().Single().InitialSnapshot; + Assert.Contains(snapshot.Properties, property => property.Name == BrowserLogsBuilderExtensions.UserDataModePropertyName && Equals(property.Value, nameof(BrowserUserDataMode.Isolated))); + } + + [Fact] + public void WithBrowserLogs_ReadsUserDataModeFromConfiguration() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:{BrowserLogsBuilderExtensions.UserDataModeConfigurationKey}"] = nameof(BrowserUserDataMode.Shared); + + var web = builder.AddResource(new TestHttpResource("web")) + .WithHttpEndpoint(targetPort: 8080) + .WithEndpoint("http", endpoint => endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", 8080)) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "TestHttp", + State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success), + Properties = [] + }); + + web.WithBrowserLogs(); + + using var app = builder.Build(); + var browserLogsResource = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + + Assert.Equal(BrowserUserDataMode.Shared, browserLogsResource.UserDataMode); + } + + [Fact] + public void WithBrowserLogs_RejectsProfileWhenUserDataModeIsIsolated() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var web = builder.AddResource(new TestHttpResource("web")) + .WithHttpEndpoint(targetPort: 8080) + .WithEndpoint("http", endpoint => endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", 8080)) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "TestHttp", + State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success), + Properties = [] + }); + + var ex = Assert.Throws( + () => web.WithBrowserLogs(profile: "Default", userDataMode: BrowserUserDataMode.Isolated)); + Assert.Contains(BrowserLogsBuilderExtensions.UserDataModeConfigurationKey, ex.Message); + } + + [Fact] + public void WithBrowserLogs_ExplicitUserDataModeOverridesConfiguration() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:{BrowserLogsBuilderExtensions.UserDataModeConfigurationKey}"] = nameof(BrowserUserDataMode.Isolated); + + var web = builder.AddResource(new TestHttpResource("web")) + .WithHttpEndpoint(targetPort: 8080) + .WithEndpoint("http", endpoint => endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", 8080)) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "TestHttp", + State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success), + Properties = [] + }); + + web.WithBrowserLogs(userDataMode: BrowserUserDataMode.Shared); + + using var app = builder.Build(); + var browserLogsResource = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + Assert.Equal(BrowserUserDataMode.Shared, browserLogsResource.UserDataMode); } [Fact] @@ -227,6 +320,7 @@ public async Task WithBrowserLogs_CommandUsesLatestConfiguredSettingsAndRefreshe builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:{BrowserLogsBuilderExtensions.BrowserConfigurationKey}"] = "chrome"; builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:{BrowserLogsBuilderExtensions.ProfileConfigurationKey}"] = "Default"; + builder.Configuration[$"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:{BrowserLogsBuilderExtensions.UserDataModeConfigurationKey}"] = nameof(BrowserUserDataMode.Shared); builder.Services.AddSingleton(sp => new BrowserLogsSessionManager( @@ -518,7 +612,7 @@ public async Task WithBrowserLogs_CommandTracksMultipleSessionsWithUniqueIds() Properties = [] }); - web.WithBrowserLogs(browser: "chrome", profile: "Default"); + web.WithBrowserLogs(browser: "chrome", profile: "Default", userDataMode: BrowserUserDataMode.Shared); using var app = builder.Build(); await app.StartAsync(); diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs index 3122b762f9a..37ddad8a87e 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs @@ -77,6 +77,79 @@ public void TryResolveBrowserUserDataDirectory_UsesChromiumPathOnLinux() Assert.Equal(expectedPath, userDataDirectory); } + [Fact] + public void ResolveBrowserProfileDirectory_MatchesDirectoryNameCaseInsensitively() + { + WithTempUserDataDirectory(userDataDirectory => + { + Directory.CreateDirectory(Path.Combine(userDataDirectory, "Profile 1")); + + var profileDirectory = BrowserLogsRunningSession.ResolveBrowserProfileDirectory(userDataDirectory, "profile 1"); + + Assert.Equal("Profile 1", profileDirectory); + }); + } + + [Fact] + public void ResolveBrowserProfileDirectory_MatchesProfileDisplayNameFromLocalState() + { + WithTempUserDataDirectory(userDataDirectory => + { + Directory.CreateDirectory(Path.Combine(userDataDirectory, "Default")); + Directory.CreateDirectory(Path.Combine(userDataDirectory, "Profile 1")); + File.WriteAllText( + Path.Combine(userDataDirectory, "Local State"), + """ + { + "profile": { + "info_cache": { + "Default": { + "name": "Profile 1" + }, + "Profile 1": { + "name": "Profile 2" + } + } + } + } + """); + + var profileDirectory = BrowserLogsRunningSession.ResolveBrowserProfileDirectory(userDataDirectory, "Profile 2"); + + Assert.Equal("Profile 1", profileDirectory); + }); + } + + [Fact] + public void ResolveBrowserProfileDirectory_ThrowsWhenDisplayNameIsAmbiguous() + { + WithTempUserDataDirectory(userDataDirectory => + { + Directory.CreateDirectory(Path.Combine(userDataDirectory, "Default")); + Directory.CreateDirectory(Path.Combine(userDataDirectory, "Profile 1")); + File.WriteAllText( + Path.Combine(userDataDirectory, "Local State"), + """ + { + "profile": { + "info_cache": { + "Default": { + "name": "Shared profile" + }, + "Profile 1": { + "name": "Shared profile" + } + } + } + } + """); + + var exception = Assert.Throws(() => BrowserLogsRunningSession.ResolveBrowserProfileDirectory(userDataDirectory, "Shared profile")); + + Assert.Contains("matched multiple Chromium profiles", exception.Message); + }); + } + [Fact] public void TrySelectTrackedTargetId_PrefersUnattachedBlankPage() { @@ -152,15 +225,16 @@ public async Task StartSessionAsync_ThrowsWhenManagerIsDisposing() var resource = new BrowserLogsResource( "web-browser-logs", new TestResourceWithEndpoints("web"), - new BrowserLogsSettings("chrome", null), + new BrowserLogsSettings("chrome", null, BrowserUserDataMode.Isolated), browserOverride: null, - profileOverride: null); + profileOverride: null, + userDataModeOverride: null); await manager.DisposeAsync(); await Assert.ThrowsAsync(() => manager.StartSessionAsync( resource, - new BrowserLogsSettings("chrome", null), + new BrowserLogsSettings("chrome", null, BrowserUserDataMode.Isolated), resource.Name, new Uri("https://localhost"), CancellationToken.None)); @@ -171,6 +245,19 @@ await Assert.ThrowsAsync(() => manager.StartSessionAsyn private static Task> CaptureLogsAsync(ResourceLoggerService resourceLoggerService, string resourceName, int targetLogCount, Action writeLogs) => ConsoleLoggingTestHelpers.CaptureLogsAsync(resourceLoggerService, resourceName, targetLogCount, writeLogs); + private static void WithTempUserDataDirectory(Action action) + { + var userDataDirectory = Directory.CreateTempSubdirectory(); + try + { + action(userDataDirectory.FullName); + } + finally + { + userDataDirectory.Delete(recursive: true); + } + } + private sealed class ThrowIfCalledSessionFactory : IBrowserLogsRunningSessionFactory { public bool WasCalled { get; private set; } From 1cdce4e8c4955f88e85241441258275d5ddc3022 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Fri, 24 Apr 2026 22:06:45 -0700 Subject: [PATCH 02/36] Make Shared the default user data mode and document singleton caveat - Flip BrowserUserDataMode default from Isolated to Shared so the tracked browser behaves like a normal browser launch by default. - Add a remarks section on BrowserUserDataMode.Shared explaining the Chromium singleton/SingletonLock + DevToolsActivePort interaction so callers understand why a second launch against an already-running real browser cannot establish a CDP endpoint. - Update the default-mode test accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogs/BrowserLogsBuilderExtensions.cs | 10 +++++----- src/Aspire.Hosting/BrowserLogs/BrowserLogsResource.cs | 8 ++++++++ .../BrowserLogsBuilderExtensionsTests.cs | 6 +++--- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs index da30fd49564..4925ea7bdc7 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs @@ -25,7 +25,7 @@ public static class BrowserLogsBuilderExtensions internal const string ProfilePropertyName = "Profile"; internal const string UserDataModeConfigurationKey = "UserDataMode"; internal const string UserDataModePropertyName = "User data mode"; - internal const BrowserUserDataMode DefaultUserDataMode = BrowserUserDataMode.Isolated; + internal const BrowserUserDataMode DefaultUserDataMode = BrowserUserDataMode.Shared; internal const string TargetUrlPropertyName = "Target URL"; internal const string ActiveSessionsPropertyName = "Active sessions"; internal const string BrowserSessionsPropertyName = "Browser sessions"; @@ -53,10 +53,10 @@ public static class BrowserLogsBuilderExtensions /// /// /// Optional that selects whether the tracked browser launches against - /// the browser's real user data directory () or a temporary - /// user data directory (). When not specified, the tracked - /// browser uses the configured value from Aspire:Hosting:BrowserLogs and otherwise defaults to - /// . + /// the browser's real user data directory (, the default) or a + /// temporary user data directory (). When not specified, the + /// tracked browser uses the configured value from Aspire:Hosting:BrowserLogs and otherwise + /// defaults to . /// /// A reference to the original for further chaining. /// diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsResource.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsResource.cs index d301b42aa50..848bc764cf6 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsResource.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsResource.cs @@ -15,6 +15,14 @@ public enum BrowserUserDataMode /// Use the browser's real user data directory so the tracked session reuses real cookies, sessions, /// extensions, and profile selection. Behaves like clicking the browser icon. /// + /// + /// NOTE: When the target browser is already running with the same user data directory, Chromium will + /// typically forward the launch to the existing instance and exit the new process. The tracked session + /// relies on --remote-debugging-port and the DevToolsActivePort file written by the + /// launched process; if launching forwards to an existing browser, the DevTools endpoint may not be + /// discoverable and the session will fail to start. Users must close existing browser windows for the + /// selected user data directory before starting a tracked session in this mode. + /// Shared, /// diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs index 3677117fd5d..fdf3ff919be 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs @@ -188,7 +188,7 @@ public void WithBrowserLogs_ExplicitArgumentsOverrideConfiguration() } [Fact] - public void WithBrowserLogs_DefaultsToIsolatedUserDataMode() + public void WithBrowserLogs_DefaultsToSharedUserDataMode() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); var web = builder.AddResource(new TestHttpResource("web")) @@ -206,9 +206,9 @@ public void WithBrowserLogs_DefaultsToIsolatedUserDataMode() using var app = builder.Build(); var browserLogsResource = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); - Assert.Equal(BrowserUserDataMode.Isolated, browserLogsResource.UserDataMode); + Assert.Equal(BrowserUserDataMode.Shared, browserLogsResource.UserDataMode); var snapshot = browserLogsResource.Annotations.OfType().Single().InitialSnapshot; - Assert.Contains(snapshot.Properties, property => property.Name == BrowserLogsBuilderExtensions.UserDataModePropertyName && Equals(property.Value, nameof(BrowserUserDataMode.Isolated))); + Assert.Contains(snapshot.Properties, property => property.Name == BrowserLogsBuilderExtensions.UserDataModePropertyName && Equals(property.Value, nameof(BrowserUserDataMode.Shared))); } [Fact] From b00e33a2bb4f5b782ea65099e85629a90f57b182 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Fri, 24 Apr 2026 23:04:09 -0700 Subject: [PATCH 03/36] Add Target lifecycle and Inspector.detached protocol parsing Adds parsing for CDP events used to detect when a tracked target ends: - Target.targetDestroyed - Target.targetCrashed - Target.detachedFromTarget - Inspector.detached Also adds command-name constants (Target.closeTarget, Target.setDiscoverTargets) that the upcoming host abstraction will use to manage tracked targets without calling Browser.close. This is purely additive; nothing wires these events up yet. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogs/BrowserLogsProtocol.cs | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsProtocol.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsProtocol.cs index aef5debca46..2049ebb78ec 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsProtocol.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsProtocol.cs @@ -46,8 +46,14 @@ internal static class BrowserLogsProtocol internal const string RuntimeEnableMethod = "Runtime.enable"; internal const string RuntimeExceptionThrownMethod = "Runtime.exceptionThrown"; internal const string TargetAttachToTargetMethod = "Target.attachToTarget"; + internal const string TargetCloseTargetMethod = "Target.closeTarget"; internal const string TargetCreateTargetMethod = "Target.createTarget"; + internal const string TargetDetachedFromTargetMethod = "Target.detachedFromTarget"; internal const string TargetGetTargetsMethod = "Target.getTargets"; + internal const string TargetSetDiscoverTargetsMethod = "Target.setDiscoverTargets"; + internal const string TargetTargetCrashedMethod = "Target.targetCrashed"; + internal const string TargetTargetDestroyedMethod = "Target.targetDestroyed"; + internal const string InspectorDetachedMethod = "Inspector.detached"; internal static BrowserLogsProtocolMessageHeader ParseMessageHeader(ReadOnlySpan framePayload) { @@ -146,6 +152,10 @@ internal static byte[] CreateCommandFrame(long id, string method, string? sessio NetworkResponseReceivedMethod => CreateResponseReceivedEvent(framePayload), NetworkLoadingFinishedMethod => CreateLoadingFinishedEvent(framePayload), NetworkLoadingFailedMethod => CreateLoadingFailedEvent(framePayload), + TargetTargetDestroyedMethod => CreateTargetDestroyedEvent(framePayload), + TargetTargetCrashedMethod => CreateTargetCrashedEvent(framePayload), + TargetDetachedFromTargetMethod => CreateDetachedFromTargetEvent(framePayload), + InspectorDetachedMethod => CreateInspectorDetachedEvent(framePayload), _ => null }; @@ -245,6 +255,36 @@ internal static string DescribeFrame(ReadOnlySpan framePayload, int maxLen : new BrowserLogsLoadingFailedEvent(envelope.SessionId, envelope.Params); } + private static BrowserLogsTargetDestroyedEvent? CreateTargetDestroyedEvent(ReadOnlySpan framePayload) + { + var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsTargetDestroyedEnvelope); + return envelope.Params is null + ? null + : new BrowserLogsTargetDestroyedEvent(envelope.SessionId, envelope.Params); + } + + private static BrowserLogsTargetCrashedEvent? CreateTargetCrashedEvent(ReadOnlySpan framePayload) + { + var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsTargetCrashedEnvelope); + return envelope.Params is null + ? null + : new BrowserLogsTargetCrashedEvent(envelope.SessionId, envelope.Params); + } + + private static BrowserLogsDetachedFromTargetEvent? CreateDetachedFromTargetEvent(ReadOnlySpan framePayload) + { + var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsDetachedFromTargetEnvelope); + return envelope.Params is null + ? null + : new BrowserLogsDetachedFromTargetEvent(envelope.SessionId, envelope.Params); + } + + private static BrowserLogsInspectorDetachedEvent? CreateInspectorDetachedEvent(ReadOnlySpan framePayload) + { + var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsInspectorDetachedEnvelope); + return new BrowserLogsInspectorDetachedEvent(envelope.SessionId, envelope.Params); + } + private static T DeserializeFrame(ReadOnlySpan framePayload, JsonTypeInfo jsonTypeInfo) where T : class { @@ -297,6 +337,18 @@ internal sealed record BrowserLogsRequestWillBeSentEvent(string? SessionId, Brow internal sealed record BrowserLogsResponseReceivedEvent(string? SessionId, BrowserLogsResponseReceivedParameters Parameters) : BrowserLogsProtocolEvent(BrowserLogsProtocol.NetworkResponseReceivedMethod, SessionId); +internal sealed record BrowserLogsTargetDestroyedEvent(string? SessionId, BrowserLogsTargetDestroyedParameters Parameters) + : BrowserLogsProtocolEvent(BrowserLogsProtocol.TargetTargetDestroyedMethod, SessionId); + +internal sealed record BrowserLogsTargetCrashedEvent(string? SessionId, BrowserLogsTargetCrashedParameters Parameters) + : BrowserLogsProtocolEvent(BrowserLogsProtocol.TargetTargetCrashedMethod, SessionId); + +internal sealed record BrowserLogsDetachedFromTargetEvent(string? SessionId, BrowserLogsDetachedFromTargetParameters Parameters) + : BrowserLogsProtocolEvent(BrowserLogsProtocol.TargetDetachedFromTargetMethod, SessionId); + +internal sealed record BrowserLogsInspectorDetachedEvent(string? SessionId, BrowserLogsInspectorDetachedParameters? Parameters) + : BrowserLogsProtocolEvent(BrowserLogsProtocol.InspectorDetachedMethod, SessionId); + internal sealed class BrowserLogsAttachToTargetResponseEnvelope { [JsonPropertyName("error")] @@ -612,6 +664,75 @@ internal class BrowserLogsSourceLocation public string? Url { get; init; } } +internal sealed class BrowserLogsTargetDestroyedEnvelope +{ + [JsonPropertyName("params")] + public BrowserLogsTargetDestroyedParameters? Params { get; init; } + + [JsonPropertyName("sessionId")] + public string? SessionId { get; init; } +} + +internal sealed class BrowserLogsTargetDestroyedParameters +{ + [JsonPropertyName("targetId")] + public string? TargetId { get; init; } +} + +internal sealed class BrowserLogsTargetCrashedEnvelope +{ + [JsonPropertyName("params")] + public BrowserLogsTargetCrashedParameters? Params { get; init; } + + [JsonPropertyName("sessionId")] + public string? SessionId { get; init; } +} + +internal sealed class BrowserLogsTargetCrashedParameters +{ + [JsonPropertyName("errorCode")] + public int? ErrorCode { get; init; } + + [JsonPropertyName("status")] + public string? Status { get; init; } + + [JsonPropertyName("targetId")] + public string? TargetId { get; init; } +} + +internal sealed class BrowserLogsDetachedFromTargetEnvelope +{ + [JsonPropertyName("params")] + public BrowserLogsDetachedFromTargetParameters? Params { get; init; } + + [JsonPropertyName("sessionId")] + public string? SessionId { get; init; } +} + +internal sealed class BrowserLogsDetachedFromTargetParameters +{ + [JsonPropertyName("sessionId")] + public string? SessionId { get; init; } + + [JsonPropertyName("targetId")] + public string? TargetId { get; init; } +} + +internal sealed class BrowserLogsInspectorDetachedEnvelope +{ + [JsonPropertyName("params")] + public BrowserLogsInspectorDetachedParameters? Params { get; init; } + + [JsonPropertyName("sessionId")] + public string? SessionId { get; init; } +} + +internal sealed class BrowserLogsInspectorDetachedParameters +{ + [JsonPropertyName("reason")] + public string? Reason { get; init; } +} + [JsonConverter(typeof(BrowserLogsProtocolValueJsonConverter))] internal abstract record BrowserLogsProtocolValue; @@ -754,11 +875,15 @@ private static BrowserLogsProtocolValue ReadValue(ref Utf8JsonReader reader, Jso [JsonSerializable(typeof(BrowserLogsCommandAckResponseEnvelope))] [JsonSerializable(typeof(BrowserLogsConsoleApiCalledEnvelope))] [JsonSerializable(typeof(BrowserLogsCreateTargetResponseEnvelope))] +[JsonSerializable(typeof(BrowserLogsDetachedFromTargetEnvelope))] [JsonSerializable(typeof(BrowserLogsGetTargetsResponseEnvelope))] [JsonSerializable(typeof(BrowserLogsExceptionThrownEnvelope))] +[JsonSerializable(typeof(BrowserLogsInspectorDetachedEnvelope))] [JsonSerializable(typeof(BrowserLogsLoadingFailedEnvelope))] [JsonSerializable(typeof(BrowserLogsLoadingFinishedEnvelope))] [JsonSerializable(typeof(BrowserLogsLogEntryAddedEnvelope))] [JsonSerializable(typeof(BrowserLogsRequestWillBeSentEnvelope))] [JsonSerializable(typeof(BrowserLogsResponseReceivedEnvelope))] +[JsonSerializable(typeof(BrowserLogsTargetCrashedEnvelope))] +[JsonSerializable(typeof(BrowserLogsTargetDestroyedEnvelope))] internal sealed partial class BrowserLogsProtocolJsonContext : JsonSerializerContext; From 1fb3f58de5b81cccd4faddba9d4096d616a12cad Mon Sep 17 00:00:00 2001 From: David Fowler Date: Fri, 24 Apr 2026 23:04:56 -0700 Subject: [PATCH 04/36] Add IBrowserHost abstraction with ownership distinction Defines the IBrowserHost interface plus BrowserHostIdentity and the BrowserHostOwnership enum. The interface separates 'a browser instance to attach CDP to' from 'a tracked log session' so the same host can back many sessions and so disposal can do the right thing for adopted browsers. Key invariants documented in the file: - Adopted hosts must never close the user's browser - Profile directory participates in identity (different profiles = different process) - Acquire/ReleaseAsync refcount, last release disposes - Completion task surfaces process exit / CDP socket close so sessions can fail fast No callers yet; this commit only adds the contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogs/IBrowserHost.cs | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs diff --git a/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs b/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs new file mode 100644 index 00000000000..ccca841f4a8 --- /dev/null +++ b/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting; + +// A browser instance 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; } + + // The CDP browser-level WebSocket endpoint. Stable for the lifetime of the host. + Uri DebugEndpoint { get; } + + // Null for Adopted hosts (we can't always discover the PID of a browser we didn't spawn) and may also become + // null for Owned hosts after the process has exited. + int? ProcessId { get; } + + // Browser identification surfaced in dashboard properties. e.g. "Microsoft Edge", "Google Chrome". + string BrowserDisplayName { get; } + + // Completes when the host should be considered dead: the underlying process exited (Owned) or the CDP socket + // closed permanently (either ownership). Sessions subscribe to this to fail fast instead of waiting on the + // target lifecycle alone. + Task Completion { get; } + + // Acquires a logical reference. Each call must be paired with ReleaseAsync. The host disposes itself when + // the last reference is released. Must be cheap and synchronous to keep the registry path simple. + void Acquire(); + + // Releases a logical reference. When the count reaches zero the host disposes its CDP connection and (for + // Owned hosts) terminates the browser process. + Task ReleaseAsync(CancellationToken cancellationToken); +} + +// Stable identity used by the host registry to decide whether two requests can share a host. Two settings that +// produce the same identity must be safe to back with the same host. Profile directory is part of the identity +// because the same user data root with two different profiles requires two browser processes. +internal readonly record struct BrowserHostIdentity( + string ExecutablePath, + string UserDataRootPath, + string? ProfileDirectory); + +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 (typically the user's already-running browser). Disposing + // only closes our CDP connection and any tracked targets we created. The browser keeps running. + Adopted, +} From 71f2f0767c2e5dc19c08c76896e3b5fa278032e0 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Fri, 24 Apr 2026 23:15:01 -0700 Subject: [PATCH 05/36] Apply review feedback to BrowserHostIdentity and document Target event routing Code-review feedback from gpt-5.4, gpt-5.3-codex, and claude-opus-4.7 on the Phase 2 foundation commits, plus a mechanical pass applying the same lens James used on the original feature PR (#16310). BrowserHostIdentity changes: - Removed ProfileDirectory from the identity. Chromium's singleton is keyed by user-data-dir, not by profile, so different profiles under the same user data root share a browser process. Profile selection is per-target, not per-host. The previous doc comment claiming otherwise was wrong. - Replaced record-struct synthesized equality (which is ordinal case-sensitive on strings) with explicit Equals/GetHashCode using StringComparer.OrdinalIgnoreCase on Windows / Ordinal elsewhere. Without this, paths that differ only in casing would create duplicate hosts on Windows. - Constructor now normalizes paths via Path.GetFullPath + TrimEndingDirectorySeparator so 'C:/foo' and 'C:\foo\' collapse to the same identity. - GetHashCode is null-safe against default(BrowserHostIdentity) since StringComparer.GetHashCode(null) throws. Protocol changes: - Documented above the new Target.* event records that the SUBJECT of these events lives in params (targetId / params.sessionId), not the envelope sessionId. The existing dispatch in BrowserLogsRunningSession filters on envelope sessionId only, so any future fanout layer that naively reuses that filter would silently drop these events. The comment makes the routing requirement unmissable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogs/BrowserLogsProtocol.cs | 9 +++ .../BrowserLogs/IBrowserHost.cs | 60 +++++++++++++++++-- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsProtocol.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsProtocol.cs index 2049ebb78ec..8f45640b8bc 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsProtocol.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsProtocol.cs @@ -337,6 +337,15 @@ internal sealed record BrowserLogsRequestWillBeSentEvent(string? SessionId, Brow internal sealed record BrowserLogsResponseReceivedEvent(string? SessionId, BrowserLogsResponseReceivedParameters Parameters) : BrowserLogsProtocolEvent(BrowserLogsProtocol.NetworkResponseReceivedMethod, SessionId); +// Target lifecycle events differ from page-domain events in routing semantics: +// - For Page/Runtime/Network/Log events, BrowserLogsProtocolEvent.SessionId is the attached page session and +// the dispatcher routes by matching it against the tracked target's session id. +// - For Target.targetDestroyed/targetCrashed and Inspector.detached, the envelope-level sessionId is typically +// absent (these are fired on the browser CDP channel, not on a target session). The SUBJECT of the event is +// carried in the parameters: targetId for target events, the parent attached sessionId for the implicit +// detach. Routing logic must not rely on BrowserLogsProtocolEvent.SessionId for these. +// - For Target.detachedFromTarget specifically, params.sessionId identifies the session that detached, which is +// the value that should be matched against the tracked target's session id. internal sealed record BrowserLogsTargetDestroyedEvent(string? SessionId, BrowserLogsTargetDestroyedParameters Parameters) : BrowserLogsProtocolEvent(BrowserLogsProtocol.TargetTargetDestroyedMethod, SessionId); diff --git a/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs b/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs index ccca841f4a8..a77fc7361cb 100644 --- a/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs +++ b/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs @@ -37,12 +37,60 @@ internal interface IBrowserHost : IAsyncDisposable } // Stable identity used by the host registry to decide whether two requests can share a host. Two settings that -// produce the same identity must be safe to back with the same host. Profile directory is part of the identity -// because the same user data root with two different profiles requires two browser processes. -internal readonly record struct BrowserHostIdentity( - string ExecutablePath, - string UserDataRootPath, - string? ProfileDirectory); +// 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 +{ + 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.IsPathRooted(path) ? path : Path.GetFullPath(path); + return Path.TrimEndingDirectorySeparator(rooted); + } +} internal enum BrowserHostOwnership { From e5d4fc481e3c5f3f4a739462db93a13756e288bf Mon Sep 17 00:00:00 2001 From: David Fowler Date: Fri, 24 Apr 2026 23:30:52 -0700 Subject: [PATCH 06/36] Harden browser logs foundation for shared browser adoption Apply the reliability fixes identified from the architecture review before continuing the larger adoption refactor: - Bound ChromeDevToolsConnection.DisposeAsync websocket close with a 3 second timeout so shutdown cannot hang indefinitely on a wedged browser. - Add Target.closeTarget and Target.setDiscoverTargets helpers and enable target discovery after each browser-CDP connection, including reconnects. - Add explicit Target lifecycle correlation helpers so future fanout routes targetDestroyed/targetCrashed by targetId and detachedFromTarget by params.sessionId instead of the usually-null envelope sessionId. - Make shared-mode default browser selection prefer Edge. Isolated mode keeps Chrome-first behavior. This avoids Chrome's default-profile remote-debugging guardrail for the default Shared experience. - Fail fast when Shared mode explicitly targets Google Chrome's default user data directory, with guidance to use Edge or Isolated mode. - Reshape the browser host contract around leases and target-session creation rather than public Acquire/Release refcount methods. This keeps exactly-once release and target ownership in the abstraction that will support adopted browsers. Adds targeted tests for the mode-aware default browser choice, browser host identity normalization/default safety, lease idempotency, and the Chrome shared-profile guard helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogsBuilderExtensions.cs | 26 ++++-- .../BrowserLogsDevToolsConnection.cs | 24 ++++- .../BrowserLogs/BrowserLogsProtocol.cs | 22 ++++- .../BrowserLogs/BrowserLogsResource.cs | 4 +- .../BrowserLogs/BrowserLogsRunningSession.cs | 27 ++++++ .../BrowserLogs/IBrowserHost.cs | 74 ++++++++++++--- .../BrowserLogsBuilderExtensionsTests.cs | 18 +++- .../BrowserLogsSessionManagerTests.cs | 92 +++++++++++++++++++ 8 files changed, 258 insertions(+), 29 deletions(-) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs index 4925ea7bdc7..8dcda3785f1 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs @@ -42,8 +42,9 @@ public static class BrowserLogsBuilderExtensions /// The resource builder. /// /// The browser to launch. When not specified, the tracked browser uses the configured value from - /// Aspire:Hosting:BrowserLogs and otherwise prefers an installed "chrome" browser, then an installed - /// "msedge" browser, before finally falling back to "chrome". Supported values include logical + /// Aspire:Hosting:BrowserLogs and otherwise prefers an installed "msedge" browser in shared user data + /// mode, an installed "chrome" browser in isolated user data mode, and finally falls back to "chrome". + /// Supported values include logical /// browser names such as "msedge" and "chrome", or an explicit browser executable path. /// /// @@ -257,10 +258,6 @@ internal static BrowserLogsSettings ResolveSettings( var browserLogsSection = configuration.GetSection(BrowserLogsConfigurationSectionName); var resourceSection = browserLogsSection.GetSection(resourceName); - var resolvedBrowser = browser - ?? resourceSection[BrowserConfigurationKey] - ?? browserLogsSection[BrowserConfigurationKey] - ?? GetDefaultBrowser(); var resolvedProfile = profile ?? resourceSection[ProfileConfigurationKey] ?? browserLogsSection[ProfileConfigurationKey]; @@ -268,6 +265,10 @@ internal static BrowserLogsSettings ResolveSettings( ?? ParseUserDataMode(resourceSection[UserDataModeConfigurationKey]) ?? ParseUserDataMode(browserLogsSection[UserDataModeConfigurationKey]) ?? DefaultUserDataMode; + var resolvedBrowser = browser + ?? resourceSection[BrowserConfigurationKey] + ?? browserLogsSection[BrowserConfigurationKey] + ?? GetDefaultBrowser(resolvedUserDataMode); if (string.IsNullOrWhiteSpace(resolvedBrowser)) { @@ -305,8 +306,17 @@ internal static BrowserLogsSettings ResolveSettings( $"Tracked browser configuration value '{value}' is not a valid '{UserDataModeConfigurationKey}'. Expected '{BrowserUserDataMode.Shared}' or '{BrowserUserDataMode.Isolated}'."); } - internal static string GetDefaultBrowser(Func resolveBrowserExecutable) + internal static string GetDefaultBrowser(Func resolveBrowserExecutable) => + GetDefaultBrowser(DefaultUserDataMode, resolveBrowserExecutable); + + internal static string GetDefaultBrowser(BrowserUserDataMode userDataMode, Func resolveBrowserExecutable) { + if (userDataMode == BrowserUserDataMode.Shared && + resolveBrowserExecutable("msedge") is not null) + { + return "msedge"; + } + if (resolveBrowserExecutable("chrome") is not null) { return "chrome"; @@ -320,5 +330,5 @@ internal static string GetDefaultBrowser(Func resolveBrowserExe return "chrome"; } - private static string GetDefaultBrowser() => GetDefaultBrowser(BrowserLogsRunningSession.TryResolveBrowserExecutable); + private static string GetDefaultBrowser(BrowserUserDataMode userDataMode) => GetDefaultBrowser(userDataMode, BrowserLogsRunningSession.TryResolveBrowserExecutable); } diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsDevToolsConnection.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsDevToolsConnection.cs index a65b4c2788d..798c3a12417 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsDevToolsConnection.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsDevToolsConnection.cs @@ -12,6 +12,7 @@ namespace Aspire.Hosting; // higher-level recovery stays in BrowserLogsRunningSession. internal sealed class ChromeDevToolsConnection : IAsyncDisposable { + private static readonly TimeSpan s_closeTimeout = TimeSpan.FromSeconds(3); private static readonly TimeSpan s_commandTimeout = TimeSpan.FromSeconds(10); private readonly CancellationTokenSource _disposeCts = new(); @@ -94,6 +95,26 @@ public Task AttachToTargetAsync(string targetId cancellationToken); } + public Task CloseTargetAsync(string targetId, CancellationToken cancellationToken) + { + return SendCommandAsync( + BrowserLogsProtocol.TargetCloseTargetMethod, + sessionId: null, + writer => writer.WriteString("targetId", targetId), + BrowserLogsProtocol.ParseCommandAckResponse, + cancellationToken); + } + + public Task EnableTargetDiscoveryAsync(CancellationToken cancellationToken) + { + return SendCommandAsync( + BrowserLogsProtocol.TargetSetDiscoverTargetsMethod, + sessionId: null, + static writer => writer.WriteBoolean("discover", true), + BrowserLogsProtocol.ParseCommandAckResponse, + cancellationToken); + } + public async Task EnablePageInstrumentationAsync(string sessionId, CancellationToken cancellationToken) { await SendCommandAsync(BrowserLogsProtocol.RuntimeEnableMethod, sessionId, writeParameters: null, BrowserLogsProtocol.ParseCommandAckResponse, cancellationToken).ConfigureAwait(false); @@ -130,7 +151,8 @@ public async ValueTask DisposeAsync() { if (_webSocket.State is WebSocketState.Open or WebSocketState.CloseReceived) { - await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Disposed", CancellationToken.None).ConfigureAwait(false); + using var closeCts = new CancellationTokenSource(s_closeTimeout); + await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Disposed", closeCts.Token).ConfigureAwait(false); } } catch diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsProtocol.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsProtocol.cs index 8f45640b8bc..63096c6b691 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsProtocol.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsProtocol.cs @@ -347,16 +347,30 @@ internal sealed record BrowserLogsResponseReceivedEvent(string? SessionId, Brows // - For Target.detachedFromTarget specifically, params.sessionId identifies the session that detached, which is // the value that should be matched against the tracked target's session id. internal sealed record BrowserLogsTargetDestroyedEvent(string? SessionId, BrowserLogsTargetDestroyedParameters Parameters) - : BrowserLogsProtocolEvent(BrowserLogsProtocol.TargetTargetDestroyedMethod, SessionId); + : BrowserLogsProtocolEvent(BrowserLogsProtocol.TargetTargetDestroyedMethod, SessionId) +{ + public string? TargetId => Parameters.TargetId; +} internal sealed record BrowserLogsTargetCrashedEvent(string? SessionId, BrowserLogsTargetCrashedParameters Parameters) - : BrowserLogsProtocolEvent(BrowserLogsProtocol.TargetTargetCrashedMethod, SessionId); + : BrowserLogsProtocolEvent(BrowserLogsProtocol.TargetTargetCrashedMethod, SessionId) +{ + public string? TargetId => Parameters.TargetId; +} internal sealed record BrowserLogsDetachedFromTargetEvent(string? SessionId, BrowserLogsDetachedFromTargetParameters Parameters) - : BrowserLogsProtocolEvent(BrowserLogsProtocol.TargetDetachedFromTargetMethod, SessionId); + : BrowserLogsProtocolEvent(BrowserLogsProtocol.TargetDetachedFromTargetMethod, SessionId) +{ + public string? DetachedSessionId => Parameters.SessionId; + + public string? TargetId => Parameters.TargetId; +} internal sealed record BrowserLogsInspectorDetachedEvent(string? SessionId, BrowserLogsInspectorDetachedParameters? Parameters) - : BrowserLogsProtocolEvent(BrowserLogsProtocol.InspectorDetachedMethod, SessionId); + : BrowserLogsProtocolEvent(BrowserLogsProtocol.InspectorDetachedMethod, SessionId) +{ + public string? Reason => Parameters?.Reason; +} internal sealed class BrowserLogsAttachToTargetResponseEnvelope { diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsResource.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsResource.cs index 848bc764cf6..6cdae63303d 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsResource.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsResource.cs @@ -21,7 +21,9 @@ public enum BrowserUserDataMode /// relies on --remote-debugging-port and the DevToolsActivePort file written by the /// launched process; if launching forwards to an existing browser, the DevTools endpoint may not be /// discoverable and the session will fail to start. Users must close existing browser windows for the - /// selected user data directory before starting a tracked session in this mode. + /// selected user data directory before starting a tracked session in this mode. Google Chrome also + /// blocks remote debugging against its default user data directory; use Microsoft Edge or + /// mode when Chrome is selected. /// Shared, diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs index b06869c05e5..fcc234f87ba 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs @@ -363,6 +363,11 @@ private async Task ConnectAsync(bool createTarget, CancellationToken cancellatio () => ChromeDevToolsConnection.ConnectAsync(browserEndpoint, HandleEventAsync, _logger, cancellationToken)).ConfigureAwait(false); _resourceLogger.LogInformation("[{SessionId}] Connected to the tracked browser debug endpoint.", _sessionId); + await ExecuteConnectionStageAsync( + "Enabling tracked browser target discovery", + () => _connection.EnableTargetDiscoveryAsync(cancellationToken)).ConfigureAwait(false); + _resourceLogger.LogInformation("[{SessionId}] Enabled tracked browser target discovery.", _sessionId); + if (createTarget) { _targetId = await GetOrCreateTrackedTargetAsync(cancellationToken).ConfigureAwait(false); @@ -699,12 +704,34 @@ private BrowserLogsUserDataDirectory CreateUserDataDirectory(BrowserLogsSettings throw new InvalidOperationException($"Browser user data directory '{userDataDirectory}' was not found for browser '{settings.Browser}'."); } + if (IsGoogleChromeDefaultUserDataDirectory(settings.Browser, browserExecutable, userDataDirectory)) + { + throw new InvalidOperationException( + $"Google Chrome blocks remote debugging against its default user data directory '{userDataDirectory}'. " + + $"Use '{BrowserLogsBuilderExtensions.UserDataModeConfigurationKey}'='{BrowserUserDataMode.Isolated}' or select Microsoft Edge for shared browser state."); + } + var profileDirectoryName = settings.Profile is { } profile ? ResolveBrowserProfileDirectory(userDataDirectory, profile) : null; return BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory, profileDirectoryName); } + internal static bool IsGoogleChromeDefaultUserDataDirectory(string browser, string browserExecutable, string userDataDirectory) + { + if (GetBrowserKind(browser, browserExecutable) != BrowserKind.Chrome || + MatchesBrowser(browser, browserExecutable, "chromium", "chromium-browser") || + TryResolveBrowserUserDataDirectory(browser, browserExecutable) is not { } defaultUserDataDirectory) + { + return false; + } + + var comparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + return comparer.Equals(NormalizePath(userDataDirectory), NormalizePath(defaultUserDataDirectory)); + + static string NormalizePath(string path) => Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); + } + internal static string? TryResolveBrowserExecutable(string browser) { if (Path.IsPathRooted(browser) && File.Exists(browser)) diff --git a/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs b/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs index a77fc7361cb..ab9797fbdc9 100644 --- a/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs +++ b/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs @@ -22,18 +22,66 @@ internal interface IBrowserHost : IAsyncDisposable // Browser identification surfaced in dashboard properties. e.g. "Microsoft Edge", "Google Chrome". string BrowserDisplayName { get; } - // Completes when the host should be considered dead: the underlying process exited (Owned) or the CDP socket - // closed permanently (either ownership). Sessions subscribe to this to fail fast instead of waiting on the - // target lifecycle alone. - Task Completion { get; } - - // Acquires a logical reference. Each call must be paired with ReleaseAsync. The host disposes itself when - // the last reference is released. Must be cheap and synchronous to keep the registry path simple. - void Acquire(); - - // Releases a logical reference. When the count reaches zero the host disposes its CDP connection and (for - // Owned hosts) terminates the browser process. - Task ReleaseAsync(CancellationToken cancellationToken); + // 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; } + + // Creates a target owned by one tracked browser-log session. The returned session owns only the target/tab; + // 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 CreateTargetSessionAsync( + string sessionId, + Uri url, + Func eventHandler, + CancellationToken cancellationToken); +} + +internal interface IBrowserTargetSession : IAsyncDisposable +{ + string TargetId { get; } + + string TargetSessionId { get; } + + // Completes when this target is no longer available: the target was closed/crashed, CDP reported a detach, + // or the host terminated. Host-level reconnects should reattach and preserve this session when possible. + Task Completion { get; } +} + +internal readonly record struct BrowserTargetSessionResult(BrowserTargetSessionCompletionKind CompletionKind, Exception? Error); + +internal enum BrowserTargetSessionCompletionKind +{ + Stopped, + TargetClosed, + TargetCrashed, + BrowserExited, + ConnectionLost +} + +internal sealed class BrowserHostLease : IAsyncDisposable +{ + private readonly Func _releaseAsync; + private int _disposed; + + public BrowserHostLease(IBrowserHost host, Func 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(TimeSpan.FromSeconds(5)); + await _releaseAsync(releaseCts.Token).ConfigureAwait(false); + } } // Stable identity used by the host registry to decide whether two requests can share a host. Two settings that @@ -87,7 +135,7 @@ public override int GetHashCode() => private static string NormalizePath(string path) { - var rooted = Path.IsPathRooted(path) ? path : Path.GetFullPath(path); + var rooted = Path.GetFullPath(path); return Path.TrimEndingDirectorySeparator(rooted); } } diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs index fdf3ff919be..a7a58e21f58 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs @@ -101,9 +101,23 @@ public void WithBrowserLogs_UsesResourceSpecificConfigurationWhenArgumentsAreOmi } [Fact] - public void GetDefaultBrowser_PrefersChromeWhenInstalled() + public void GetDefaultBrowser_PrefersEdgeWhenSharedModeAndEdgeIsInstalled() { - var browser = BrowserLogsBuilderExtensions.GetDefaultBrowser(browser => + var browser = BrowserLogsBuilderExtensions.GetDefaultBrowser(BrowserUserDataMode.Shared, browser => + browser switch + { + "chrome" => "/resolved/chrome", + "msedge" => "/resolved/edge", + _ => null + }); + + Assert.Equal("msedge", browser); + } + + [Fact] + public void GetDefaultBrowser_PrefersChromeWhenIsolatedModeAndChromeIsInstalled() + { + var browser = BrowserLogsBuilderExtensions.GetDefaultBrowser(BrowserUserDataMode.Isolated, browser => browser switch { "chrome" => "/resolved/chrome", diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs index 37ddad8a87e..b64c5aa2be1 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs @@ -34,6 +34,44 @@ public void TryParseBrowserDebugEndpoint_ReturnsNullForInvalidMetadata(string me Assert.Null(endpoint); } + [Fact] + public void BrowserHostIdentity_NormalizesTrailingDirectorySeparators() + { + WithTempUserDataDirectory(userDataDirectory => + { + var executablePath = Path.Combine(userDataDirectory, "browser"); + var identity = new BrowserHostIdentity(executablePath, userDataDirectory); + var identityWithTrailingSeparator = new BrowserHostIdentity(executablePath, userDataDirectory + Path.DirectorySeparatorChar); + + Assert.Equal(identity, identityWithTrailingSeparator); + Assert.Equal(identity.GetHashCode(), identityWithTrailingSeparator.GetHashCode()); + }); + } + + [Fact] + public void BrowserHostIdentity_DefaultValueDoesNotThrowWhenHashed() + { + var exception = Record.Exception(() => default(BrowserHostIdentity).GetHashCode()); + + Assert.Null(exception); + } + + [Fact] + public async Task BrowserHostLease_ReleasesOnlyOnce() + { + var releaseCount = 0; + var lease = new BrowserHostLease(new TestBrowserHost(), _ => + { + releaseCount++; + return ValueTask.CompletedTask; + }); + + await lease.DisposeAsync(); + await lease.DisposeAsync(); + + Assert.Equal(1, releaseCount); + } + [Fact] public void TryResolveBrowserUserDataDirectory_ReturnsExpectedPathForKnownBrowser() { @@ -54,6 +92,34 @@ public void TryResolveBrowserUserDataDirectory_ReturnsExpectedPathForKnownBrowse Assert.Equal(expectedPath, userDataDirectory); } + [Fact] + public void IsGoogleChromeDefaultUserDataDirectory_ReturnsTrueForGoogleChromeDefaultPath() + { + var browserExecutable = OperatingSystem.IsWindows() + ? @"C:\Program Files\Google\Chrome\Application\chrome.exe" + : OperatingSystem.IsMacOS() + ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + : "/usr/bin/google-chrome"; + var userDataDirectory = BrowserLogsRunningSession.TryResolveBrowserUserDataDirectory("chrome", browserExecutable); + + Assert.NotNull(userDataDirectory); + Assert.True(BrowserLogsRunningSession.IsGoogleChromeDefaultUserDataDirectory("chrome", browserExecutable, userDataDirectory)); + } + + [Fact] + public void IsGoogleChromeDefaultUserDataDirectory_ReturnsFalseForChromium() + { + var browserExecutable = OperatingSystem.IsWindows() + ? @"C:\Program Files\Chromium\Application\chrome.exe" + : OperatingSystem.IsMacOS() + ? "/Applications/Chromium.app/Contents/MacOS/Chromium" + : "/usr/bin/chromium"; + var userDataDirectory = BrowserLogsRunningSession.TryResolveBrowserUserDataDirectory("chromium", browserExecutable); + + Assert.NotNull(userDataDirectory); + Assert.False(BrowserLogsRunningSession.IsGoogleChromeDefaultUserDataDirectory("chromium", browserExecutable, userDataDirectory)); + } + [Fact] public void TryResolveBrowserUserDataDirectory_ReturnsNullForUnknownBrowser() { @@ -275,5 +341,31 @@ public Task StartSessionAsync( } } + private sealed class TestBrowserHost : IBrowserHost + { + public BrowserHostIdentity Identity { get; } = new( + Path.Combine(AppContext.BaseDirectory, "browser"), + Path.Combine(AppContext.BaseDirectory, "user-data")); + + public BrowserHostOwnership Ownership => BrowserHostOwnership.Owned; + + public Uri DebugEndpoint { get; } = new("ws://127.0.0.1/devtools/browser/test"); + + public int? ProcessId => 1; + + public string BrowserDisplayName => "Test"; + + public Task Termination { get; } = Task.CompletedTask; + + public Task CreateTargetSessionAsync( + string sessionId, + Uri url, + Func eventHandler, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + private sealed class TestResourceWithEndpoints(string name) : Resource(name), IResourceWithEndpoints; } From e6a1f56b845496e24d903d61c4e426c094dc5f08 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 25 Apr 2026 00:15:04 -0700 Subject: [PATCH 07/36] Support shared browser host adoption for browser logs Refactor tracked browser logs around shared browser hosts and per-target sessions so shared user-data mode can reuse an existing debug-enabled Chromium instance without ever closing the user's browser. Add endpoint metadata discovery, owned/adopted host ownership, target lifecycle monitoring, and stale metadata handling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogs/BrowserEndpointDiscovery.cs | 260 ++++++++ src/Aspire.Hosting/BrowserLogs/BrowserHost.cs | 314 ++++++++++ .../BrowserLogs/BrowserHostRegistry.cs | 207 +++++++ .../BrowserLogsDevToolsConnection.cs | 10 - .../BrowserLogs/BrowserLogsProtocol.cs | 1 - .../BrowserLogs/BrowserLogsRunningSession.cs | 575 +++--------------- .../BrowserLogs/BrowserLogsSessionManager.cs | 20 +- .../BrowserLogsUserDataDirectory.cs | 32 + .../BrowserLogs/BrowserTargetSession.cs | 304 +++++++++ .../BrowserLogsBuilderExtensionsTests.cs | 8 +- .../BrowserLogsSessionManagerTests.cs | 99 +++ 11 files changed, 1303 insertions(+), 527 deletions(-) create mode 100644 src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs create mode 100644 src/Aspire.Hosting/BrowserLogs/BrowserHost.cs create mode 100644 src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs create mode 100644 src/Aspire.Hosting/BrowserLogs/BrowserLogsUserDataDirectory.cs create mode 100644 src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs new file mode 100644 index 00000000000..7338604cb59 --- /dev/null +++ b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs @@ -0,0 +1,260 @@ +// 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.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting; + +internal sealed class BrowserEndpointDiscovery(ILogger logger) +{ + private static readonly TimeSpan s_probeTimeout = TimeSpan.FromSeconds(2); + private readonly ILogger _logger = logger; + + public static string GetEndpointMetadataFilePath(string userDataDirectory) => + Path.Combine(userDataDirectory, "aspire-debug-endpoint.json"); + + public async Task TryReadAndValidateAsync(BrowserHostIdentity identity, string? profileDirectoryName, CancellationToken cancellationToken) + { + var metadataPath = GetEndpointMetadataFilePath(identity.UserDataRootPath); + BrowserDebugEndpointMetadata? metadata; + + try + { + if (!File.Exists(metadataPath)) + { + return null; + } + + using var stream = File.OpenRead(metadataPath); + metadata = await JsonSerializer.DeserializeAsync(stream, BrowserEndpointJsonContext.Default.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; + } + + if (metadata is null || + metadata.SchemaVersion != BrowserDebugEndpointMetadata.CurrentSchemaVersion || + !Uri.TryCreate(metadata.Endpoint, UriKind.Absolute, out var endpoint) || + !string.Equals(NormalizePath(metadata.ExecutablePath), identity.ExecutablePath, GetPathComparison()) || + !string.Equals(NormalizePath(metadata.UserDataRootPath), 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; + } + + 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; + } + + if (!string.Equals(metadata.ProfileDirectoryName, profileDirectoryName, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"A tracked browser is already running for user data directory '{identity.UserDataRootPath}' with profile '{metadata.ProfileDirectoryName ?? "(default)"}'. " + + $"The requested profile is '{profileDirectoryName ?? "(default)"}'. Close the existing tracked browser session or use isolated user data mode."); + } + + 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"; + var metadata = new BrowserDebugEndpointMetadata + { + SchemaVersion = BrowserDebugEndpointMetadata.CurrentSchemaVersion, + Endpoint = endpoint.ToString(), + ProcessId = processId, + ExecutablePath = identity.ExecutablePath, + UserDataRootPath = identity.UserDataRootPath, + ProfileDirectoryName = profileDirectoryName, + CreatedAt = DateTimeOffset.UtcNow + }; + + try + { + using (var stream = File.Create(tempPath)) + { + await JsonSerializer.SerializeAsync(stream, metadata, BrowserEndpointJsonContext.Default.BrowserDebugEndpointMetadata, cancellationToken).ConfigureAwait(false); + } + + File.Move(tempPath, metadataPath, overwrite: true); + } + finally + { + TryDelete(tempPath); + } + } + + public static void DeleteEndpointMetadata(string userDataDirectory) => + TryDelete(GetEndpointMetadataFilePath(userDataDirectory)); + + public static void DeleteDevToolsActivePort(string userDataDirectory) => + TryDelete(Path.Combine(userDataDirectory, "DevToolsActivePort")); + + public static bool IsNonDebuggableBrowserRunning(string userDataDirectory) + { + var singletonLockPath = Path.Combine(userDataDirectory, "SingletonLock"); + FileInfo singletonLock; + try + { + singletonLock = new FileInfo(singletonLockPath); + if (!singletonLock.Exists && string.IsNullOrWhiteSpace(singletonLock.LinkTarget)) + { + return false; + } + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + + if (TryGetSingletonLockProcessId(singletonLock) is { } pid) + { + return IsProcessAlive(pid); + } + + return OperatingSystem.IsWindows(); + } + + private static async Task ProbeBrowserEndpointAsync(Uri browserEndpoint, CancellationToken cancellationToken) + { + var versionEndpoint = new UriBuilder(browserEndpoint) + { + Scheme = browserEndpoint.Scheme == Uri.UriSchemeWss ? Uri.UriSchemeHttps : Uri.UriSchemeHttp, + Path = "/json/version", + Query = null + }.Uri; + + using var httpClient = new HttpClient { Timeout = s_probeTimeout }; + using var response = await httpClient.GetAsync(versionEndpoint, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + return false; + } + + using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + return document.RootElement.TryGetProperty("webSocketDebuggerUrl", out var endpointElement) && + endpointElement.ValueKind == JsonValueKind.String && + Uri.TryCreate(endpointElement.GetString(), UriKind.Absolute, out _); + } + + private static int? TryGetSingletonLockProcessId(FileInfo singletonLock) + { + try + { + var linkTarget = singletonLock.LinkTarget; + if (string.IsNullOrWhiteSpace(linkTarget)) + { + return null; + } + + var separatorIndex = linkTarget.LastIndexOf('-'); + if (separatorIndex < 0 || separatorIndex == linkTarget.Length - 1) + { + return null; + } + + return int.TryParse(linkTarget.AsSpan(separatorIndex + 1), out var pid) ? pid : null; + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + } + + 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 StringComparison GetPathComparison() => + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + private static void TryDelete(string path) + { + try + { + File.Delete(path); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } +} + +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; } +} + +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(BrowserDebugEndpointMetadata))] +internal sealed partial class BrowserEndpointJsonContext : JsonSerializerContext; diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs b/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs new file mode 100644 index 00000000000..ede5a6e6d0b --- /dev/null +++ b/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs @@ -0,0 +1,314 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREFILESYSTEM001 // Type is for evaluation purposes only + +using Aspire.Hosting.Dcp.Process; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting; + +internal abstract class BrowserHost( + BrowserHostIdentity identity, + BrowserHostOwnership ownership, + Uri debugEndpoint, + string browserDisplayName, + ILogger logger, + TimeProvider timeProvider, + bool reuseInitialBlankTarget) : IBrowserHost +{ + private readonly ILogger _logger = logger; + private readonly bool _reuseInitialBlankTarget = reuseInitialBlankTarget; + private readonly TimeProvider _timeProvider = timeProvider; + + public BrowserHostIdentity Identity { get; } = identity; + + public BrowserHostOwnership Ownership { get; } = ownership; + + public Uri DebugEndpoint { get; } = debugEndpoint; + + public abstract int? ProcessId { get; } + + public string BrowserDisplayName { get; } = browserDisplayName; + + public abstract Task Termination { get; } + + public Task CreateTargetSessionAsync( + string sessionId, + Uri url, + Func eventHandler, + CancellationToken cancellationToken) + { + return CreateTargetSessionCoreAsync(sessionId, url, eventHandler, cancellationToken); + } + + public abstract ValueTask DisposeAsync(); + + protected static string BuildBrowserArguments(BrowserLogsUserDataDirectory userDataDirectory) + { + List arguments = + [ + $"--user-data-dir={userDataDirectory.Path}", + "--remote-debugging-address=127.0.0.1", + "--remote-debugging-port=0", + "--no-first-run", + "--no-default-browser-check", + "--new-window", + "--allow-insecure-localhost" + ]; + + if (userDataDirectory.ProfileDirectoryName is { } profileDirectoryName) + { + arguments.Add($"--profile-directory={profileDirectoryName}"); + } + + arguments.Add("about:blank"); + + return BrowserLogsRunningSession.BuildCommandLine(arguments); + } + + private async Task CreateTargetSessionCoreAsync( + string sessionId, + Uri url, + Func eventHandler, + CancellationToken cancellationToken) + { + return await BrowserTargetSession.StartAsync( + this, + sessionId, + url, + eventHandler, + _logger, + _timeProvider, + _reuseInitialBlankTarget, + cancellationToken).ConfigureAwait(false); + } +} + +internal sealed class OwnedBrowserHost : BrowserHost +{ + private static readonly TimeSpan s_browserEndpointTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan s_browserShutdownTimeout = TimeSpan.FromSeconds(5); + + private readonly BrowserLogsUserDataDirectory _userDataDirectory; + private readonly IAsyncDisposable _processLifetime; + private readonly Task _processTask; + private readonly Task _termination; + private int _disposed; + + private OwnedBrowserHost( + BrowserHostIdentity identity, + Uri debugEndpoint, + string browserDisplayName, + int processId, + BrowserLogsUserDataDirectory userDataDirectory, + IAsyncDisposable processLifetime, + Task processTask, + ILogger logger, + TimeProvider timeProvider) + : base(identity, BrowserHostOwnership.Owned, debugEndpoint, browserDisplayName, logger, timeProvider, reuseInitialBlankTarget: true) + { + _processLifetime = processLifetime; + _processTask = processTask; + _termination = processTask; + _userDataDirectory = userDataDirectory; + ProcessId = processId; + } + + public override int? ProcessId { get; } + + public override Task Termination => _termination; + + public static async Task StartAsync( + BrowserHostIdentity identity, + string browserDisplayName, + BrowserLogsUserDataDirectory userDataDirectory, + ILogger logger, + TimeProvider timeProvider, + CancellationToken cancellationToken) + { + var processStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var devToolsActivePortFilePath = Path.Combine(userDataDirectory.Path, "DevToolsActivePort"); + var previousWriteTimeUtc = PrepareBrowserEndpointFile(devToolsActivePortFilePath, logger); + BrowserEndpointDiscovery.DeleteEndpointMetadata(userDataDirectory.Path); + + var processSpec = new ProcessSpec(identity.ExecutablePath) + { + Arguments = BuildBrowserArguments(userDataDirectory), + InheritEnv = true, + OnErrorData = error => logger.LogTrace("Tracked browser stderr: {Line}", error), + OnOutputData = output => logger.LogTrace("Tracked browser stdout: {Line}", output), + OnStart = processId => processStarted.TrySetResult(processId), + ThrowOnNonZeroReturnCode = false + }; + + var (processTask, processLifetime) = ProcessUtil.Run(processSpec); + int processId; + Uri browserEndpoint; + try + { + processId = await WaitForProcessStartAsync(processStarted.Task, processTask, cancellationToken).ConfigureAwait(false); + browserEndpoint = await WaitForBrowserEndpointAsync(processTask, devToolsActivePortFilePath, previousWriteTimeUtc, timeProvider, cancellationToken).ConfigureAwait(false); + await BrowserEndpointDiscovery.WriteAsync(identity, userDataDirectory.ProfileDirectoryName, browserEndpoint, processId, cancellationToken).ConfigureAwait(false); + } + catch + { + await processLifetime.DisposeAsync().ConfigureAwait(false); + userDataDirectory.Dispose(); + throw; + } + + return new OwnedBrowserHost( + identity, + browserEndpoint, + browserDisplayName, + processId, + userDataDirectory, + processLifetime, + processTask, + logger, + timeProvider); + } + + public override async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + BrowserEndpointDiscovery.DeleteEndpointMetadata(_userDataDirectory.Path); + + await _processLifetime.DisposeAsync().ConfigureAwait(false); + + using var shutdownCts = new CancellationTokenSource(s_browserShutdownTimeout); + try + { + await _processTask.WaitAsync(shutdownCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + + _userDataDirectory.Dispose(); + } + + private static async Task WaitForProcessStartAsync(Task processStarted, Task processTask, CancellationToken cancellationToken) + { + var completedTask = await Task.WhenAny(processStarted, processTask).WaitAsync(cancellationToken).ConfigureAwait(false); + if (completedTask == processStarted) + { + return await processStarted.ConfigureAwait(false); + } + + var result = await processTask.ConfigureAwait(false); + throw new InvalidOperationException($"Tracked browser process exited with code {result.ExitCode} before reporting its process id."); + } + + private static async Task WaitForBrowserEndpointAsync( + Task processTask, + string devToolsActivePortFilePath, + DateTime? previousWriteTimeUtc, + TimeProvider timeProvider, + CancellationToken cancellationToken) + { + var timeoutAt = timeProvider.GetUtcNow() + s_browserEndpointTimeout; + + while (timeProvider.GetUtcNow() < timeoutAt) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (processTask.IsCompleted) + { + var result = await processTask.ConfigureAwait(false); + throw new InvalidOperationException( + $"Tracked browser process exited with code {result.ExitCode} before the debug endpoint metadata was written to '{devToolsActivePortFilePath}'."); + } + + try + { + if (File.Exists(devToolsActivePortFilePath)) + { + if (previousWriteTimeUtc is { } previousWriteTime && + File.GetLastWriteTimeUtc(devToolsActivePortFilePath) <= previousWriteTime) + { + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken).ConfigureAwait(false); + continue; + } + + var contents = await File.ReadAllTextAsync(devToolsActivePortFilePath, cancellationToken).ConfigureAwait(false); + if (BrowserLogsDebugEndpointParser.TryParseBrowserDebugEndpoint(contents) is { } browserEndpoint) + { + return browserEndpoint; + } + } + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken).ConfigureAwait(false); + } + + throw new TimeoutException($"Timed out waiting for the tracked browser to write '{devToolsActivePortFilePath}'."); + } + + private static DateTime? PrepareBrowserEndpointFile(string devToolsActivePortFilePath, ILogger logger) + { + if (!File.Exists(devToolsActivePortFilePath)) + { + return null; + } + + var previousWriteTimeUtc = File.GetLastWriteTimeUtc(devToolsActivePortFilePath); + + try + { + File.Delete(devToolsActivePortFilePath); + return null; + } + catch (IOException ex) + { + logger.LogDebug(ex, "Unable to delete stale tracked browser endpoint metadata '{DevToolsActivePortFilePath}'. Waiting for a fresh file instead.", devToolsActivePortFilePath); + return previousWriteTimeUtc; + } + catch (UnauthorizedAccessException ex) + { + logger.LogDebug(ex, "Unable to delete stale tracked browser endpoint metadata '{DevToolsActivePortFilePath}'. Waiting for a fresh file instead.", devToolsActivePortFilePath); + return previousWriteTimeUtc; + } + } +} + +internal sealed class AdoptedBrowserHost : BrowserHost +{ + private readonly TaskCompletionSource _terminationSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _disposed; + + public AdoptedBrowserHost( + BrowserHostIdentity identity, + Uri debugEndpoint, + string browserDisplayName, + ILogger logger, + TimeProvider timeProvider) + : base(identity, BrowserHostOwnership.Adopted, debugEndpoint, browserDisplayName, logger, timeProvider, reuseInitialBlankTarget: false) + { + } + + public override int? ProcessId => null; + + public override Task Termination => _terminationSource.Task; + + public override ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + { + _terminationSource.TrySetResult(); + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs b/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs new file mode 100644 index 00000000000..a72f5ac06fe --- /dev/null +++ b/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs @@ -0,0 +1,207 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREFILESYSTEM001 // Type is for evaluation purposes only + +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting; + +internal sealed class BrowserHostRegistry : IAsyncDisposable +{ + private readonly BrowserEndpointDiscovery _endpointDiscovery; + private readonly IFileSystemService _fileSystemService; + private readonly Dictionary _hosts = new(); + // Keep the semaphore available for late no-op releases from outstanding leases during registry disposal. + private readonly SemaphoreSlim _lock = new(1, 1); + private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + private int _disposed; + + public BrowserHostRegistry(IFileSystemService fileSystemService, ILogger logger, TimeProvider timeProvider) + { + _endpointDiscovery = new BrowserEndpointDiscovery(logger); + _fileSystemService = fileSystemService; + _logger = logger; + _timeProvider = timeProvider; + } + + public async Task AcquireAsync(BrowserLogsSettings settings, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); + + var browserExecutable = BrowserLogsRunningSession.TryResolveBrowserExecutable(settings.Browser) + ?? throw new InvalidOperationException($"Unable to locate browser '{settings.Browser}'. Specify an installed Chromium-based browser or an explicit executable path."); + var userDataDirectory = CreateUserDataDirectory(settings, browserExecutable); + var identity = new BrowserHostIdentity(browserExecutable, userDataDirectory.Path); + + await _lock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); + + if (_hosts.TryGetValue(identity, out var entry)) + { + ValidateProfileCompatibility(identity, entry.ProfileDirectoryName, userDataDirectory.ProfileDirectoryName); + entry.ReferenceCount++; + userDataDirectory.Dispose(); + return new BrowserHostLease(entry.Host, releaseAsync: token => ReleaseAsync(identity, token)); + } + + var host = await CreateHostAsync(settings, identity, userDataDirectory, cancellationToken).ConfigureAwait(false); + _hosts[identity] = new BrowserHostEntry(host, userDataDirectory.ProfileDirectoryName, ReferenceCount: 1); + return new BrowserHostLease(host, releaseAsync: token => ReleaseAsync(identity, token)); + } + catch + { + if (!_hosts.ContainsKey(identity)) + { + userDataDirectory.Dispose(); + } + + throw; + } + finally + { + _lock.Release(); + } + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + List hosts; + await _lock.WaitAsync(CancellationToken.None).ConfigureAwait(false); + try + { + hosts = [.. _hosts.Values.Select(static entry => entry.Host)]; + _hosts.Clear(); + } + finally + { + _lock.Release(); + } + + foreach (var host in hosts) + { + await host.DisposeAsync().ConfigureAwait(false); + } + } + + private async ValueTask ReleaseAsync(BrowserHostIdentity identity, CancellationToken cancellationToken) + { + IBrowserHost? hostToDispose = null; + + if (Volatile.Read(ref _disposed) != 0) + { + return; + } + + await _lock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_hosts.TryGetValue(identity, out var entry)) + { + entry.ReferenceCount--; + if (entry.ReferenceCount == 0) + { + _hosts.Remove(identity); + hostToDispose = entry.Host; + } + } + } + finally + { + _lock.Release(); + } + + if (hostToDispose is not null) + { + await hostToDispose.DisposeAsync().ConfigureAwait(false); + } + + } + + private async Task CreateHostAsync( + BrowserLogsSettings settings, + BrowserHostIdentity identity, + BrowserLogsUserDataDirectory userDataDirectory, + CancellationToken cancellationToken) + { + if (settings.UserDataMode == BrowserUserDataMode.Shared) + { + if (await _endpointDiscovery.TryReadAndValidateAsync(identity, userDataDirectory.ProfileDirectoryName, cancellationToken).ConfigureAwait(false) is { } metadata) + { + var endpoint = new Uri(metadata.Endpoint, UriKind.Absolute); + _logger.LogInformation("Adopting tracked browser host '{BrowserExecutable}' at '{Endpoint}'.", identity.ExecutablePath, endpoint); + userDataDirectory.Dispose(); + return new AdoptedBrowserHost(identity, endpoint, settings.Browser, _logger, _timeProvider); + } + + if (BrowserEndpointDiscovery.IsNonDebuggableBrowserRunning(identity.UserDataRootPath)) + { + userDataDirectory.Dispose(); + throw new InvalidOperationException( + $"Browser user data directory '{identity.UserDataRootPath}' is already in use by a non-debuggable browser. " + + $"Close that browser, use '{BrowserLogsBuilderExtensions.UserDataModeConfigurationKey}'='{BrowserUserDataMode.Isolated}', or start the browser from Aspire first."); + } + } + + _logger.LogInformation("Starting tracked browser host '{BrowserExecutable}'.", identity.ExecutablePath); + return await OwnedBrowserHost.StartAsync(identity, settings.Browser, userDataDirectory, _logger, _timeProvider, cancellationToken).ConfigureAwait(false); + } + + private BrowserLogsUserDataDirectory CreateUserDataDirectory(BrowserLogsSettings settings, string browserExecutable) + { + if (settings.UserDataMode == BrowserUserDataMode.Isolated) + { + return BrowserLogsUserDataDirectory.CreateTemporary(_fileSystemService.TempDirectory.CreateTempSubdirectory("aspire-browser-logs")); + } + + var userDataDirectory = BrowserLogsRunningSession.TryResolveBrowserUserDataDirectory(settings.Browser, browserExecutable) + ?? throw new InvalidOperationException($"Unable to resolve the user data directory for browser '{settings.Browser}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode."); + + if (!Directory.Exists(userDataDirectory)) + { + throw new InvalidOperationException($"Browser user data directory '{userDataDirectory}' was not found for browser '{settings.Browser}'."); + } + + if (BrowserLogsRunningSession.IsGoogleChromeDefaultUserDataDirectory(settings.Browser, browserExecutable, userDataDirectory)) + { + throw new InvalidOperationException( + $"Google Chrome blocks remote debugging against its default user data directory '{userDataDirectory}'. " + + $"Use '{BrowserLogsBuilderExtensions.UserDataModeConfigurationKey}'='{BrowserUserDataMode.Isolated}' or select Microsoft Edge for shared browser state."); + } + + var profileDirectoryName = settings.Profile is { } profile + ? BrowserLogsRunningSession.ResolveBrowserProfileDirectory(userDataDirectory, profile) + : null; + return BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory, profileDirectoryName); + } + + private static void ValidateProfileCompatibility(BrowserHostIdentity identity, string? existingProfileDirectoryName, string? requestedProfileDirectoryName) + { + if (requestedProfileDirectoryName is null || + string.Equals(existingProfileDirectoryName, requestedProfileDirectoryName, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + throw new InvalidOperationException( + $"A tracked browser is already running for user data directory '{identity.UserDataRootPath}' with profile '{existingProfileDirectoryName ?? "(default)"}'. " + + $"The requested profile is '{requestedProfileDirectoryName}'. Close the existing tracked browser session or use isolated user data mode."); + } + + private sealed class BrowserHostEntry(IBrowserHost host, string? profileDirectoryName, int ReferenceCount) + { + public IBrowserHost Host { get; } = host; + + public string? ProfileDirectoryName { get; } = profileDirectoryName; + + public int ReferenceCount { get; set; } = ReferenceCount; + } +} diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsDevToolsConnection.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsDevToolsConnection.cs index 798c3a12417..afdd716fdad 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsDevToolsConnection.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsDevToolsConnection.cs @@ -133,16 +133,6 @@ public Task NavigateAsync(string sessionId, Uri url, Canc cancellationToken); } - public Task CloseBrowserAsync(CancellationToken cancellationToken) - { - return SendCommandAsync( - BrowserLogsProtocol.BrowserCloseMethod, - sessionId: null, - writeParameters: null, - BrowserLogsProtocol.ParseCommandAckResponse, - cancellationToken); - } - public async ValueTask DisposeAsync() { _disposeCts.Cancel(); diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsProtocol.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsProtocol.cs index 63096c6b691..67432754f03 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsProtocol.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsProtocol.cs @@ -32,7 +32,6 @@ internal static class BrowserLogsProtocol Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; - internal const string BrowserCloseMethod = "Browser.close"; internal const string LogEnableMethod = "Log.enable"; internal const string LogEntryAddedMethod = "Log.entryAdded"; internal const string NetworkEnableMethod = "Network.enable"; diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs index fcc234f87ba..5d510355423 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs @@ -6,7 +6,6 @@ using System.Globalization; using System.Text; using System.Text.Json; -using Aspire.Hosting.Dcp.Process; using Microsoft.Extensions.Logging; namespace Aspire.Hosting; @@ -19,13 +18,13 @@ internal interface IBrowserLogsRunningSession Uri BrowserDebugEndpoint { get; } - int ProcessId { get; } + int? ProcessId { get; } DateTime StartedAt { get; } string TargetId { get; } - Task StartCompletionObserver(Func onCompleted); + Task StartCompletionObserver(Func onCompleted); Task StopAsync(CancellationToken cancellationToken); } @@ -41,15 +40,15 @@ Task StartSessionAsync( CancellationToken cancellationToken); } -internal sealed class BrowserLogsRunningSessionFactory : IBrowserLogsRunningSessionFactory +internal sealed class BrowserLogsRunningSessionFactory : IBrowserLogsRunningSessionFactory, IAsyncDisposable { - private readonly IFileSystemService _fileSystemService; + private readonly BrowserHostRegistry _browserHostRegistry; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; public BrowserLogsRunningSessionFactory(IFileSystemService fileSystemService, ILogger logger, TimeProvider timeProvider) { - _fileSystemService = fileSystemService; + _browserHostRegistry = new BrowserHostRegistry(fileSystemService, logger, timeProvider); _logger = logger; _timeProvider = timeProvider; } @@ -67,26 +66,23 @@ public async Task StartSessionAsync( resourceName, sessionId, url, - _fileSystemService, + _browserHostRegistry, resourceLogger, _logger, _timeProvider, cancellationToken).ConfigureAwait(false); } + + public ValueTask DisposeAsync() => _browserHostRegistry.DisposeAsync(); } // Owns one launched browser instance and its attached CDP target. The manager keeps aggregate dashboard state; // this type keeps per-browser lifecycle, diagnostics, and recovery. internal sealed class BrowserLogsRunningSession : IBrowserLogsRunningSession { - private static readonly TimeSpan s_browserEndpointTimeout = TimeSpan.FromSeconds(30); - private static readonly TimeSpan s_browserShutdownTimeout = TimeSpan.FromSeconds(5); - private static readonly TimeSpan s_connectionRecoveryDelay = TimeSpan.FromMilliseconds(200); - private static readonly TimeSpan s_connectionRecoveryTimeout = TimeSpan.FromSeconds(5); - private readonly BrowserEventLogger _eventLogger; private readonly BrowserConnectionDiagnosticsLogger _connectionDiagnostics; - private readonly IFileSystemService _fileSystemService; + private readonly BrowserHostRegistry _browserHostRegistry; private readonly ILogger _logger; private readonly ILogger _resourceLogger; private readonly string _resourceName; @@ -98,29 +94,27 @@ internal sealed class BrowserLogsRunningSession : IBrowserLogsRunningSession private string? _browserExecutable; private Uri? _browserEndpoint; - private Task? _browserProcessTask; - private IAsyncDisposable? _browserProcessLifetime; - private ChromeDevToolsConnection? _connection; + private BrowserHostLease? _browserHostLease; private Task? _completion; private int _cleanupState; private int? _processId; private string? _targetId; + private IBrowserTargetSession? _targetSession; private string? _targetSessionId; - private BrowserLogsUserDataDirectory? _userDataDirectory; private BrowserLogsRunningSession( BrowserLogsSettings settings, string resourceName, string sessionId, Uri url, - IFileSystemService fileSystemService, + BrowserHostRegistry browserHostRegistry, ILogger resourceLogger, ILogger logger, TimeProvider timeProvider) { _eventLogger = new BrowserEventLogger(sessionId, resourceLogger); _connectionDiagnostics = new BrowserConnectionDiagnosticsLogger(sessionId, resourceLogger); - _fileSystemService = fileSystemService; + _browserHostRegistry = browserHostRegistry; _logger = logger; _resourceLogger = resourceLogger; _resourceName = resourceName; @@ -136,7 +130,7 @@ private BrowserLogsRunningSession( public Uri BrowserDebugEndpoint => _browserEndpoint ?? throw new InvalidOperationException("Browser debugging endpoint is not available before the session starts."); - public int ProcessId => _processId ?? throw new InvalidOperationException("Browser process has not started."); + public int? ProcessId => _processId; public DateTime StartedAt { get; private set; } @@ -149,13 +143,13 @@ public static async Task StartAsync( string resourceName, string sessionId, Uri url, - IFileSystemService fileSystemService, + BrowserHostRegistry browserHostRegistry, ILogger resourceLogger, ILogger logger, TimeProvider timeProvider, CancellationToken cancellationToken) { - var session = new BrowserLogsRunningSession(settings, resourceName, sessionId, url, fileSystemService, resourceLogger, logger, timeProvider); + var session = new BrowserLogsRunningSession(settings, resourceName, sessionId, url, browserHostRegistry, resourceLogger, logger, timeProvider); try { @@ -170,7 +164,7 @@ public static async Task StartAsync( } } - public Task StartCompletionObserver(Func onCompleted) + public Task StartCompletionObserver(Func onCompleted) { return ObserveCompletionAsync(onCompleted); } @@ -179,43 +173,8 @@ public async Task StopAsync(CancellationToken cancellationToken) { _stopCts.Cancel(); - if (_connection is not null) - { - try - { - await _connection.CloseBrowserAsync(cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to close tracked browser for resource '{ResourceName}' via CDP.", _resourceName); - } - } - - if (_browserProcessTask is { IsCompleted: false } browserProcessTask) - { - OperationCanceledException? waitCanceledException = null; - using var waitCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - waitCts.CancelAfter(s_browserShutdownTimeout); - - try - { - await browserProcessTask.WaitAsync(waitCts.Token).ConfigureAwait(false); - } - catch (OperationCanceledException ex) - { - waitCanceledException = ex; - } - - if (!browserProcessTask.IsCompleted) - { - await DisposeBrowserProcessAsync().ConfigureAwait(false); - } - - if (waitCanceledException is not null && cancellationToken.IsCancellationRequested) - { - throw new OperationCanceledException(waitCanceledException.Message, waitCanceledException, cancellationToken); - } - } + await DisposeTargetSessionAsync().ConfigureAwait(false); + await DisposeBrowserHostLeaseAsync().ConfigureAwait(false); try { @@ -228,252 +187,66 @@ public async Task StopAsync(CancellationToken cancellationToken) private async Task InitializeAsync(CancellationToken cancellationToken) { - _browserExecutable = TryResolveBrowserExecutable(_settings.Browser); - if (_browserExecutable is null) - { - throw new InvalidOperationException($"Unable to locate browser '{_settings.Browser}'. Specify an installed Chromium-based browser or an explicit executable path."); - } - - _userDataDirectory = CreateUserDataDirectory(_settings, _browserExecutable); - var devToolsActivePortFilePath = GetDevToolsActivePortFilePath(); - var previousBrowserEndpointWriteTimeUtc = PrepareBrowserEndpointFile(devToolsActivePortFilePath); - await StartBrowserProcessAsync(cancellationToken).ConfigureAwait(false); - _resourceLogger.LogInformation("[{SessionId}] Started tracked browser process '{BrowserExecutable}'.", _sessionId, _browserExecutable); - if (_settings.Profile is not null) - { - if (_userDataDirectory?.ProfileDirectoryName is { } profileDirectoryName && - !string.Equals(profileDirectoryName, _settings.Profile, StringComparison.OrdinalIgnoreCase)) - { - _resourceLogger.LogInformation( - "[{SessionId}] Using tracked browser profile '{Profile}' (directory '{ProfileDirectoryName}').", - _sessionId, - _settings.Profile, - profileDirectoryName); - } - else - { - _resourceLogger.LogInformation("[{SessionId}] Using tracked browser profile '{Profile}'.", _sessionId, _settings.Profile); - } - } - _resourceLogger.LogInformation("[{SessionId}] Waiting for tracked browser debug endpoint metadata in '{DevToolsActivePortFilePath}'.", _sessionId, devToolsActivePortFilePath); - try { - _browserEndpoint = await WaitForBrowserEndpointAsync(devToolsActivePortFilePath, previousBrowserEndpointWriteTimeUtc, cancellationToken).ConfigureAwait(false); + _browserHostLease = await _browserHostRegistry.AcquireAsync(_settings, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { - _connectionDiagnostics.LogSetupFailure("Discovering the tracked browser debug endpoint", ex); + _connectionDiagnostics.LogSetupFailure("Acquiring the tracked browser host", ex); throw; } - _resourceLogger.LogInformation("[{SessionId}] Discovered tracked browser debug endpoint '{Endpoint}'.", _sessionId, _browserEndpoint); + var browserHost = _browserHostLease.Host; + _browserExecutable = browserHost.Identity.ExecutablePath; + _browserEndpoint = browserHost.DebugEndpoint; + _processId = browserHost.ProcessId; + StartedAt = _timeProvider.GetUtcNow().UtcDateTime; + _resourceLogger.LogInformation( + "[{SessionId}] Using {Ownership} tracked browser host '{BrowserExecutable}' at '{Endpoint}'.", + _sessionId, + browserHost.Ownership, + _browserExecutable, + _browserEndpoint); try { - await ConnectAsync(createTarget: true, cancellationToken).ConfigureAwait(false); + _targetSession = await browserHost.CreateTargetSessionAsync( + _sessionId, + _url, + protocolEvent => + { + _eventLogger.HandleEvent(protocolEvent); + return ValueTask.CompletedTask; + }, + cancellationToken).ConfigureAwait(false); + _targetId = _targetSession.TargetId; + _targetSessionId = _targetSession.TargetSessionId; } catch (Exception ex) { - _connectionDiagnostics.LogSetupFailure("Setting up the tracked browser debug connection", ex); + _connectionDiagnostics.LogSetupFailure("Setting up the tracked browser target", ex); throw; } _resourceLogger.LogInformation("[{SessionId}] Tracking browser console logs for '{Url}'.", _sessionId, _url); } - private async Task StartBrowserProcessAsync(CancellationToken cancellationToken) - { - var processStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var browserExecutable = _browserExecutable ?? throw new InvalidOperationException("Browser executable was not resolved."); - var processSpec = new ProcessSpec(browserExecutable) - { - Arguments = BuildBrowserArguments(), - InheritEnv = true, - OnErrorData = error => _logger.LogTrace("[{SessionId}] Tracked browser stderr: {Line}", _sessionId, error), - OnOutputData = output => _logger.LogTrace("[{SessionId}] Tracked browser stdout: {Line}", _sessionId, output), - OnStart = processId => - { - _processId = processId; - processStarted.TrySetResult(processId); - }, - ThrowOnNonZeroReturnCode = false - }; - - var (browserProcessTask, browserProcessLifetime) = ProcessUtil.Run(processSpec); - _browserProcessTask = browserProcessTask; - _browserProcessLifetime = browserProcessLifetime; - StartedAt = _timeProvider.GetUtcNow().UtcDateTime; - - await processStarted.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - } - - private string BuildBrowserArguments() - { - var userDataDirectory = _userDataDirectory ?? throw new InvalidOperationException("Browser user data directory was not initialized."); - List arguments = - [ - $"--user-data-dir={userDataDirectory.Path}", - "--remote-debugging-port=0", - "--no-first-run", - "--no-default-browser-check", - "--new-window", - "--allow-insecure-localhost" - ]; - - if (userDataDirectory.ProfileDirectoryName is { } profileDirectoryName) - { - arguments.Add($"--profile-directory={profileDirectoryName}"); - } - - arguments.Add("about:blank"); - - return BuildCommandLine(arguments); - } - - private async Task GetOrCreateTrackedTargetAsync(CancellationToken cancellationToken) - { - var targets = await ExecuteConnectionStageAsync( - "Discovering the tracked browser target", - () => _connection!.GetTargetsAsync(cancellationToken)).ConfigureAwait(false); - - if (TrySelectTrackedTargetId(targets.TargetInfos) is { } targetId) - { - _resourceLogger.LogInformation("[{SessionId}] Reusing tracked browser target '{TargetId}'.", _sessionId, targetId); - return targetId; - } - - var createTargetResult = await ExecuteConnectionStageAsync( - "Creating the tracked browser target", - () => _connection!.CreateTargetAsync(cancellationToken)).ConfigureAwait(false); - targetId = createTargetResult.TargetId - ?? throw new InvalidOperationException("Browser target creation did not return a target id."); - _resourceLogger.LogInformation("[{SessionId}] Created tracked browser target '{TargetId}'.", _sessionId, targetId); - return targetId; - } - - private async Task ConnectAsync(bool createTarget, CancellationToken cancellationToken) - { - var browserEndpoint = _browserEndpoint ?? throw new InvalidOperationException("Browser debugging endpoint is not available."); - - await DisposeConnectionAsync().ConfigureAwait(false); - - _connection = await ExecuteConnectionStageAsync( - "Connecting to the tracked browser debug endpoint", - () => ChromeDevToolsConnection.ConnectAsync(browserEndpoint, HandleEventAsync, _logger, cancellationToken)).ConfigureAwait(false); - _resourceLogger.LogInformation("[{SessionId}] Connected to the tracked browser debug endpoint.", _sessionId); - - await ExecuteConnectionStageAsync( - "Enabling tracked browser target discovery", - () => _connection.EnableTargetDiscoveryAsync(cancellationToken)).ConfigureAwait(false); - _resourceLogger.LogInformation("[{SessionId}] Enabled tracked browser target discovery.", _sessionId); - - if (createTarget) - { - _targetId = await GetOrCreateTrackedTargetAsync(cancellationToken).ConfigureAwait(false); - } - - if (_targetId is null) - { - throw new InvalidOperationException("Tracked browser target id is not available."); - } - - var attachToTargetResult = await ExecuteConnectionStageAsync( - "Attaching to the tracked browser target", - () => _connection.AttachToTargetAsync(_targetId, cancellationToken)).ConfigureAwait(false); - _targetSessionId = attachToTargetResult.SessionId - ?? throw new InvalidOperationException("Browser target attachment did not return a session id."); - _resourceLogger.LogInformation("[{SessionId}] Attached to the tracked browser target.", _sessionId); - - await ExecuteConnectionStageAsync( - "Enabling tracked browser instrumentation", - () => _connection.EnablePageInstrumentationAsync(_targetSessionId, cancellationToken)).ConfigureAwait(false); - _resourceLogger.LogInformation("[{SessionId}] Enabled tracked browser logging.", _sessionId); - - if (createTarget) - { - await ExecuteConnectionStageAsync( - "Navigating the tracked browser target", - () => _connection.NavigateAsync(_targetSessionId, _url, cancellationToken)).ConfigureAwait(false); - _resourceLogger.LogInformation("[{SessionId}] Navigated tracked browser to '{Url}'.", _sessionId, _url); - } - } - - // Wrap the CDP stage boundaries so resource logs can identify which phase failed without losing the inner cause. - private static async Task ExecuteConnectionStageAsync(string stage, Func> action) - { - try - { - return await action().ConfigureAwait(false); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - throw new InvalidOperationException($"{stage} failed.", ex); - } - } - - private static async Task ExecuteConnectionStageAsync(string stage, Func action) - { - try - { - await action().ConfigureAwait(false); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - throw new InvalidOperationException($"{stage} failed.", ex); - } - } - private async Task MonitorAsync() { try { - var browserProcessTask = _browserProcessTask ?? throw new InvalidOperationException("Browser process task is not available."); - - while (true) + var targetSession = _targetSession ?? throw new InvalidOperationException("Browser target session is not available."); + var result = await targetSession.Completion.ConfigureAwait(false); + return result.CompletionKind switch { - var connection = _connection ?? throw new InvalidOperationException("Tracked browser debug connection is not available."); - var completedTask = await Task.WhenAny(browserProcessTask, connection.Completion).ConfigureAwait(false); - - if (completedTask == browserProcessTask) - { - var processResult = await browserProcessTask.ConfigureAwait(false); - if (!_stopCts.IsCancellationRequested) - { - _resourceLogger.LogInformation("[{SessionId}] Tracked browser exited with code {ExitCode}.", _sessionId, processResult.ExitCode); - } - - return new BrowserSessionResult(processResult.ExitCode, Error: null); - } - - Exception? connectionError = null; - try - { - await connection.Completion.ConfigureAwait(false); - } - catch (Exception ex) - { - connectionError = ex; - } - - if (_stopCts.IsCancellationRequested) - { - var processResult = await browserProcessTask.ConfigureAwait(false); - return new BrowserSessionResult(processResult.ExitCode, Error: null); - } - - connectionError ??= new InvalidOperationException("The tracked browser debug connection closed without reporting a reason."); - - if (await TryReconnectAsync(connectionError).ConfigureAwait(false)) - { - continue; - } - - await DisposeBrowserProcessAsync().ConfigureAwait(false); - - var exitResult = await browserProcessTask.ConfigureAwait(false); - return new BrowserSessionResult(exitResult.ExitCode, connectionError); - } + BrowserTargetSessionCompletionKind.Stopped => new BrowserSessionResult(ExitCode: null, Error: null), + BrowserTargetSessionCompletionKind.TargetClosed => new BrowserSessionResult(ExitCode: null, Error: null), + BrowserTargetSessionCompletionKind.BrowserExited => new BrowserSessionResult(ExitCode: null, result.Error), + BrowserTargetSessionCompletionKind.TargetCrashed => new BrowserSessionResult(ExitCode: null, result.Error), + BrowserTargetSessionCompletionKind.ConnectionLost => new BrowserSessionResult(ExitCode: null, result.Error), + _ => new BrowserSessionResult(ExitCode: null, Error: null) + }; } finally { @@ -481,81 +254,7 @@ private async Task MonitorAsync() } } - private async Task TryReconnectAsync(Exception? connectionError) - { - if (_browserEndpoint is null || _targetId is null) - { - return false; - } - - connectionError ??= new InvalidOperationException("The tracked browser debug connection closed without reporting a reason."); - _connectionDiagnostics.LogConnectionLost(connectionError); - await DisposeConnectionAsync().ConfigureAwait(false); - - // Recovery reuses the existing target instead of creating a second browser/tab. If that cannot be restored - // quickly, the process is torn down so the resource state matches reality. - var reconnectDeadline = _timeProvider.GetUtcNow() + s_connectionRecoveryTimeout; - Exception? lastError = connectionError; - var attempt = 0; - - while (!_stopCts.IsCancellationRequested && _timeProvider.GetUtcNow() < reconnectDeadline) - { - if (_browserProcessTask?.IsCompleted == true) - { - return false; - } - - try - { - attempt++; - await ConnectAsync(createTarget: false, _stopCts.Token).ConfigureAwait(false); - _resourceLogger.LogInformation("[{SessionId}] Reconnected tracked browser debug connection.", _sessionId); - return true; - } - catch (OperationCanceledException) when (_stopCts.IsCancellationRequested) - { - return false; - } - catch (Exception ex) - { - lastError = ex; - _connectionDiagnostics.LogReconnectAttemptFailed(attempt, ex); - await DisposeConnectionAsync().ConfigureAwait(false); - } - - try - { - await Task.Delay(s_connectionRecoveryDelay, _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 debug session for resource '{ResourceName}' and session '{SessionId}'.", _resourceName, _sessionId); - } - - return false; - } - - private ValueTask HandleEventAsync(BrowserLogsProtocolEvent protocolEvent) - { - // The browser-level websocket can surface events for other targets. Only forward the target attached for - // this tracked browser session. - if (!string.Equals(protocolEvent.SessionId, _targetSessionId, StringComparison.Ordinal)) - { - return ValueTask.CompletedTask; - } - - _eventLogger.HandleEvent(protocolEvent); - return ValueTask.CompletedTask; - } - - private async Task ObserveCompletionAsync(Func onCompleted) + private async Task ObserveCompletionAsync(Func onCompleted) { try { @@ -575,148 +274,33 @@ private async Task CleanupAsync() return; } - await DisposeConnectionAsync().ConfigureAwait(false); - await DisposeBrowserProcessAsync().ConfigureAwait(false); + await DisposeTargetSessionAsync().ConfigureAwait(false); + await DisposeBrowserHostLeaseAsync().ConfigureAwait(false); _stopCts.Dispose(); - _userDataDirectory?.Dispose(); } - private async Task DisposeBrowserProcessAsync() + private async Task DisposeTargetSessionAsync() { - var browserProcessLifetime = _browserProcessLifetime; - _browserProcessLifetime = null; + var targetSession = _targetSession; + _targetSession = null; - if (browserProcessLifetime is not null) + if (targetSession is not null) { - await browserProcessLifetime.DisposeAsync().ConfigureAwait(false); + await targetSession.DisposeAsync().ConfigureAwait(false); } } - private async Task DisposeConnectionAsync() + private async Task DisposeBrowserHostLeaseAsync() { - var connection = _connection; - _connection = null; + var browserHostLease = _browserHostLease; + _browserHostLease = null; - if (connection is not null) + if (browserHostLease is not null) { - await connection.DisposeAsync().ConfigureAwait(false); + await browserHostLease.DisposeAsync().ConfigureAwait(false); } } - private async Task WaitForBrowserEndpointAsync(string devToolsActivePortFilePath, DateTime? previousWriteTimeUtc, CancellationToken cancellationToken) - { - var timeoutAt = _timeProvider.GetUtcNow() + s_browserEndpointTimeout; - - // Chromium chooses the actual debugging port when asked for port 0 and writes it to DevToolsActivePort. - // Waiting on that file avoids the reserve-and-release race of probing a fixed port ahead of launch. - while (_timeProvider.GetUtcNow() < timeoutAt) - { - cancellationToken.ThrowIfCancellationRequested(); - await ThrowIfBrowserExitedBeforeEndpointWasWrittenAsync(devToolsActivePortFilePath).ConfigureAwait(false); - - try - { - if (File.Exists(devToolsActivePortFilePath)) - { - if (previousWriteTimeUtc is { } previousWriteTime && - File.GetLastWriteTimeUtc(devToolsActivePortFilePath) <= previousWriteTime) - { - await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken).ConfigureAwait(false); - continue; - } - - var contents = await File.ReadAllTextAsync(devToolsActivePortFilePath, cancellationToken).ConfigureAwait(false); - if (BrowserLogsDebugEndpointParser.TryParseBrowserDebugEndpoint(contents) is { } browserEndpoint) - { - return browserEndpoint; - } - } - } - catch (IOException) - { - } - catch (UnauthorizedAccessException) - { - } - - await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken).ConfigureAwait(false); - } - - throw new TimeoutException($"Timed out waiting for the tracked browser to write '{devToolsActivePortFilePath}'."); - } - - private DateTime? PrepareBrowserEndpointFile(string devToolsActivePortFilePath) - { - if (!File.Exists(devToolsActivePortFilePath)) - { - return null; - } - - var previousWriteTimeUtc = File.GetLastWriteTimeUtc(devToolsActivePortFilePath); - - try - { - File.Delete(devToolsActivePortFilePath); - return null; - } - catch (IOException ex) - { - _logger.LogDebug(ex, "[{SessionId}] Unable to delete stale tracked browser endpoint metadata '{DevToolsActivePortFilePath}'. Waiting for a fresh file instead.", _sessionId, devToolsActivePortFilePath); - return previousWriteTimeUtc; - } - catch (UnauthorizedAccessException ex) - { - _logger.LogDebug(ex, "[{SessionId}] Unable to delete stale tracked browser endpoint metadata '{DevToolsActivePortFilePath}'. Waiting for a fresh file instead.", _sessionId, devToolsActivePortFilePath); - return previousWriteTimeUtc; - } - } - - private async Task ThrowIfBrowserExitedBeforeEndpointWasWrittenAsync(string devToolsActivePortFilePath) - { - if (_browserProcessTask is not { IsCompleted: true } browserProcessTask) - { - return; - } - - var result = await browserProcessTask.ConfigureAwait(false); - throw new InvalidOperationException( - $"Tracked browser process exited with code {result.ExitCode} before the debug endpoint metadata was written to '{devToolsActivePortFilePath}'."); - } - - private string GetDevToolsActivePortFilePath() - { - var userDataDirectory = _userDataDirectory ?? throw new InvalidOperationException("Browser user data directory was not initialized."); - return Path.Combine(userDataDirectory.Path, "DevToolsActivePort"); - } - - private BrowserLogsUserDataDirectory CreateUserDataDirectory(BrowserLogsSettings settings, string browserExecutable) - { - if (settings.UserDataMode == BrowserUserDataMode.Isolated) - { - return BrowserLogsUserDataDirectory.CreateTemporary(_fileSystemService.TempDirectory.CreateTempSubdirectory("aspire-browser-logs")); - } - - var userDataDirectory = TryResolveBrowserUserDataDirectory(settings.Browser, browserExecutable) - ?? throw new InvalidOperationException($"Unable to resolve the user data directory for browser '{settings.Browser}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode."); - - if (!Directory.Exists(userDataDirectory)) - { - throw new InvalidOperationException($"Browser user data directory '{userDataDirectory}' was not found for browser '{settings.Browser}'."); - } - - if (IsGoogleChromeDefaultUserDataDirectory(settings.Browser, browserExecutable, userDataDirectory)) - { - throw new InvalidOperationException( - $"Google Chrome blocks remote debugging against its default user data directory '{userDataDirectory}'. " + - $"Use '{BrowserLogsBuilderExtensions.UserDataModeConfigurationKey}'='{BrowserUserDataMode.Isolated}' or select Microsoft Edge for shared browser state."); - } - - var profileDirectoryName = settings.Profile is { } profile - ? ResolveBrowserProfileDirectory(userDataDirectory, profile) - : null; - return BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory, profileDirectoryName); - } - internal static bool IsGoogleChromeDefaultUserDataDirectory(string browser, string browserExecutable, string userDataDirectory) { if (GetBrowserKind(browser, browserExecutable) != BrowserKind.Chrome || @@ -1021,7 +605,7 @@ private static bool MatchesBrowserProfileProperty(JsonElement profileElement, st string.Equals(propertyElement.GetString(), profile, StringComparison.OrdinalIgnoreCase); } - private static string BuildCommandLine(IReadOnlyList arguments) + internal static string BuildCommandLine(IReadOnlyList arguments) { var builder = new StringBuilder(); @@ -1093,7 +677,7 @@ private static void AppendCommandLineArgument(StringBuilder builder, string argu builder.Append('"'); } - private sealed record BrowserSessionResult(int ExitCode, Exception? Error); + private sealed record BrowserSessionResult(int? ExitCode, Exception? Error); private enum BrowserKind { @@ -1102,27 +686,6 @@ private enum BrowserKind Chrome } - private sealed class BrowserLogsUserDataDirectory : IDisposable - { - private readonly TempDirectory? _temporaryDirectory; - - private BrowserLogsUserDataDirectory(string path, string? profileDirectoryName, TempDirectory? temporaryDirectory) - { - Path = path; - ProfileDirectoryName = profileDirectoryName; - _temporaryDirectory = temporaryDirectory; - } - - public string Path { get; } - - public string? ProfileDirectoryName { get; } - - public static BrowserLogsUserDataDirectory CreatePersistent(string path, string? profileDirectoryName) => new(path, profileDirectoryName, temporaryDirectory: null); - - public static BrowserLogsUserDataDirectory CreateTemporary(TempDirectory temporaryDirectory) => new(temporaryDirectory.Path, profileDirectoryName: null, temporaryDirectory); - - public void Dispose() => _temporaryDirectory?.Dispose(); - } } internal static class BrowserLogsDebugEndpointParser diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs index 7be8616d545..d1f3bc71bd7 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs @@ -194,6 +194,11 @@ public async ValueTask DisposeAsync() { resourceState.Lock.Dispose(); } + + if (_sessionFactory is IAsyncDisposable asyncDisposableFactory) + { + await asyncDisposableFactory.DisposeAsync().ConfigureAwait(false); + } } private async Task HandleSessionCompletedAsync( @@ -201,7 +206,7 @@ private async Task HandleSessionCompletedAsync( string resourceName, ResourceSessionState resourceState, string sessionId, - int exitCode, + int? exitCode, Exception? error) { if (Volatile.Read(ref _disposing) != 0) @@ -225,7 +230,7 @@ private async Task HandleSessionCompletedAsync( : error switch { not null => (KnownResourceStates.Exited, KnownResourceStateStyles.Error), - null when exitCode == 0 => (KnownResourceStates.Finished, KnownResourceStateStyles.Success), + null when exitCode is null or 0 => (KnownResourceStates.Finished, KnownResourceStateStyles.Success), _ => (KnownResourceStates.Exited, KnownResourceStateStyles.Error) }; @@ -281,7 +286,7 @@ private ImmutableArray GetHealthReports(ResourceSessionSta reports.Add(new HealthReportSnapshot( session.SessionId, HealthStatus.Healthy, - $"PID {session.ProcessId} targeting {session.TargetUrl}", + $"{FormatProcessId(session.ProcessId)} targeting {session.TargetUrl}", null) { LastRunAt = runAt @@ -373,7 +378,7 @@ private static string FormatActiveSessions(IEnumerable ses { var activeSessions = sessions .OrderBy(static session => session.SessionId, StringComparer.Ordinal) - .Select(static session => $"{session.SessionId} (PID {session.ProcessId})") + .Select(static session => $"{session.SessionId} ({FormatProcessId(session.ProcessId)})") .ToArray(); return activeSessions.Length > 0 @@ -430,13 +435,16 @@ private sealed class ResourceSessionState public string? LastProfile { get; set; } } + private static string FormatProcessId(int? processId) => + processId is { } pid ? $"PID {pid}" : "adopted browser"; + private sealed record ActiveBrowserSession( string SessionId, string Browser, string BrowserExecutable, string? Profile, Uri BrowserDebugEndpoint, - int ProcessId, + int? ProcessId, DateTime StartedAt, string TargetId, Uri TargetUrl, @@ -447,7 +455,7 @@ private sealed record BrowserSessionPropertyValue( string SessionId, string Browser, string BrowserExecutable, - int ProcessId, + int? ProcessId, string? Profile, DateTime StartedAt, string TargetUrl, diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsUserDataDirectory.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsUserDataDirectory.cs new file mode 100644 index 00000000000..47e4ec72044 --- /dev/null +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsUserDataDirectory.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREFILESYSTEM001 // Type is for evaluation purposes only + +namespace Aspire.Hosting; + +internal sealed class BrowserLogsUserDataDirectory : IDisposable +{ + private readonly TempDirectory? _temporaryDirectory; + + private BrowserLogsUserDataDirectory(string path, string? profileDirectoryName, TempDirectory? temporaryDirectory) + { + Path = path; + ProfileDirectoryName = profileDirectoryName; + _temporaryDirectory = temporaryDirectory; + } + + public string Path { get; } + + public string? ProfileDirectoryName { get; } + + public bool IsTemporary => _temporaryDirectory is not null; + + public static BrowserLogsUserDataDirectory CreatePersistent(string path, string? profileDirectoryName) => + new(path, profileDirectoryName, temporaryDirectory: null); + + public static BrowserLogsUserDataDirectory CreateTemporary(TempDirectory temporaryDirectory) => + new(temporaryDirectory.Path, profileDirectoryName: null, temporaryDirectory); + + public void Dispose() => _temporaryDirectory?.Dispose(); +} diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs b/src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs new file mode 100644 index 00000000000..5e49086c9f6 --- /dev/null +++ b/src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs @@ -0,0 +1,304 @@ +// 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; + +internal sealed class BrowserTargetSession : IBrowserTargetSession +{ + private static readonly TimeSpan s_connectionRecoveryDelay = TimeSpan.FromMilliseconds(200); + private static readonly TimeSpan s_connectionRecoveryTimeout = TimeSpan.FromSeconds(5); + + private readonly TaskCompletionSource _completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Func _eventHandler; + private readonly IBrowserHost _host; + private readonly ILogger _logger; + private readonly bool _reuseInitialBlankTarget; + private readonly string _sessionId; + private readonly CancellationTokenSource _stopCts = new(); + private readonly TimeProvider _timeProvider; + private readonly Uri _url; + + private ChromeDevToolsConnection? _connection; + private Task? _monitorTask; + private int _disposed; + private string? _targetId; + private string? _targetSessionId; + + private BrowserTargetSession( + IBrowserHost host, + string sessionId, + Uri url, + Func eventHandler, + ILogger logger, + TimeProvider timeProvider, + bool reuseInitialBlankTarget) + { + _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 Completion => _monitorTask ?? throw new InvalidOperationException("Browser target session has not started."); + + public static async Task StartAsync( + IBrowserHost host, + string sessionId, + Uri url, + Func eventHandler, + ILogger logger, + TimeProvider timeProvider, + bool reuseInitialBlankTarget, + CancellationToken cancellationToken) + { + var targetSession = new BrowserTargetSession(host, sessionId, url, eventHandler, logger, timeProvider, reuseInitialBlankTarget); + try + { + await targetSession.ConnectAsync(createTarget: true, cancellationToken).ConfigureAwait(false); + targetSession._monitorTask = targetSession.MonitorAsync(); + return targetSession; + } + catch + { + await targetSession.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _stopCts.Cancel(); + + var connection = _connection; + if (connection is not null && _targetId is not null) + { + try + { + using var closeTargetCts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + 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); + } + } + + _completionSource.TrySetResult(new BrowserTargetSessionResult(BrowserTargetSessionCompletionKind.Stopped, Error: null)); + + await DisposeConnectionAsync().ConfigureAwait(false); + + if (_monitorTask is not null) + { + try + { + await _monitorTask.ConfigureAwait(false); + } + catch + { + } + } + + _stopCts.Dispose(); + } + + private async Task ConnectAsync(bool createTarget, CancellationToken cancellationToken) + { + await DisposeConnectionAsync().ConfigureAwait(false); + + _connection = await ChromeDevToolsConnection.ConnectAsync(_host.DebugEndpoint, HandleEventAsync, _logger, cancellationToken).ConfigureAwait(false); + 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."); + } + + var attachToTargetResult = await _connection.AttachToTargetAsync(_targetId, cancellationToken).ConfigureAwait(false); + _targetSessionId = attachToTargetResult.SessionId + ?? throw new InvalidOperationException("Browser target attachment did not return a session id."); + + await _connection.EnablePageInstrumentationAsync(_targetSessionId, cancellationToken).ConfigureAwait(false); + + if (createTarget) + { + await _connection.NavigateAsync(_targetSessionId, _url, cancellationToken).ConfigureAwait(false); + } + } + + private async Task CreateTargetAsync(CancellationToken cancellationToken) + { + if (_reuseInitialBlankTarget && _connection is not null) + { + var targets = await _connection.GetTargetsAsync(cancellationToken).ConfigureAwait(false); + if (BrowserLogsRunningSession.TrySelectTrackedTargetId(targets.TargetInfos) is { } targetId) + { + return targetId; + } + } + + var createTargetResult = await _connection!.CreateTargetAsync(cancellationToken).ConfigureAwait(false); + return createTargetResult.TargetId + ?? throw new InvalidOperationException("Browser target creation did not return a target id."); + } + + private async Task MonitorAsync() + { + try + { + while (true) + { + var connection = _connection ?? throw new InvalidOperationException("Tracked browser debug connection is not available."); + var completedTask = await Task.WhenAny(_host.Termination, connection.Completion, _completionSource.Task).ConfigureAwait(false); + + if (completedTask == _completionSource.Task) + { + return await _completionSource.Task.ConfigureAwait(false); + } + + if (completedTask == _host.Termination) + { + return new BrowserTargetSessionResult(BrowserTargetSessionCompletionKind.BrowserExited, Error: null); + } + + Exception? connectionError = null; + try + { + await connection.Completion.ConfigureAwait(false); + } + catch (Exception ex) + { + connectionError = ex; + } + + if (_stopCts.IsCancellationRequested) + { + return new BrowserTargetSessionResult(BrowserTargetSessionCompletionKind.Stopped, Error: null); + } + + connectionError ??= new InvalidOperationException("The tracked browser debug connection closed without reporting a reason."); + if (await TryReconnectAsync(connectionError).ConfigureAwait(false)) + { + continue; + } + + return new BrowserTargetSessionResult(BrowserTargetSessionCompletionKind.ConnectionLost, connectionError); + } + } + finally + { + await DisposeConnectionAsync().ConfigureAwait(false); + } + } + + private async Task TryReconnectAsync(Exception connectionError) + { + await DisposeConnectionAsync().ConfigureAwait(false); + + var reconnectDeadline = _timeProvider.GetUtcNow() + s_connectionRecoveryTimeout; + Exception? lastError = connectionError; + + while (!_stopCts.IsCancellationRequested && _timeProvider.GetUtcNow() < reconnectDeadline) + { + if (_host.Termination.IsCompleted) + { + return false; + } + + try + { + await ConnectAsync(createTarget: false, _stopCts.Token).ConfigureAwait(false); + return true; + } + catch (OperationCanceledException) when (_stopCts.IsCancellationRequested) + { + return false; + } + catch (Exception ex) + { + lastError = ex; + await DisposeConnectionAsync().ConfigureAwait(false); + } + + try + { + await Task.Delay(s_connectionRecoveryDelay, _stopCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (_stopCts.IsCancellationRequested) + { + return false; + } + } + + if (lastError is not null) + { + _logger.LogDebug(lastError, "Timed out reconnecting tracked browser target session '{SessionId}'.", _sessionId); + } + + return false; + } + + private async ValueTask HandleEventAsync(BrowserLogsProtocolEvent protocolEvent) + { + switch (protocolEvent) + { + case BrowserLogsTargetDestroyedEvent targetDestroyed when string.Equals(targetDestroyed.TargetId, _targetId, StringComparison.Ordinal): + _completionSource.TrySetResult(new BrowserTargetSessionResult(BrowserTargetSessionCompletionKind.TargetClosed, Error: null)); + return; + case BrowserLogsTargetCrashedEvent targetCrashed when string.Equals(targetCrashed.TargetId, _targetId, StringComparison.Ordinal): + _completionSource.TrySetResult(new BrowserTargetSessionResult( + BrowserTargetSessionCompletionKind.TargetCrashed, + new InvalidOperationException($"Tracked browser target crashed with status '{targetCrashed.Parameters.Status}' and error code '{targetCrashed.Parameters.ErrorCode}'."))); + return; + case BrowserLogsDetachedFromTargetEvent detached when + string.Equals(detached.DetachedSessionId, _targetSessionId, StringComparison.Ordinal) || + string.Equals(detached.TargetId, _targetId, StringComparison.Ordinal): + _completionSource.TrySetResult(new BrowserTargetSessionResult(BrowserTargetSessionCompletionKind.TargetClosed, Error: null)); + return; + case BrowserLogsInspectorDetachedEvent inspectorDetached when string.Equals(inspectorDetached.SessionId, _targetSessionId, StringComparison.Ordinal): + var completionKind = string.Equals(inspectorDetached.Reason, "target_closed", StringComparison.OrdinalIgnoreCase) + ? BrowserTargetSessionCompletionKind.TargetClosed + : BrowserTargetSessionCompletionKind.ConnectionLost; + _completionSource.TrySetResult(new BrowserTargetSessionResult( + completionKind, + completionKind == BrowserTargetSessionCompletionKind.ConnectionLost + ? new InvalidOperationException($"Tracked browser inspector detached: {inspectorDetached.Reason ?? "unknown reason"}.") + : null)); + return; + } + + if (string.Equals(protocolEvent.SessionId, _targetSessionId, StringComparison.Ordinal)) + { + await _eventHandler(protocolEvent).ConfigureAwait(false); + } + } + + private async Task DisposeConnectionAsync() + { + var connection = _connection; + _connection = null; + + if (connection is not null) + { + await connection.DisposeAsync().ConfigureAwait(false); + } + } +} diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs index a7a58e21f58..a4b4acf66b9 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs @@ -978,7 +978,7 @@ private sealed class FakeBrowserLogsRunningSession( public Uri BrowserDebugEndpoint { get; } = new($"ws://127.0.0.1:{processId + 8000}/devtools/browser/browser-{processId - 1000}"); - public int ProcessId { get; } = processId; + public int? ProcessId { get; } = processId; public DateTime StartedAt { get; } = startedAt; @@ -990,7 +990,7 @@ private sealed class FakeBrowserLogsRunningSession( private TaskCompletionSource CompletionObserverStartedSource { get; set; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - public Task StartCompletionObserver(Func onCompleted) + public Task StartCompletionObserver(Func onCompleted) { _completionObserverTask = ObserveCompletionAsync(onCompleted); return _completionObserverTask; @@ -1020,7 +1020,7 @@ public void ResumeCompletionObserver() _completionObserverGate.TrySetResult(null); } - private async Task ObserveCompletionAsync(Func onCompleted) + private async Task ObserveCompletionAsync(Func onCompleted) { var (exitCode, error) = await _completionSource.Task; CompletionObserverStartedSource.TrySetResult(null); @@ -1042,7 +1042,7 @@ private sealed record BrowserSessionPropertyValue( string SessionId, string Browser, string BrowserExecutable, - int ProcessId, + int? ProcessId, string? Profile, DateTime StartedAt, string TargetUrl, diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs index b64c5aa2be1..5cf21fe9468 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs @@ -1,7 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Net; +using System.Net.Sockets; using System.Net.WebSockets; +using System.Text; using Aspire.Hosting.Tests.Utils; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -72,6 +75,68 @@ public async Task BrowserHostLease_ReleasesOnlyOnce() Assert.Equal(1, releaseCount); } + [Fact] + public async Task BrowserEndpointDiscovery_DeletesStaleMetadataBeforeProfileCompatibilityCheck() + { + var userDataDirectory = Directory.CreateTempSubdirectory(); + try + { + var identity = new BrowserHostIdentity( + Path.Combine(userDataDirectory.FullName, "browser"), + userDataDirectory.FullName); + var metadataPath = BrowserEndpointDiscovery.GetEndpointMetadataFilePath(userDataDirectory.FullName); + var discovery = new BrowserEndpointDiscovery(NullLogger.Instance); + + await BrowserEndpointDiscovery.WriteAsync( + identity, + profileDirectoryName: "Profile 1", + new Uri("ws://127.0.0.1:9/devtools/browser/stale"), + processId: int.MaxValue, + CancellationToken.None); + + var metadata = await discovery.TryReadAndValidateAsync(identity, profileDirectoryName: "Default", CancellationToken.None); + + Assert.Null(metadata); + Assert.False(File.Exists(metadataPath)); + } + finally + { + userDataDirectory.Delete(recursive: true); + } + } + + [Fact] + public async Task BrowserEndpointDiscovery_ThrowsForLiveEndpointWithDifferentProfile() + { + var userDataDirectory = Directory.CreateTempSubdirectory(); + try + { + var identity = new BrowserHostIdentity( + Path.Combine(userDataDirectory.FullName, "browser"), + userDataDirectory.FullName); + var discovery = new BrowserEndpointDiscovery(NullLogger.Instance); + var browserEndpoint = StartBrowserVersionEndpoint(out var serverTask); + + await BrowserEndpointDiscovery.WriteAsync( + identity, + profileDirectoryName: "Profile 1", + browserEndpoint, + Environment.ProcessId, + CancellationToken.None); + + var exception = await Assert.ThrowsAsync(() => + discovery.TryReadAndValidateAsync(identity, profileDirectoryName: "Default", CancellationToken.None)); + + await serverTask.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Contains("with profile 'Profile 1'", exception.Message); + Assert.Contains("The requested profile is 'Default'", exception.Message); + } + finally + { + userDataDirectory.Delete(recursive: true); + } + } + [Fact] public void TryResolveBrowserUserDataDirectory_ReturnsExpectedPathForKnownBrowser() { @@ -324,6 +389,40 @@ private static void WithTempUserDataDirectory(Action action) } } + private static Uri StartBrowserVersionEndpoint(out Task serverTask) + { + var listener = new TcpListener(IPAddress.Loopback, port: 0); + listener.Start(); + + var browserEndpoint = new Uri($"ws://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}/devtools/browser/test"); + serverTask = Task.Run(async () => + { + try + { + using var client = await listener.AcceptTcpClientAsync().ConfigureAwait(false); + await using var stream = client.GetStream(); + var buffer = new byte[4096]; + await stream.ReadAsync(buffer).ConfigureAwait(false); + + var body = $$"""{"webSocketDebuggerUrl":"{{browserEndpoint}}"}"""; + var response = Encoding.UTF8.GetBytes( + "HTTP/1.1 200 OK\r\n" + + "Content-Type: application/json\r\n" + + $"Content-Length: {Encoding.UTF8.GetByteCount(body)}\r\n" + + "Connection: close\r\n" + + "\r\n" + + body); + await stream.WriteAsync(response).ConfigureAwait(false); + } + finally + { + listener.Stop(); + } + }); + + return browserEndpoint; + } + private sealed class ThrowIfCalledSessionFactory : IBrowserLogsRunningSessionFactory { public bool WasCalled { get; private set; } From bbcaa811bb31b567df153c09f3eee51526cefe08 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 25 Apr 2026 00:45:50 -0700 Subject: [PATCH 08/36] Improve browser logs diagnostics Add architecture comments for the shared/adopted browser pieces and target discovery. Surface browser host ownership and last-error diagnostics in resource state, preserve failed-session diagnostics, and log host/target/reconnect failures through resource logs. Harden endpoint metadata validation for malformed adoption hints. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogs/BrowserEndpointDiscovery.cs | 43 +++++- src/Aspire.Hosting/BrowserLogs/BrowserHost.cs | 11 +- .../BrowserLogs/BrowserHostRegistry.cs | 4 + .../BrowserLogsBuilderExtensions.cs | 2 + .../BrowserLogsDevToolsConnection.cs | 12 +- .../BrowserLogs/BrowserLogsEventLogger.cs | 5 + .../BrowserLogs/BrowserLogsProtocol.cs | 3 + .../BrowserLogs/BrowserLogsRunningSession.cs | 19 +++ .../BrowserLogs/BrowserLogsSessionManager.cs | 54 ++++++- .../BrowserLogsUserDataDirectory.cs | 2 + .../BrowserLogs/BrowserTargetSession.cs | 21 ++- .../BrowserLogs/IBrowserHost.cs | 9 ++ .../BrowserLogsBuilderExtensionsTests.cs | 140 +++++++++++++++++- .../BrowserLogsSessionManagerTests.cs | 34 +++++ 14 files changed, 349 insertions(+), 10 deletions(-) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs index 7338604cb59..68b19ad010b 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs @@ -8,6 +8,9 @@ namespace Aspire.Hosting; +// Bridges owned and adopted hosts by persisting and validating the browser-level CDP endpoint for a shared +// user-data directory. The registry treats this metadata as a hint only; this type proves the endpoint is live before +// an existing browser can be adopted. internal sealed class BrowserEndpointDiscovery(ILogger logger) { private static readonly TimeSpan s_probeTimeout = TimeSpan.FromSeconds(2); @@ -38,11 +41,18 @@ public static string GetEndpointMetadataFilePath(string userDataDirectory) => return null; } + var metadataExecutablePath = TryNormalizePath(metadata?.ExecutablePath); + var metadataUserDataRootPath = TryNormalizePath(metadata?.UserDataRootPath); + if (metadata is null || metadata.SchemaVersion != BrowserDebugEndpointMetadata.CurrentSchemaVersion || + metadata.ProcessId <= 0 || + string.IsNullOrWhiteSpace(metadata.Endpoint) || !Uri.TryCreate(metadata.Endpoint, UriKind.Absolute, out var endpoint) || - !string.Equals(NormalizePath(metadata.ExecutablePath), identity.ExecutablePath, GetPathComparison()) || - !string.Equals(NormalizePath(metadata.UserDataRootPath), identity.UserDataRootPath, GetPathComparison())) + metadataExecutablePath is null || + metadataUserDataRootPath is null || + !string.Equals(metadataExecutablePath, identity.ExecutablePath, GetPathComparison()) || + !string.Equals(metadataUserDataRootPath, identity.UserDataRootPath, GetPathComparison())) { TryDelete(metadataPath); return null; @@ -126,6 +136,7 @@ public static bool IsNonDebuggableBrowserRunning(string userDataDirectory) try { singletonLock = new FileInfo(singletonLockPath); + // Broken Unix symlinks can report Exists=false while still exposing the host-pid LinkTarget we need. if (!singletonLock.Exists && string.IsNullOrWhiteSpace(singletonLock.LinkTarget)) { return false; @@ -218,6 +229,31 @@ private static bool IsProcessAlive(int processId) 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; @@ -236,6 +272,8 @@ private static void TryDelete(string path) } } +// On-disk adoption hint written by an owned host. 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; @@ -255,6 +293,7 @@ internal sealed record BrowserDebugEndpointMetadata public DateTimeOffset CreatedAt { get; init; } } +// Source-generated JSON context for the small metadata file exchanged between owned and adopted host paths. [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(BrowserDebugEndpointMetadata))] internal sealed partial class BrowserEndpointJsonContext : JsonSerializerContext; diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs b/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs index ede5a6e6d0b..d13e4303f23 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs @@ -8,6 +8,8 @@ namespace Aspire.Hosting; +// Base implementation for browser hosts. It centralizes the shared mechanics for creating per-tab target sessions +// while concrete hosts decide who owns the browser process lifetime. internal abstract class BrowserHost( BrowserHostIdentity identity, BrowserHostOwnership ownership, @@ -36,10 +38,11 @@ internal abstract class BrowserHost( public Task CreateTargetSessionAsync( string sessionId, Uri url, + BrowserConnectionDiagnosticsLogger connectionDiagnostics, Func eventHandler, CancellationToken cancellationToken) { - return CreateTargetSessionCoreAsync(sessionId, url, eventHandler, cancellationToken); + return CreateTargetSessionCoreAsync(sessionId, url, connectionDiagnostics, eventHandler, cancellationToken); } public abstract ValueTask DisposeAsync(); @@ -70,6 +73,7 @@ protected static string BuildBrowserArguments(BrowserLogsUserDataDirectory userD private async Task CreateTargetSessionCoreAsync( string sessionId, Uri url, + BrowserConnectionDiagnosticsLogger connectionDiagnostics, Func eventHandler, CancellationToken cancellationToken) { @@ -77,6 +81,7 @@ private async Task CreateTargetSessionCoreAsync( this, sessionId, url, + connectionDiagnostics, eventHandler, _logger, _timeProvider, @@ -85,6 +90,8 @@ private async Task CreateTargetSessionCoreAsync( } } +// Host implementation for browsers Aspire starts itself. Owned hosts are responsible for spawning Chromium with a +// browser-level CDP endpoint, writing adoption metadata, and terminating the browser when the final lease is released. internal sealed class OwnedBrowserHost : BrowserHost { private static readonly TimeSpan s_browserEndpointTimeout = TimeSpan.FromSeconds(30); @@ -283,6 +290,8 @@ private static async Task WaitForBrowserEndpointAsync( } } +// Host implementation for browsers Aspire discovers from validated endpoint metadata. Adopted hosts create and close +// tracked targets, but never terminate the browser process because it may be the user's normal browser. internal sealed class AdoptedBrowserHost : BrowserHost { private readonly TaskCompletionSource _terminationSource = new(TaskCreationOptions.RunContinuationsAsynchronously); diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs b/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs index a72f5ac06fe..0c34832df26 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs @@ -7,6 +7,9 @@ namespace Aspire.Hosting; +// Coordinates host sharing for all tracked browser sessions in an AppHost. The registry is the only component that +// decides whether a request reuses an in-process host, adopts a previously launched debug-enabled browser, or starts a +// new owned browser, and it centralizes reference counting for those choices. internal sealed class BrowserHostRegistry : IAsyncDisposable { private readonly BrowserEndpointDiscovery _endpointDiscovery; @@ -44,6 +47,7 @@ public async Task AcquireAsync(BrowserLogsSettings settings, C { ValidateProfileCompatibility(identity, entry.ProfileDirectoryName, userDataDirectory.ProfileDirectoryName); entry.ReferenceCount++; + _logger.LogInformation("Reusing tracked browser host '{BrowserExecutable}' at '{Endpoint}'. Active leases: {ReferenceCount}.", identity.ExecutablePath, entry.Host.DebugEndpoint, entry.ReferenceCount); userDataDirectory.Dispose(); return new BrowserHostLease(entry.Host, releaseAsync: token => ReleaseAsync(identity, token)); } diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs index 8dcda3785f1..2c82702dc29 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs @@ -21,6 +21,7 @@ public static class BrowserLogsBuilderExtensions internal const string BrowserConfigurationKey = "Browser"; internal const string BrowserPropertyName = "Browser"; internal const string BrowserExecutablePropertyName = "Browser executable"; + internal const string BrowserHostOwnershipPropertyName = "Browser host ownership"; internal const string ProfileConfigurationKey = "Profile"; internal const string ProfilePropertyName = "Profile"; internal const string UserDataModeConfigurationKey = "UserDataMode"; @@ -31,6 +32,7 @@ public static class BrowserLogsBuilderExtensions internal const string BrowserSessionsPropertyName = "Browser sessions"; internal const string ActiveSessionCountPropertyName = "Active session count"; internal const string TotalSessionsLaunchedPropertyName = "Total sessions launched"; + internal const string LastErrorPropertyName = "Last error"; internal const string LastSessionPropertyName = "Last session"; internal const string OpenTrackedBrowserCommandName = "open-tracked-browser"; diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsDevToolsConnection.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsDevToolsConnection.cs index afdd716fdad..886c23b618b 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsDevToolsConnection.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsDevToolsConnection.cs @@ -8,8 +8,8 @@ namespace Aspire.Hosting; -// Owns the browser-level websocket only. Protocol parsing stays in BrowserLogsProtocol and -// higher-level recovery stays in BrowserLogsRunningSession. +// Owns the browser-level websocket only. Protocol parsing stays in BrowserLogsProtocol, while target lifecycle and +// reconnection policy stay in BrowserTargetSession. internal sealed class ChromeDevToolsConnection : IAsyncDisposable { private static readonly TimeSpan s_closeTimeout = TimeSpan.FromSeconds(3); @@ -107,6 +107,10 @@ public Task CloseTargetAsync(string targetId, Cancellatio public Task 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( BrowserLogsProtocol.TargetSetDiscoverTargetsMethod, sessionId: null, @@ -357,6 +361,8 @@ public void SetResult(ReadOnlyMemory framePayload) } } +// 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); @@ -366,6 +372,8 @@ internal interface IClientWebSocketConnector : IDisposable ClientWebSocket DetachConnectedWebSocket(); } +// Thin ownership wrapper around ClientWebSocket. It lets ChromeDevToolsConnection 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(); diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsEventLogger.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsEventLogger.cs index 05a64f90dd9..0ce52037f65 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsEventLogger.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsEventLogger.cs @@ -427,6 +427,11 @@ public void LogReconnectFailed(Exception exception) _resourceLogger.LogError("[{SessionId}] Unable to reconnect tracked browser debug connection. Closing the tracked browser session. Last error: {Reason}", _sessionId, DescribeConnectionProblem(exception)); } + public void LogHostTerminated(Exception exception) + { + _resourceLogger.LogError("[{SessionId}] Tracked browser host ended before the tracked target session completed: {Reason}", _sessionId, DescribeConnectionProblem(exception)); + } + internal static string DescribeConnectionProblem(Exception exception) { var messages = new List(); diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsProtocol.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsProtocol.cs index 67432754f03..36c868f8d0f 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsProtocol.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsProtocol.cs @@ -49,6 +49,9 @@ internal static class BrowserLogsProtocol internal const string TargetCreateTargetMethod = "Target.createTarget"; internal const string TargetDetachedFromTargetMethod = "Target.detachedFromTarget"; internal const string TargetGetTargetsMethod = "Target.getTargets"; + // Turns on browser-level target discovery. In CDP a "target" is a debuggable entity such as a page/tab, worker, + // or iframe. We use this subscription for page target lifecycle events; without it, closing or crashing the + // tracked tab can look like an unexplained connection loss. internal const string TargetSetDiscoverTargetsMethod = "Target.setDiscoverTargets"; internal const string TargetTargetCrashedMethod = "Target.targetCrashed"; internal const string TargetTargetDestroyedMethod = "Target.targetDestroyed"; diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs index 5d510355423..7b1f2c3a321 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs @@ -18,6 +18,8 @@ internal interface IBrowserLogsRunningSession Uri BrowserDebugEndpoint { get; } + BrowserHostOwnership BrowserHostOwnership { get; } + int? ProcessId { get; } DateTime StartedAt { get; } @@ -95,6 +97,7 @@ internal sealed class BrowserLogsRunningSession : IBrowserLogsRunningSession private string? _browserExecutable; private Uri? _browserEndpoint; private BrowserHostLease? _browserHostLease; + private BrowserHostOwnership? _browserHostOwnership; private Task? _completion; private int _cleanupState; private int? _processId; @@ -130,6 +133,8 @@ private BrowserLogsRunningSession( public Uri BrowserDebugEndpoint => _browserEndpoint ?? throw new InvalidOperationException("Browser debugging endpoint is not available before the session starts."); + public BrowserHostOwnership BrowserHostOwnership => _browserHostOwnership ?? throw new InvalidOperationException("Browser host ownership is not available before the session starts."); + public int? ProcessId => _processId; public DateTime StartedAt { get; private set; } @@ -187,6 +192,13 @@ public async Task StopAsync(CancellationToken cancellationToken) private async Task InitializeAsync(CancellationToken cancellationToken) { + _resourceLogger.LogInformation( + "[{SessionId}] Resolving tracked browser host. User data mode: {UserDataMode}; browser: '{Browser}'; profile: '{Profile}'.", + _sessionId, + _settings.UserDataMode, + _settings.Browser, + _settings.Profile ?? "(default)"); + try { _browserHostLease = await _browserHostRegistry.AcquireAsync(_settings, cancellationToken).ConfigureAwait(false); @@ -200,6 +212,7 @@ private async Task InitializeAsync(CancellationToken cancellationToken) var browserHost = _browserHostLease.Host; _browserExecutable = browserHost.Identity.ExecutablePath; _browserEndpoint = browserHost.DebugEndpoint; + _browserHostOwnership = browserHost.Ownership; _processId = browserHost.ProcessId; StartedAt = _timeProvider.GetUtcNow().UtcDateTime; _resourceLogger.LogInformation( @@ -214,6 +227,7 @@ private async Task InitializeAsync(CancellationToken cancellationToken) _targetSession = await browserHost.CreateTargetSessionAsync( _sessionId, _url, + _connectionDiagnostics, protocolEvent => { _eventLogger.HandleEvent(protocolEvent); @@ -222,6 +236,11 @@ private async Task InitializeAsync(CancellationToken cancellationToken) cancellationToken).ConfigureAwait(false); _targetId = _targetSession.TargetId; _targetSessionId = _targetSession.TargetSessionId; + _resourceLogger.LogInformation( + "[{SessionId}] Attached to tracked browser target '{TargetId}' with target session '{TargetSessionId}'.", + _sessionId, + _targetId, + _targetSessionId); } catch (Exception ex) { diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs index d1f3bc71bd7..4be38939654 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs @@ -12,6 +12,8 @@ namespace Aspire.Hosting; +// Coordinates browser-log commands with dashboard resource state. The running session owns CDP capture; this manager +// owns session ids, resource logs, health reports, and snapshot properties that make failures diagnosable in the dashboard. internal sealed class BrowserLogsSessionManager : IBrowserLogsSessionManager, IAsyncDisposable { private static readonly JsonSerializerOptions s_browserSessionPropertyJsonOptions = new(JsonSerializerDefaults.Web); @@ -74,6 +76,11 @@ public async Task StartSessionAsync(BrowserLogsResource resource, BrowserLogsSet resourceState.LastTargetUrl = url.ToString(); resourceState.LastBrowser = settings.Browser; resourceState.LastBrowserExecutable = BrowserLogsRunningSession.TryResolveBrowserExecutable(settings.Browser); + if (resourceState.ActiveSessions.Count == 0) + { + resourceState.LastBrowserHostOwnership = null; + } + resourceState.LastError = null; resourceState.LastProfile = settings.Profile; var resourceLogger = _resourceLoggerService.GetLogger(resourceName); @@ -105,6 +112,7 @@ await PublishResourceSnapshotAsync( } catch (Exception ex) { + resourceState.LastError = BrowserConnectionDiagnosticsLogger.DescribeConnectionProblem(ex); resourceLogger.LogError(ex, "[{SessionId}] Failed to open tracked browser for '{Url}'.", sessionId, url); await PublishResourceSnapshotAsync( @@ -122,6 +130,8 @@ await PublishResourceSnapshotAsync( } resourceState.LastBrowserExecutable = session.BrowserExecutable; + resourceState.LastBrowserHostOwnership = session.BrowserHostOwnership.ToString(); + resourceState.LastError = null; var completionObserver = session.StartCompletionObserver(async (exitCode, error) => { await HandleSessionCompletedAsync(resource, resourceName, resourceState, session.SessionId, exitCode, error).ConfigureAwait(false); @@ -133,6 +143,7 @@ await PublishResourceSnapshotAsync( session.BrowserExecutable, settings.Profile, session.BrowserDebugEndpoint, + session.BrowserHostOwnership.ToString(), session.ProcessId, session.StartedAt, session.TargetId, @@ -223,11 +234,16 @@ private async Task HandleSessionCompletedAsync( return; } + if (error is not null) + { + resourceState.LastError = BrowserConnectionDiagnosticsLogger.DescribeConnectionProblem(error); + } + var completedAt = _timeProvider.GetUtcNow().UtcDateTime; var hasActiveSessions = resourceState.ActiveSessions.Count > 0; var (stateText, stateStyle) = hasActiveSessions ? (KnownResourceStates.Running, KnownResourceStateStyles.Success) - : error switch + : resourceState.LastError switch { not null => (KnownResourceStates.Exited, KnownResourceStateStyles.Error), null when exitCode is null or 0 => (KnownResourceStates.Finished, KnownResourceStateStyles.Success), @@ -304,6 +320,17 @@ private ImmutableArray GetHealthReports(ResourceSessionSta LastRunAt = runAt }); } + else if (resourceState.LastError is not null) + { + reports.Add(new HealthReportSnapshot( + BrowserLogsBuilderExtensions.LastErrorPropertyName, + HealthStatus.Unhealthy, + resourceState.LastError, + null) + { + LastRunAt = runAt + }); + } return [.. reports]; } @@ -329,6 +356,16 @@ private static IEnumerable GetPropertyUpdates(Resource { yield return new ResourcePropertySnapshot(BrowserLogsBuilderExtensions.BrowserExecutablePropertyName, resourceState.LastBrowserExecutable); } + + if (resourceState.LastBrowserHostOwnership is not null) + { + yield return new ResourcePropertySnapshot(BrowserLogsBuilderExtensions.BrowserHostOwnershipPropertyName, resourceState.LastBrowserHostOwnership); + } + + if (resourceState.LastError is not null) + { + yield return new ResourcePropertySnapshot(BrowserLogsBuilderExtensions.LastErrorPropertyName, resourceState.LastError); + } } private static ImmutableArray UpdateProperties( @@ -344,6 +381,14 @@ private static ImmutableArray UpdateProperties( ? properties.SetResourceProperty(BrowserLogsBuilderExtensions.BrowserExecutablePropertyName, resourceState.LastBrowserExecutable) : RemoveProperty(properties, BrowserLogsBuilderExtensions.BrowserExecutablePropertyName); + properties = resourceState.LastBrowserHostOwnership is not null + ? properties.SetResourceProperty(BrowserLogsBuilderExtensions.BrowserHostOwnershipPropertyName, resourceState.LastBrowserHostOwnership) + : RemoveProperty(properties, BrowserLogsBuilderExtensions.BrowserHostOwnershipPropertyName); + + properties = resourceState.LastError is not null + ? properties.SetResourceProperty(BrowserLogsBuilderExtensions.LastErrorPropertyName, resourceState.LastError) + : RemoveProperty(properties, BrowserLogsBuilderExtensions.LastErrorPropertyName); + properties = resourceState.LastProfile is not null ? properties.SetResourceProperty(BrowserLogsBuilderExtensions.ProfilePropertyName, resourceState.LastProfile) : RemoveProperty(properties, BrowserLogsBuilderExtensions.ProfilePropertyName); @@ -398,6 +443,7 @@ private static string FormatBrowserSessions(IEnumerable se session.Profile, session.StartedAt, session.TargetUrl.ToString(), + session.BrowserHostOwnership, session.BrowserDebugEndpoint.ToString(), GetPageDebugEndpoint(session.BrowserDebugEndpoint, session.TargetId), session.TargetId)) @@ -430,6 +476,10 @@ private sealed class ResourceSessionState public string? LastBrowserExecutable { get; set; } + public string? LastBrowserHostOwnership { get; set; } + + public string? LastError { get; set; } + public string? LastBrowser { get; set; } public string? LastProfile { get; set; } @@ -444,6 +494,7 @@ private sealed record ActiveBrowserSession( string BrowserExecutable, string? Profile, Uri BrowserDebugEndpoint, + string BrowserHostOwnership, int? ProcessId, DateTime StartedAt, string TargetId, @@ -459,6 +510,7 @@ private sealed record BrowserSessionPropertyValue( string? Profile, DateTime StartedAt, string TargetUrl, + string BrowserHostOwnership, string CdpEndpoint, string PageCdpEndpoint, string TargetId); diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsUserDataDirectory.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsUserDataDirectory.cs index 47e4ec72044..12056d79dc7 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsUserDataDirectory.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsUserDataDirectory.cs @@ -5,6 +5,8 @@ namespace Aspire.Hosting; +// Represents the user-data root and optional profile directory chosen for a host acquisition. Persistent instances +// point at a real browser profile root and are never deleted; temporary instances own the isolated directory lifetime. internal sealed class BrowserLogsUserDataDirectory : IDisposable { private readonly TempDirectory? _temporaryDirectory; diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs b/src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs index 5e49086c9f6..65e1cc672a9 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs @@ -5,12 +5,16 @@ namespace Aspire.Hosting; +// Owns one browser target (tab) for one browser-log session. The host may be shared by many sessions, but each +// BrowserTargetSession has its own browser CDP connection, attached target session id, instrumentation setup, +// lifecycle monitoring, and reconnection loop. internal sealed class BrowserTargetSession : IBrowserTargetSession { private static readonly TimeSpan s_connectionRecoveryDelay = TimeSpan.FromMilliseconds(200); private static readonly TimeSpan s_connectionRecoveryTimeout = TimeSpan.FromSeconds(5); private readonly TaskCompletionSource _completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly BrowserConnectionDiagnosticsLogger _connectionDiagnostics; private readonly Func _eventHandler; private readonly IBrowserHost _host; private readonly ILogger _logger; @@ -30,11 +34,13 @@ private BrowserTargetSession( IBrowserHost host, string sessionId, Uri url, + BrowserConnectionDiagnosticsLogger connectionDiagnostics, Func eventHandler, ILogger logger, TimeProvider timeProvider, bool reuseInitialBlankTarget) { + _connectionDiagnostics = connectionDiagnostics; _eventHandler = eventHandler; _host = host; _logger = logger; @@ -54,13 +60,14 @@ public static async Task StartAsync( IBrowserHost host, string sessionId, Uri url, + BrowserConnectionDiagnosticsLogger connectionDiagnostics, Func eventHandler, ILogger logger, TimeProvider timeProvider, bool reuseInitialBlankTarget, CancellationToken cancellationToken) { - var targetSession = new BrowserTargetSession(host, sessionId, url, eventHandler, logger, timeProvider, reuseInitialBlankTarget); + var targetSession = new BrowserTargetSession(host, sessionId, url, connectionDiagnostics, eventHandler, logger, timeProvider, reuseInitialBlankTarget); try { await targetSession.ConnectAsync(createTarget: true, cancellationToken).ConfigureAwait(false); @@ -120,6 +127,9 @@ private async Task ConnectAsync(bool createTarget, CancellationToken cancellatio await DisposeConnectionAsync().ConfigureAwait(false); _connection = await ChromeDevToolsConnection.ConnectAsync(_host.DebugEndpoint, HandleEventAsync, _logger, cancellationToken).ConfigureAwait(false); + // Target discovery must be re-enabled for every browser-level connection, including reconnects. The + // subscription is attached to this websocket, 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) @@ -176,7 +186,9 @@ private async Task MonitorAsync() if (completedTask == _host.Termination) { - return new BrowserTargetSessionResult(BrowserTargetSessionCompletionKind.BrowserExited, Error: null); + var error = new InvalidOperationException($"Tracked browser host '{_host.Identity}' ended before the target session completed."); + _connectionDiagnostics.LogHostTerminated(error); + return new BrowserTargetSessionResult(BrowserTargetSessionCompletionKind.BrowserExited, error); } Exception? connectionError = null; @@ -195,6 +207,7 @@ private async Task MonitorAsync() } connectionError ??= new InvalidOperationException("The tracked browser debug connection closed without reporting a reason."); + _connectionDiagnostics.LogConnectionLost(connectionError); if (await TryReconnectAsync(connectionError).ConfigureAwait(false)) { continue; @@ -214,6 +227,7 @@ private async Task TryReconnectAsync(Exception connectionError) await DisposeConnectionAsync().ConfigureAwait(false); var reconnectDeadline = _timeProvider.GetUtcNow() + s_connectionRecoveryTimeout; + var reconnectAttempt = 0; Exception? lastError = connectionError; while (!_stopCts.IsCancellationRequested && _timeProvider.GetUtcNow() < reconnectDeadline) @@ -235,6 +249,8 @@ private async Task TryReconnectAsync(Exception connectionError) catch (Exception ex) { lastError = ex; + reconnectAttempt++; + _connectionDiagnostics.LogReconnectAttemptFailed(reconnectAttempt, ex); await DisposeConnectionAsync().ConfigureAwait(false); } @@ -250,6 +266,7 @@ private async Task TryReconnectAsync(Exception connectionError) if (lastError is not null) { + _connectionDiagnostics.LogReconnectFailed(lastError); _logger.LogDebug(lastError, "Timed out reconnecting tracked browser target session '{SessionId}'.", _sessionId); } diff --git a/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs b/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs index ab9797fbdc9..083624ce8f8 100644 --- a/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs +++ b/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs @@ -33,6 +33,7 @@ internal interface IBrowserHost : IAsyncDisposable Task CreateTargetSessionAsync( string sessionId, Uri url, + BrowserConnectionDiagnosticsLogger connectionDiagnostics, Func eventHandler, CancellationToken cancellationToken); } @@ -48,8 +49,12 @@ internal interface IBrowserTargetSession : IAsyncDisposable Task Completion { get; } } +// Normalized target-session completion signal consumed by BrowserLogsRunningSession so manager state is independent of +// the exact CDP event or transport failure that ended the target. internal readonly record struct BrowserTargetSessionResult(BrowserTargetSessionCompletionKind CompletionKind, Exception? Error); +// Small vocabulary for target lifecycle outcomes. The manager uses this to distinguish normal tab closes from crashes +// or unrecoverable browser connection loss. internal enum BrowserTargetSessionCompletionKind { Stopped, @@ -59,6 +64,8 @@ internal enum BrowserTargetSessionCompletionKind 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 { private readonly Func _releaseAsync; @@ -140,6 +147,8 @@ private static string NormalizePath(string path) } } +// 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. diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs index a4b4acf66b9..cf38d07c2f9 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs @@ -446,7 +446,12 @@ await app.ResourceNotifications.WaitForResourceAsync( resourceEvent.Snapshot.State?.Text == KnownResourceStates.Running && HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.BrowserPropertyName, tempBrowserPath) && HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.BrowserExecutablePropertyName, tempBrowserPath) && - HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.ActiveSessionCountPropertyName, 1)).DefaultTimeout(); + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.BrowserHostOwnershipPropertyName, nameof(BrowserHostOwnership.Owned)) && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.LastErrorPropertyName, "InvalidOperationException: Launch failed.") && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.ActiveSessionCountPropertyName, 1) && + resourceEvent.Snapshot.HealthReports.Any(report => + report.Name == BrowserLogsBuilderExtensions.LastErrorPropertyName && + report.Status == HealthStatus.Unhealthy)).DefaultTimeout(); Assert.Collection( GetBrowserSessions(failedEvent.Snapshot), @@ -517,7 +522,12 @@ await app.ResourceNotifications.WaitForResourceAsync( resourceEvent.Snapshot.State?.Text == KnownResourceStates.Running && HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.BrowserPropertyName, "missing-browser") && !resourceEvent.Snapshot.Properties.Any(property => property.Name == BrowserLogsBuilderExtensions.BrowserExecutablePropertyName) && - HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.ActiveSessionCountPropertyName, 1)).DefaultTimeout(); + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.BrowserHostOwnershipPropertyName, nameof(BrowserHostOwnership.Owned)) && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.LastErrorPropertyName, "InvalidOperationException: Launch failed.") && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.ActiveSessionCountPropertyName, 1) && + resourceEvent.Snapshot.HealthReports.Any(report => + report.Name == BrowserLogsBuilderExtensions.LastErrorPropertyName && + report.Status == HealthStatus.Unhealthy)).DefaultTimeout(); Assert.Collection( GetBrowserSessions(failedEvent.Snapshot), @@ -528,6 +538,61 @@ await app.ResourceNotifications.WaitForResourceAsync( }); } + [Fact] + public async Task WithBrowserLogs_CommandPublishesFailureDiagnosticsWhenLaunchFailsBeforeAnySession() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + var sessionFactory = new FakeBrowserLogsRunningSessionFactory + { + NextStartException = new InvalidOperationException("Launch failed.", new TimeoutException("CDP timed out.")) + }; + + builder.Services.AddSingleton(sp => + new BrowserLogsSessionManager( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sessionFactory)); + + var web = builder.AddResource(new TestHttpResource("web")) + .WithHttpEndpoint(targetPort: 8080) + .WithEndpoint("http", endpoint => endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", 8080)) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "TestHttp", + State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success), + Properties = [] + }); + + web.WithBrowserLogs(browser: "chrome"); + + using var app = builder.Build(); + await app.StartAsync(); + + var browserLogsResource = app.Services.GetRequiredService().Resources.OfType().Single(); + var result = await app.ResourceCommands.ExecuteCommandAsync(browserLogsResource, BrowserLogsBuilderExtensions.OpenTrackedBrowserCommandName).DefaultTimeout(); + + Assert.False(result.Success); + Assert.Equal("Launch failed.", result.Message); + + var errorText = "InvalidOperationException: Launch failed. --> TimeoutException: CDP timed out."; + var failedEvent = await app.ResourceNotifications.WaitForResourceAsync( + browserLogsResource.Name, + resourceEvent => + resourceEvent.Snapshot.State?.Text == KnownResourceStates.FailedToStart && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.ActiveSessionCountPropertyName, 0) && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.ActiveSessionsPropertyName, "None") && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.LastErrorPropertyName, errorText) && + resourceEvent.Snapshot.HealthReports.Any(report => + report.Name == BrowserLogsBuilderExtensions.LastErrorPropertyName && + report.Status == HealthStatus.Unhealthy && + report.Description == errorText)).DefaultTimeout(); + + Assert.Single(failedEvent.Snapshot.HealthReports); + Assert.Empty(GetBrowserSessions(failedEvent.Snapshot)); + } + [Fact] public async Task WithBrowserLogs_CommandFailsWhenEndpointIsMissing() { @@ -658,6 +723,7 @@ public async Task WithBrowserLogs_CommandTracksMultipleSessionsWithUniqueIds() Assert.Equal("session-0001", session.SessionId); Assert.Equal("chrome", session.Browser); Assert.Equal("/fake/browser-1", session.BrowserExecutable); + Assert.Equal(nameof(BrowserHostOwnership.Owned), session.BrowserHostOwnership); Assert.Equal(1001, session.ProcessId); Assert.Equal("Default", session.Profile); Assert.Equal("http://localhost:8080/", session.TargetUrl); @@ -735,6 +801,73 @@ public async Task WithBrowserLogs_CommandTracksMultipleSessionsWithUniqueIds() Assert.Empty(GetBrowserSessions(allCompletedEvent.Snapshot)); } + [Fact] + public async Task WithBrowserLogs_PreservesLastErrorWhenOneOfMultipleSessionsFails() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + var sessionFactory = new FakeBrowserLogsRunningSessionFactory(); + + builder.Services.AddSingleton(sp => + new BrowserLogsSessionManager( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sessionFactory)); + + var web = builder.AddResource(new TestHttpResource("web")) + .WithHttpEndpoint(targetPort: 8080) + .WithEndpoint("http", endpoint => endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", 8080)) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "TestHttp", + State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success), + Properties = [] + }); + + web.WithBrowserLogs(browser: "chrome"); + + using var app = builder.Build(); + await app.StartAsync(); + + var browserLogsResource = app.Services.GetRequiredService().Resources.OfType().Single(); + + Assert.True((await app.ResourceCommands.ExecuteCommandAsync(browserLogsResource, BrowserLogsBuilderExtensions.OpenTrackedBrowserCommandName).DefaultTimeout()).Success); + Assert.True((await app.ResourceCommands.ExecuteCommandAsync(browserLogsResource, BrowserLogsBuilderExtensions.OpenTrackedBrowserCommandName).DefaultTimeout()).Success); + + var firstSession = sessionFactory.Sessions[0]; + var secondSession = sessionFactory.Sessions[1]; + await firstSession.CompleteAsync(exitCode: 0, error: new InvalidOperationException("Target crashed.")); + + var errorText = "InvalidOperationException: Target crashed."; + await app.ResourceNotifications.WaitForResourceAsync( + browserLogsResource.Name, + resourceEvent => + resourceEvent.Snapshot.State?.Text == KnownResourceStates.Running && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.ActiveSessionCountPropertyName, 1) && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.LastErrorPropertyName, errorText) && + resourceEvent.Snapshot.HealthReports.Any(report => + report.Name == "session-0002" && + report.Status == HealthStatus.Healthy) && + resourceEvent.Snapshot.HealthReports.Any(report => + report.Name == BrowserLogsBuilderExtensions.LastErrorPropertyName && + report.Status == HealthStatus.Unhealthy && + report.Description == errorText)).DefaultTimeout(); + + await secondSession.CompleteAsync(exitCode: 0); + + await app.ResourceNotifications.WaitForResourceAsync( + browserLogsResource.Name, + resourceEvent => + resourceEvent.Snapshot.State?.Text == KnownResourceStates.Exited && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.ActiveSessionCountPropertyName, 0) && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.LastErrorPropertyName, errorText) && + resourceEvent.Snapshot.HealthReports.Any(report => + report.Name == BrowserLogsBuilderExtensions.LastErrorPropertyName && + report.Status == HealthStatus.Unhealthy && + report.Description == errorText)).DefaultTimeout(); + } + [Fact] public async Task WithBrowserLogs_DisposeWaitsForCompletionObservers() { @@ -978,6 +1111,8 @@ private sealed class FakeBrowserLogsRunningSession( public Uri BrowserDebugEndpoint { get; } = new($"ws://127.0.0.1:{processId + 8000}/devtools/browser/browser-{processId - 1000}"); + public BrowserHostOwnership BrowserHostOwnership => BrowserHostOwnership.Owned; + public int? ProcessId { get; } = processId; public DateTime StartedAt { get; } = startedAt; @@ -1046,6 +1181,7 @@ private sealed record BrowserSessionPropertyValue( string? Profile, DateTime StartedAt, string TargetUrl, + string BrowserHostOwnership, string CdpEndpoint, string PageCdpEndpoint, string TargetId); diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs index 5cf21fe9468..8ff6d00acaa 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs @@ -105,6 +105,39 @@ await BrowserEndpointDiscovery.WriteAsync( } } + [Fact] + public async Task BrowserEndpointDiscovery_DeletesMalformedEndpointMetadata() + { + var userDataDirectory = Directory.CreateTempSubdirectory(); + try + { + var identity = new BrowserHostIdentity( + Path.Combine(userDataDirectory.FullName, "browser"), + userDataDirectory.FullName); + var metadataPath = BrowserEndpointDiscovery.GetEndpointMetadataFilePath(userDataDirectory.FullName); + var discovery = new BrowserEndpointDiscovery(NullLogger.Instance); + await File.WriteAllTextAsync( + metadataPath, + $$""" + { + "schemaVersion": 1, + "endpoint": "ws://127.0.0.1:9/devtools/browser/stale", + "processId": {{Environment.ProcessId}}, + "userDataRootPath": {{System.Text.Json.JsonSerializer.Serialize(userDataDirectory.FullName)}} + } + """); + + var metadata = await discovery.TryReadAndValidateAsync(identity, profileDirectoryName: null, CancellationToken.None); + + Assert.Null(metadata); + Assert.False(File.Exists(metadataPath)); + } + finally + { + userDataDirectory.Delete(recursive: true); + } + } + [Fact] public async Task BrowserEndpointDiscovery_ThrowsForLiveEndpointWithDifferentProfile() { @@ -459,6 +492,7 @@ private sealed class TestBrowserHost : IBrowserHost public Task CreateTargetSessionAsync( string sessionId, Uri url, + BrowserConnectionDiagnosticsLogger connectionDiagnostics, Func eventHandler, CancellationToken cancellationToken) => throw new NotSupportedException(); From 11d5f9b34004d44d5813f56a160cec45e41389e3 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 25 Apr 2026 07:21:14 -0700 Subject: [PATCH 09/36] Update browser logs codegen snapshots Refresh ATS code-generation snapshots after adding BrowserUserDataMode to WithBrowserLogs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...TwoPassScanningGeneratedAspire.verified.go | 48 +++++++-- ...oPassScanningGeneratedAspire.verified.java | 101 +++++++++++++++--- ...TwoPassScanningGeneratedAspire.verified.py | 20 +++- ...TwoPassScanningGeneratedAspire.verified.rs | 59 ++++++++-- ...TwoPassScanningGeneratedAspire.verified.ts | 61 +++++++---- 5 files changed, 235 insertions(+), 54 deletions(-) diff --git a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go index 2c6841c87aa..3a9fa112ed7 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go +++ b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go @@ -12,6 +12,14 @@ import ( // Enums // ============================================================================ +// BrowserUserDataMode represents BrowserUserDataMode. +type BrowserUserDataMode string + +const ( + BrowserUserDataModeShared BrowserUserDataMode = "Shared" + BrowserUserDataModeIsolated BrowserUserDataMode = "Isolated" +) + // ContainerLifetime represents ContainerLifetime. type ContainerLifetime string @@ -656,7 +664,7 @@ func NewCSharpAppResource(handle *Handle, client *AspireClient) *CSharpAppResour } // WithBrowserLogs adds a child browser logs resource that opens tracked browser sessions and captures browser logs. -func (s *CSharpAppResource) WithBrowserLogs(browser *string, profile *string) (*IResourceWithEndpoints, error) { +func (s *CSharpAppResource) WithBrowserLogs(browser *string, profile *string, userDataMode *BrowserUserDataMode) (*IResourceWithEndpoints, error) { reqArgs := map[string]any{ "builder": SerializeValue(s.Handle()), } @@ -666,6 +674,9 @@ func (s *CSharpAppResource) WithBrowserLogs(browser *string, profile *string) (* if profile != nil { reqArgs["profile"] = SerializeValue(profile) } + if userDataMode != nil { + reqArgs["userDataMode"] = SerializeValue(userDataMode) + } result, err := s.Client().InvokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs) if err != nil { return nil, err @@ -3997,7 +4008,7 @@ func NewContainerResource(handle *Handle, client *AspireClient) *ContainerResour } // WithBrowserLogs adds a child browser logs resource that opens tracked browser sessions and captures browser logs. -func (s *ContainerResource) WithBrowserLogs(browser *string, profile *string) (*IResourceWithEndpoints, error) { +func (s *ContainerResource) WithBrowserLogs(browser *string, profile *string, userDataMode *BrowserUserDataMode) (*IResourceWithEndpoints, error) { reqArgs := map[string]any{ "builder": SerializeValue(s.Handle()), } @@ -4007,6 +4018,9 @@ func (s *ContainerResource) WithBrowserLogs(browser *string, profile *string) (* if profile != nil { reqArgs["profile"] = SerializeValue(profile) } + if userDataMode != nil { + reqArgs["userDataMode"] = SerializeValue(userDataMode) + } result, err := s.Client().InvokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs) if err != nil { return nil, err @@ -6230,7 +6244,7 @@ func NewDotnetToolResource(handle *Handle, client *AspireClient) *DotnetToolReso } // WithBrowserLogs adds a child browser logs resource that opens tracked browser sessions and captures browser logs. -func (s *DotnetToolResource) WithBrowserLogs(browser *string, profile *string) (*IResourceWithEndpoints, error) { +func (s *DotnetToolResource) WithBrowserLogs(browser *string, profile *string, userDataMode *BrowserUserDataMode) (*IResourceWithEndpoints, error) { reqArgs := map[string]any{ "builder": SerializeValue(s.Handle()), } @@ -6240,6 +6254,9 @@ func (s *DotnetToolResource) WithBrowserLogs(browser *string, profile *string) ( if profile != nil { reqArgs["profile"] = SerializeValue(profile) } + if userDataMode != nil { + reqArgs["userDataMode"] = SerializeValue(userDataMode) + } result, err := s.Client().InvokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs) if err != nil { return nil, err @@ -8489,7 +8506,7 @@ func NewExecutableResource(handle *Handle, client *AspireClient) *ExecutableReso } // WithBrowserLogs adds a child browser logs resource that opens tracked browser sessions and captures browser logs. -func (s *ExecutableResource) WithBrowserLogs(browser *string, profile *string) (*IResourceWithEndpoints, error) { +func (s *ExecutableResource) WithBrowserLogs(browser *string, profile *string, userDataMode *BrowserUserDataMode) (*IResourceWithEndpoints, error) { reqArgs := map[string]any{ "builder": SerializeValue(s.Handle()), } @@ -8499,6 +8516,9 @@ func (s *ExecutableResource) WithBrowserLogs(browser *string, profile *string) ( if profile != nil { reqArgs["profile"] = SerializeValue(profile) } + if userDataMode != nil { + reqArgs["userDataMode"] = SerializeValue(userDataMode) + } result, err := s.Client().InvokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs) if err != nil { return nil, err @@ -13711,7 +13731,7 @@ func NewProjectResource(handle *Handle, client *AspireClient) *ProjectResource { } // WithBrowserLogs adds a child browser logs resource that opens tracked browser sessions and captures browser logs. -func (s *ProjectResource) WithBrowserLogs(browser *string, profile *string) (*IResourceWithEndpoints, error) { +func (s *ProjectResource) WithBrowserLogs(browser *string, profile *string, userDataMode *BrowserUserDataMode) (*IResourceWithEndpoints, error) { reqArgs := map[string]any{ "builder": SerializeValue(s.Handle()), } @@ -13721,6 +13741,9 @@ func (s *ProjectResource) WithBrowserLogs(browser *string, profile *string) (*IR if profile != nil { reqArgs["profile"] = SerializeValue(profile) } + if userDataMode != nil { + reqArgs["userDataMode"] = SerializeValue(userDataMode) + } result, err := s.Client().InvokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs) if err != nil { return nil, err @@ -15783,7 +15806,7 @@ func NewTestDatabaseResource(handle *Handle, client *AspireClient) *TestDatabase } // WithBrowserLogs adds a child browser logs resource that opens tracked browser sessions and captures browser logs. -func (s *TestDatabaseResource) WithBrowserLogs(browser *string, profile *string) (*IResourceWithEndpoints, error) { +func (s *TestDatabaseResource) WithBrowserLogs(browser *string, profile *string, userDataMode *BrowserUserDataMode) (*IResourceWithEndpoints, error) { reqArgs := map[string]any{ "builder": SerializeValue(s.Handle()), } @@ -15793,6 +15816,9 @@ func (s *TestDatabaseResource) WithBrowserLogs(browser *string, profile *string) if profile != nil { reqArgs["profile"] = SerializeValue(profile) } + if userDataMode != nil { + reqArgs["userDataMode"] = SerializeValue(userDataMode) + } result, err := s.Client().InvokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs) if err != nil { return nil, err @@ -17592,7 +17618,7 @@ func NewTestRedisResource(handle *Handle, client *AspireClient) *TestRedisResour } // WithBrowserLogs adds a child browser logs resource that opens tracked browser sessions and captures browser logs. -func (s *TestRedisResource) WithBrowserLogs(browser *string, profile *string) (*IResourceWithEndpoints, error) { +func (s *TestRedisResource) WithBrowserLogs(browser *string, profile *string, userDataMode *BrowserUserDataMode) (*IResourceWithEndpoints, error) { reqArgs := map[string]any{ "builder": SerializeValue(s.Handle()), } @@ -17602,6 +17628,9 @@ func (s *TestRedisResource) WithBrowserLogs(browser *string, profile *string) (* if profile != nil { reqArgs["profile"] = SerializeValue(profile) } + if userDataMode != nil { + reqArgs["userDataMode"] = SerializeValue(userDataMode) + } result, err := s.Client().InvokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs) if err != nil { return nil, err @@ -19626,7 +19655,7 @@ func NewTestVaultResource(handle *Handle, client *AspireClient) *TestVaultResour } // WithBrowserLogs adds a child browser logs resource that opens tracked browser sessions and captures browser logs. -func (s *TestVaultResource) WithBrowserLogs(browser *string, profile *string) (*IResourceWithEndpoints, error) { +func (s *TestVaultResource) WithBrowserLogs(browser *string, profile *string, userDataMode *BrowserUserDataMode) (*IResourceWithEndpoints, error) { reqArgs := map[string]any{ "builder": SerializeValue(s.Handle()), } @@ -19636,6 +19665,9 @@ func (s *TestVaultResource) WithBrowserLogs(browser *string, profile *string) (* if profile != nil { reqArgs["profile"] = SerializeValue(profile) } + if userDataMode != nil { + reqArgs["userDataMode"] = SerializeValue(userDataMode) + } result, err := s.Client().InvokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs) if err != nil { return nil, err diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java index 2db6ed984e2..fae9fa1dee8 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java @@ -1385,6 +1385,35 @@ public DistributedApplicationModel model() { } +// ===== BrowserUserDataMode.java ===== +// BrowserUserDataMode.java - GENERATED CODE - DO NOT EDIT + +package aspire; + +import java.util.*; +import java.util.function.*; + +/** BrowserUserDataMode enum. */ +public enum BrowserUserDataMode implements WireValueEnum { + SHARED("Shared"), + ISOLATED("Isolated"); + + private final String value; + + BrowserUserDataMode(String value) { + this.value = value; + } + + public String getValue() { return value; } + + public static BrowserUserDataMode fromValue(String value) { + for (BrowserUserDataMode e : values()) { + if (e.value.equals(value)) return e; + } + throw new IllegalArgumentException("Unknown value: " + value); + } +} + // ===== BuildOptions.java ===== // BuildOptions.java - GENERATED CODE - DO NOT EDIT @@ -1430,7 +1459,8 @@ public class CSharpAppResource extends ResourceBuilderBase { public CSharpAppResource withBrowserLogs(WithBrowserLogsOptions options) { var browser = options == null ? null : options.getBrowser(); var profile = options == null ? null : options.getProfile(); - return withBrowserLogsImpl(browser, profile); + var userDataMode = options == null ? null : options.getUserDataMode(); + return withBrowserLogsImpl(browser, profile, userDataMode); } public CSharpAppResource withBrowserLogs() { @@ -1438,7 +1468,7 @@ public CSharpAppResource withBrowserLogs() { } /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ - private CSharpAppResource withBrowserLogsImpl(String browser, String profile) { + private CSharpAppResource withBrowserLogsImpl(String browser, String profile, BrowserUserDataMode userDataMode) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (browser != null) { @@ -1447,6 +1477,9 @@ private CSharpAppResource withBrowserLogsImpl(String browser, String profile) { if (profile != null) { reqArgs.put("profile", AspireClient.serializeValue(profile)); } + if (userDataMode != null) { + reqArgs.put("userDataMode", AspireClient.serializeValue(userDataMode)); + } getClient().invokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs); return this; } @@ -5177,7 +5210,8 @@ public class ContainerResource extends ResourceBuilderBase { public ContainerResource withBrowserLogs(WithBrowserLogsOptions options) { var browser = options == null ? null : options.getBrowser(); var profile = options == null ? null : options.getProfile(); - return withBrowserLogsImpl(browser, profile); + var userDataMode = options == null ? null : options.getUserDataMode(); + return withBrowserLogsImpl(browser, profile, userDataMode); } public ContainerResource withBrowserLogs() { @@ -5185,7 +5219,7 @@ public ContainerResource withBrowserLogs() { } /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ - private ContainerResource withBrowserLogsImpl(String browser, String profile) { + private ContainerResource withBrowserLogsImpl(String browser, String profile, BrowserUserDataMode userDataMode) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (browser != null) { @@ -5194,6 +5228,9 @@ private ContainerResource withBrowserLogsImpl(String browser, String profile) { if (profile != null) { reqArgs.put("profile", AspireClient.serializeValue(profile)); } + if (userDataMode != null) { + reqArgs.put("userDataMode", AspireClient.serializeValue(userDataMode)); + } getClient().invokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs); return this; } @@ -7509,7 +7546,8 @@ public class DotnetToolResource extends ResourceBuilderBase { public DotnetToolResource withBrowserLogs(WithBrowserLogsOptions options) { var browser = options == null ? null : options.getBrowser(); var profile = options == null ? null : options.getProfile(); - return withBrowserLogsImpl(browser, profile); + var userDataMode = options == null ? null : options.getUserDataMode(); + return withBrowserLogsImpl(browser, profile, userDataMode); } public DotnetToolResource withBrowserLogs() { @@ -7517,7 +7555,7 @@ public DotnetToolResource withBrowserLogs() { } /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ - private DotnetToolResource withBrowserLogsImpl(String browser, String profile) { + private DotnetToolResource withBrowserLogsImpl(String browser, String profile, BrowserUserDataMode userDataMode) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (browser != null) { @@ -7526,6 +7564,9 @@ private DotnetToolResource withBrowserLogsImpl(String browser, String profile) { if (profile != null) { reqArgs.put("profile", AspireClient.serializeValue(profile)); } + if (userDataMode != null) { + reqArgs.put("userDataMode", AspireClient.serializeValue(userDataMode)); + } getClient().invokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs); return this; } @@ -9620,7 +9661,8 @@ public class ExecutableResource extends ResourceBuilderBase { public ExecutableResource withBrowserLogs(WithBrowserLogsOptions options) { var browser = options == null ? null : options.getBrowser(); var profile = options == null ? null : options.getProfile(); - return withBrowserLogsImpl(browser, profile); + var userDataMode = options == null ? null : options.getUserDataMode(); + return withBrowserLogsImpl(browser, profile, userDataMode); } public ExecutableResource withBrowserLogs() { @@ -9628,7 +9670,7 @@ public ExecutableResource withBrowserLogs() { } /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ - private ExecutableResource withBrowserLogsImpl(String browser, String profile) { + private ExecutableResource withBrowserLogsImpl(String browser, String profile, BrowserUserDataMode userDataMode) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (browser != null) { @@ -9637,6 +9679,9 @@ private ExecutableResource withBrowserLogsImpl(String browser, String profile) { if (profile != null) { reqArgs.put("profile", AspireClient.serializeValue(profile)); } + if (userDataMode != null) { + reqArgs.put("userDataMode", AspireClient.serializeValue(userDataMode)); + } getClient().invokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs); return this; } @@ -15129,7 +15174,8 @@ public class ProjectResource extends ResourceBuilderBase { public ProjectResource withBrowserLogs(WithBrowserLogsOptions options) { var browser = options == null ? null : options.getBrowser(); var profile = options == null ? null : options.getProfile(); - return withBrowserLogsImpl(browser, profile); + var userDataMode = options == null ? null : options.getUserDataMode(); + return withBrowserLogsImpl(browser, profile, userDataMode); } public ProjectResource withBrowserLogs() { @@ -15137,7 +15183,7 @@ public ProjectResource withBrowserLogs() { } /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ - private ProjectResource withBrowserLogsImpl(String browser, String profile) { + private ProjectResource withBrowserLogsImpl(String browser, String profile, BrowserUserDataMode userDataMode) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (browser != null) { @@ -15146,6 +15192,9 @@ private ProjectResource withBrowserLogsImpl(String browser, String profile) { if (profile != null) { reqArgs.put("profile", AspireClient.serializeValue(profile)); } + if (userDataMode != null) { + reqArgs.put("userDataMode", AspireClient.serializeValue(userDataMode)); + } getClient().invokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs); return this; } @@ -17561,7 +17610,8 @@ public class TestDatabaseResource extends ResourceBuilderBase { public TestDatabaseResource withBrowserLogs(WithBrowserLogsOptions options) { var browser = options == null ? null : options.getBrowser(); var profile = options == null ? null : options.getProfile(); - return withBrowserLogsImpl(browser, profile); + var userDataMode = options == null ? null : options.getUserDataMode(); + return withBrowserLogsImpl(browser, profile, userDataMode); } public TestDatabaseResource withBrowserLogs() { @@ -17569,7 +17619,7 @@ public TestDatabaseResource withBrowserLogs() { } /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ - private TestDatabaseResource withBrowserLogsImpl(String browser, String profile) { + private TestDatabaseResource withBrowserLogsImpl(String browser, String profile, BrowserUserDataMode userDataMode) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (browser != null) { @@ -17578,6 +17628,9 @@ private TestDatabaseResource withBrowserLogsImpl(String browser, String profile) if (profile != null) { reqArgs.put("profile", AspireClient.serializeValue(profile)); } + if (userDataMode != null) { + reqArgs.put("userDataMode", AspireClient.serializeValue(userDataMode)); + } getClient().invokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs); return this; } @@ -19480,7 +19533,8 @@ public class TestRedisResource extends ResourceBuilderBase { public TestRedisResource withBrowserLogs(WithBrowserLogsOptions options) { var browser = options == null ? null : options.getBrowser(); var profile = options == null ? null : options.getProfile(); - return withBrowserLogsImpl(browser, profile); + var userDataMode = options == null ? null : options.getUserDataMode(); + return withBrowserLogsImpl(browser, profile, userDataMode); } public TestRedisResource withBrowserLogs() { @@ -19488,7 +19542,7 @@ public TestRedisResource withBrowserLogs() { } /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ - private TestRedisResource withBrowserLogsImpl(String browser, String profile) { + private TestRedisResource withBrowserLogsImpl(String browser, String profile, BrowserUserDataMode userDataMode) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (browser != null) { @@ -19497,6 +19551,9 @@ private TestRedisResource withBrowserLogsImpl(String browser, String profile) { if (profile != null) { reqArgs.put("profile", AspireClient.serializeValue(profile)); } + if (userDataMode != null) { + reqArgs.put("userDataMode", AspireClient.serializeValue(userDataMode)); + } getClient().invokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs); return this; } @@ -21552,7 +21609,8 @@ public class TestVaultResource extends ResourceBuilderBase { public TestVaultResource withBrowserLogs(WithBrowserLogsOptions options) { var browser = options == null ? null : options.getBrowser(); var profile = options == null ? null : options.getProfile(); - return withBrowserLogsImpl(browser, profile); + var userDataMode = options == null ? null : options.getUserDataMode(); + return withBrowserLogsImpl(browser, profile, userDataMode); } public TestVaultResource withBrowserLogs() { @@ -21560,7 +21618,7 @@ public TestVaultResource withBrowserLogs() { } /** Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. */ - private TestVaultResource withBrowserLogsImpl(String browser, String profile) { + private TestVaultResource withBrowserLogsImpl(String browser, String profile, BrowserUserDataMode userDataMode) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (browser != null) { @@ -21569,6 +21627,9 @@ private TestVaultResource withBrowserLogsImpl(String browser, String profile) { if (profile != null) { reqArgs.put("profile", AspireClient.serializeValue(profile)); } + if (userDataMode != null) { + reqArgs.put("userDataMode", AspireClient.serializeValue(userDataMode)); + } getClient().invokeCapability("Aspire.Hosting/withBrowserLogs", reqArgs); return this; } @@ -23430,6 +23491,7 @@ public interface WireValueEnum { public final class WithBrowserLogsOptions { private String browser; private String profile; + private BrowserUserDataMode userDataMode; public String getBrowser() { return browser; } public WithBrowserLogsOptions browser(String value) { @@ -23443,6 +23505,12 @@ public WithBrowserLogsOptions profile(String value) { return this; } + public BrowserUserDataMode getUserDataMode() { return userDataMode; } + public WithBrowserLogsOptions userDataMode(BrowserUserDataMode value) { + this.userDataMode = value; + return this; + } + } // ===== WithContainerCertificatePathsOptions.java ===== @@ -24138,6 +24206,7 @@ public WithVolumeOptions isReadOnly(Boolean value) { .modules/BaseRegistrations.java .modules/BeforeResourceStartedEvent.java .modules/BeforeStartEvent.java +.modules/BrowserUserDataMode.java .modules/BuildOptions.java .modules/CSharpAppResource.java .modules/CancellationToken.java diff --git a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py index fe855e48b82..27ca145f508 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py +++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py @@ -1494,6 +1494,8 @@ def _validate_dict_types(args: typing.Any, arg_types: typing.Any) -> bool: # Enum Types # ============================================================================ +BrowserUserDataMode = typing.Literal["Shared", "Isolated"] + CertificateTrustScope = typing.Literal["None", "Append", "Override", "System"] CommandResultFormat = typing.Literal["Text", "Json", "Markdown"] @@ -1580,6 +1582,7 @@ class MergeRouteParameters(typing.TypedDict, total=False): class BrowserLogsParameters(typing.TypedDict, total=False): browser: str profile: str + user_data_mode: BrowserUserDataMode class BindMountParameters(typing.TypedDict, total=False): @@ -5835,7 +5838,7 @@ class AbstractResourceWithEndpoints(AbstractResource): """Abstract base class for AbstractResourceWithEndpoints interface.""" @abc.abstractmethod - def with_browser_logs(self, *, browser: str | None = None, profile: str | None = None) -> typing.Self: + def with_browser_logs(self, *, browser: str | None = None, profile: str | None = None, user_data_mode: BrowserUserDataMode | None = None) -> typing.Self: """Adds a child browser logs resource that opens tracked browser sessions and captures browser logs.""" @abc.abstractmethod @@ -7223,13 +7226,15 @@ class ContainerResource(_BaseResource, AbstractResourceWithEnvironment, Abstract def __repr__(self) -> str: return "ContainerResource(handle={self._handle.handle_id})" - def with_browser_logs(self, *, browser: str | None = None, profile: str | None = None) -> typing.Self: + def with_browser_logs(self, *, browser: str | None = None, profile: str | None = None, user_data_mode: BrowserUserDataMode | None = None) -> typing.Self: """Adds a child browser logs resource that opens tracked browser sessions and captures browser logs.""" rpc_args: dict[str, typing.Any] = {'builder': self._handle} if browser is not None: rpc_args['browser'] = browser if profile is not None: rpc_args['profile'] = profile + if user_data_mode is not None: + rpc_args['userDataMode'] = user_data_mode result = self._client.invoke_capability( 'Aspire.Hosting/withBrowserLogs', rpc_args, @@ -8024,6 +8029,7 @@ def __init__(self, handle: Handle, client: AspireClient, **kwargs: typing.Unpack rpc_args: dict[str, typing.Any] = {"builder": handle} rpc_args["browser"] = typing.cast(BrowserLogsParameters, _browser_logs).get("browser") rpc_args["profile"] = typing.cast(BrowserLogsParameters, _browser_logs).get("profile") + rpc_args["userDataMode"] = typing.cast(BrowserLogsParameters, _browser_logs).get("user_data_mode") handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withBrowserLogs', rpc_args)) elif _browser_logs is True: rpc_args: dict[str, typing.Any] = {"builder": handle} @@ -8646,13 +8652,15 @@ class ProjectResource(_BaseResource, AbstractResourceWithEnvironment, AbstractRe def __repr__(self) -> str: return "ProjectResource(handle={self._handle.handle_id})" - def with_browser_logs(self, *, browser: str | None = None, profile: str | None = None) -> typing.Self: + def with_browser_logs(self, *, browser: str | None = None, profile: str | None = None, user_data_mode: BrowserUserDataMode | None = None) -> typing.Self: """Adds a child browser logs resource that opens tracked browser sessions and captures browser logs.""" rpc_args: dict[str, typing.Any] = {'builder': self._handle} if browser is not None: rpc_args['browser'] = browser if profile is not None: rpc_args['profile'] = profile + if user_data_mode is not None: + rpc_args['userDataMode'] = user_data_mode result = self._client.invoke_capability( 'Aspire.Hosting/withBrowserLogs', rpc_args, @@ -9251,6 +9259,7 @@ def __init__(self, handle: Handle, client: AspireClient, **kwargs: typing.Unpack rpc_args: dict[str, typing.Any] = {"builder": handle} rpc_args["browser"] = typing.cast(BrowserLogsParameters, _browser_logs).get("browser") rpc_args["profile"] = typing.cast(BrowserLogsParameters, _browser_logs).get("profile") + rpc_args["userDataMode"] = typing.cast(BrowserLogsParameters, _browser_logs).get("user_data_mode") handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withBrowserLogs', rpc_args)) elif _browser_logs is True: rpc_args: dict[str, typing.Any] = {"builder": handle} @@ -9741,13 +9750,15 @@ class ExecutableResource(_BaseResource, AbstractResourceWithEnvironment, Abstrac def __repr__(self) -> str: return "ExecutableResource(handle={self._handle.handle_id})" - def with_browser_logs(self, *, browser: str | None = None, profile: str | None = None) -> typing.Self: + def with_browser_logs(self, *, browser: str | None = None, profile: str | None = None, user_data_mode: BrowserUserDataMode | None = None) -> typing.Self: """Adds a child browser logs resource that opens tracked browser sessions and captures browser logs.""" rpc_args: dict[str, typing.Any] = {'builder': self._handle} if browser is not None: rpc_args['browser'] = browser if profile is not None: rpc_args['profile'] = profile + if user_data_mode is not None: + rpc_args['userDataMode'] = user_data_mode result = self._client.invoke_capability( 'Aspire.Hosting/withBrowserLogs', rpc_args, @@ -10336,6 +10347,7 @@ def __init__(self, handle: Handle, client: AspireClient, **kwargs: typing.Unpack rpc_args: dict[str, typing.Any] = {"builder": handle} rpc_args["browser"] = typing.cast(BrowserLogsParameters, _browser_logs).get("browser") rpc_args["profile"] = typing.cast(BrowserLogsParameters, _browser_logs).get("profile") + rpc_args["userDataMode"] = typing.cast(BrowserLogsParameters, _browser_logs).get("user_data_mode") handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withBrowserLogs', rpc_args)) elif _browser_logs is True: rpc_args: dict[str, typing.Any] = {"builder": handle} diff --git a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs index ea6cd9658af..1a8b0de3d00 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs +++ b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs @@ -20,6 +20,25 @@ use crate::base::{ // Enums // ============================================================================ +/// BrowserUserDataMode +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum BrowserUserDataMode { + #[default] + #[serde(rename = "Shared")] + Shared, + #[serde(rename = "Isolated")] + Isolated, +} + +impl std::fmt::Display for BrowserUserDataMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Shared => write!(f, "Shared"), + Self::Isolated => write!(f, "Isolated"), + } + } +} + /// ContainerLifetime #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum ContainerLifetime { @@ -1071,7 +1090,7 @@ impl CSharpAppResource { } /// Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. - pub fn with_browser_logs(&self, browser: Option<&str>, profile: Option<&str>) -> Result> { + pub fn with_browser_logs(&self, browser: Option<&str>, profile: Option<&str>, user_data_mode: Option) -> Result> { let mut args: HashMap = HashMap::new(); args.insert("builder".to_string(), self.handle.to_json()); if let Some(ref v) = browser { @@ -1080,6 +1099,9 @@ impl CSharpAppResource { if let Some(ref v) = profile { args.insert("profile".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); } + if let Some(ref v) = user_data_mode { + args.insert("userDataMode".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } let result = self.client.invoke_capability("Aspire.Hosting/withBrowserLogs", args)?; let handle: Handle = serde_json::from_value(result)?; Ok(IResourceWithEndpoints::new(handle, self.client.clone())) @@ -3848,7 +3870,7 @@ impl ContainerResource { } /// Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. - pub fn with_browser_logs(&self, browser: Option<&str>, profile: Option<&str>) -> Result> { + pub fn with_browser_logs(&self, browser: Option<&str>, profile: Option<&str>, user_data_mode: Option) -> Result> { let mut args: HashMap = HashMap::new(); args.insert("builder".to_string(), self.handle.to_json()); if let Some(ref v) = browser { @@ -3857,6 +3879,9 @@ impl ContainerResource { if let Some(ref v) = profile { args.insert("profile".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); } + if let Some(ref v) = user_data_mode { + args.insert("userDataMode".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } let result = self.client.invoke_capability("Aspire.Hosting/withBrowserLogs", args)?; let handle: Handle = serde_json::from_value(result)?; Ok(IResourceWithEndpoints::new(handle, self.client.clone())) @@ -5774,7 +5799,7 @@ impl DotnetToolResource { } /// Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. - pub fn with_browser_logs(&self, browser: Option<&str>, profile: Option<&str>) -> Result> { + pub fn with_browser_logs(&self, browser: Option<&str>, profile: Option<&str>, user_data_mode: Option) -> Result> { let mut args: HashMap = HashMap::new(); args.insert("builder".to_string(), self.handle.to_json()); if let Some(ref v) = browser { @@ -5783,6 +5808,9 @@ impl DotnetToolResource { if let Some(ref v) = profile { args.insert("profile".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); } + if let Some(ref v) = user_data_mode { + args.insert("userDataMode".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } let result = self.client.invoke_capability("Aspire.Hosting/withBrowserLogs", args)?; let handle: Handle = serde_json::from_value(result)?; Ok(IResourceWithEndpoints::new(handle, self.client.clone())) @@ -7604,7 +7632,7 @@ impl ExecutableResource { } /// Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. - pub fn with_browser_logs(&self, browser: Option<&str>, profile: Option<&str>) -> Result> { + pub fn with_browser_logs(&self, browser: Option<&str>, profile: Option<&str>, user_data_mode: Option) -> Result> { let mut args: HashMap = HashMap::new(); args.insert("builder".to_string(), self.handle.to_json()); if let Some(ref v) = browser { @@ -7613,6 +7641,9 @@ impl ExecutableResource { if let Some(ref v) = profile { args.insert("profile".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); } + if let Some(ref v) = user_data_mode { + args.insert("userDataMode".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } let result = self.client.invoke_capability("Aspire.Hosting/withBrowserLogs", args)?; let handle: Handle = serde_json::from_value(result)?; Ok(IResourceWithEndpoints::new(handle, self.client.clone())) @@ -12370,7 +12401,7 @@ impl ProjectResource { } /// Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. - pub fn with_browser_logs(&self, browser: Option<&str>, profile: Option<&str>) -> Result> { + pub fn with_browser_logs(&self, browser: Option<&str>, profile: Option<&str>, user_data_mode: Option) -> Result> { let mut args: HashMap = HashMap::new(); args.insert("builder".to_string(), self.handle.to_json()); if let Some(ref v) = browser { @@ -12379,6 +12410,9 @@ impl ProjectResource { if let Some(ref v) = profile { args.insert("profile".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); } + if let Some(ref v) = user_data_mode { + args.insert("userDataMode".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } let result = self.client.invoke_capability("Aspire.Hosting/withBrowserLogs", args)?; let handle: Handle = serde_json::from_value(result)?; Ok(IResourceWithEndpoints::new(handle, self.client.clone())) @@ -14175,7 +14209,7 @@ impl TestDatabaseResource { } /// Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. - pub fn with_browser_logs(&self, browser: Option<&str>, profile: Option<&str>) -> Result> { + pub fn with_browser_logs(&self, browser: Option<&str>, profile: Option<&str>, user_data_mode: Option) -> Result> { let mut args: HashMap = HashMap::new(); args.insert("builder".to_string(), self.handle.to_json()); if let Some(ref v) = browser { @@ -14184,6 +14218,9 @@ impl TestDatabaseResource { if let Some(ref v) = profile { args.insert("profile".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); } + if let Some(ref v) = user_data_mode { + args.insert("userDataMode".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } let result = self.client.invoke_capability("Aspire.Hosting/withBrowserLogs", args)?; let handle: Handle = serde_json::from_value(result)?; Ok(IResourceWithEndpoints::new(handle, self.client.clone())) @@ -15633,7 +15670,7 @@ impl TestRedisResource { } /// Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. - pub fn with_browser_logs(&self, browser: Option<&str>, profile: Option<&str>) -> Result> { + pub fn with_browser_logs(&self, browser: Option<&str>, profile: Option<&str>, user_data_mode: Option) -> Result> { let mut args: HashMap = HashMap::new(); args.insert("builder".to_string(), self.handle.to_json()); if let Some(ref v) = browser { @@ -15642,6 +15679,9 @@ impl TestRedisResource { if let Some(ref v) = profile { args.insert("profile".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); } + if let Some(ref v) = user_data_mode { + args.insert("userDataMode".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } let result = self.client.invoke_capability("Aspire.Hosting/withBrowserLogs", args)?; let handle: Handle = serde_json::from_value(result)?; Ok(IResourceWithEndpoints::new(handle, self.client.clone())) @@ -17264,7 +17304,7 @@ impl TestVaultResource { } /// Adds a child browser logs resource that opens tracked browser sessions and captures browser logs. - pub fn with_browser_logs(&self, browser: Option<&str>, profile: Option<&str>) -> Result> { + pub fn with_browser_logs(&self, browser: Option<&str>, profile: Option<&str>, user_data_mode: Option) -> Result> { let mut args: HashMap = HashMap::new(); args.insert("builder".to_string(), self.handle.to_json()); if let Some(ref v) = browser { @@ -17273,6 +17313,9 @@ impl TestVaultResource { if let Some(ref v) = profile { args.insert("profile".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); } + if let Some(ref v) = user_data_mode { + args.insert("userDataMode".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } let result = self.client.invoke_capability("Aspire.Hosting/withBrowserLogs", args)?; let handle: Handle = serde_json::from_value(result)?; Ok(IResourceWithEndpoints::new(handle, self.client.clone())) diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts index 5118d1a60c4..6cf6dcf9ada 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts @@ -275,6 +275,12 @@ type IServiceProviderHandle = Handle<'System.ComponentModel/System.IServiceProvi // Enum Types // ============================================================================ +/** Enum type for BrowserUserDataMode */ +export enum BrowserUserDataMode { + Shared = "Shared", + Isolated = "Isolated", +} + /** Enum type for CertificateTrustScope */ export enum CertificateTrustScope { None = "None", @@ -745,6 +751,7 @@ export interface WithBindMountOptions { export interface WithBrowserLogsOptions { browser?: string; profile?: string; + userDataMode?: BrowserUserDataMode; } export interface WithCommandOptions { @@ -10240,10 +10247,11 @@ class ContainerResourceImpl extends ResourceBuilderBase } /** @internal */ - private async _withBrowserLogsInternal(browser?: string, profile?: string): Promise { + private async _withBrowserLogsInternal(browser?: string, profile?: string, userDataMode?: BrowserUserDataMode): Promise { const rpcArgs: Record = { builder: this._handle }; if (browser !== undefined) rpcArgs.browser = browser; if (profile !== undefined) rpcArgs.profile = profile; + if (userDataMode !== undefined) rpcArgs.userDataMode = userDataMode; const result = await this._client.invokeCapability( 'Aspire.Hosting/withBrowserLogs', rpcArgs @@ -10255,7 +10263,8 @@ class ContainerResourceImpl extends ResourceBuilderBase withBrowserLogs(options?: WithBrowserLogsOptions): ContainerResourcePromise { const browser = options?.browser; const profile = options?.profile; - return new ContainerResourcePromiseImpl(this._withBrowserLogsInternal(browser, profile), this._client); + const userDataMode = options?.userDataMode; + return new ContainerResourcePromiseImpl(this._withBrowserLogsInternal(browser, profile, userDataMode), this._client); } /** @internal */ @@ -12968,10 +12977,11 @@ class CSharpAppResourceImpl extends ResourceBuilderBase } /** @internal */ - private async _withBrowserLogsInternal(browser?: string, profile?: string): Promise { + private async _withBrowserLogsInternal(browser?: string, profile?: string, userDataMode?: BrowserUserDataMode): Promise { const rpcArgs: Record = { builder: this._handle }; if (browser !== undefined) rpcArgs.browser = browser; if (profile !== undefined) rpcArgs.profile = profile; + if (userDataMode !== undefined) rpcArgs.userDataMode = userDataMode; const result = await this._client.invokeCapability( 'Aspire.Hosting/withBrowserLogs', rpcArgs @@ -12983,7 +12993,8 @@ class CSharpAppResourceImpl extends ResourceBuilderBase withBrowserLogs(options?: WithBrowserLogsOptions): CSharpAppResourcePromise { const browser = options?.browser; const profile = options?.profile; - return new CSharpAppResourcePromiseImpl(this._withBrowserLogsInternal(browser, profile), this._client); + const userDataMode = options?.userDataMode; + return new CSharpAppResourcePromiseImpl(this._withBrowserLogsInternal(browser, profile, userDataMode), this._client); } /** @internal */ @@ -15369,10 +15380,11 @@ class DotnetToolResourceImpl extends ResourceBuilderBase { + private async _withBrowserLogsInternal(browser?: string, profile?: string, userDataMode?: BrowserUserDataMode): Promise { const rpcArgs: Record = { builder: this._handle }; if (browser !== undefined) rpcArgs.browser = browser; if (profile !== undefined) rpcArgs.profile = profile; + if (userDataMode !== undefined) rpcArgs.userDataMode = userDataMode; const result = await this._client.invokeCapability( 'Aspire.Hosting/withBrowserLogs', rpcArgs @@ -15384,7 +15396,8 @@ class DotnetToolResourceImpl extends ResourceBuilderBase { + private async _withBrowserLogsInternal(browser?: string, profile?: string, userDataMode?: BrowserUserDataMode): Promise { const rpcArgs: Record = { builder: this._handle }; if (browser !== undefined) rpcArgs.browser = browser; if (profile !== undefined) rpcArgs.profile = profile; + if (userDataMode !== undefined) rpcArgs.userDataMode = userDataMode; const result = await this._client.invokeCapability( 'Aspire.Hosting/withBrowserLogs', rpcArgs @@ -17890,7 +17904,8 @@ class ExecutableResourceImpl extends ResourceBuilderBase imp } /** @internal */ - private async _withBrowserLogsInternal(browser?: string, profile?: string): Promise { + private async _withBrowserLogsInternal(browser?: string, profile?: string, userDataMode?: BrowserUserDataMode): Promise { const rpcArgs: Record = { builder: this._handle }; if (browser !== undefined) rpcArgs.browser = browser; if (profile !== undefined) rpcArgs.profile = profile; + if (userDataMode !== undefined) rpcArgs.userDataMode = userDataMode; const result = await this._client.invokeCapability( 'Aspire.Hosting/withBrowserLogs', rpcArgs @@ -22610,7 +22626,8 @@ class ProjectResourceImpl extends ResourceBuilderBase imp withBrowserLogs(options?: WithBrowserLogsOptions): ProjectResourcePromise { const browser = options?.browser; const profile = options?.profile; - return new ProjectResourcePromiseImpl(this._withBrowserLogsInternal(browser, profile), this._client); + const userDataMode = options?.userDataMode; + return new ProjectResourcePromiseImpl(this._withBrowserLogsInternal(browser, profile, userDataMode), this._client); } /** @internal */ @@ -25016,10 +25033,11 @@ class TestDatabaseResourceImpl extends ResourceBuilderBase { + private async _withBrowserLogsInternal(browser?: string, profile?: string, userDataMode?: BrowserUserDataMode): Promise { const rpcArgs: Record = { builder: this._handle }; if (browser !== undefined) rpcArgs.browser = browser; if (profile !== undefined) rpcArgs.profile = profile; + if (userDataMode !== undefined) rpcArgs.userDataMode = userDataMode; const result = await this._client.invokeCapability( 'Aspire.Hosting/withBrowserLogs', rpcArgs @@ -25031,7 +25049,8 @@ class TestDatabaseResourceImpl extends ResourceBuilderBase } /** @internal */ - private async _withBrowserLogsInternal(browser?: string, profile?: string): Promise { + private async _withBrowserLogsInternal(browser?: string, profile?: string, userDataMode?: BrowserUserDataMode): Promise { const rpcArgs: Record = { builder: this._handle }; if (browser !== undefined) rpcArgs.browser = browser; if (profile !== undefined) rpcArgs.profile = profile; + if (userDataMode !== undefined) rpcArgs.userDataMode = userDataMode; const result = await this._client.invokeCapability( 'Aspire.Hosting/withBrowserLogs', rpcArgs @@ -27823,7 +27843,8 @@ class TestRedisResourceImpl extends ResourceBuilderBase withBrowserLogs(options?: WithBrowserLogsOptions): TestRedisResourcePromise { const browser = options?.browser; const profile = options?.profile; - return new TestRedisResourcePromiseImpl(this._withBrowserLogsInternal(browser, profile), this._client); + const userDataMode = options?.userDataMode; + return new TestRedisResourcePromiseImpl(this._withBrowserLogsInternal(browser, profile, userDataMode), this._client); } /** @internal */ @@ -30878,10 +30899,11 @@ class TestVaultResourceImpl extends ResourceBuilderBase } /** @internal */ - private async _withBrowserLogsInternal(browser?: string, profile?: string): Promise { + private async _withBrowserLogsInternal(browser?: string, profile?: string, userDataMode?: BrowserUserDataMode): Promise { const rpcArgs: Record = { builder: this._handle }; if (browser !== undefined) rpcArgs.browser = browser; if (profile !== undefined) rpcArgs.profile = profile; + if (userDataMode !== undefined) rpcArgs.userDataMode = userDataMode; const result = await this._client.invokeCapability( 'Aspire.Hosting/withBrowserLogs', rpcArgs @@ -30893,7 +30915,8 @@ class TestVaultResourceImpl extends ResourceBuilderBase withBrowserLogs(options?: WithBrowserLogsOptions): TestVaultResourcePromise { const browser = options?.browser; const profile = options?.profile; - return new TestVaultResourcePromiseImpl(this._withBrowserLogsInternal(browser, profile), this._client); + const userDataMode = options?.userDataMode; + return new TestVaultResourcePromiseImpl(this._withBrowserLogsInternal(browser, profile, userDataMode), this._client); } /** @internal */ @@ -35133,10 +35156,11 @@ class ResourceWithEndpointsImpl extends ResourceBuilderBase { + private async _withBrowserLogsInternal(browser?: string, profile?: string, userDataMode?: BrowserUserDataMode): Promise { const rpcArgs: Record = { builder: this._handle }; if (browser !== undefined) rpcArgs.browser = browser; if (profile !== undefined) rpcArgs.profile = profile; + if (userDataMode !== undefined) rpcArgs.userDataMode = userDataMode; const result = await this._client.invokeCapability( 'Aspire.Hosting/withBrowserLogs', rpcArgs @@ -35148,7 +35172,8 @@ class ResourceWithEndpointsImpl extends ResourceBuilderBase Date: Sat, 25 Apr 2026 08:12:35 -0700 Subject: [PATCH 10/36] Add browser logs component tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogs/BrowserHostRegistry.cs | 20 ++- .../BrowserLogs/BrowserTargetSession.cs | 55 ++++--- .../BrowserLogsBuilderExtensionsTests.cs | 134 +++++++++++++++- .../BrowserLogsProtocolTests.cs | 44 ++++++ .../BrowserLogsSessionManagerTests.cs | 146 +++++++++++++++++- 5 files changed, 362 insertions(+), 37 deletions(-) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs b/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs index 0c34832df26..d79ae9d835d 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs @@ -13,6 +13,8 @@ namespace Aspire.Hosting; internal sealed class BrowserHostRegistry : IAsyncDisposable { private readonly BrowserEndpointDiscovery _endpointDiscovery; + private readonly Func _createUserDataDirectory; + private readonly Func> _createHostAsync; private readonly IFileSystemService _fileSystemService; private readonly Dictionary _hosts = new(); // Keep the semaphore available for late no-op releases from outstanding leases during registry disposal. @@ -22,8 +24,20 @@ internal sealed class BrowserHostRegistry : IAsyncDisposable private int _disposed; public BrowserHostRegistry(IFileSystemService fileSystemService, ILogger logger, TimeProvider timeProvider) + : this(fileSystemService, logger, timeProvider, createUserDataDirectory: null, createHostAsync: null) + { + } + + internal BrowserHostRegistry( + IFileSystemService fileSystemService, + ILogger logger, + TimeProvider timeProvider, + Func? createUserDataDirectory, + Func>? createHostAsync) { _endpointDiscovery = new BrowserEndpointDiscovery(logger); + _createUserDataDirectory = createUserDataDirectory ?? CreateUserDataDirectory; + _createHostAsync = createHostAsync ?? CreateHostCoreAsync; _fileSystemService = fileSystemService; _logger = logger; _timeProvider = timeProvider; @@ -35,7 +49,7 @@ public async Task AcquireAsync(BrowserLogsSettings settings, C var browserExecutable = BrowserLogsRunningSession.TryResolveBrowserExecutable(settings.Browser) ?? throw new InvalidOperationException($"Unable to locate browser '{settings.Browser}'. Specify an installed Chromium-based browser or an explicit executable path."); - var userDataDirectory = CreateUserDataDirectory(settings, browserExecutable); + var userDataDirectory = _createUserDataDirectory(settings, browserExecutable); var identity = new BrowserHostIdentity(browserExecutable, userDataDirectory.Path); await _lock.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -52,7 +66,7 @@ public async Task AcquireAsync(BrowserLogsSettings settings, C return new BrowserHostLease(entry.Host, releaseAsync: token => ReleaseAsync(identity, token)); } - var host = await CreateHostAsync(settings, identity, userDataDirectory, cancellationToken).ConfigureAwait(false); + var host = await _createHostAsync(settings, identity, userDataDirectory, cancellationToken).ConfigureAwait(false); _hosts[identity] = new BrowserHostEntry(host, userDataDirectory.ProfileDirectoryName, ReferenceCount: 1); return new BrowserHostLease(host, releaseAsync: token => ReleaseAsync(identity, token)); } @@ -130,7 +144,7 @@ private async ValueTask ReleaseAsync(BrowserHostIdentity identity, CancellationT } - private async Task CreateHostAsync( + private async Task CreateHostCoreAsync( BrowserLogsSettings settings, BrowserHostIdentity identity, BrowserLogsUserDataDirectory userDataDirectory, diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs b/src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs index 65e1cc672a9..4db1d11e2ea 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs @@ -56,6 +56,34 @@ private BrowserTargetSession( public Task Completion => _monitorTask ?? throw new InvalidOperationException("Browser target session has not started."); + internal static BrowserTargetSessionResult? TryGetTargetCompletion(BrowserLogsProtocolEvent protocolEvent, string? targetId, string? targetSessionId) + { + return protocolEvent switch + { + BrowserLogsTargetDestroyedEvent targetDestroyed when string.Equals(targetDestroyed.TargetId, targetId, StringComparison.Ordinal) => + new BrowserTargetSessionResult(BrowserTargetSessionCompletionKind.TargetClosed, Error: null), + + BrowserLogsTargetCrashedEvent targetCrashed when string.Equals(targetCrashed.TargetId, targetId, StringComparison.Ordinal) => + new BrowserTargetSessionResult( + BrowserTargetSessionCompletionKind.TargetCrashed, + new InvalidOperationException($"Tracked browser target 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 BrowserTargetSessionResult(BrowserTargetSessionCompletionKind.TargetClosed, Error: null), + + BrowserLogsInspectorDetachedEvent inspectorDetached when string.Equals(inspectorDetached.SessionId, targetSessionId, StringComparison.Ordinal) => + string.Equals(inspectorDetached.Reason, "target_closed", StringComparison.OrdinalIgnoreCase) + ? new BrowserTargetSessionResult(BrowserTargetSessionCompletionKind.TargetClosed, Error: null) + : new BrowserTargetSessionResult( + BrowserTargetSessionCompletionKind.ConnectionLost, + new InvalidOperationException($"Tracked browser inspector detached: {inspectorDetached.Reason ?? "unknown reason"}.")), + + _ => null + }; + } + public static async Task StartAsync( IBrowserHost host, string sessionId, @@ -275,31 +303,10 @@ private async Task TryReconnectAsync(Exception connectionError) private async ValueTask HandleEventAsync(BrowserLogsProtocolEvent protocolEvent) { - switch (protocolEvent) + if (TryGetTargetCompletion(protocolEvent, _targetId, _targetSessionId) is { } targetCompletion) { - case BrowserLogsTargetDestroyedEvent targetDestroyed when string.Equals(targetDestroyed.TargetId, _targetId, StringComparison.Ordinal): - _completionSource.TrySetResult(new BrowserTargetSessionResult(BrowserTargetSessionCompletionKind.TargetClosed, Error: null)); - return; - case BrowserLogsTargetCrashedEvent targetCrashed when string.Equals(targetCrashed.TargetId, _targetId, StringComparison.Ordinal): - _completionSource.TrySetResult(new BrowserTargetSessionResult( - BrowserTargetSessionCompletionKind.TargetCrashed, - new InvalidOperationException($"Tracked browser target crashed with status '{targetCrashed.Parameters.Status}' and error code '{targetCrashed.Parameters.ErrorCode}'."))); - return; - case BrowserLogsDetachedFromTargetEvent detached when - string.Equals(detached.DetachedSessionId, _targetSessionId, StringComparison.Ordinal) || - string.Equals(detached.TargetId, _targetId, StringComparison.Ordinal): - _completionSource.TrySetResult(new BrowserTargetSessionResult(BrowserTargetSessionCompletionKind.TargetClosed, Error: null)); - return; - case BrowserLogsInspectorDetachedEvent inspectorDetached when string.Equals(inspectorDetached.SessionId, _targetSessionId, StringComparison.Ordinal): - var completionKind = string.Equals(inspectorDetached.Reason, "target_closed", StringComparison.OrdinalIgnoreCase) - ? BrowserTargetSessionCompletionKind.TargetClosed - : BrowserTargetSessionCompletionKind.ConnectionLost; - _completionSource.TrySetResult(new BrowserTargetSessionResult( - completionKind, - completionKind == BrowserTargetSessionCompletionKind.ConnectionLost - ? new InvalidOperationException($"Tracked browser inspector detached: {inspectorDetached.Reason ?? "unknown reason"}.") - : null)); - return; + _completionSource.TrySetResult(targetCompletion); + return; } if (string.Equals(protocolEvent.SessionId, _targetSessionId, StringComparison.Ordinal)) diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs index cf38d07c2f9..62173bb9348 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs @@ -593,6 +593,113 @@ public async Task WithBrowserLogs_CommandPublishesFailureDiagnosticsWhenLaunchFa Assert.Empty(GetBrowserSessions(failedEvent.Snapshot)); } + [Fact] + public async Task WithBrowserLogs_CommandClearsLastErrorAfterSuccessfulLaunch() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + var sessionFactory = new FakeBrowserLogsRunningSessionFactory + { + NextStartException = new InvalidOperationException("Launch failed.") + }; + + builder.Services.AddSingleton(sp => + new BrowserLogsSessionManager( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sessionFactory)); + + var web = builder.AddResource(new TestHttpResource("web")) + .WithHttpEndpoint(targetPort: 8080) + .WithEndpoint("http", endpoint => endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", 8080)) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "TestHttp", + State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success), + Properties = [] + }); + + web.WithBrowserLogs(browser: "chrome"); + + using var app = builder.Build(); + await app.StartAsync(); + + var browserLogsResource = app.Services.GetRequiredService().Resources.OfType().Single(); + var failedResult = await app.ResourceCommands.ExecuteCommandAsync(browserLogsResource, BrowserLogsBuilderExtensions.OpenTrackedBrowserCommandName).DefaultTimeout(); + Assert.False(failedResult.Success); + + await app.ResourceNotifications.WaitForResourceAsync( + browserLogsResource.Name, + resourceEvent => + resourceEvent.Snapshot.State?.Text == KnownResourceStates.FailedToStart && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.LastErrorPropertyName, "InvalidOperationException: Launch failed.")).DefaultTimeout(); + + var successfulResult = await app.ResourceCommands.ExecuteCommandAsync(browserLogsResource, BrowserLogsBuilderExtensions.OpenTrackedBrowserCommandName).DefaultTimeout(); + Assert.True(successfulResult.Success); + + var runningEvent = await app.ResourceNotifications.WaitForResourceAsync( + browserLogsResource.Name, + resourceEvent => + resourceEvent.Snapshot.State?.Text == KnownResourceStates.Running && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.ActiveSessionCountPropertyName, 1) && + DoesNotHaveProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.LastErrorPropertyName) && + !resourceEvent.Snapshot.HealthReports.Any(report => report.Name == BrowserLogsBuilderExtensions.LastErrorPropertyName)).DefaultTimeout(); + + Assert.Collection( + GetBrowserSessions(runningEvent.Snapshot), + session => Assert.Equal("session-0002", session.SessionId)); + } + + [Fact] + public async Task WithBrowserLogs_CommandSurfacesAdoptedBrowserDiagnostics() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + var sessionFactory = new FakeBrowserLogsRunningSessionFactory + { + NextBrowserHostOwnership = BrowserHostOwnership.Adopted, + NextProcessIdIsNull = true + }; + + builder.Services.AddSingleton(sp => + new BrowserLogsSessionManager( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sessionFactory)); + + var web = builder.AddResource(new TestHttpResource("web")) + .WithHttpEndpoint(targetPort: 8080) + .WithEndpoint("http", endpoint => endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", 8080)) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "TestHttp", + State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success), + Properties = [] + }); + + web.WithBrowserLogs(browser: "msedge"); + + using var app = builder.Build(); + await app.StartAsync(); + + var browserLogsResource = app.Services.GetRequiredService().Resources.OfType().Single(); + var result = await app.ResourceCommands.ExecuteCommandAsync(browserLogsResource, BrowserLogsBuilderExtensions.OpenTrackedBrowserCommandName).DefaultTimeout(); + Assert.True(result.Success); + + var runningEvent = await app.ResourceNotifications.WaitForResourceAsync( + browserLogsResource.Name, + resourceEvent => + resourceEvent.Snapshot.State?.Text == KnownResourceStates.Running && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.BrowserHostOwnershipPropertyName, nameof(BrowserHostOwnership.Adopted)) && + HasProperty(resourceEvent.Snapshot, BrowserLogsBuilderExtensions.ActiveSessionsPropertyName, "session-0001 (adopted browser)")).DefaultTimeout(); + + var session = Assert.Single(GetBrowserSessions(runningEvent.Snapshot)); + Assert.Equal(nameof(BrowserHostOwnership.Adopted), session.BrowserHostOwnership); + Assert.Null(session.ProcessId); + } + [Fact] public async Task WithBrowserLogs_CommandFailsWhenEndpointIsMissing() { @@ -1030,6 +1137,9 @@ private sealed class TestHttpResource(string name) : Resource(name), IResourceWi private static bool HasProperty(CustomResourceSnapshot snapshot, string name, object expectedValue) => snapshot.Properties.Any(property => property.Name == name && Equals(property.Value, expectedValue)); + private static bool DoesNotHaveProperty(CustomResourceSnapshot snapshot, string name) => + !snapshot.Properties.Any(property => property.Name == name); + private static IReadOnlyList GetBrowserSessions(CustomResourceSnapshot snapshot) { var property = snapshot.Properties.Single(property => property.Name == BrowserLogsBuilderExtensions.BrowserSessionsPropertyName); @@ -1066,6 +1176,9 @@ private sealed class FakeBrowserLogsRunningSessionFactory : IBrowserLogsRunningS public List Sessions { get; } = []; public List Settings { get; } = []; public Exception? NextStartException { get; set; } + public BrowserHostOwnership NextBrowserHostOwnership { get; set; } = BrowserHostOwnership.Owned; + public int? NextProcessId { get; set; } + public bool NextProcessIdIsNull { get; set; } public Task StartSessionAsync( BrowserLogsSettings settings, @@ -1083,13 +1196,20 @@ public Task StartSessionAsync( return Task.FromException(exception); } + var sessionNumber = Sessions.Count + 1; + var processId = NextProcessIdIsNull ? (int?)null : NextProcessId ?? 1000 + sessionNumber; var session = new FakeBrowserLogsRunningSession( sessionId, - $"/fake/browser-{Sessions.Count + 1}", - processId: 1001 + Sessions.Count, + $"/fake/browser-{sessionNumber}", + processId, + sessionNumber, + NextBrowserHostOwnership, startedAt: DateTime.UtcNow); Sessions.Add(session); + NextBrowserHostOwnership = BrowserHostOwnership.Owned; + NextProcessId = null; + NextProcessIdIsNull = false; return Task.FromResult(session); } @@ -1098,7 +1218,9 @@ public Task StartSessionAsync( private sealed class FakeBrowserLogsRunningSession( string sessionId, string browserExecutable, - int processId, + int? processId, + int sessionNumber, + BrowserHostOwnership browserHostOwnership, DateTime startedAt) : IBrowserLogsRunningSession { private TaskCompletionSource _completionObserverGate = CreateSignaledTaskCompletionSource(); @@ -1109,15 +1231,15 @@ private sealed class FakeBrowserLogsRunningSession( public string BrowserExecutable { get; } = browserExecutable; - public Uri BrowserDebugEndpoint { get; } = new($"ws://127.0.0.1:{processId + 8000}/devtools/browser/browser-{processId - 1000}"); + public Uri BrowserDebugEndpoint { get; } = new($"ws://127.0.0.1:{9000 + sessionNumber}/devtools/browser/browser-{sessionNumber}"); - public BrowserHostOwnership BrowserHostOwnership => BrowserHostOwnership.Owned; + public BrowserHostOwnership BrowserHostOwnership { get; } = browserHostOwnership; public int? ProcessId { get; } = processId; public DateTime StartedAt { get; } = startedAt; - public string TargetId { get; } = $"target-{processId - 1000}"; + public string TargetId { get; } = $"target-{sessionNumber}"; public int StopCallCount { get; private set; } diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsProtocolTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsProtocolTests.cs index 51d6411e21d..5d9c7bd74ca 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsProtocolTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsProtocolTests.cs @@ -78,6 +78,50 @@ public void ParseCommandAckResponse_IncludesProtocolErrorDetails() Assert.Contains("-32601", exception.Message); } + [Fact] + public void ParseEvent_TargetDetachedFromTarget_UsesParameterSessionId() + { + var payload = Encoding.UTF8.GetBytes(""" + { + "method": "Target.detachedFromTarget", + "sessionId": "browser-session", + "params": { + "sessionId": "target-session-1", + "targetId": "target-1" + } + } + """); + + var header = BrowserLogsProtocol.ParseMessageHeader(payload); + var @event = Assert.IsType(BrowserLogsProtocol.ParseEvent(header, payload)); + + Assert.Equal("browser-session", @event.SessionId); + Assert.Equal("target-session-1", @event.DetachedSessionId); + Assert.Equal("target-1", @event.TargetId); + } + + [Fact] + public void ParseEvent_TargetCrashed_ReturnsTargetStatusAndErrorCode() + { + var payload = Encoding.UTF8.GetBytes(""" + { + "method": "Target.targetCrashed", + "params": { + "targetId": "target-1", + "status": "crashed", + "errorCode": 1337 + } + } + """); + + var header = BrowserLogsProtocol.ParseMessageHeader(payload); + var @event = Assert.IsType(BrowserLogsProtocol.ParseEvent(header, payload)); + + Assert.Equal("target-1", @event.TargetId); + Assert.Equal("crashed", @event.Parameters.Status); + Assert.Equal(1337, @event.Parameters.ErrorCode); + } + [Fact] public void CreateCommandFrame_DoesNotEscapeNonAsciiCharacters() { diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs index 8ff6d00acaa..1c71e71d499 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs @@ -9,6 +9,8 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +#pragma warning disable ASPIREFILESYSTEM001 // Type is for evaluation purposes only + namespace Aspire.Hosting.Tests; [Trait("Partition", "2")] @@ -75,6 +77,121 @@ public async Task BrowserHostLease_ReleasesOnlyOnce() Assert.Equal(1, releaseCount); } + [Fact] + public async Task BrowserHostRegistry_ReusesHostUntilFinalLeaseReleasesIt() + { + var userDataDirectory = Directory.CreateTempSubdirectory(); + try + { + var browserExecutable = Path.Combine(userDataDirectory.FullName, "browser"); + File.WriteAllText(browserExecutable, string.Empty); + var createdHosts = new List(); + await using var registry = new BrowserHostRegistry( + fileSystemService: null!, + NullLogger.Instance, + TimeProvider.System, + createUserDataDirectory: (settings, _) => BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory.FullName, settings.Profile), + createHostAsync: (settings, identity, _, _) => + { + var host = new TestBrowserHost(identity, settings.Profile); + createdHosts.Add(host); + return Task.FromResult(host); + }); + var settings = new BrowserLogsSettings(browserExecutable, Profile: null, BrowserUserDataMode.Shared); + + var firstLease = await registry.AcquireAsync(settings, CancellationToken.None); + var secondLease = await registry.AcquireAsync(settings, CancellationToken.None); + + Assert.Single(createdHosts); + Assert.Same(firstLease.Host, secondLease.Host); + + await firstLease.DisposeAsync(); + Assert.False(createdHosts[0].Disposed); + + await secondLease.DisposeAsync(); + Assert.True(createdHosts[0].Disposed); + } + finally + { + userDataDirectory.Delete(recursive: true); + } + } + + [Fact] + public async Task BrowserHostRegistry_RejectsDifferentProfileForSharedHost() + { + var userDataDirectory = Directory.CreateTempSubdirectory(); + try + { + var browserExecutable = Path.Combine(userDataDirectory.FullName, "browser"); + File.WriteAllText(browserExecutable, string.Empty); + await using var registry = new BrowserHostRegistry( + fileSystemService: null!, + NullLogger.Instance, + TimeProvider.System, + createUserDataDirectory: (settings, _) => BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory.FullName, settings.Profile), + createHostAsync: (settings, identity, _, _) => Task.FromResult(new TestBrowserHost(identity, settings.Profile))); + + var firstLease = await registry.AcquireAsync( + new BrowserLogsSettings(browserExecutable, Profile: "Profile 1", BrowserUserDataMode.Shared), + CancellationToken.None); + + var exception = await Assert.ThrowsAsync(() => + registry.AcquireAsync( + new BrowserLogsSettings(browserExecutable, Profile: "Profile 2", BrowserUserDataMode.Shared), + CancellationToken.None)); + + await firstLease.DisposeAsync(); + Assert.Contains("with profile 'Profile 1'", exception.Message); + Assert.Contains("The requested profile is 'Profile 2'", exception.Message); + } + finally + { + userDataDirectory.Delete(recursive: true); + } + } + + [Fact] + public void BrowserTargetSession_MapsTargetLifecycleEventsToCompletion() + { + var closed = BrowserTargetSession.TryGetTargetCompletion( + new BrowserLogsDetachedFromTargetEvent( + SessionId: null, + new BrowserLogsDetachedFromTargetParameters + { + SessionId = "target-session-1", + TargetId = "target-1" + }), + targetId: "target-1", + targetSessionId: "target-session-1"); + var crashed = BrowserTargetSession.TryGetTargetCompletion( + new BrowserLogsTargetCrashedEvent( + SessionId: null, + new BrowserLogsTargetCrashedParameters + { + TargetId = "target-1", + Status = "crashed", + ErrorCode = 1337 + }), + targetId: "target-1", + targetSessionId: "target-session-1"); + var unrelated = BrowserTargetSession.TryGetTargetCompletion( + new BrowserLogsInspectorDetachedEvent( + SessionId: "other-session", + new BrowserLogsInspectorDetachedParameters + { + Reason = "target_closed" + }), + targetId: "target-1", + targetSessionId: "target-session-1"); + + Assert.Equal(BrowserTargetSessionCompletionKind.TargetClosed, closed?.CompletionKind); + Assert.Null(closed?.Error); + Assert.Equal(BrowserTargetSessionCompletionKind.TargetCrashed, crashed?.CompletionKind); + Assert.Contains("1337", crashed?.Error?.Message); + Assert.Null(unrelated); + } + [Fact] public async Task BrowserEndpointDiscovery_DeletesStaleMetadataBeforeProfileCompatibilityCheck() { @@ -475,9 +592,26 @@ public Task StartSessionAsync( private sealed class TestBrowserHost : IBrowserHost { - public BrowserHostIdentity Identity { get; } = new( - Path.Combine(AppContext.BaseDirectory, "browser"), - Path.Combine(AppContext.BaseDirectory, "user-data")); + public TestBrowserHost() + : this( + new BrowserHostIdentity( + Path.Combine(AppContext.BaseDirectory, "browser"), + Path.Combine(AppContext.BaseDirectory, "user-data")), + profileDirectoryName: null) + { + } + + public TestBrowserHost(BrowserHostIdentity identity, string? profileDirectoryName) + { + Identity = identity; + ProfileDirectoryName = profileDirectoryName; + } + + public BrowserHostIdentity Identity { get; } + + public string? ProfileDirectoryName { get; } + + public bool Disposed { get; private set; } public BrowserHostOwnership Ownership => BrowserHostOwnership.Owned; @@ -497,7 +631,11 @@ public Task CreateTargetSessionAsync( CancellationToken cancellationToken) => throw new NotSupportedException(); - public ValueTask DisposeAsync() => ValueTask.CompletedTask; + public ValueTask DisposeAsync() + { + Disposed = true; + return ValueTask.CompletedTask; + } } private sealed class TestResourceWithEndpoints(string name) : Resource(name), IResourceWithEndpoints; From ac96567d70c7b0a05cf068d3c959c608f93f3d1f Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 25 Apr 2026 12:34:19 -0700 Subject: [PATCH 11/36] Reuse browser endpoint probe HttpClient Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogs/BrowserEndpointDiscovery.cs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs index 68b19ad010b..5e58e5e4369 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs @@ -14,6 +14,13 @@ namespace Aspire.Hosting; internal sealed class BrowserEndpointDiscovery(ILogger logger) { 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 = Timeout.InfiniteTimeSpan + }; + private readonly ILogger _logger = logger; public static string GetEndpointMetadataFilePath(string userDataDirectory) => @@ -168,15 +175,17 @@ private static async Task ProbeBrowserEndpointAsync(Uri browserEndpoint, C Query = null }.Uri; - using var httpClient = new HttpClient { Timeout = s_probeTimeout }; - using var response = await httpClient.GetAsync(versionEndpoint, cancellationToken).ConfigureAwait(false); + 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; } - using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + using var stream = await response.Content.ReadAsStreamAsync(probeCts.Token).ConfigureAwait(false); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: probeCts.Token).ConfigureAwait(false); return document.RootElement.TryGetProperty("webSocketDebuggerUrl", out var endpointElement) && endpointElement.ValueKind == JsonValueKind.String && Uri.TryCreate(endpointElement.GetString(), UriKind.Absolute, out _); From ef381b0754b5ae3f8deade2ea7648e2738a5ff6b Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 25 Apr 2026 12:37:11 -0700 Subject: [PATCH 12/36] Use typed browser endpoint probe response Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogs/BrowserEndpointDiscovery.cs | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs index 5e58e5e4369..71c5174b2ce 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs @@ -185,10 +185,8 @@ private static async Task ProbeBrowserEndpointAsync(Uri browserEndpoint, C } using var stream = await response.Content.ReadAsStreamAsync(probeCts.Token).ConfigureAwait(false); - using var document = await JsonDocument.ParseAsync(stream, cancellationToken: probeCts.Token).ConfigureAwait(false); - return document.RootElement.TryGetProperty("webSocketDebuggerUrl", out var endpointElement) && - endpointElement.ValueKind == JsonValueKind.String && - Uri.TryCreate(endpointElement.GetString(), UriKind.Absolute, out _); + var version = await JsonSerializer.DeserializeAsync(stream, BrowserEndpointJsonContext.Default.BrowserJsonVersionResponse, probeCts.Token).ConfigureAwait(false); + return Uri.TryCreate(version?.WebSocketDebuggerUrl, UriKind.Absolute, out _); } private static int? TryGetSingletonLockProcessId(FileInfo singletonLock) @@ -302,7 +300,19 @@ internal sealed record BrowserDebugEndpointMetadata public DateTimeOffset CreatedAt { get; init; } } -// Source-generated JSON context for the small metadata file exchanged between owned and adopted host paths. +// 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/" } +internal sealed record BrowserJsonVersionResponse +{ + public string? WebSocketDebuggerUrl { get; init; } +} + +// Source-generated JSON context for the small metadata file exchanged between owned and adopted host 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; From 3f259b1fb74c0c64a4906fbbecaaaa208d91c00a Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 25 Apr 2026 12:45:16 -0700 Subject: [PATCH 13/36] Remove redundant adopted browser disposal guard Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting/BrowserLogs/BrowserHost.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs b/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs index d13e4303f23..931ad8c4aad 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs @@ -295,7 +295,6 @@ private static async Task WaitForBrowserEndpointAsync( internal sealed class AdoptedBrowserHost : BrowserHost { private readonly TaskCompletionSource _terminationSource = new(TaskCreationOptions.RunContinuationsAsynchronously); - private int _disposed; public AdoptedBrowserHost( BrowserHostIdentity identity, @@ -313,10 +312,7 @@ public AdoptedBrowserHost( public override ValueTask DisposeAsync() { - if (Interlocked.Exchange(ref _disposed, 1) == 0) - { - _terminationSource.TrySetResult(); - } + _terminationSource.TrySetResult(); return ValueTask.CompletedTask; } From fba86ac910130a214ba7c83d7da70edfdf431b44 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 25 Apr 2026 12:46:53 -0700 Subject: [PATCH 14/36] Document browser host registry decisions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogs/BrowserHostRegistry.cs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs b/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs index d79ae9d835d..99e92e51a5c 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs @@ -52,6 +52,16 @@ public async Task AcquireAsync(BrowserLogsSettings settings, C var userDataDirectory = _createUserDataDirectory(settings, browserExecutable); var identity = new BrowserHostIdentity(browserExecutable, userDataDirectory.Path); + // The core AcquireAsync flow has to make one atomic decision per browser identity: + // + // 1. If the registry already has a host for this executable + user data root, reuse it and increment the lease + // count. + // 2. Otherwise, create a host exactly once and publish it into the registry with the first lease. + // + // Keep the lock held across CreateHostCoreAsync. That method may adopt an existing debug-enabled browser or + // start a new process, both of which depend on filesystem endpoint metadata for the same user data root. If two + // callers ran that decision concurrently they could both miss the dictionary entry and race to adopt/start a + // browser for the same profile. await _lock.WaitAsync(cancellationToken).ConfigureAwait(false); try { @@ -59,6 +69,9 @@ public async Task AcquireAsync(BrowserLogsSettings settings, C if (_hosts.TryGetValue(identity, out var entry)) { + // The identity is rooted at the browser executable and user data directory, not at a specific profile. + // That lets multiple sessions share one debug-enabled browser for the same user data root while still + // rejecting requests that need a different named profile from the browser we already track. ValidateProfileCompatibility(identity, entry.ProfileDirectoryName, userDataDirectory.ProfileDirectoryName); entry.ReferenceCount++; _logger.LogInformation("Reusing tracked browser host '{BrowserExecutable}' at '{Endpoint}'. Active leases: {ReferenceCount}.", identity.ExecutablePath, entry.Host.DebugEndpoint, entry.ReferenceCount); @@ -66,6 +79,10 @@ public async Task AcquireAsync(BrowserLogsSettings settings, C return new BrowserHostLease(entry.Host, releaseAsync: token => ReleaseAsync(identity, token)); } + // No host exists for this identity yet. CreateHostCoreAsync owns the second-stage decision: + // adopt a validated shared browser if one is already running, reject an incompatible locked profile, or + // start a new owned browser. The returned host is inserted before returning the first lease so future + // callers can reuse it. var host = await _createHostAsync(settings, identity, userDataDirectory, cancellationToken).ConfigureAwait(false); _hosts[identity] = new BrowserHostEntry(host, userDataDirectory.ProfileDirectoryName, ReferenceCount: 1); return new BrowserHostLease(host, releaseAsync: token => ReleaseAsync(identity, token)); @@ -116,6 +133,8 @@ private async ValueTask ReleaseAsync(BrowserHostIdentity identity, CancellationT if (Volatile.Read(ref _disposed) != 0) { + // DisposeAsync clears the registry and disposes every host. Late lease releases can safely no-op because + // the host they refer to is already part of the registry-wide disposal path. return; } @@ -152,6 +171,15 @@ private async Task CreateHostCoreAsync( { if (settings.UserDataMode == BrowserUserDataMode.Shared) { + // Shared mode has three outcomes, in this order: + // + // 1. Adopt a browser that Aspire previously launched for this user data root and profile. The endpoint file + // must validate against this browser identity, which protects us from stale metadata left behind by a + // different browser or profile. + // 2. If the profile is locked but no valid Aspire endpoint exists, fail with guidance. That means a normal + // browser is using the profile without remote debugging, so we cannot attach and must not start a second + // browser against the same locked user data directory. + // 3. If nothing is running, fall through and start an owned debug-enabled browser. if (await _endpointDiscovery.TryReadAndValidateAsync(identity, userDataDirectory.ProfileDirectoryName, cancellationToken).ConfigureAwait(false) is { } metadata) { var endpoint = new Uri(metadata.Endpoint, UriKind.Absolute); @@ -177,9 +205,13 @@ private BrowserLogsUserDataDirectory CreateUserDataDirectory(BrowserLogsSettings { if (settings.UserDataMode == BrowserUserDataMode.Isolated) { + // Isolated mode never reuses the user's normal profile. Each host gets a temp user data directory that can + // be safely deleted when the last lease releases the owned browser. return BrowserLogsUserDataDirectory.CreateTemporary(_fileSystemService.TempDirectory.CreateTempSubdirectory("aspire-browser-logs")); } + // Shared mode intentionally points at the browser's real user data root so user state, extensions, and profiles + // are available. The later endpoint/probe logic decides whether that root is reusable, adoptable, or locked. var userDataDirectory = BrowserLogsRunningSession.TryResolveBrowserUserDataDirectory(settings.Browser, browserExecutable) ?? throw new InvalidOperationException($"Unable to resolve the user data directory for browser '{settings.Browser}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode."); @@ -203,6 +235,9 @@ private BrowserLogsUserDataDirectory CreateUserDataDirectory(BrowserLogsSettings private static void ValidateProfileCompatibility(BrowserHostIdentity identity, string? existingProfileDirectoryName, string? requestedProfileDirectoryName) { + // A request without an explicit profile can attach to any tracked browser for the same user data root. Once a + // caller asks for a named profile, however, reusing a host launched for a different profile would put the session + // in the wrong browser context, so fail instead of silently attaching to the wrong profile. if (requestedProfileDirectoryName is null || string.Equals(existingProfileDirectoryName, requestedProfileDirectoryName, StringComparison.OrdinalIgnoreCase)) { From ba981ec43cfe8a79df3cc2c2ca8364771a707997 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 25 Apr 2026 13:02:10 -0700 Subject: [PATCH 15/36] Add browser observation notes to registry comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogs/BrowserHostRegistry.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs b/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs index 99e92e51a5c..e01794ddd5b 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs @@ -72,6 +72,8 @@ public async Task AcquireAsync(BrowserLogsSettings settings, C // The identity is rooted at the browser executable and user data directory, not at a specific profile. // That lets multiple sessions share one debug-enabled browser for the same user data root while still // rejecting requests that need a different named profile from the browser we already track. + // In the playground this shows up as one browser window/process with additional tracked targets/tabs + // as more resources start browser-log sessions, rather than one browser process per session. ValidateProfileCompatibility(identity, entry.ProfileDirectoryName, userDataDirectory.ProfileDirectoryName); entry.ReferenceCount++; _logger.LogInformation("Reusing tracked browser host '{BrowserExecutable}' at '{Endpoint}'. Active leases: {ReferenceCount}.", identity.ExecutablePath, entry.Host.DebugEndpoint, entry.ReferenceCount); @@ -82,7 +84,8 @@ public async Task AcquireAsync(BrowserLogsSettings settings, C // No host exists for this identity yet. CreateHostCoreAsync owns the second-stage decision: // adopt a validated shared browser if one is already running, reject an incompatible locked profile, or // start a new owned browser. The returned host is inserted before returning the first lease so future - // callers can reuse it. + // callers can reuse it. This keeps the visible behavior stable when several resources request browser logs + // together: the first request may open/adopt the browser, and the rest should attach to that result. var host = await _createHostAsync(settings, identity, userDataDirectory, cancellationToken).ConfigureAwait(false); _hosts[identity] = new BrowserHostEntry(host, userDataDirectory.ProfileDirectoryName, ReferenceCount: 1); return new BrowserHostLease(host, releaseAsync: token => ReleaseAsync(identity, token)); @@ -175,10 +178,12 @@ private async Task CreateHostCoreAsync( // // 1. Adopt a browser that Aspire previously launched for this user data root and profile. The endpoint file // must validate against this browser identity, which protects us from stale metadata left behind by a - // different browser or profile. + // different browser or profile. Real browser sessions can leave sidecar files behind if they are closed + // externally or crash, so the file is only useful after the process and /json/version endpoint respond. // 2. If the profile is locked but no valid Aspire endpoint exists, fail with guidance. That means a normal // browser is using the profile without remote debugging, so we cannot attach and must not start a second - // browser against the same locked user data directory. + // browser against the same locked user data directory. On real Chromium profiles that second launch tends + // to hand off to the already-running browser or fail before writing a usable DevTools endpoint. // 3. If nothing is running, fall through and start an owned debug-enabled browser. if (await _endpointDiscovery.TryReadAndValidateAsync(identity, userDataDirectory.ProfileDirectoryName, cancellationToken).ConfigureAwait(false) is { } metadata) { @@ -211,7 +216,9 @@ private BrowserLogsUserDataDirectory CreateUserDataDirectory(BrowserLogsSettings } // Shared mode intentionally points at the browser's real user data root so user state, extensions, and profiles - // are available. The later endpoint/probe logic decides whether that root is reusable, adoptable, or locked. + // are available. Chromium puts SingletonLock, DevToolsActivePort, and our Aspire endpoint sidecar at that root; + // named profiles are subdirectories selected by command-line argument. The later endpoint/probe logic decides + // whether that root is reusable, adoptable, or locked. var userDataDirectory = BrowserLogsRunningSession.TryResolveBrowserUserDataDirectory(settings.Browser, browserExecutable) ?? throw new InvalidOperationException($"Unable to resolve the user data directory for browser '{settings.Browser}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode."); From 46edb616afc9706db53133de5847958622fe5b00 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 25 Apr 2026 13:06:05 -0700 Subject: [PATCH 16/36] Document browser log runtime behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogs/BrowserEndpointDiscovery.cs | 10 ++++++++++ src/Aspire.Hosting/BrowserLogs/BrowserHost.cs | 14 ++++++++++++++ .../BrowserLogs/BrowserLogsDevToolsConnection.cs | 10 ++++++++++ .../BrowserLogs/BrowserLogsEventLogger.cs | 5 +++++ .../BrowserLogs/BrowserLogsRunningSession.cs | 10 ++++++++++ .../BrowserLogs/BrowserLogsSessionManager.cs | 8 ++++++++ .../BrowserLogs/BrowserTargetSession.cs | 11 +++++++++++ 7 files changed, 68 insertions(+) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs index 71c5174b2ce..99bbac74baa 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs @@ -38,6 +38,9 @@ public static string GetEndpointMetadataFilePath(string userDataDirectory) => 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. using var stream = File.OpenRead(metadataPath); metadata = await JsonSerializer.DeserializeAsync(stream, BrowserEndpointJsonContext.Default.BrowserDebugEndpointMetadata, cancellationToken).ConfigureAwait(false); } @@ -72,6 +75,9 @@ metadataUserDataRootPath is null || 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 { @@ -90,6 +96,8 @@ metadataUserDataRootPath is null || 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( @@ -163,6 +171,8 @@ public static bool IsNonDebuggableBrowserRunning(string userDataDirectory) return IsProcessAlive(pid); } + // On Windows the singleton is a locked file rather than a host-pid symlink, so the best available signal is the + // presence of the lock path. On Unix we avoid treating old broken symlinks as an active browser. return OperatingSystem.IsWindows(); } diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs b/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs index 931ad8c4aad..b5c52fa2a8e 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs @@ -49,6 +49,9 @@ public Task CreateTargetSessionAsync( protected static string BuildBrowserArguments(BrowserLogsUserDataDirectory userDataDirectory) { + // Chromium writes DevToolsActivePort only when remote debugging is enabled. Let it choose the port so + // playground runs do not collide with a user's existing browser or another AppHost. The initial about:blank + // page gives owned hosts a predictable first target that can be navigated instead of leaving an extra blank tab. List arguments = [ $"--user-data-dir={userDataDirectory.Path}", @@ -136,7 +139,12 @@ public static async Task StartAsync( { var processStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var devToolsActivePortFilePath = Path.Combine(userDataDirectory.Path, "DevToolsActivePort"); + // DevToolsActivePort is Chromium's hand-off file for the browser-level websocket. Real profile directories can + // contain a stale file from a previous run, and a live browser can keep it locked, so remember the previous + // timestamp and only accept a fresh write from the process we just launched. var previousWriteTimeUtc = PrepareBrowserEndpointFile(devToolsActivePortFilePath, logger); + // Clear Aspire's adoption sidecar before launch so a later AcquireAsync cannot adopt stale metadata while this + // owned process is still proving which endpoint Chromium actually opened. BrowserEndpointDiscovery.DeleteEndpointMetadata(userDataDirectory.Path); var processSpec = new ProcessSpec(identity.ExecutablePath) @@ -156,6 +164,8 @@ public static async Task StartAsync( { processId = await WaitForProcessStartAsync(processStarted.Task, processTask, cancellationToken).ConfigureAwait(false); browserEndpoint = await WaitForBrowserEndpointAsync(processTask, devToolsActivePortFilePath, previousWriteTimeUtc, timeProvider, cancellationToken).ConfigureAwait(false); + // Once Chromium has written DevToolsActivePort and responded with a browser endpoint, write our sidecar so a + // later AppHost run can adopt the same debug-enabled browser instead of opening a second window. await BrowserEndpointDiscovery.WriteAsync(identity, userDataDirectory.ProfileDirectoryName, browserEndpoint, processId, cancellationToken).ConfigureAwait(false); } catch @@ -184,6 +194,8 @@ public override async ValueTask DisposeAsync() return; } + // Remove the adoption sidecar before tearing down the process so a subsequent AppHost does not adopt a browser + // that is already exiting. BrowserEndpointDiscovery.DeleteEndpointMetadata(_userDataDirectory.Path); await _processLifetime.DisposeAsync().ConfigureAwait(false); @@ -296,6 +308,8 @@ internal sealed class AdoptedBrowserHost : BrowserHost { private readonly TaskCompletionSource _terminationSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + // An adopted browser may already contain user-owned tabs. Always create a new target for Aspire rather than reusing + // an arbitrary about:blank page that happened to exist in the user's real browser. public AdoptedBrowserHost( BrowserHostIdentity identity, Uri debugEndpoint, diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsDevToolsConnection.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsDevToolsConnection.cs index 886c23b618b..6111a177f31 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsDevToolsConnection.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsDevToolsConnection.cs @@ -56,6 +56,8 @@ internal static async Task ConnectAsync( Func 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(TimeSpan.FromSeconds(15)); await connector.ConnectAsync(webSocketUri, cancellationToken).ConfigureAwait(false); return new ChromeDevToolsConnection(connector.DetachConnectedWebSocket(), eventHandler, logger); @@ -121,6 +123,9 @@ public Task EnableTargetDiscoveryAsync(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(BrowserLogsProtocol.RuntimeEnableMethod, sessionId, writeParameters: null, BrowserLogsProtocol.ParseCommandAckResponse, cancellationToken).ConfigureAwait(false); await SendCommandAsync(BrowserLogsProtocol.LogEnableMethod, sessionId, writeParameters: null, BrowserLogsProtocol.ParseCommandAckResponse, cancellationToken).ConfigureAwait(false); await SendCommandAsync(BrowserLogsProtocol.PageEnableMethod, sessionId, writeParameters: null, BrowserLogsProtocol.ParseCommandAckResponse, cancellationToken).ConfigureAwait(false); @@ -235,6 +240,8 @@ private async Task ReceiveLoopAsync() break; } + // 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) { @@ -287,6 +294,9 @@ private async Task ReceiveLoopAsync() private async Task HandleFrameAsync(byte[] frame) { var header = BrowserLogsProtocol.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)) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsEventLogger.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsEventLogger.cs index 0ce52037f65..8c4f09b5980 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsEventLogger.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsEventLogger.cs @@ -21,6 +21,9 @@ internal sealed class BrowserEventLogger(string sessionId, ILogger resourceLogge private readonly string _sessionId = sessionId; private readonly ILogger _resourceLogger = resourceLogger; + // Network request information arrives as several independent CDP events. A live page can redirect, fail, or serve + // from cache/service worker before the terminal event arrives, so keep just enough per-request state to emit one + // resource-log line when the request is complete. private readonly Dictionary _networkRequests = new(StringComparer.Ordinal); public void HandleEvent(BrowserLogsProtocolEvent protocolEvent) @@ -134,6 +137,8 @@ private void TrackResponseReceived(BrowserLogsResponseReceivedParameters paramet if (parameters.Response is not null) { + // Cache and service-worker flags are only available on the response event, while the duration and encoded + // byte count arrive later on loadingFinished. UpdateResponse(request, parameters.Response); } diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs index 7b1f2c3a321..3f2ea7c9f10 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs @@ -178,6 +178,8 @@ public async Task StopAsync(CancellationToken cancellationToken) { _stopCts.Cancel(); + // Stopping a dashboard browser-log session should close only the page target it created. The shared browser + // process/window is released through the lease and may stay alive while other resource sessions are still active. await DisposeTargetSessionAsync().ConfigureAwait(false); await DisposeBrowserHostLeaseAsync().ConfigureAwait(false); @@ -224,6 +226,9 @@ private async Task InitializeAsync(CancellationToken cancellationToken) try { + // A running session represents one resource URL, not one browser process. In the playground multiple + // resources can share a host, while each resource still gets its own CDP target so console and network + // events stay scoped to the right resource logs. _targetSession = await browserHost.CreateTargetSessionAsync( _sessionId, _url, @@ -257,6 +262,8 @@ private async Task MonitorAsync() { var targetSession = _targetSession ?? throw new InvalidOperationException("Browser target session is not available."); var result = await targetSession.Completion.ConfigureAwait(false); + // Closing the tracked tab by hand is a normal completion. Browser process exit, target crash, or an + // unrecoverable CDP connection loss is surfaced as an error so the dashboard resource shows what happened. return result.CompletionKind switch { BrowserTargetSessionCompletionKind.Stopped => new BrowserSessionResult(ExitCode: null, Error: null), @@ -575,6 +582,9 @@ private static BrowserKind GetBrowserKind(string browser, string browserExecutab return null; } + // Owned Chromium launches start with the about:blank target from BuildBrowserArguments. Reusing it means the + // first tracked session navigates the visible empty tab instead of creating a second tab and leaving a blank one + // behind. If the browser reported a different unattached page first, fall back to that. var preferredTarget = targetInfos.FirstOrDefault(static targetInfo => string.Equals(targetInfo.Type, "page", StringComparison.Ordinal) && targetInfo.Attached != true && diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs index 4be38939654..fb83a8ae724 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs @@ -64,6 +64,9 @@ public async Task StartSessionAsync(BrowserLogsResource resource, BrowserLogsSet ThrowIfDisposing(); var resourceState = _resourceStates.GetOrAdd(resourceName, static _ => new ResourceSessionState()); + // Dashboard commands can start/stop browser-log sessions for the same resource while previous targets are still + // completing. Serialize per resource so session ids, health reports, and properties describe the same observed + // set of browser targets. await resourceState.Lock.WaitAsync(cancellationToken).ConfigureAwait(false); try @@ -137,6 +140,9 @@ await PublishResourceSnapshotAsync( await HandleSessionCompletedAsync(resource, resourceName, resourceState, session.SessionId, exitCode, error).ConfigureAwait(false); }); + // The session snapshot intentionally stores both browser-level and page-level endpoints. In the dashboard, + // the browser endpoint explains what process was adopted/owned, while the page endpoint lets a developer + // inspect the exact target that is producing resource logs. resourceState.ActiveSessions[session.SessionId] = new ActiveBrowserSession( session.SessionId, settings.Browser, @@ -234,6 +240,8 @@ private async Task HandleSessionCompletedAsync( return; } + // Multiple active sessions can share one visible browser. If one tab is closed or crashes, keep the resource + // running while other tabs are still producing logs; only the last completion controls stop time and exit code. if (error is not null) { resourceState.LastError = BrowserConnectionDiagnosticsLogger.DescribeConnectionProblem(error); diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs b/src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs index 4db1d11e2ea..b3e10bc9635 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs @@ -170,10 +170,14 @@ private async Task ConnectAsync(bool createTarget, CancellationToken cancellatio throw new InvalidOperationException("Tracked browser target id is not available."); } + // Reconnects reuse the existing target id. A transient websocket 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) @@ -193,6 +197,8 @@ private async Task CreateTargetAsync(CancellationToken cancellationToken } } + // 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."); @@ -254,6 +260,9 @@ private async Task TryReconnectAsync(Exception connectionError) { await DisposeConnectionAsync().ConfigureAwait(false); + // In a real browser the CDP websocket 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; @@ -303,6 +312,8 @@ private async Task TryReconnectAsync(Exception connectionError) private async ValueTask HandleEventAsync(BrowserLogsProtocolEvent 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 (TryGetTargetCompletion(protocolEvent, _targetId, _targetSessionId) is { } targetCompletion) { _completionSource.TrySetResult(targetCompletion); From 07c9794b998fca1fe5208d0f1337cbd04c0b0ab5 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 25 Apr 2026 13:20:15 -0700 Subject: [PATCH 17/36] Name browser logs timeout values Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs | 3 ++- src/Aspire.Hosting/BrowserLogs/BrowserHost.cs | 5 +++-- .../BrowserLogs/BrowserLogsDevToolsConnection.cs | 3 ++- src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs | 3 ++- src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs | 4 +++- 5 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs index 99bbac74baa..ac53c349626 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs @@ -13,12 +13,13 @@ namespace Aspire.Hosting; // an existing browser can be adopted. internal sealed class BrowserEndpointDiscovery(ILogger logger) { + private static readonly TimeSpan s_probeHttpClientTimeout = Timeout.InfiniteTimeSpan; 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 = Timeout.InfiniteTimeSpan + Timeout = s_probeHttpClientTimeout }; private readonly ILogger _logger = logger; diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs b/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs index b5c52fa2a8e..cc854e28896 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs @@ -98,6 +98,7 @@ private async Task CreateTargetSessionCoreAsync( internal sealed class OwnedBrowserHost : BrowserHost { private static readonly TimeSpan s_browserEndpointTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan s_browserEndpointPollInterval = TimeSpan.FromMilliseconds(100); private static readonly TimeSpan s_browserShutdownTimeout = TimeSpan.FromSeconds(5); private readonly BrowserLogsUserDataDirectory _userDataDirectory; @@ -251,7 +252,7 @@ private static async Task WaitForBrowserEndpointAsync( if (previousWriteTimeUtc is { } previousWriteTime && File.GetLastWriteTimeUtc(devToolsActivePortFilePath) <= previousWriteTime) { - await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken).ConfigureAwait(false); + await Task.Delay(s_browserEndpointPollInterval, cancellationToken).ConfigureAwait(false); continue; } @@ -269,7 +270,7 @@ private static async Task WaitForBrowserEndpointAsync( { } - await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken).ConfigureAwait(false); + await Task.Delay(s_browserEndpointPollInterval, cancellationToken).ConfigureAwait(false); } throw new TimeoutException($"Timed out waiting for the tracked browser to write '{devToolsActivePortFilePath}'."); diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsDevToolsConnection.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsDevToolsConnection.cs index 6111a177f31..ba951fcf17e 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsDevToolsConnection.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsDevToolsConnection.cs @@ -14,6 +14,7 @@ internal sealed class ChromeDevToolsConnection : IAsyncDisposable { private static readonly TimeSpan s_closeTimeout = TimeSpan.FromSeconds(3); private static readonly TimeSpan s_commandTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan s_keepAliveInterval = TimeSpan.FromSeconds(15); private readonly CancellationTokenSource _disposeCts = new(); private readonly Func _eventHandler; @@ -58,7 +59,7 @@ internal static async Task ConnectAsync( 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(TimeSpan.FromSeconds(15)); + connector.SetKeepAliveInterval(s_keepAliveInterval); await connector.ConnectAsync(webSocketUri, cancellationToken).ConfigureAwait(false); return new ChromeDevToolsConnection(connector.DetachConnectedWebSocket(), eventHandler, logger); } diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs b/src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs index b3e10bc9635..3888ace6ecc 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs @@ -12,6 +12,7 @@ internal sealed class BrowserTargetSession : IBrowserTargetSession { 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 _completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly BrowserConnectionDiagnosticsLogger _connectionDiagnostics; @@ -123,7 +124,7 @@ public async ValueTask DisposeAsync() { try { - using var closeTargetCts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + using var closeTargetCts = new CancellationTokenSource(s_closeTargetTimeout); await connection.CloseTargetAsync(_targetId, closeTargetCts.Token).ConfigureAwait(false); } catch (Exception ex) diff --git a/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs b/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs index 083624ce8f8..094115f6af4 100644 --- a/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs +++ b/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs @@ -68,6 +68,8 @@ internal enum BrowserTargetSessionCompletionKind // releases a shared host, which keeps owned/adopted browser lifetime centralized in BrowserHostRegistry. internal sealed class BrowserHostLease : IAsyncDisposable { + private static readonly TimeSpan s_releaseTimeout = TimeSpan.FromSeconds(5); + private readonly Func _releaseAsync; private int _disposed; @@ -86,7 +88,7 @@ public async ValueTask DisposeAsync() return; } - using var releaseCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using var releaseCts = new CancellationTokenSource(s_releaseTimeout); await _releaseAsync(releaseCts.Token).ConfigureAwait(false); } } From c812643367aac0ab6f6760a754ac494292c18f5a Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 25 Apr 2026 13:34:00 -0700 Subject: [PATCH 18/36] Rename browser logs CDP transport types Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting/BrowserLogs/BrowserHost.cs | 4 +- ...nection.cs => BrowserLogsCdpConnection.cs} | 64 ++++---- ...sProtocol.cs => BrowserLogsCdpProtocol.cs} | 154 +++++++++--------- .../BrowserLogs/BrowserLogsEventLogger.cs | 30 ++-- .../BrowserLogs/BrowserTargetSession.cs | 14 +- .../BrowserLogs/IBrowserHost.cs | 2 +- .../BrowserLogsBuilderExtensionsTests.cs | 4 +- ...ts.cs => BrowserLogsCdpConnectionTests.cs} | 4 +- ...ests.cs => BrowserLogsCdpProtocolTests.cs} | 32 ++-- .../BrowserLogsSessionManagerTests.cs | 2 +- 10 files changed, 155 insertions(+), 155 deletions(-) rename src/Aspire.Hosting/BrowserLogs/{BrowserLogsDevToolsConnection.cs => BrowserLogsCdpConnection.cs} (83%) rename src/Aspire.Hosting/BrowserLogs/{BrowserLogsProtocol.cs => BrowserLogsCdpProtocol.cs} (81%) rename tests/Aspire.Hosting.Tests/{BrowserLogsDevToolsConnectionTests.cs => BrowserLogsCdpConnectionTests.cs} (94%) rename tests/Aspire.Hosting.Tests/{BrowserLogsProtocolTests.cs => BrowserLogsCdpProtocolTests.cs} (76%) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs b/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs index cc854e28896..cf324fd4dd4 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs @@ -39,7 +39,7 @@ public Task CreateTargetSessionAsync( string sessionId, Uri url, BrowserConnectionDiagnosticsLogger connectionDiagnostics, - Func eventHandler, + Func eventHandler, CancellationToken cancellationToken) { return CreateTargetSessionCoreAsync(sessionId, url, connectionDiagnostics, eventHandler, cancellationToken); @@ -77,7 +77,7 @@ private async Task CreateTargetSessionCoreAsync( string sessionId, Uri url, BrowserConnectionDiagnosticsLogger connectionDiagnostics, - Func eventHandler, + Func eventHandler, CancellationToken cancellationToken) { return await BrowserTargetSession.StartAsync( diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsDevToolsConnection.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsCdpConnection.cs similarity index 83% rename from src/Aspire.Hosting/BrowserLogs/BrowserLogsDevToolsConnection.cs rename to src/Aspire.Hosting/BrowserLogs/BrowserLogsCdpConnection.cs index ba951fcf17e..a1985ff41d1 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsDevToolsConnection.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsCdpConnection.cs @@ -8,16 +8,16 @@ namespace Aspire.Hosting; -// Owns the browser-level websocket only. Protocol parsing stays in BrowserLogsProtocol, while target lifecycle and +// Owns the browser-level websocket only. Protocol parsing stays in BrowserLogsCdpProtocol, while target lifecycle and // reconnection policy stay in BrowserTargetSession. -internal sealed class ChromeDevToolsConnection : IAsyncDisposable +internal sealed class BrowserLogsCdpConnection : IAsyncDisposable { private static readonly TimeSpan s_closeTimeout = TimeSpan.FromSeconds(3); private static readonly TimeSpan s_commandTimeout = TimeSpan.FromSeconds(10); private static readonly TimeSpan s_keepAliveInterval = TimeSpan.FromSeconds(15); private readonly CancellationTokenSource _disposeCts = new(); - private readonly Func _eventHandler; + private readonly Func _eventHandler; private readonly ILogger _logger; private readonly ConcurrentDictionary _pendingCommands = new(); private readonly Task _receiveLoop; @@ -25,7 +25,7 @@ internal sealed class ChromeDevToolsConnection : IAsyncDisposable private readonly ClientWebSocket _webSocket; private long _nextCommandId; - private ChromeDevToolsConnection(ClientWebSocket webSocket, Func eventHandler, ILogger logger) + private BrowserLogsCdpConnection(ClientWebSocket webSocket, Func eventHandler, ILogger logger) { _eventHandler = eventHandler; _logger = logger; @@ -35,9 +35,9 @@ private ChromeDevToolsConnection(ClientWebSocket webSocket, Func _receiveLoop; - public static async Task ConnectAsync( + public static async Task ConnectAsync( Uri webSocketUri, - Func eventHandler, + Func eventHandler, ILogger logger, CancellationToken cancellationToken) { @@ -49,9 +49,9 @@ public static async Task ConnectAsync( static () => new ClientWebSocketConnector()).ConfigureAwait(false); } - internal static async Task ConnectAsync( + internal static async Task ConnectAsync( Uri webSocketUri, - Func eventHandler, + Func eventHandler, ILogger logger, CancellationToken cancellationToken, Func connectorFactory) @@ -61,50 +61,50 @@ internal static async Task ConnectAsync( // 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 new ChromeDevToolsConnection(connector.DetachConnectedWebSocket(), eventHandler, logger); + return new BrowserLogsCdpConnection(connector.DetachConnectedWebSocket(), eventHandler, logger); } public Task CreateTargetAsync(CancellationToken cancellationToken) { return SendCommandAsync( - BrowserLogsProtocol.TargetCreateTargetMethod, + BrowserLogsCdpProtocol.TargetCreateTargetMethod, sessionId: null, static writer => writer.WriteString("url", "about:blank"), - BrowserLogsProtocol.ParseCreateTargetResponse, + BrowserLogsCdpProtocol.ParseCreateTargetResponse, cancellationToken); } public Task GetTargetsAsync(CancellationToken cancellationToken) { return SendCommandAsync( - BrowserLogsProtocol.TargetGetTargetsMethod, + BrowserLogsCdpProtocol.TargetGetTargetsMethod, sessionId: null, writeParameters: null, - BrowserLogsProtocol.ParseGetTargetsResponse, + BrowserLogsCdpProtocol.ParseGetTargetsResponse, cancellationToken); } public Task AttachToTargetAsync(string targetId, CancellationToken cancellationToken) { return SendCommandAsync( - BrowserLogsProtocol.TargetAttachToTargetMethod, + BrowserLogsCdpProtocol.TargetAttachToTargetMethod, sessionId: null, writer => { writer.WriteString("targetId", targetId); writer.WriteBoolean("flatten", true); }, - BrowserLogsProtocol.ParseAttachToTargetResponse, + BrowserLogsCdpProtocol.ParseAttachToTargetResponse, cancellationToken); } public Task CloseTargetAsync(string targetId, CancellationToken cancellationToken) { return SendCommandAsync( - BrowserLogsProtocol.TargetCloseTargetMethod, + BrowserLogsCdpProtocol.TargetCloseTargetMethod, sessionId: null, writer => writer.WriteString("targetId", targetId), - BrowserLogsProtocol.ParseCommandAckResponse, + BrowserLogsCdpProtocol.ParseCommandAckResponse, cancellationToken); } @@ -115,10 +115,10 @@ public Task EnableTargetDiscoveryAsync(CancellationToken // 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( - BrowserLogsProtocol.TargetSetDiscoverTargetsMethod, + BrowserLogsCdpProtocol.TargetSetDiscoverTargetsMethod, sessionId: null, static writer => writer.WriteBoolean("discover", true), - BrowserLogsProtocol.ParseCommandAckResponse, + BrowserLogsCdpProtocol.ParseCommandAckResponse, cancellationToken); } @@ -127,19 +127,19 @@ public async Task EnablePageInstrumentationAsync(string sessionId, CancellationT // 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(BrowserLogsProtocol.RuntimeEnableMethod, sessionId, writeParameters: null, BrowserLogsProtocol.ParseCommandAckResponse, cancellationToken).ConfigureAwait(false); - await SendCommandAsync(BrowserLogsProtocol.LogEnableMethod, sessionId, writeParameters: null, BrowserLogsProtocol.ParseCommandAckResponse, cancellationToken).ConfigureAwait(false); - await SendCommandAsync(BrowserLogsProtocol.PageEnableMethod, sessionId, writeParameters: null, BrowserLogsProtocol.ParseCommandAckResponse, cancellationToken).ConfigureAwait(false); - await SendCommandAsync(BrowserLogsProtocol.NetworkEnableMethod, sessionId, writeParameters: null, BrowserLogsProtocol.ParseCommandAckResponse, cancellationToken).ConfigureAwait(false); + 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 NavigateAsync(string sessionId, Uri url, CancellationToken cancellationToken) { return SendCommandAsync( - BrowserLogsProtocol.PageNavigateMethod, + BrowserLogsCdpProtocol.PageNavigateMethod, sessionId, writer => writer.WriteString("url", url.ToString()), - BrowserLogsProtocol.ParseCommandAckResponse, + BrowserLogsCdpProtocol.ParseCommandAckResponse, cancellationToken); } @@ -197,8 +197,8 @@ private async Task SendCommandAsync( ((IPendingCommand)state!).SetCanceled(); }, pendingCommand); - var payload = BrowserLogsProtocol.CreateCommandFrame(commandId, method, sessionId, writeParameters); - _logger.LogTrace("Tracked browser protocol -> {Frame}", BrowserLogsProtocol.DescribeFrame(payload)); + 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 @@ -252,7 +252,7 @@ private async Task ReceiveLoopAsync() var frame = messageBuffer.ToArray(); messageBuffer.SetLength(0); - _logger.LogTrace("Tracked browser protocol <- {Frame}", BrowserLogsProtocol.DescribeFrame(frame)); + _logger.LogTrace("Tracked browser protocol <- {Frame}", BrowserLogsCdpProtocol.DescribeFrame(frame)); try { @@ -261,7 +261,7 @@ private async Task ReceiveLoopAsync() catch (Exception ex) { terminalException = new InvalidOperationException( - $"Tracked browser protocol receive loop failed while processing frame {BrowserLogsProtocol.DescribeFrame(frame)}.", + $"Tracked browser protocol receive loop failed while processing frame {BrowserLogsCdpProtocol.DescribeFrame(frame)}.", ex); break; } @@ -294,7 +294,7 @@ private async Task ReceiveLoopAsync() private async Task HandleFrameAsync(byte[] frame) { - var header = BrowserLogsProtocol.ParseMessageHeader(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. @@ -308,7 +308,7 @@ private async Task HandleFrameAsync(byte[] frame) return; } - if (header.Method is not null && BrowserLogsProtocol.ParseEvent(header, frame) is { } protocolEvent) + if (header.Method is not null && BrowserLogsCdpProtocol.ParseEvent(header, frame) is { } protocolEvent) { await _eventHandler(protocolEvent).ConfigureAwait(false); } @@ -383,7 +383,7 @@ internal interface IClientWebSocketConnector : IDisposable ClientWebSocket DetachConnectedWebSocket(); } -// Thin ownership wrapper around ClientWebSocket. It lets ChromeDevToolsConnection transfer the connected socket into +// 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 { diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsProtocol.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsCdpProtocol.cs similarity index 81% rename from src/Aspire.Hosting/BrowserLogs/BrowserLogsProtocol.cs rename to src/Aspire.Hosting/BrowserLogs/BrowserLogsCdpProtocol.cs index 36c868f8d0f..fad1eecf2de 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsProtocol.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsCdpProtocol.cs @@ -25,7 +25,7 @@ namespace Aspire.Hosting; // // Keep this file focused on protocol serialization and parsing so browser networking and session orchestration can be // tested independently from CDP frame handling. -internal static class BrowserLogsProtocol +internal static class BrowserLogsCdpProtocol { private static readonly JsonWriterOptions s_commandFrameWriterOptions = new() { @@ -57,7 +57,7 @@ internal static class BrowserLogsProtocol internal const string TargetTargetDestroyedMethod = "Target.targetDestroyed"; internal const string InspectorDetachedMethod = "Inspector.detached"; - internal static BrowserLogsProtocolMessageHeader ParseMessageHeader(ReadOnlySpan framePayload) + internal static BrowserLogsCdpProtocolMessageHeader ParseMessageHeader(ReadOnlySpan framePayload) { var reader = new Utf8JsonReader(framePayload, isFinalBlock: true, state: default); @@ -114,7 +114,7 @@ internal static BrowserLogsProtocolMessageHeader ParseMessageHeader(ReadOnlySpan } } - return new BrowserLogsProtocolMessageHeader(id, method, sessionId); + return new BrowserLogsCdpProtocolMessageHeader(id, method, sessionId); } internal static byte[] CreateCommandFrame(long id, string method, string? sessionId, Action? writeParameters) @@ -145,7 +145,7 @@ internal static byte[] CreateCommandFrame(long id, string method, string? sessio return buffer.WrittenSpan.ToArray(); } - internal static BrowserLogsProtocolEvent? ParseEvent(BrowserLogsProtocolMessageHeader header, ReadOnlySpan framePayload) => header.Method switch + internal static BrowserLogsCdpProtocolEvent? ParseEvent(BrowserLogsCdpProtocolMessageHeader header, ReadOnlySpan framePayload) => header.Method switch { RuntimeConsoleApiCalledMethod => CreateConsoleApiCalledEvent(framePayload), RuntimeExceptionThrownMethod => CreateExceptionThrownEvent(framePayload), @@ -163,7 +163,7 @@ internal static byte[] CreateCommandFrame(long id, string method, string? sessio internal static BrowserLogsCreateTargetResult ParseCreateTargetResponse(ReadOnlySpan framePayload) { - var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsCreateTargetResponseEnvelope); + var envelope = DeserializeFrame(framePayload, BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsCreateTargetResponseEnvelope); ThrowIfProtocolError(envelope.Error); return envelope.Result ?? throw new InvalidOperationException("Tracked browser target creation did not return a result payload."); @@ -171,7 +171,7 @@ internal static BrowserLogsCreateTargetResult ParseCreateTargetResponse(ReadOnly internal static BrowserLogsAttachToTargetResult ParseAttachToTargetResponse(ReadOnlySpan framePayload) { - var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsAttachToTargetResponseEnvelope); + var envelope = DeserializeFrame(framePayload, BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsAttachToTargetResponseEnvelope); ThrowIfProtocolError(envelope.Error); return envelope.Result ?? throw new InvalidOperationException("Tracked browser target attachment did not return a result payload."); @@ -179,7 +179,7 @@ internal static BrowserLogsAttachToTargetResult ParseAttachToTargetResponse(Read internal static BrowserLogsGetTargetsResult ParseGetTargetsResponse(ReadOnlySpan framePayload) { - var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsGetTargetsResponseEnvelope); + var envelope = DeserializeFrame(framePayload, BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsGetTargetsResponseEnvelope); ThrowIfProtocolError(envelope.Error); return envelope.Result ?? throw new InvalidOperationException("Tracked browser target discovery did not return a result payload."); @@ -187,7 +187,7 @@ internal static BrowserLogsGetTargetsResult ParseGetTargetsResponse(ReadOnlySpan internal static BrowserLogsCommandAck ParseCommandAckResponse(ReadOnlySpan framePayload) { - var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsCommandAckResponseEnvelope); + var envelope = DeserializeFrame(framePayload, BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsCommandAckResponseEnvelope); ThrowIfProtocolError(envelope.Error); return BrowserLogsCommandAck.Instance; @@ -203,7 +203,7 @@ internal static string DescribeFrame(ReadOnlySpan framePayload, int maxLen private static BrowserLogsConsoleApiCalledEvent? CreateConsoleApiCalledEvent(ReadOnlySpan framePayload) { - var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsConsoleApiCalledEnvelope); + var envelope = DeserializeFrame(framePayload, BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsConsoleApiCalledEnvelope); return envelope.Params is null ? null : new BrowserLogsConsoleApiCalledEvent(envelope.SessionId, envelope.Params); @@ -211,7 +211,7 @@ internal static string DescribeFrame(ReadOnlySpan framePayload, int maxLen private static BrowserLogsExceptionThrownEvent? CreateExceptionThrownEvent(ReadOnlySpan framePayload) { - var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsExceptionThrownEnvelope); + var envelope = DeserializeFrame(framePayload, BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsExceptionThrownEnvelope); return envelope.Params is null ? null : new BrowserLogsExceptionThrownEvent(envelope.SessionId, envelope.Params); @@ -219,7 +219,7 @@ internal static string DescribeFrame(ReadOnlySpan framePayload, int maxLen private static BrowserLogsLogEntryAddedEvent? CreateLogEntryAddedEvent(ReadOnlySpan framePayload) { - var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsLogEntryAddedEnvelope); + var envelope = DeserializeFrame(framePayload, BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsLogEntryAddedEnvelope); return envelope.Params is null ? null : new BrowserLogsLogEntryAddedEvent(envelope.SessionId, envelope.Params); @@ -227,7 +227,7 @@ internal static string DescribeFrame(ReadOnlySpan framePayload, int maxLen private static BrowserLogsRequestWillBeSentEvent? CreateRequestWillBeSentEvent(ReadOnlySpan framePayload) { - var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsRequestWillBeSentEnvelope); + var envelope = DeserializeFrame(framePayload, BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsRequestWillBeSentEnvelope); return envelope.Params is null ? null : new BrowserLogsRequestWillBeSentEvent(envelope.SessionId, envelope.Params); @@ -235,7 +235,7 @@ internal static string DescribeFrame(ReadOnlySpan framePayload, int maxLen private static BrowserLogsResponseReceivedEvent? CreateResponseReceivedEvent(ReadOnlySpan framePayload) { - var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsResponseReceivedEnvelope); + var envelope = DeserializeFrame(framePayload, BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsResponseReceivedEnvelope); return envelope.Params is null ? null : new BrowserLogsResponseReceivedEvent(envelope.SessionId, envelope.Params); @@ -243,7 +243,7 @@ internal static string DescribeFrame(ReadOnlySpan framePayload, int maxLen private static BrowserLogsLoadingFinishedEvent? CreateLoadingFinishedEvent(ReadOnlySpan framePayload) { - var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsLoadingFinishedEnvelope); + var envelope = DeserializeFrame(framePayload, BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsLoadingFinishedEnvelope); return envelope.Params is null ? null : new BrowserLogsLoadingFinishedEvent(envelope.SessionId, envelope.Params); @@ -251,7 +251,7 @@ internal static string DescribeFrame(ReadOnlySpan framePayload, int maxLen private static BrowserLogsLoadingFailedEvent? CreateLoadingFailedEvent(ReadOnlySpan framePayload) { - var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsLoadingFailedEnvelope); + var envelope = DeserializeFrame(framePayload, BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsLoadingFailedEnvelope); return envelope.Params is null ? null : new BrowserLogsLoadingFailedEvent(envelope.SessionId, envelope.Params); @@ -259,7 +259,7 @@ internal static string DescribeFrame(ReadOnlySpan framePayload, int maxLen private static BrowserLogsTargetDestroyedEvent? CreateTargetDestroyedEvent(ReadOnlySpan framePayload) { - var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsTargetDestroyedEnvelope); + var envelope = DeserializeFrame(framePayload, BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsTargetDestroyedEnvelope); return envelope.Params is null ? null : new BrowserLogsTargetDestroyedEvent(envelope.SessionId, envelope.Params); @@ -267,7 +267,7 @@ internal static string DescribeFrame(ReadOnlySpan framePayload, int maxLen private static BrowserLogsTargetCrashedEvent? CreateTargetCrashedEvent(ReadOnlySpan framePayload) { - var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsTargetCrashedEnvelope); + var envelope = DeserializeFrame(framePayload, BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsTargetCrashedEnvelope); return envelope.Params is null ? null : new BrowserLogsTargetCrashedEvent(envelope.SessionId, envelope.Params); @@ -275,7 +275,7 @@ internal static string DescribeFrame(ReadOnlySpan framePayload, int maxLen private static BrowserLogsDetachedFromTargetEvent? CreateDetachedFromTargetEvent(ReadOnlySpan framePayload) { - var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsDetachedFromTargetEnvelope); + var envelope = DeserializeFrame(framePayload, BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsDetachedFromTargetEnvelope); return envelope.Params is null ? null : new BrowserLogsDetachedFromTargetEvent(envelope.SessionId, envelope.Params); @@ -283,7 +283,7 @@ internal static string DescribeFrame(ReadOnlySpan framePayload, int maxLen private static BrowserLogsInspectorDetachedEvent? CreateInspectorDetachedEvent(ReadOnlySpan framePayload) { - var envelope = DeserializeFrame(framePayload, BrowserLogsProtocolJsonContext.Default.BrowserLogsInspectorDetachedEnvelope); + var envelope = DeserializeFrame(framePayload, BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsInspectorDetachedEnvelope); return new BrowserLogsInspectorDetachedEvent(envelope.SessionId, envelope.Params); } @@ -294,7 +294,7 @@ private static T DeserializeFrame(ReadOnlySpan framePayload, JsonTypeIn ?? throw new InvalidOperationException("Tracked browser protocol frame was empty."); } - private static void ThrowIfProtocolError(BrowserLogsProtocolError? error) + private static void ThrowIfProtocolError(BrowserLogsCdpProtocolError? error) { if (error is null) { @@ -314,54 +314,54 @@ private static void ThrowIfProtocolError(BrowserLogsProtocolError? error) } } -internal readonly record struct BrowserLogsProtocolMessageHeader(long? Id, string? Method, string? SessionId); +internal readonly record struct BrowserLogsCdpProtocolMessageHeader(long? Id, string? Method, string? SessionId); -internal abstract record BrowserLogsProtocolEvent(string Method, string? SessionId); +internal abstract record BrowserLogsCdpProtocolEvent(string Method, string? SessionId); internal sealed record BrowserLogsConsoleApiCalledEvent(string? SessionId, BrowserLogsRuntimeConsoleApiCalledParameters Parameters) - : BrowserLogsProtocolEvent(BrowserLogsProtocol.RuntimeConsoleApiCalledMethod, SessionId); + : BrowserLogsCdpProtocolEvent(BrowserLogsCdpProtocol.RuntimeConsoleApiCalledMethod, SessionId); internal sealed record BrowserLogsExceptionThrownEvent(string? SessionId, BrowserLogsExceptionThrownParameters Parameters) - : BrowserLogsProtocolEvent(BrowserLogsProtocol.RuntimeExceptionThrownMethod, SessionId); + : BrowserLogsCdpProtocolEvent(BrowserLogsCdpProtocol.RuntimeExceptionThrownMethod, SessionId); internal sealed record BrowserLogsLoadingFailedEvent(string? SessionId, BrowserLogsLoadingFailedParameters Parameters) - : BrowserLogsProtocolEvent(BrowserLogsProtocol.NetworkLoadingFailedMethod, SessionId); + : BrowserLogsCdpProtocolEvent(BrowserLogsCdpProtocol.NetworkLoadingFailedMethod, SessionId); internal sealed record BrowserLogsLoadingFinishedEvent(string? SessionId, BrowserLogsLoadingFinishedParameters Parameters) - : BrowserLogsProtocolEvent(BrowserLogsProtocol.NetworkLoadingFinishedMethod, SessionId); + : BrowserLogsCdpProtocolEvent(BrowserLogsCdpProtocol.NetworkLoadingFinishedMethod, SessionId); internal sealed record BrowserLogsLogEntryAddedEvent(string? SessionId, BrowserLogsLogEntryAddedParameters Parameters) - : BrowserLogsProtocolEvent(BrowserLogsProtocol.LogEntryAddedMethod, SessionId); + : BrowserLogsCdpProtocolEvent(BrowserLogsCdpProtocol.LogEntryAddedMethod, SessionId); internal sealed record BrowserLogsRequestWillBeSentEvent(string? SessionId, BrowserLogsRequestWillBeSentParameters Parameters) - : BrowserLogsProtocolEvent(BrowserLogsProtocol.NetworkRequestWillBeSentMethod, SessionId); + : BrowserLogsCdpProtocolEvent(BrowserLogsCdpProtocol.NetworkRequestWillBeSentMethod, SessionId); internal sealed record BrowserLogsResponseReceivedEvent(string? SessionId, BrowserLogsResponseReceivedParameters Parameters) - : BrowserLogsProtocolEvent(BrowserLogsProtocol.NetworkResponseReceivedMethod, SessionId); + : BrowserLogsCdpProtocolEvent(BrowserLogsCdpProtocol.NetworkResponseReceivedMethod, SessionId); // Target lifecycle events differ from page-domain events in routing semantics: -// - For Page/Runtime/Network/Log events, BrowserLogsProtocolEvent.SessionId is the attached page session and +// - For Page/Runtime/Network/Log events, BrowserLogsCdpProtocolEvent.SessionId is the attached page session and // the dispatcher routes by matching it against the tracked target's session id. // - For Target.targetDestroyed/targetCrashed and Inspector.detached, the envelope-level sessionId is typically // absent (these are fired on the browser CDP channel, not on a target session). The SUBJECT of the event is // carried in the parameters: targetId for target events, the parent attached sessionId for the implicit -// detach. Routing logic must not rely on BrowserLogsProtocolEvent.SessionId for these. +// detach. Routing logic must not rely on BrowserLogsCdpProtocolEvent.SessionId for these. // - For Target.detachedFromTarget specifically, params.sessionId identifies the session that detached, which is // the value that should be matched against the tracked target's session id. internal sealed record BrowserLogsTargetDestroyedEvent(string? SessionId, BrowserLogsTargetDestroyedParameters Parameters) - : BrowserLogsProtocolEvent(BrowserLogsProtocol.TargetTargetDestroyedMethod, SessionId) + : BrowserLogsCdpProtocolEvent(BrowserLogsCdpProtocol.TargetTargetDestroyedMethod, SessionId) { public string? TargetId => Parameters.TargetId; } internal sealed record BrowserLogsTargetCrashedEvent(string? SessionId, BrowserLogsTargetCrashedParameters Parameters) - : BrowserLogsProtocolEvent(BrowserLogsProtocol.TargetTargetCrashedMethod, SessionId) + : BrowserLogsCdpProtocolEvent(BrowserLogsCdpProtocol.TargetTargetCrashedMethod, SessionId) { public string? TargetId => Parameters.TargetId; } internal sealed record BrowserLogsDetachedFromTargetEvent(string? SessionId, BrowserLogsDetachedFromTargetParameters Parameters) - : BrowserLogsProtocolEvent(BrowserLogsProtocol.TargetDetachedFromTargetMethod, SessionId) + : BrowserLogsCdpProtocolEvent(BrowserLogsCdpProtocol.TargetDetachedFromTargetMethod, SessionId) { public string? DetachedSessionId => Parameters.SessionId; @@ -369,7 +369,7 @@ internal sealed record BrowserLogsDetachedFromTargetEvent(string? SessionId, Bro } internal sealed record BrowserLogsInspectorDetachedEvent(string? SessionId, BrowserLogsInspectorDetachedParameters? Parameters) - : BrowserLogsProtocolEvent(BrowserLogsProtocol.InspectorDetachedMethod, SessionId) + : BrowserLogsCdpProtocolEvent(BrowserLogsCdpProtocol.InspectorDetachedMethod, SessionId) { public string? Reason => Parameters?.Reason; } @@ -377,7 +377,7 @@ internal sealed record BrowserLogsInspectorDetachedEvent(string? SessionId, Brow internal sealed class BrowserLogsAttachToTargetResponseEnvelope { [JsonPropertyName("error")] - public BrowserLogsProtocolError? Error { get; init; } + public BrowserLogsCdpProtocolError? Error { get; init; } [JsonPropertyName("id")] public long Id { get; init; } @@ -404,7 +404,7 @@ private BrowserLogsCommandAck() internal sealed class BrowserLogsCommandAckResponseEnvelope { [JsonPropertyName("error")] - public BrowserLogsProtocolError? Error { get; init; } + public BrowserLogsCdpProtocolError? Error { get; init; } [JsonPropertyName("id")] public long Id { get; init; } @@ -422,7 +422,7 @@ internal sealed class BrowserLogsConsoleApiCalledEnvelope internal sealed class BrowserLogsCreateTargetResponseEnvelope { [JsonPropertyName("error")] - public BrowserLogsProtocolError? Error { get; init; } + public BrowserLogsCdpProtocolError? Error { get; init; } [JsonPropertyName("id")] public long Id { get; init; } @@ -440,7 +440,7 @@ internal sealed class BrowserLogsCreateTargetResult internal sealed class BrowserLogsGetTargetsResponseEnvelope { [JsonPropertyName("error")] - public BrowserLogsProtocolError? Error { get; init; } + public BrowserLogsCdpProtocolError? Error { get; init; } [JsonPropertyName("id")] public long Id { get; init; } @@ -572,7 +572,7 @@ internal sealed class BrowserLogsLogEntryAddedParameters public BrowserLogsLogEntry? Entry { get; init; } } -internal sealed class BrowserLogsProtocolError +internal sealed class BrowserLogsCdpProtocolError { [JsonPropertyName("code")] public int? Code { get; init; } @@ -581,7 +581,7 @@ internal sealed class BrowserLogsProtocolError public string? Message { get; init; } } -internal sealed class BrowserLogsProtocolRemoteObject +internal sealed class BrowserLogsCdpProtocolRemoteObject { [JsonPropertyName("description")] public string? Description { get; init; } @@ -590,7 +590,7 @@ internal sealed class BrowserLogsProtocolRemoteObject public string? UnserializableValue { get; init; } [JsonPropertyName("value")] - public BrowserLogsProtocolValue? Value { get; init; } + public BrowserLogsCdpProtocolValue? Value { get; init; } } internal sealed class BrowserLogsRequest @@ -671,7 +671,7 @@ internal sealed class BrowserLogsResponseReceivedParameters internal sealed class BrowserLogsRuntimeConsoleApiCalledParameters { [JsonPropertyName("args")] - public BrowserLogsProtocolRemoteObject[]? Args { get; init; } + public BrowserLogsCdpProtocolRemoteObject[]? Args { get; init; } [JsonPropertyName("type")] public string? Type { get; init; } @@ -758,47 +758,47 @@ internal sealed class BrowserLogsInspectorDetachedParameters public string? Reason { get; init; } } -[JsonConverter(typeof(BrowserLogsProtocolValueJsonConverter))] -internal abstract record BrowserLogsProtocolValue; +[JsonConverter(typeof(BrowserLogsCdpProtocolValueJsonConverter))] +internal abstract record BrowserLogsCdpProtocolValue; -internal sealed record BrowserLogsProtocolArrayValue(IReadOnlyList Items) : BrowserLogsProtocolValue; +internal sealed record BrowserLogsCdpProtocolArrayValue(IReadOnlyList Items) : BrowserLogsCdpProtocolValue; -internal sealed record BrowserLogsProtocolBooleanValue(bool Value) : BrowserLogsProtocolValue; +internal sealed record BrowserLogsCdpProtocolBooleanValue(bool Value) : BrowserLogsCdpProtocolValue; -internal sealed record BrowserLogsProtocolNullValue : BrowserLogsProtocolValue +internal sealed record BrowserLogsCdpProtocolNullValue : BrowserLogsCdpProtocolValue { - public static BrowserLogsProtocolNullValue Instance { get; } = new(); + public static BrowserLogsCdpProtocolNullValue Instance { get; } = new(); - private BrowserLogsProtocolNullValue() + private BrowserLogsCdpProtocolNullValue() { } } -internal sealed record BrowserLogsProtocolNumberValue(string RawValue) : BrowserLogsProtocolValue; +internal sealed record BrowserLogsCdpProtocolNumberValue(string RawValue) : BrowserLogsCdpProtocolValue; -internal sealed record BrowserLogsProtocolObjectValue(IReadOnlyDictionary Properties) : BrowserLogsProtocolValue; +internal sealed record BrowserLogsCdpProtocolObjectValue(IReadOnlyDictionary Properties) : BrowserLogsCdpProtocolValue; -internal sealed record BrowserLogsProtocolStringValue(string Value) : BrowserLogsProtocolValue; +internal sealed record BrowserLogsCdpProtocolStringValue(string Value) : BrowserLogsCdpProtocolValue; -internal sealed class BrowserLogsProtocolValueJsonConverter : JsonConverter +internal sealed class BrowserLogsCdpProtocolValueJsonConverter : JsonConverter { - public override BrowserLogsProtocolValue Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.TokenType switch + public override BrowserLogsCdpProtocolValue Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.TokenType switch { JsonTokenType.StartArray => ReadArray(ref reader, options), JsonTokenType.StartObject => ReadObject(ref reader, options), - JsonTokenType.String => new BrowserLogsProtocolStringValue(reader.GetString() ?? string.Empty), - JsonTokenType.True => new BrowserLogsProtocolBooleanValue(true), - JsonTokenType.False => new BrowserLogsProtocolBooleanValue(false), - JsonTokenType.Null => BrowserLogsProtocolNullValue.Instance, - JsonTokenType.Number => new BrowserLogsProtocolNumberValue(GetRawNumber(ref reader)), + JsonTokenType.String => new BrowserLogsCdpProtocolStringValue(reader.GetString() ?? string.Empty), + JsonTokenType.True => new BrowserLogsCdpProtocolBooleanValue(true), + JsonTokenType.False => new BrowserLogsCdpProtocolBooleanValue(false), + JsonTokenType.Null => BrowserLogsCdpProtocolNullValue.Instance, + JsonTokenType.Number => new BrowserLogsCdpProtocolNumberValue(GetRawNumber(ref reader)), _ => throw new JsonException($"Unsupported JSON token '{reader.TokenType}' for tracked browser protocol value.") }; - public override void Write(Utf8JsonWriter writer, BrowserLogsProtocolValue value, JsonSerializerOptions options) + public override void Write(Utf8JsonWriter writer, BrowserLogsCdpProtocolValue value, JsonSerializerOptions options) { switch (value) { - case BrowserLogsProtocolArrayValue arrayValue: + case BrowserLogsCdpProtocolArrayValue arrayValue: writer.WriteStartArray(); foreach (var item in arrayValue.Items) { @@ -807,16 +807,16 @@ public override void Write(Utf8JsonWriter writer, BrowserLogsProtocolValue value writer.WriteEndArray(); break; - case BrowserLogsProtocolBooleanValue booleanValue: + case BrowserLogsCdpProtocolBooleanValue booleanValue: writer.WriteBooleanValue(booleanValue.Value); break; - case BrowserLogsProtocolNullValue: + case BrowserLogsCdpProtocolNullValue: writer.WriteNullValue(); break; - case BrowserLogsProtocolNumberValue numberValue: + case BrowserLogsCdpProtocolNumberValue numberValue: writer.WriteRawValue(numberValue.RawValue, skipInputValidation: true); break; - case BrowserLogsProtocolObjectValue objectValue: + case BrowserLogsCdpProtocolObjectValue objectValue: writer.WriteStartObject(); foreach (var (propertyName, propertyValue) in objectValue.Properties) { @@ -826,7 +826,7 @@ public override void Write(Utf8JsonWriter writer, BrowserLogsProtocolValue value writer.WriteEndObject(); break; - case BrowserLogsProtocolStringValue stringValue: + case BrowserLogsCdpProtocolStringValue stringValue: writer.WriteStringValue(stringValue.Value); break; default: @@ -841,9 +841,9 @@ private static string GetRawNumber(ref Utf8JsonReader reader) : Encoding.UTF8.GetString(reader.ValueSpan); } - private static BrowserLogsProtocolArrayValue ReadArray(ref Utf8JsonReader reader, JsonSerializerOptions options) + private static BrowserLogsCdpProtocolArrayValue ReadArray(ref Utf8JsonReader reader, JsonSerializerOptions options) { - var items = new List(); + var items = new List(); while (reader.Read()) { @@ -855,12 +855,12 @@ private static BrowserLogsProtocolArrayValue ReadArray(ref Utf8JsonReader reader items.Add(ReadValue(ref reader, options)); } - return new BrowserLogsProtocolArrayValue(items); + return new BrowserLogsCdpProtocolArrayValue(items); } - private static BrowserLogsProtocolObjectValue ReadObject(ref Utf8JsonReader reader, JsonSerializerOptions options) + private static BrowserLogsCdpProtocolObjectValue ReadObject(ref Utf8JsonReader reader, JsonSerializerOptions options) { - var properties = new Dictionary(StringComparer.Ordinal); + var properties = new Dictionary(StringComparer.Ordinal); while (reader.Read()) { @@ -885,13 +885,13 @@ private static BrowserLogsProtocolObjectValue ReadObject(ref Utf8JsonReader read properties[propertyName] = ReadValue(ref reader, options); } - return new BrowserLogsProtocolObjectValue(properties); + return new BrowserLogsCdpProtocolObjectValue(properties); } - private static BrowserLogsProtocolValue ReadValue(ref Utf8JsonReader reader, JsonSerializerOptions options) + private static BrowserLogsCdpProtocolValue ReadValue(ref Utf8JsonReader reader, JsonSerializerOptions options) { - var converter = (BrowserLogsProtocolValueJsonConverter)options.GetConverter(typeof(BrowserLogsProtocolValue)); - return converter.Read(ref reader, typeof(BrowserLogsProtocolValue), options); + var converter = (BrowserLogsCdpProtocolValueJsonConverter)options.GetConverter(typeof(BrowserLogsCdpProtocolValue)); + return converter.Read(ref reader, typeof(BrowserLogsCdpProtocolValue), options); } } @@ -911,4 +911,4 @@ private static BrowserLogsProtocolValue ReadValue(ref Utf8JsonReader reader, Jso [JsonSerializable(typeof(BrowserLogsResponseReceivedEnvelope))] [JsonSerializable(typeof(BrowserLogsTargetCrashedEnvelope))] [JsonSerializable(typeof(BrowserLogsTargetDestroyedEnvelope))] -internal sealed partial class BrowserLogsProtocolJsonContext : JsonSerializerContext; +internal sealed partial class BrowserLogsCdpProtocolJsonContext : JsonSerializerContext; diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsEventLogger.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsEventLogger.cs index 8c4f09b5980..c4a78f84d68 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsEventLogger.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsEventLogger.cs @@ -26,7 +26,7 @@ internal sealed class BrowserEventLogger(string sessionId, ILogger resourceLogge // resource-log line when the request is complete. private readonly Dictionary _networkRequests = new(StringComparer.Ordinal); - public void HandleEvent(BrowserLogsProtocolEvent protocolEvent) + public void HandleEvent(BrowserLogsCdpProtocolEvent protocolEvent) { switch (protocolEvent) { @@ -296,18 +296,18 @@ private static string FormatDetails(IReadOnlyList details) => _ => LogLevel.Information }; - private static string FormatRemoteObject(BrowserLogsProtocolRemoteObject remoteObject) + private static string FormatRemoteObject(BrowserLogsCdpProtocolRemoteObject remoteObject) { // Console arguments can arrive either as pre-rendered descriptions or as structured values that need stable // formatting for logs and tests. - if (remoteObject.Value is BrowserLogsProtocolValue value) + if (remoteObject.Value is BrowserLogsCdpProtocolValue value) { return value switch { - BrowserLogsProtocolStringValue stringValue => stringValue.Value, - BrowserLogsProtocolNullValue => "null", - BrowserLogsProtocolBooleanValue booleanValue => booleanValue.Value ? bool.TrueString : bool.FalseString, - BrowserLogsProtocolNumberValue numberValue => numberValue.RawValue, + BrowserLogsCdpProtocolStringValue stringValue => stringValue.Value, + BrowserLogsCdpProtocolNullValue => "null", + BrowserLogsCdpProtocolBooleanValue booleanValue => booleanValue.Value ? bool.TrueString : bool.FalseString, + BrowserLogsCdpProtocolNumberValue numberValue => numberValue.RawValue, _ => FormatStructuredValue(value) }; } @@ -320,7 +320,7 @@ private static string FormatRemoteObject(BrowserLogsProtocolRemoteObject remoteO return remoteObject.Description ?? string.Empty; } - private static string FormatStructuredValue(BrowserLogsProtocolValue value) + private static string FormatStructuredValue(BrowserLogsCdpProtocolValue value) { var buffer = new ArrayBufferWriter(); using var writer = new Utf8JsonWriter(buffer, s_structuredValueWriterOptions); @@ -329,11 +329,11 @@ private static string FormatStructuredValue(BrowserLogsProtocolValue value) return Encoding.UTF8.GetString(buffer.WrittenSpan); } - private static void WriteStructuredValue(Utf8JsonWriter writer, BrowserLogsProtocolValue value) + private static void WriteStructuredValue(Utf8JsonWriter writer, BrowserLogsCdpProtocolValue value) { switch (value) { - case BrowserLogsProtocolArrayValue arrayValue: + case BrowserLogsCdpProtocolArrayValue arrayValue: writer.WriteStartArray(); foreach (var item in arrayValue.Items) { @@ -342,16 +342,16 @@ private static void WriteStructuredValue(Utf8JsonWriter writer, BrowserLogsProto writer.WriteEndArray(); break; - case BrowserLogsProtocolBooleanValue booleanValue: + case BrowserLogsCdpProtocolBooleanValue booleanValue: writer.WriteBooleanValue(booleanValue.Value); break; - case BrowserLogsProtocolNullValue: + case BrowserLogsCdpProtocolNullValue: writer.WriteNullValue(); break; - case BrowserLogsProtocolNumberValue numberValue: + case BrowserLogsCdpProtocolNumberValue numberValue: writer.WriteRawValue(numberValue.RawValue, skipInputValidation: false); break; - case BrowserLogsProtocolObjectValue objectValue: + case BrowserLogsCdpProtocolObjectValue objectValue: writer.WriteStartObject(); foreach (var (propertyName, propertyValue) in objectValue.Properties) { @@ -361,7 +361,7 @@ private static void WriteStructuredValue(Utf8JsonWriter writer, BrowserLogsProto writer.WriteEndObject(); break; - case BrowserLogsProtocolStringValue stringValue: + case BrowserLogsCdpProtocolStringValue stringValue: writer.WriteStringValue(stringValue.Value); break; } diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs b/src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs index 3888ace6ecc..a97bc7b006a 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs @@ -16,7 +16,7 @@ internal sealed class BrowserTargetSession : IBrowserTargetSession private readonly TaskCompletionSource _completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly BrowserConnectionDiagnosticsLogger _connectionDiagnostics; - private readonly Func _eventHandler; + private readonly Func _eventHandler; private readonly IBrowserHost _host; private readonly ILogger _logger; private readonly bool _reuseInitialBlankTarget; @@ -25,7 +25,7 @@ internal sealed class BrowserTargetSession : IBrowserTargetSession private readonly TimeProvider _timeProvider; private readonly Uri _url; - private ChromeDevToolsConnection? _connection; + private BrowserLogsCdpConnection? _connection; private Task? _monitorTask; private int _disposed; private string? _targetId; @@ -36,7 +36,7 @@ private BrowserTargetSession( string sessionId, Uri url, BrowserConnectionDiagnosticsLogger connectionDiagnostics, - Func eventHandler, + Func eventHandler, ILogger logger, TimeProvider timeProvider, bool reuseInitialBlankTarget) @@ -57,7 +57,7 @@ private BrowserTargetSession( public Task Completion => _monitorTask ?? throw new InvalidOperationException("Browser target session has not started."); - internal static BrowserTargetSessionResult? TryGetTargetCompletion(BrowserLogsProtocolEvent protocolEvent, string? targetId, string? targetSessionId) + internal static BrowserTargetSessionResult? TryGetTargetCompletion(BrowserLogsCdpProtocolEvent protocolEvent, string? targetId, string? targetSessionId) { return protocolEvent switch { @@ -90,7 +90,7 @@ public static async Task StartAsync( string sessionId, Uri url, BrowserConnectionDiagnosticsLogger connectionDiagnostics, - Func eventHandler, + Func eventHandler, ILogger logger, TimeProvider timeProvider, bool reuseInitialBlankTarget, @@ -155,7 +155,7 @@ private async Task ConnectAsync(bool createTarget, CancellationToken cancellatio { await DisposeConnectionAsync().ConfigureAwait(false); - _connection = await ChromeDevToolsConnection.ConnectAsync(_host.DebugEndpoint, HandleEventAsync, _logger, cancellationToken).ConfigureAwait(false); + _connection = await BrowserLogsCdpConnection.ConnectAsync(_host.DebugEndpoint, HandleEventAsync, _logger, cancellationToken).ConfigureAwait(false); // Target discovery must be re-enabled for every browser-level connection, including reconnects. The // subscription is attached to this websocket, 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. @@ -311,7 +311,7 @@ private async Task TryReconnectAsync(Exception connectionError) return false; } - private async ValueTask HandleEventAsync(BrowserLogsProtocolEvent protocolEvent) + 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. diff --git a/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs b/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs index 094115f6af4..cfc620db00b 100644 --- a/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs +++ b/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs @@ -34,7 +34,7 @@ Task CreateTargetSessionAsync( string sessionId, Uri url, BrowserConnectionDiagnosticsLogger connectionDiagnostics, - Func eventHandler, + Func eventHandler, CancellationToken cancellationToken); } diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs index 62173bb9348..ce7fa307623 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs @@ -1148,10 +1148,10 @@ private static IReadOnlyList GetBrowserSessions(Cus ?? throw new InvalidOperationException("Expected browser session property JSON."); } - private static BrowserLogsProtocolEvent ParseProtocolEvent(string json) + private static BrowserLogsCdpProtocolEvent ParseProtocolEvent(string json) { var payload = Encoding.UTF8.GetBytes(json); - return BrowserLogsProtocol.ParseEvent(BrowserLogsProtocol.ParseMessageHeader(payload), payload) + return BrowserLogsCdpProtocol.ParseEvent(BrowserLogsCdpProtocol.ParseMessageHeader(payload), payload) ?? throw new InvalidOperationException("Expected a browser protocol event frame."); } diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsDevToolsConnectionTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsCdpConnectionTests.cs similarity index 94% rename from tests/Aspire.Hosting.Tests/BrowserLogsDevToolsConnectionTests.cs rename to tests/Aspire.Hosting.Tests/BrowserLogsCdpConnectionTests.cs index ac4a2dcb3cb..4fd6d717a43 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsDevToolsConnectionTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsCdpConnectionTests.cs @@ -7,7 +7,7 @@ namespace Aspire.Hosting.Tests; [Trait("Partition", "2")] -public class BrowserLogsDevToolsConnectionTests +public class BrowserLogsCdpConnectionTests { [Fact] public async Task ConnectAsync_DisposesConnectorWhenConnectFails() @@ -15,7 +15,7 @@ public async Task ConnectAsync_DisposesConnectorWhenConnectFails() var connectException = new WebSocketException("Connection refused"); var connector = new ThrowingClientWebSocketConnector(connectException); - var exception = await Assert.ThrowsAsync(() => ChromeDevToolsConnection.ConnectAsync( + var exception = await Assert.ThrowsAsync(() => BrowserLogsCdpConnection.ConnectAsync( new Uri("ws://127.0.0.1:12345/devtools/browser/test"), static _ => ValueTask.CompletedTask, NullLogger.Instance, diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsProtocolTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsCdpProtocolTests.cs similarity index 76% rename from tests/Aspire.Hosting.Tests/BrowserLogsProtocolTests.cs rename to tests/Aspire.Hosting.Tests/BrowserLogsCdpProtocolTests.cs index 5d9c7bd74ca..3043a014e7d 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsProtocolTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsCdpProtocolTests.cs @@ -6,7 +6,7 @@ namespace Aspire.Hosting.Tests; [Trait("Partition", "2")] -public class BrowserLogsProtocolTests +public class BrowserLogsCdpProtocolTests { [Fact] public void ParseEvent_ConsoleApiCalled_ReturnsStronglyTypedParameters() @@ -28,17 +28,17 @@ public void ParseEvent_ConsoleApiCalled_ReturnsStronglyTypedParameters() } """); - var header = BrowserLogsProtocol.ParseMessageHeader(payload); - var @event = Assert.IsType(BrowserLogsProtocol.ParseEvent(header, payload)); + var header = BrowserLogsCdpProtocol.ParseMessageHeader(payload); + var @event = Assert.IsType(BrowserLogsCdpProtocol.ParseEvent(header, payload)); Assert.Equal("target-session-1", @event.SessionId); Assert.Equal("error", @event.Parameters.Type); - var args = Assert.IsType(@event.Parameters.Args); - Assert.IsType(args[0].Value); - Assert.IsType(args[1].Value); - Assert.IsType(args[2].Value); - Assert.IsType(args[3].Value); + var args = Assert.IsType(@event.Parameters.Args); + Assert.IsType(args[0].Value); + Assert.IsType(args[1].Value); + Assert.IsType(args[2].Value); + Assert.IsType(args[3].Value); Assert.Equal("Infinity", args[4].UnserializableValue); } @@ -54,7 +54,7 @@ public void ParseCreateTargetResponse_ReturnsTypedResult() } """); - var result = BrowserLogsProtocol.ParseCreateTargetResponse(payload); + var result = BrowserLogsCdpProtocol.ParseCreateTargetResponse(payload); Assert.Equal("target-123", result.TargetId); } @@ -72,7 +72,7 @@ public void ParseCommandAckResponse_IncludesProtocolErrorDetails() } """); - var exception = Assert.Throws(() => BrowserLogsProtocol.ParseCommandAckResponse(payload)); + var exception = Assert.Throws(() => BrowserLogsCdpProtocol.ParseCommandAckResponse(payload)); Assert.Contains("Method not found", exception.Message); Assert.Contains("-32601", exception.Message); @@ -92,8 +92,8 @@ public void ParseEvent_TargetDetachedFromTarget_UsesParameterSessionId() } """); - var header = BrowserLogsProtocol.ParseMessageHeader(payload); - var @event = Assert.IsType(BrowserLogsProtocol.ParseEvent(header, payload)); + var header = BrowserLogsCdpProtocol.ParseMessageHeader(payload); + var @event = Assert.IsType(BrowserLogsCdpProtocol.ParseEvent(header, payload)); Assert.Equal("browser-session", @event.SessionId); Assert.Equal("target-session-1", @event.DetachedSessionId); @@ -114,8 +114,8 @@ public void ParseEvent_TargetCrashed_ReturnsTargetStatusAndErrorCode() } """); - var header = BrowserLogsProtocol.ParseMessageHeader(payload); - var @event = Assert.IsType(BrowserLogsProtocol.ParseEvent(header, payload)); + var header = BrowserLogsCdpProtocol.ParseMessageHeader(payload); + var @event = Assert.IsType(BrowserLogsCdpProtocol.ParseEvent(header, payload)); Assert.Equal("target-1", @event.TargetId); Assert.Equal("crashed", @event.Parameters.Status); @@ -125,9 +125,9 @@ public void ParseEvent_TargetCrashed_ReturnsTargetStatusAndErrorCode() [Fact] public void CreateCommandFrame_DoesNotEscapeNonAsciiCharacters() { - var payload = BrowserLogsProtocol.CreateCommandFrame( + var payload = BrowserLogsCdpProtocol.CreateCommandFrame( 7, - BrowserLogsProtocol.PageNavigateMethod, + BrowserLogsCdpProtocol.PageNavigateMethod, "session-1", writer => writer.WriteString("url", "https://example.test/über")); diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs index 1c71e71d499..3c3892cec99 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs @@ -627,7 +627,7 @@ public Task CreateTargetSessionAsync( string sessionId, Uri url, BrowserConnectionDiagnosticsLogger connectionDiagnostics, - Func eventHandler, + Func eventHandler, CancellationToken cancellationToken) => throw new NotSupportedException(); From adb4bbe71337f53d4723691da53c3b4f5381a65b Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 25 Apr 2026 14:05:00 -0700 Subject: [PATCH 19/36] Document browser endpoint discovery flow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogs/BrowserEndpointDiscovery.cs | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs index ac53c349626..c60dd03cbba 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs @@ -9,8 +9,15 @@ namespace Aspire.Hosting; // Bridges owned and adopted hosts by persisting and validating the browser-level CDP endpoint for a shared -// user-data directory. The registry treats this metadata as a hint only; this type proves the endpoint is live before -// an existing browser can be adopted. +// user-data directory. +// +// Why this exists: +// - Chromium's singleton is keyed by the user data root, not by Aspire. If Aspire already launched a debug-enabled +// browser for that root, a later browser-log session should 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. +// We write our own sidecar with the exact browser identity and endpoint we 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 logger) { private static readonly TimeSpan s_probeHttpClientTimeout = Timeout.InfiniteTimeSpan; @@ -24,6 +31,9 @@ internal sealed class BrowserEndpointDiscovery(ILogger _logger = logger; + // Aspire sidecar file stored at the Chromium user data root next to browser singleton files such as + // SingletonLock 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"); @@ -55,6 +65,9 @@ public static string GetEndpointMetadataFilePath(string userDataDirectory) => 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 || @@ -113,6 +126,19 @@ public static async Task WriteAsync(BrowserHostIdentity identity, string? profil { 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/", + // "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, @@ -126,6 +152,8 @@ public static async Task WriteAsync(BrowserHostIdentity identity, string? profil 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, BrowserEndpointJsonContext.Default.BrowserDebugEndpointMetadata, cancellationToken).ConfigureAwait(false); @@ -179,6 +207,8 @@ public static bool IsNonDebuggableBrowserRunning(string userDataDirectory) private static async Task 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, @@ -216,6 +246,8 @@ private static async Task ProbeBrowserEndpointAsync(Uri browserEndpoint, C return null; } + // Unix Chromium SingletonLock symlinks are usually shaped like "-". The host segment can + // contain dashes, so parse from the final dash instead of splitting on every dash. return int.TryParse(linkTarget.AsSpan(separatorIndex + 1), out var pid) ? pid : null; } catch (IOException) From 8b664f4f607126db5ee3c6a9ded5e8e107452b5f Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 25 Apr 2026 14:14:17 -0700 Subject: [PATCH 20/36] Link Chromium singleton documentation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs index c60dd03cbba..d369e8dff3d 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs @@ -200,6 +200,8 @@ public static bool IsNonDebuggableBrowserRunning(string userDataDirectory) return IsProcessAlive(pid); } + // Chromium documents this singleton behavior in process_singleton_posix.cc: + // https://chromium.googlesource.com/chromium/src/+/main/chrome/browser/process_singleton_posix.cc // On Windows the singleton is a locked file rather than a host-pid symlink, so the best available signal is the // presence of the lock path. On Unix we avoid treating old broken symlinks as an active browser. return OperatingSystem.IsWindows(); @@ -246,8 +248,9 @@ private static async Task ProbeBrowserEndpointAsync(Uri browserEndpoint, C return null; } - // Unix Chromium SingletonLock symlinks are usually shaped like "-". The host segment can - // contain dashes, so parse from the final dash instead of splitting on every dash. + // The Chromium source describes the POSIX lock as a symlink to a non-existent destination shaped like + // "-". The host segment can contain dashes, so parse from the final dash instead of splitting + // on every dash. return int.TryParse(linkTarget.AsSpan(separatorIndex + 1), out var pid) ? pid : null; } catch (IOException) From 57baf51f87a535eed04bef0d3f4e5d8052d64ba9 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 25 Apr 2026 15:16:12 -0700 Subject: [PATCH 21/36] Clarify browser page session model Rename the per-tab browser logs session abstraction around pages, document the host/context/page model, and detect Chromium's Windows lockfile for non-debuggable shared profiles. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogs/BrowserEndpointDiscovery.cs | 25 ++++--- src/Aspire.Hosting/BrowserLogs/BrowserHost.cs | 13 ++-- .../BrowserLogs/BrowserHostRegistry.cs | 14 ++-- .../BrowserLogs/BrowserLogsCdpConnection.cs | 4 +- .../BrowserLogs/BrowserLogsEventLogger.cs | 2 +- .../BrowserLogs/BrowserLogsResource.cs | 21 +++--- .../BrowserLogs/BrowserLogsRunningSession.cs | 44 ++++++------- ...TargetSession.cs => BrowserPageSession.cs} | 65 ++++++++++--------- .../BrowserLogs/IBrowserHost.cs | 31 ++++----- .../BrowserLogsSessionManagerTests.cs | 47 ++++++++++++-- 10 files changed, 153 insertions(+), 113 deletions(-) rename src/Aspire.Hosting/BrowserLogs/{BrowserTargetSession.cs => BrowserPageSession.cs} (80%) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs index d369e8dff3d..31d7f1a8aa6 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs @@ -32,8 +32,8 @@ internal sealed class BrowserEndpointDiscovery(ILogger _logger = logger; // Aspire sidecar file stored at the Chromium user data root next to browser singleton files such as - // SingletonLock and DevToolsActivePort. Keeping it under the user data root makes the adoption state specific to - // the same browser singleton boundary that Chromium itself uses. + // 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"); @@ -173,15 +173,22 @@ public static void DeleteEndpointMetadata(string userDataDirectory) => public static void DeleteDevToolsActivePort(string userDataDirectory) => TryDelete(Path.Combine(userDataDirectory, "DevToolsActivePort")); - public static bool IsNonDebuggableBrowserRunning(string userDataDirectory) + public static bool IsNonDebuggableBrowserRunning(string userDataDirectory) => + IsNonDebuggableBrowserRunning(userDataDirectory, OperatingSystem.IsWindows()); + + internal static bool IsNonDebuggableBrowserRunning(string userDataDirectory, bool isWindows) { - var singletonLockPath = Path.Combine(userDataDirectory, "SingletonLock"); - FileInfo singletonLock; + // Chromium uses different singleton lock files by platform: + // - POSIX/macOS: SingletonLock is a symlink shaped like "-". + // - Windows: lockfile is held open with FILE_FLAG_DELETE_ON_CLOSE and has no PID payload. + // See Chromium's process_singleton_posix.cc and process_singleton_win.cc for the platform-specific details. + var lockPath = Path.Combine(userDataDirectory, isWindows ? "lockfile" : "SingletonLock"); + FileInfo lockFile; try { - singletonLock = new FileInfo(singletonLockPath); + lockFile = new FileInfo(lockPath); // Broken Unix symlinks can report Exists=false while still exposing the host-pid LinkTarget we need. - if (!singletonLock.Exists && string.IsNullOrWhiteSpace(singletonLock.LinkTarget)) + if (!lockFile.Exists && string.IsNullOrWhiteSpace(lockFile.LinkTarget)) { return false; } @@ -195,7 +202,7 @@ public static bool IsNonDebuggableBrowserRunning(string userDataDirectory) return false; } - if (TryGetSingletonLockProcessId(singletonLock) is { } pid) + if (TryGetSingletonLockProcessId(lockFile) is { } pid) { return IsProcessAlive(pid); } @@ -204,7 +211,7 @@ public static bool IsNonDebuggableBrowserRunning(string userDataDirectory) // https://chromium.googlesource.com/chromium/src/+/main/chrome/browser/process_singleton_posix.cc // On Windows the singleton is a locked file rather than a host-pid symlink, so the best available signal is the // presence of the lock path. On Unix we avoid treating old broken symlinks as an active browser. - return OperatingSystem.IsWindows(); + return isWindows; } private static async Task ProbeBrowserEndpointAsync(Uri browserEndpoint, CancellationToken cancellationToken) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs b/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs index cf324fd4dd4..c519ebb968d 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs @@ -8,7 +8,7 @@ namespace Aspire.Hosting; -// Base implementation for browser hosts. It centralizes the shared mechanics for creating per-tab target sessions +// Base implementation for browser hosts. It centralizes the shared mechanics for creating per-page sessions // while concrete hosts decide who owns the browser process lifetime. internal abstract class BrowserHost( BrowserHostIdentity identity, @@ -35,14 +35,14 @@ internal abstract class BrowserHost( public abstract Task Termination { get; } - public Task CreateTargetSessionAsync( + public Task CreatePageSessionAsync( string sessionId, Uri url, BrowserConnectionDiagnosticsLogger connectionDiagnostics, Func eventHandler, CancellationToken cancellationToken) { - return CreateTargetSessionCoreAsync(sessionId, url, connectionDiagnostics, eventHandler, cancellationToken); + return CreatePageSessionCoreAsync(sessionId, url, connectionDiagnostics, eventHandler, cancellationToken); } public abstract ValueTask DisposeAsync(); @@ -51,7 +51,8 @@ protected static string BuildBrowserArguments(BrowserLogsUserDataDirectory userD { // Chromium writes DevToolsActivePort only when remote debugging is enabled. Let it choose the port so // playground runs do not collide with a user's existing browser or another AppHost. The initial about:blank - // page gives owned hosts a predictable first target that can be navigated instead of leaving an extra blank tab. + // page gives owned hosts a predictable first page target that can be navigated instead of leaving an extra + // blank tab. List arguments = [ $"--user-data-dir={userDataDirectory.Path}", @@ -73,14 +74,14 @@ protected static string BuildBrowserArguments(BrowserLogsUserDataDirectory userD return BrowserLogsRunningSession.BuildCommandLine(arguments); } - private async Task CreateTargetSessionCoreAsync( + private async Task CreatePageSessionCoreAsync( string sessionId, Uri url, BrowserConnectionDiagnosticsLogger connectionDiagnostics, Func eventHandler, CancellationToken cancellationToken) { - return await BrowserTargetSession.StartAsync( + return await BrowserPageSession.StartAsync( this, sessionId, url, diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs b/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs index e01794ddd5b..a906fb864f3 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs @@ -70,9 +70,9 @@ public async Task AcquireAsync(BrowserLogsSettings settings, C if (_hosts.TryGetValue(identity, out var entry)) { // The identity is rooted at the browser executable and user data directory, not at a specific profile. - // That lets multiple sessions share one debug-enabled browser for the same user data root while still - // rejecting requests that need a different named profile from the browser we already track. - // In the playground this shows up as one browser window/process with additional tracked targets/tabs + // In Playwright terms, the user data directory is the persistent-context boundary: multiple pages can + // share one browser process/context, while requests for a different named profile are rejected. + // In the playground this shows up as one browser window/process with additional tracked page targets // as more resources start browser-log sessions, rather than one browser process per session. ValidateProfileCompatibility(identity, entry.ProfileDirectoryName, userDataDirectory.ProfileDirectoryName); entry.ReferenceCount++; @@ -215,10 +215,10 @@ private BrowserLogsUserDataDirectory CreateUserDataDirectory(BrowserLogsSettings return BrowserLogsUserDataDirectory.CreateTemporary(_fileSystemService.TempDirectory.CreateTempSubdirectory("aspire-browser-logs")); } - // Shared mode intentionally points at the browser's real user data root so user state, extensions, and profiles - // are available. Chromium puts SingletonLock, DevToolsActivePort, and our Aspire endpoint sidecar at that root; - // named profiles are subdirectories selected by command-line argument. The later endpoint/probe logic decides - // whether that root is reusable, adoptable, or locked. + // Shared mode is a persistent browser context over the browser's real user data root, so user state, + // extensions, and profiles are available. Chromium puts singleton locks, DevToolsActivePort, and our Aspire + // endpoint sidecar at that root; named profiles are subdirectories selected by command-line argument. The later + // endpoint/probe logic decides whether that root is reusable, adoptable, or locked. var userDataDirectory = BrowserLogsRunningSession.TryResolveBrowserUserDataDirectory(settings.Browser, browserExecutable) ?? throw new InvalidOperationException($"Unable to resolve the user data directory for browser '{settings.Browser}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode."); diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsCdpConnection.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsCdpConnection.cs index a1985ff41d1..46ecd9cf91e 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsCdpConnection.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsCdpConnection.cs @@ -8,8 +8,8 @@ namespace Aspire.Hosting; -// Owns the browser-level websocket only. Protocol parsing stays in BrowserLogsCdpProtocol, while target lifecycle and -// reconnection policy stay in BrowserTargetSession. +// Owns the browser-level websocket only. Protocol parsing stays in BrowserLogsCdpProtocol, while page lifecycle and +// reconnection policy stay in BrowserPageSession. internal sealed class BrowserLogsCdpConnection : IAsyncDisposable { private static readonly TimeSpan s_closeTimeout = TimeSpan.FromSeconds(3); diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsEventLogger.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsEventLogger.cs index c4a78f84d68..9bc97277e29 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsEventLogger.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsEventLogger.cs @@ -434,7 +434,7 @@ public void LogReconnectFailed(Exception exception) public void LogHostTerminated(Exception exception) { - _resourceLogger.LogError("[{SessionId}] Tracked browser host ended before the tracked target session completed: {Reason}", _sessionId, DescribeConnectionProblem(exception)); + _resourceLogger.LogError("[{SessionId}] Tracked browser host ended before the tracked page session completed: {Reason}", _sessionId, DescribeConnectionProblem(exception)); } internal static string DescribeConnectionProblem(Exception exception) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsResource.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsResource.cs index 6cdae63303d..f107d55694f 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsResource.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsResource.cs @@ -12,24 +12,21 @@ namespace Aspire.Hosting; public enum BrowserUserDataMode { /// - /// Use the browser's real user data directory so the tracked session reuses real cookies, sessions, - /// extensions, and profile selection. Behaves like clicking the browser icon. + /// Use the browser's real user data directory so the tracked session behaves like a persistent browser context + /// with real cookies, sessions, extensions, and profile selection. /// /// - /// NOTE: When the target browser is already running with the same user data directory, Chromium will - /// typically forward the launch to the existing instance and exit the new process. The tracked session - /// relies on --remote-debugging-port and the DevToolsActivePort file written by the - /// launched process; if launching forwards to an existing browser, the DevTools endpoint may not be - /// discoverable and the session will fail to start. Users must close existing browser windows for the - /// selected user data directory before starting a tracked session in this mode. Google Chrome also - /// blocks remote debugging against its default user data directory; use Microsoft Edge or - /// mode when Chrome is selected. + /// NOTE: Aspire can adopt a shared browser only when it previously launched that browser with remote debugging + /// enabled. If a normal non-debuggable browser is already using the selected user data directory, the tracked + /// session fails with guidance instead of opening a second browser against the same profile store. Google Chrome + /// also blocks remote debugging against its default user data directory; use Microsoft Edge or + /// mode when Chrome is selected. /// Shared, /// - /// Launch the tracked browser against a temporary user data directory so the session starts from clean - /// state and does not affect the user's normal browser profiles. + /// Launch the tracked browser against a temporary user data directory, like a disposable persistent browser + /// context, so the session starts from clean state and does not affect the user's normal browser profiles. /// Isolated, } diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs index 3f2ea7c9f10..8760e073e75 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs @@ -102,7 +102,7 @@ internal sealed class BrowserLogsRunningSession : IBrowserLogsRunningSession private int _cleanupState; private int? _processId; private string? _targetId; - private IBrowserTargetSession? _targetSession; + private IBrowserPageSession? _pageSession; private string? _targetSessionId; private BrowserLogsRunningSession( @@ -180,7 +180,7 @@ public async Task StopAsync(CancellationToken cancellationToken) // Stopping a dashboard browser-log session should close only the page target it created. The shared browser // process/window is released through the lease and may stay alive while other resource sessions are still active. - await DisposeTargetSessionAsync().ConfigureAwait(false); + await DisposePageSessionAsync().ConfigureAwait(false); await DisposeBrowserHostLeaseAsync().ConfigureAwait(false); try @@ -227,9 +227,9 @@ private async Task InitializeAsync(CancellationToken cancellationToken) try { // A running session represents one resource URL, not one browser process. In the playground multiple - // resources can share a host, while each resource still gets its own CDP target so console and network + // resources can share a host, while each resource still gets its own page target so console and network // events stay scoped to the right resource logs. - _targetSession = await browserHost.CreateTargetSessionAsync( + _pageSession = await browserHost.CreatePageSessionAsync( _sessionId, _url, _connectionDiagnostics, @@ -239,17 +239,17 @@ private async Task InitializeAsync(CancellationToken cancellationToken) return ValueTask.CompletedTask; }, cancellationToken).ConfigureAwait(false); - _targetId = _targetSession.TargetId; - _targetSessionId = _targetSession.TargetSessionId; + _targetId = _pageSession.TargetId; + _targetSessionId = _pageSession.TargetSessionId; _resourceLogger.LogInformation( - "[{SessionId}] Attached to tracked browser target '{TargetId}' with target session '{TargetSessionId}'.", + "[{SessionId}] Attached to tracked browser page target '{TargetId}' with target session '{TargetSessionId}'.", _sessionId, _targetId, _targetSessionId); } catch (Exception ex) { - _connectionDiagnostics.LogSetupFailure("Setting up the tracked browser target", ex); + _connectionDiagnostics.LogSetupFailure("Setting up the tracked browser page", ex); throw; } @@ -260,17 +260,17 @@ private async Task MonitorAsync() { try { - var targetSession = _targetSession ?? throw new InvalidOperationException("Browser target session is not available."); - var result = await targetSession.Completion.ConfigureAwait(false); - // Closing the tracked tab by hand is a normal completion. Browser process exit, target crash, or an + var pageSession = _pageSession ?? throw new InvalidOperationException("Browser page session is not available."); + var result = await pageSession.Completion.ConfigureAwait(false); + // Closing the tracked tab by hand is a normal completion. Browser process exit, page crash, or an // unrecoverable CDP connection loss is surfaced as an error so the dashboard resource shows what happened. return result.CompletionKind switch { - BrowserTargetSessionCompletionKind.Stopped => new BrowserSessionResult(ExitCode: null, Error: null), - BrowserTargetSessionCompletionKind.TargetClosed => new BrowserSessionResult(ExitCode: null, Error: null), - BrowserTargetSessionCompletionKind.BrowserExited => new BrowserSessionResult(ExitCode: null, result.Error), - BrowserTargetSessionCompletionKind.TargetCrashed => new BrowserSessionResult(ExitCode: null, result.Error), - BrowserTargetSessionCompletionKind.ConnectionLost => new BrowserSessionResult(ExitCode: null, result.Error), + BrowserPageSessionCompletionKind.Stopped => new BrowserSessionResult(ExitCode: null, Error: null), + BrowserPageSessionCompletionKind.PageClosed => new BrowserSessionResult(ExitCode: null, Error: null), + BrowserPageSessionCompletionKind.BrowserExited => new BrowserSessionResult(ExitCode: null, result.Error), + BrowserPageSessionCompletionKind.PageCrashed => new BrowserSessionResult(ExitCode: null, result.Error), + BrowserPageSessionCompletionKind.ConnectionLost => new BrowserSessionResult(ExitCode: null, result.Error), _ => new BrowserSessionResult(ExitCode: null, Error: null) }; } @@ -300,19 +300,19 @@ private async Task CleanupAsync() return; } - await DisposeTargetSessionAsync().ConfigureAwait(false); + await DisposePageSessionAsync().ConfigureAwait(false); await DisposeBrowserHostLeaseAsync().ConfigureAwait(false); _stopCts.Dispose(); } - private async Task DisposeTargetSessionAsync() + private async Task DisposePageSessionAsync() { - var targetSession = _targetSession; - _targetSession = null; + var pageSession = _pageSession; + _pageSession = null; - if (targetSession is not null) + if (pageSession is not null) { - await targetSession.DisposeAsync().ConfigureAwait(false); + await pageSession.DisposeAsync().ConfigureAwait(false); } } diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs b/src/Aspire.Hosting/BrowserLogs/BrowserPageSession.cs similarity index 80% rename from src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs rename to src/Aspire.Hosting/BrowserLogs/BrowserPageSession.cs index a97bc7b006a..08ff458e05e 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserTargetSession.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserPageSession.cs @@ -5,16 +5,17 @@ namespace Aspire.Hosting; -// Owns one browser target (tab) for one browser-log session. The host may be shared by many sessions, but each -// BrowserTargetSession has its own browser CDP connection, attached target session id, instrumentation setup, -// lifecycle monitoring, and reconnection loop. -internal sealed class BrowserTargetSession : IBrowserTargetSession +// 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 { 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 _completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly BrowserConnectionDiagnosticsLogger _connectionDiagnostics; private readonly Func _eventHandler; private readonly IBrowserHost _host; @@ -26,12 +27,12 @@ internal sealed class BrowserTargetSession : IBrowserTargetSession private readonly Uri _url; private BrowserLogsCdpConnection? _connection; - private Task? _monitorTask; + private Task? _monitorTask; private int _disposed; private string? _targetId; private string? _targetSessionId; - private BrowserTargetSession( + private BrowserPageSession( IBrowserHost host, string sessionId, Uri url, @@ -55,37 +56,37 @@ private BrowserTargetSession( public string TargetSessionId => _targetSessionId ?? throw new InvalidOperationException("Browser target session id is not available before the target session starts."); - public Task Completion => _monitorTask ?? throw new InvalidOperationException("Browser target session has not started."); + public Task Completion => _monitorTask ?? throw new InvalidOperationException("Browser page session has not started."); - internal static BrowserTargetSessionResult? TryGetTargetCompletion(BrowserLogsCdpProtocolEvent protocolEvent, string? targetId, string? targetSessionId) + internal static BrowserPageSessionResult? TryGetPageCompletion(BrowserLogsCdpProtocolEvent protocolEvent, string? targetId, string? targetSessionId) { return protocolEvent switch { BrowserLogsTargetDestroyedEvent targetDestroyed when string.Equals(targetDestroyed.TargetId, targetId, StringComparison.Ordinal) => - new BrowserTargetSessionResult(BrowserTargetSessionCompletionKind.TargetClosed, Error: null), + new BrowserPageSessionResult(BrowserPageSessionCompletionKind.PageClosed, Error: null), BrowserLogsTargetCrashedEvent targetCrashed when string.Equals(targetCrashed.TargetId, targetId, StringComparison.Ordinal) => - new BrowserTargetSessionResult( - BrowserTargetSessionCompletionKind.TargetCrashed, - new InvalidOperationException($"Tracked browser target crashed with status '{targetCrashed.Parameters.Status}' and error code '{targetCrashed.Parameters.ErrorCode}'.")), + 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 BrowserTargetSessionResult(BrowserTargetSessionCompletionKind.TargetClosed, Error: null), + 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 BrowserTargetSessionResult(BrowserTargetSessionCompletionKind.TargetClosed, Error: null) - : new BrowserTargetSessionResult( - BrowserTargetSessionCompletionKind.ConnectionLost, + ? 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 StartAsync( + public static async Task StartAsync( IBrowserHost host, string sessionId, Uri url, @@ -96,16 +97,16 @@ public static async Task StartAsync( bool reuseInitialBlankTarget, CancellationToken cancellationToken) { - var targetSession = new BrowserTargetSession(host, sessionId, url, connectionDiagnostics, eventHandler, logger, timeProvider, reuseInitialBlankTarget); + var pageSession = new BrowserPageSession(host, sessionId, url, connectionDiagnostics, eventHandler, logger, timeProvider, reuseInitialBlankTarget); try { - await targetSession.ConnectAsync(createTarget: true, cancellationToken).ConfigureAwait(false); - targetSession._monitorTask = targetSession.MonitorAsync(); - return targetSession; + await pageSession.ConnectAsync(createTarget: true, cancellationToken).ConfigureAwait(false); + pageSession._monitorTask = pageSession.MonitorAsync(); + return pageSession; } catch { - await targetSession.DisposeAsync().ConfigureAwait(false); + await pageSession.DisposeAsync().ConfigureAwait(false); throw; } } @@ -133,7 +134,7 @@ public async ValueTask DisposeAsync() } } - _completionSource.TrySetResult(new BrowserTargetSessionResult(BrowserTargetSessionCompletionKind.Stopped, Error: null)); + _completionSource.TrySetResult(new BrowserPageSessionResult(BrowserPageSessionCompletionKind.Stopped, Error: null)); await DisposeConnectionAsync().ConfigureAwait(false); @@ -205,7 +206,7 @@ private async Task CreateTargetAsync(CancellationToken cancellationToken ?? throw new InvalidOperationException("Browser target creation did not return a target id."); } - private async Task MonitorAsync() + private async Task MonitorAsync() { try { @@ -221,9 +222,9 @@ private async Task MonitorAsync() if (completedTask == _host.Termination) { - var error = new InvalidOperationException($"Tracked browser host '{_host.Identity}' ended before the target session completed."); + var error = new InvalidOperationException($"Tracked browser host '{_host.Identity}' ended before the page session completed."); _connectionDiagnostics.LogHostTerminated(error); - return new BrowserTargetSessionResult(BrowserTargetSessionCompletionKind.BrowserExited, error); + return new BrowserPageSessionResult(BrowserPageSessionCompletionKind.BrowserExited, error); } Exception? connectionError = null; @@ -238,7 +239,7 @@ private async Task MonitorAsync() if (_stopCts.IsCancellationRequested) { - return new BrowserTargetSessionResult(BrowserTargetSessionCompletionKind.Stopped, Error: null); + return new BrowserPageSessionResult(BrowserPageSessionCompletionKind.Stopped, Error: null); } connectionError ??= new InvalidOperationException("The tracked browser debug connection closed without reporting a reason."); @@ -248,7 +249,7 @@ private async Task MonitorAsync() continue; } - return new BrowserTargetSessionResult(BrowserTargetSessionCompletionKind.ConnectionLost, connectionError); + return new BrowserPageSessionResult(BrowserPageSessionCompletionKind.ConnectionLost, connectionError); } } finally @@ -305,7 +306,7 @@ private async Task TryReconnectAsync(Exception connectionError) if (lastError is not null) { _connectionDiagnostics.LogReconnectFailed(lastError); - _logger.LogDebug(lastError, "Timed out reconnecting tracked browser target session '{SessionId}'.", _sessionId); + _logger.LogDebug(lastError, "Timed out reconnecting tracked browser page session '{SessionId}'.", _sessionId); } return false; @@ -315,9 +316,9 @@ private async ValueTask HandleEventAsync(BrowserLogsCdpProtocolEvent protocolEve { // 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 (TryGetTargetCompletion(protocolEvent, _targetId, _targetSessionId) is { } targetCompletion) + if (TryGetPageCompletion(protocolEvent, _targetId, _targetSessionId) is { } pageCompletion) { - _completionSource.TrySetResult(targetCompletion); + _completionSource.TrySetResult(pageCompletion); return; } diff --git a/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs b/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs index cfc620db00b..276d18c3079 100644 --- a/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs +++ b/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs @@ -3,9 +3,10 @@ namespace Aspire.Hosting; -// A browser instance 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. +// 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; } @@ -27,10 +28,10 @@ internal interface IBrowserHost : IAsyncDisposable // termination because sessions can reconnect and reattach to their targets. Task Termination { get; } - // Creates a target owned by one tracked browser-log session. The returned session owns only the target/tab; + // 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 CreateTargetSessionAsync( + Task CreatePageSessionAsync( string sessionId, Uri url, BrowserConnectionDiagnosticsLogger connectionDiagnostics, @@ -38,28 +39,28 @@ Task CreateTargetSessionAsync( CancellationToken cancellationToken); } -internal interface IBrowserTargetSession : IAsyncDisposable +internal interface IBrowserPageSession : IAsyncDisposable { string TargetId { get; } string TargetSessionId { get; } - // Completes when this target is no longer available: the target was closed/crashed, CDP reported a detach, + // 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 Completion { get; } + Task Completion { get; } } -// Normalized target-session completion signal consumed by BrowserLogsRunningSession so manager state is independent of -// the exact CDP event or transport failure that ended the target. -internal readonly record struct BrowserTargetSessionResult(BrowserTargetSessionCompletionKind CompletionKind, Exception? Error); +// 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 target lifecycle outcomes. The manager uses this to distinguish normal tab closes from crashes +// Small vocabulary for page lifecycle outcomes. The manager uses this to distinguish normal tab closes from crashes // or unrecoverable browser connection loss. -internal enum BrowserTargetSessionCompletionKind +internal enum BrowserPageSessionCompletionKind { Stopped, - TargetClosed, - TargetCrashed, + PageClosed, + PageCrashed, BrowserExited, ConnectionLost } diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs index 3c3892cec99..3bb452525a2 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs @@ -152,9 +152,9 @@ public async Task BrowserHostRegistry_RejectsDifferentProfileForSharedHost() } [Fact] - public void BrowserTargetSession_MapsTargetLifecycleEventsToCompletion() + public void BrowserPageSession_MapsTargetLifecycleEventsToCompletion() { - var closed = BrowserTargetSession.TryGetTargetCompletion( + var closed = BrowserPageSession.TryGetPageCompletion( new BrowserLogsDetachedFromTargetEvent( SessionId: null, new BrowserLogsDetachedFromTargetParameters @@ -164,7 +164,7 @@ public void BrowserTargetSession_MapsTargetLifecycleEventsToCompletion() }), targetId: "target-1", targetSessionId: "target-session-1"); - var crashed = BrowserTargetSession.TryGetTargetCompletion( + var crashed = BrowserPageSession.TryGetPageCompletion( new BrowserLogsTargetCrashedEvent( SessionId: null, new BrowserLogsTargetCrashedParameters @@ -175,7 +175,7 @@ public void BrowserTargetSession_MapsTargetLifecycleEventsToCompletion() }), targetId: "target-1", targetSessionId: "target-session-1"); - var unrelated = BrowserTargetSession.TryGetTargetCompletion( + var unrelated = BrowserPageSession.TryGetPageCompletion( new BrowserLogsInspectorDetachedEvent( SessionId: "other-session", new BrowserLogsInspectorDetachedParameters @@ -185,9 +185,9 @@ public void BrowserTargetSession_MapsTargetLifecycleEventsToCompletion() targetId: "target-1", targetSessionId: "target-session-1"); - Assert.Equal(BrowserTargetSessionCompletionKind.TargetClosed, closed?.CompletionKind); + Assert.Equal(BrowserPageSessionCompletionKind.PageClosed, closed?.CompletionKind); Assert.Null(closed?.Error); - Assert.Equal(BrowserTargetSessionCompletionKind.TargetCrashed, crashed?.CompletionKind); + Assert.Equal(BrowserPageSessionCompletionKind.PageCrashed, crashed?.CompletionKind); Assert.Contains("1337", crashed?.Error?.Message); Assert.Null(unrelated); } @@ -287,6 +287,39 @@ await BrowserEndpointDiscovery.WriteAsync( } } + [Fact] + public void BrowserEndpointDiscovery_DetectsWindowsLockfileAsNonDebuggableBrowser() + { + var userDataDirectory = Directory.CreateTempSubdirectory(); + try + { + File.WriteAllText(Path.Combine(userDataDirectory.FullName, "lockfile"), string.Empty); + + Assert.True(BrowserEndpointDiscovery.IsNonDebuggableBrowserRunning(userDataDirectory.FullName, isWindows: true)); + Assert.False(BrowserEndpointDiscovery.IsNonDebuggableBrowserRunning(userDataDirectory.FullName, isWindows: false)); + } + finally + { + userDataDirectory.Delete(recursive: true); + } + } + + [Fact] + public void BrowserEndpointDiscovery_IgnoresPosixSingletonLockWithoutPidTarget() + { + var userDataDirectory = Directory.CreateTempSubdirectory(); + try + { + File.WriteAllText(Path.Combine(userDataDirectory.FullName, "SingletonLock"), string.Empty); + + Assert.False(BrowserEndpointDiscovery.IsNonDebuggableBrowserRunning(userDataDirectory.FullName, isWindows: false)); + } + finally + { + userDataDirectory.Delete(recursive: true); + } + } + [Fact] public void TryResolveBrowserUserDataDirectory_ReturnsExpectedPathForKnownBrowser() { @@ -623,7 +656,7 @@ public TestBrowserHost(BrowserHostIdentity identity, string? profileDirectoryNam public Task Termination { get; } = Task.CompletedTask; - public Task CreateTargetSessionAsync( + public Task CreatePageSessionAsync( string sessionId, Uri url, BrowserConnectionDiagnosticsLogger connectionDiagnostics, From ef4423d65c69f94bbb6f685f029249008c395a53 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 25 Apr 2026 16:07:55 -0700 Subject: [PATCH 22/36] Dispose browser host registry lock Track active registry lock users so BrowserHostRegistry can dispose its SemaphoreSlim without breaking late lease releases after registry disposal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogs/BrowserHostRegistry.cs | 131 ++++++++++++++++-- .../BrowserLogsSessionManagerTests.cs | 35 +++++ 2 files changed, 156 insertions(+), 10 deletions(-) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs b/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs index a906fb864f3..84750a80bdf 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs @@ -17,11 +17,14 @@ internal sealed class BrowserHostRegistry : IAsyncDisposable private readonly Func> _createHostAsync; private readonly IFileSystemService _fileSystemService; private readonly Dictionary _hosts = new(); - // Keep the semaphore available for late no-op releases from outstanding leases during registry disposal. private readonly SemaphoreSlim _lock = new(1, 1); + private readonly object _lockLifetimeGate = new(); private readonly ILogger _logger; private readonly TimeProvider _timeProvider; + private TaskCompletionSource? _lockUsersDrained; + private int _activeLockUsers; private int _disposed; + private bool _lockDisposed; public BrowserHostRegistry(IFileSystemService fileSystemService, ILogger logger, TimeProvider timeProvider) : this(fileSystemService, logger, timeProvider, createUserDataDirectory: null, createHostAsync: null) @@ -62,9 +65,12 @@ public async Task AcquireAsync(BrowserLogsSettings settings, C // start a new process, both of which depend on filesystem endpoint metadata for the same user data root. If two // callers ran that decision concurrently they could both miss the dictionary entry and race to adopt/start a // browser for the same profile. - await _lock.WaitAsync(cancellationToken).ConfigureAwait(false); + var lockAcquired = false; + var hostPublished = false; try { + lockAcquired = await TryWaitForLockAsync(cancellationToken).ConfigureAwait(false); + ObjectDisposedException.ThrowIf(!lockAcquired, this); ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); if (_hosts.TryGetValue(identity, out var entry)) @@ -88,11 +94,12 @@ public async Task AcquireAsync(BrowserLogsSettings settings, C // together: the first request may open/adopt the browser, and the rest should attach to that result. var host = await _createHostAsync(settings, identity, userDataDirectory, cancellationToken).ConfigureAwait(false); _hosts[identity] = new BrowserHostEntry(host, userDataDirectory.ProfileDirectoryName, ReferenceCount: 1); + hostPublished = true; return new BrowserHostLease(host, releaseAsync: token => ReleaseAsync(identity, token)); } catch { - if (!_hosts.ContainsKey(identity)) + if (!hostPublished) { userDataDirectory.Dispose(); } @@ -101,7 +108,10 @@ public async Task AcquireAsync(BrowserLogsSettings settings, C } finally { - _lock.Release(); + if (lockAcquired) + { + ReleaseLock(); + } } } @@ -113,7 +123,8 @@ public async ValueTask DisposeAsync() } List hosts; - await _lock.WaitAsync(CancellationToken.None).ConfigureAwait(false); + var lockAcquired = await TryWaitForLockAsync(CancellationToken.None).ConfigureAwait(false); + ObjectDisposedException.ThrowIf(!lockAcquired, this); try { hosts = [.. _hosts.Values.Select(static entry => entry.Host)]; @@ -121,12 +132,19 @@ public async ValueTask DisposeAsync() } finally { - _lock.Release(); + ReleaseLock(); } - foreach (var host in hosts) + try + { + foreach (var host in hosts) + { + await host.DisposeAsync().ConfigureAwait(false); + } + } + finally { - await host.DisposeAsync().ConfigureAwait(false); + await DisposeLockAsync().ConfigureAwait(false); } } @@ -141,9 +159,19 @@ private async ValueTask ReleaseAsync(BrowserHostIdentity identity, CancellationT return; } - await _lock.WaitAsync(cancellationToken).ConfigureAwait(false); + var lockAcquired = await TryWaitForLockAsync(cancellationToken).ConfigureAwait(false); + if (!lockAcquired) + { + return; + } + try { + if (Volatile.Read(ref _disposed) != 0) + { + return; + } + if (_hosts.TryGetValue(identity, out var entry)) { entry.ReferenceCount--; @@ -156,7 +184,7 @@ private async ValueTask ReleaseAsync(BrowserHostIdentity identity, CancellationT } finally { - _lock.Release(); + ReleaseLock(); } if (hostToDispose is not null) @@ -166,6 +194,89 @@ private async ValueTask ReleaseAsync(BrowserHostIdentity identity, CancellationT } + private async Task TryWaitForLockAsync(CancellationToken cancellationToken) + { + if (!TryAddLockUser()) + { + return false; + } + + try + { + await _lock.WaitAsync(cancellationToken).ConfigureAwait(false); + return true; + } + catch + { + RemoveLockUser(); + throw; + } + } + + private bool TryAddLockUser() + { + lock (_lockLifetimeGate) + { + if (_lockDisposed) + { + return false; + } + + _activeLockUsers++; + return true; + } + } + + private void ReleaseLock() + { + try + { + _lock.Release(); + } + finally + { + RemoveLockUser(); + } + } + + private void RemoveLockUser() + { + TaskCompletionSource? lockUsersDrained = null; + + lock (_lockLifetimeGate) + { + _activeLockUsers--; + if (_lockDisposed && _activeLockUsers == 0) + { + lockUsersDrained = _lockUsersDrained; + } + } + + lockUsersDrained?.TrySetResult(); + } + + private async Task DisposeLockAsync() + { + Task? lockUsersDrained = null; + + lock (_lockLifetimeGate) + { + _lockDisposed = true; + if (_activeLockUsers > 0) + { + _lockUsersDrained = new(TaskCreationOptions.RunContinuationsAsynchronously); + lockUsersDrained = _lockUsersDrained.Task; + } + } + + if (lockUsersDrained is not null) + { + await lockUsersDrained.ConfigureAwait(false); + } + + _lock.Dispose(); + } + private async Task CreateHostCoreAsync( BrowserLogsSettings settings, BrowserHostIdentity identity, diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs index 3bb452525a2..03b217c3b53 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs @@ -117,6 +117,41 @@ public async Task BrowserHostRegistry_ReusesHostUntilFinalLeaseReleasesIt() } } + [Fact] + public async Task BrowserHostRegistry_LateLeaseReleaseAfterRegistryDisposeNoOps() + { + var userDataDirectory = Directory.CreateTempSubdirectory(); + try + { + var browserExecutable = Path.Combine(userDataDirectory.FullName, "browser"); + File.WriteAllText(browserExecutable, string.Empty); + var createdHosts = new List(); + var registry = new BrowserHostRegistry( + fileSystemService: null!, + NullLogger.Instance, + TimeProvider.System, + createUserDataDirectory: (settings, _) => BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory.FullName, settings.Profile), + createHostAsync: (settings, identity, _, _) => + { + var host = new TestBrowserHost(identity, settings.Profile); + createdHosts.Add(host); + return Task.FromResult(host); + }); + var settings = new BrowserLogsSettings(browserExecutable, Profile: null, BrowserUserDataMode.Shared); + + var lease = await registry.AcquireAsync(settings, CancellationToken.None); + + await registry.DisposeAsync(); + await lease.DisposeAsync(); + + Assert.True(createdHosts[0].Disposed); + } + finally + { + userDataDirectory.Delete(recursive: true); + } + } + [Fact] public async Task BrowserHostRegistry_RejectsDifferentProfileForSharedHost() { From cb4badabe8f7a95c3f00c5272a99928415ce4115 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 25 Apr 2026 16:19:14 -0700 Subject: [PATCH 23/36] Move browser logs configuration resolution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogs/BrowserConfiguration.cs | 147 ++++++++++++++++++ .../BrowserLogs/BrowserHostRegistry.cs | 40 ++--- .../BrowserLogsBuilderExtensions.cs | 105 ++----------- .../BrowserLogs/BrowserLogsResource.cs | 39 +---- .../BrowserLogs/BrowserLogsRunningSession.cs | 28 ++-- .../BrowserLogs/BrowserLogsSessionManager.cs | 18 +-- .../BrowserLogs/IBrowserHost.cs | 2 +- .../BrowserLogs/IBrowserLogsSessionManager.cs | 2 +- .../BrowserLogsBuilderExtensionsTests.cs | 32 ++-- .../BrowserLogsSessionManagerTests.cs | 36 ++--- 10 files changed, 243 insertions(+), 206 deletions(-) create mode 100644 src/Aspire.Hosting/BrowserLogs/BrowserConfiguration.cs diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserConfiguration.cs b/src/Aspire.Hosting/BrowserLogs/BrowserConfiguration.cs new file mode 100644 index 00000000000..6f5f351f409 --- /dev/null +++ b/src/Aspire.Hosting/BrowserLogs/BrowserConfiguration.cs @@ -0,0 +1,147 @@ +// 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.Configuration; + +namespace Aspire.Hosting; + +/// +/// Selects the Chromium user data directory used by tracked browser sessions. +/// +public enum BrowserUserDataMode +{ + /// + /// Use the browser's real user data directory so the tracked session behaves like a persistent browser context + /// with real cookies, sessions, extensions, and profile selection. + /// + /// + /// NOTE: Aspire can adopt a shared browser only when it previously launched that browser with remote debugging + /// enabled. If a normal non-debuggable browser is already using the selected user data directory, the tracked + /// session fails with guidance instead of opening a second browser against the same profile store. Google Chrome + /// also blocks remote debugging against its default user data directory; use Microsoft Edge or + /// mode when Chrome is selected. + /// + Shared, + + /// + /// Launch the tracked browser against a temporary user data directory, like a disposable persistent browser + /// context, so the session starts from clean state and does not affect the user's normal browser profiles. + /// + Isolated, +} + +/// +/// Resolved browser configuration used for one tracked browser session. +/// +/// +/// Resolution keeps "which browser/profile did the caller ask for?" separate from "which user data directory +/// does that imply?". The later user-data-directory decision belongs to , where +/// the resolved browser executable path is available. +/// +internal readonly record struct BrowserConfiguration(string Browser, string? Profile, BrowserUserDataMode UserDataMode) +{ + /// + /// The default mode matches a normal browser launch by using the browser's real user data directory. + /// + public const BrowserUserDataMode DefaultUserDataMode = BrowserUserDataMode.Shared; + + /// + /// Resolves explicit method arguments, resource-scoped configuration, global configuration, and defaults. + /// + public static BrowserConfiguration Resolve( + IConfiguration configuration, + string resourceName, + string? browserOverride, + string? profileOverride, + BrowserUserDataMode? userDataModeOverride) + { + ArgumentNullException.ThrowIfNull(configuration); + ArgumentException.ThrowIfNullOrWhiteSpace(resourceName); + + var browserLogsSection = configuration.GetSection(BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName); + var resourceSection = browserLogsSection.GetSection(resourceName); + + // Resolution order is explicit argument -> resource-specific config -> global browser-log config -> default. + // Resolve user-data mode before browser so the browser default can prefer Edge for shared state and Chrome for + // disposable isolated state. + var resolvedProfile = profileOverride + ?? resourceSection[BrowserLogsBuilderExtensions.ProfileConfigurationKey] + ?? browserLogsSection[BrowserLogsBuilderExtensions.ProfileConfigurationKey]; + var resolvedUserDataMode = userDataModeOverride + ?? ParseUserDataMode(resourceSection[BrowserLogsBuilderExtensions.UserDataModeConfigurationKey]) + ?? ParseUserDataMode(browserLogsSection[BrowserLogsBuilderExtensions.UserDataModeConfigurationKey]) + ?? DefaultUserDataMode; + var resolvedBrowser = browserOverride + ?? resourceSection[BrowserLogsBuilderExtensions.BrowserConfigurationKey] + ?? browserLogsSection[BrowserLogsBuilderExtensions.BrowserConfigurationKey] + ?? GetDefaultBrowser(resolvedUserDataMode); + + if (string.IsNullOrWhiteSpace(resolvedBrowser)) + { + throw new InvalidOperationException("Tracked browser configuration resolved an empty browser value."); + } + + if (resolvedProfile is not null && string.IsNullOrWhiteSpace(resolvedProfile)) + { + throw new InvalidOperationException("Tracked browser configuration resolved an empty profile value."); + } + + if (resolvedUserDataMode == BrowserUserDataMode.Isolated && resolvedProfile is not null) + { + throw new InvalidOperationException( + $"Tracked browser configuration set '{BrowserLogsBuilderExtensions.ProfileConfigurationKey}' to '{resolvedProfile}' while '{BrowserLogsBuilderExtensions.UserDataModeConfigurationKey}' is '{BrowserUserDataMode.Isolated}'. " + + $"Profiles can only be selected when '{BrowserLogsBuilderExtensions.UserDataModeConfigurationKey}' is '{BrowserUserDataMode.Shared}'."); + } + + return new BrowserConfiguration(resolvedBrowser, resolvedProfile, resolvedUserDataMode); + } + + /// + /// Selects the default browser for the default user data mode. + /// + internal static string GetDefaultBrowser(Func resolveBrowserExecutable) => + GetDefaultBrowser(DefaultUserDataMode, resolveBrowserExecutable); + + /// + /// Selects the default browser for the effective user data mode. + /// + internal static string GetDefaultBrowser(BrowserUserDataMode userDataMode, Func resolveBrowserExecutable) + { + if (userDataMode == BrowserUserDataMode.Shared && + resolveBrowserExecutable("msedge") is not null) + { + return "msedge"; + } + + if (resolveBrowserExecutable("chrome") is not null) + { + return "chrome"; + } + + if (resolveBrowserExecutable("msedge") is not null) + { + return "msedge"; + } + + return "chrome"; + } + + private static BrowserUserDataMode? ParseUserDataMode(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + if (Enum.TryParse(value, ignoreCase: true, out var parsed)) + { + return parsed; + } + + throw new InvalidOperationException( + $"Tracked browser configuration value '{value}' is not a valid '{BrowserLogsBuilderExtensions.UserDataModeConfigurationKey}'. Expected '{BrowserUserDataMode.Shared}' or '{BrowserUserDataMode.Isolated}'."); + } + + private static string GetDefaultBrowser(BrowserUserDataMode userDataMode) => + GetDefaultBrowser(userDataMode, BrowserLogsRunningSession.TryResolveBrowserExecutable); +} diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs b/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs index 84750a80bdf..df80c1783e7 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs @@ -13,8 +13,8 @@ namespace Aspire.Hosting; internal sealed class BrowserHostRegistry : IAsyncDisposable { private readonly BrowserEndpointDiscovery _endpointDiscovery; - private readonly Func _createUserDataDirectory; - private readonly Func> _createHostAsync; + private readonly Func _createUserDataDirectory; + private readonly Func> _createHostAsync; private readonly IFileSystemService _fileSystemService; private readonly Dictionary _hosts = new(); private readonly SemaphoreSlim _lock = new(1, 1); @@ -35,8 +35,8 @@ internal BrowserHostRegistry( IFileSystemService fileSystemService, ILogger logger, TimeProvider timeProvider, - Func? createUserDataDirectory, - Func>? createHostAsync) + Func? createUserDataDirectory, + Func>? createHostAsync) { _endpointDiscovery = new BrowserEndpointDiscovery(logger); _createUserDataDirectory = createUserDataDirectory ?? CreateUserDataDirectory; @@ -46,13 +46,13 @@ internal BrowserHostRegistry( _timeProvider = timeProvider; } - public async Task AcquireAsync(BrowserLogsSettings settings, CancellationToken cancellationToken) + public async Task AcquireAsync(BrowserConfiguration configuration, CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); - var browserExecutable = BrowserLogsRunningSession.TryResolveBrowserExecutable(settings.Browser) - ?? throw new InvalidOperationException($"Unable to locate browser '{settings.Browser}'. Specify an installed Chromium-based browser or an explicit executable path."); - var userDataDirectory = _createUserDataDirectory(settings, browserExecutable); + var browserExecutable = BrowserLogsRunningSession.TryResolveBrowserExecutable(configuration.Browser) + ?? throw new InvalidOperationException($"Unable to locate browser '{configuration.Browser}'. Specify an installed Chromium-based browser or an explicit executable path."); + var userDataDirectory = _createUserDataDirectory(configuration, browserExecutable); var identity = new BrowserHostIdentity(browserExecutable, userDataDirectory.Path); // The core AcquireAsync flow has to make one atomic decision per browser identity: @@ -92,7 +92,7 @@ public async Task AcquireAsync(BrowserLogsSettings settings, C // start a new owned browser. The returned host is inserted before returning the first lease so future // callers can reuse it. This keeps the visible behavior stable when several resources request browser logs // together: the first request may open/adopt the browser, and the rest should attach to that result. - var host = await _createHostAsync(settings, identity, userDataDirectory, cancellationToken).ConfigureAwait(false); + var host = await _createHostAsync(configuration, identity, userDataDirectory, cancellationToken).ConfigureAwait(false); _hosts[identity] = new BrowserHostEntry(host, userDataDirectory.ProfileDirectoryName, ReferenceCount: 1); hostPublished = true; return new BrowserHostLease(host, releaseAsync: token => ReleaseAsync(identity, token)); @@ -278,12 +278,12 @@ private async Task DisposeLockAsync() } private async Task CreateHostCoreAsync( - BrowserLogsSettings settings, + BrowserConfiguration configuration, BrowserHostIdentity identity, BrowserLogsUserDataDirectory userDataDirectory, CancellationToken cancellationToken) { - if (settings.UserDataMode == BrowserUserDataMode.Shared) + if (configuration.UserDataMode == BrowserUserDataMode.Shared) { // Shared mode has three outcomes, in this order: // @@ -301,7 +301,7 @@ private async Task CreateHostCoreAsync( var endpoint = new Uri(metadata.Endpoint, UriKind.Absolute); _logger.LogInformation("Adopting tracked browser host '{BrowserExecutable}' at '{Endpoint}'.", identity.ExecutablePath, endpoint); userDataDirectory.Dispose(); - return new AdoptedBrowserHost(identity, endpoint, settings.Browser, _logger, _timeProvider); + return new AdoptedBrowserHost(identity, endpoint, configuration.Browser, _logger, _timeProvider); } if (BrowserEndpointDiscovery.IsNonDebuggableBrowserRunning(identity.UserDataRootPath)) @@ -314,12 +314,12 @@ private async Task CreateHostCoreAsync( } _logger.LogInformation("Starting tracked browser host '{BrowserExecutable}'.", identity.ExecutablePath); - return await OwnedBrowserHost.StartAsync(identity, settings.Browser, userDataDirectory, _logger, _timeProvider, cancellationToken).ConfigureAwait(false); + return await OwnedBrowserHost.StartAsync(identity, configuration.Browser, userDataDirectory, _logger, _timeProvider, cancellationToken).ConfigureAwait(false); } - private BrowserLogsUserDataDirectory CreateUserDataDirectory(BrowserLogsSettings settings, string browserExecutable) + private BrowserLogsUserDataDirectory CreateUserDataDirectory(BrowserConfiguration configuration, string browserExecutable) { - if (settings.UserDataMode == BrowserUserDataMode.Isolated) + if (configuration.UserDataMode == BrowserUserDataMode.Isolated) { // Isolated mode never reuses the user's normal profile. Each host gets a temp user data directory that can // be safely deleted when the last lease releases the owned browser. @@ -330,22 +330,22 @@ private BrowserLogsUserDataDirectory CreateUserDataDirectory(BrowserLogsSettings // extensions, and profiles are available. Chromium puts singleton locks, DevToolsActivePort, and our Aspire // endpoint sidecar at that root; named profiles are subdirectories selected by command-line argument. The later // endpoint/probe logic decides whether that root is reusable, adoptable, or locked. - var userDataDirectory = BrowserLogsRunningSession.TryResolveBrowserUserDataDirectory(settings.Browser, browserExecutable) - ?? throw new InvalidOperationException($"Unable to resolve the user data directory for browser '{settings.Browser}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode."); + var userDataDirectory = BrowserLogsRunningSession.TryResolveBrowserUserDataDirectory(configuration.Browser, browserExecutable) + ?? throw new InvalidOperationException($"Unable to resolve the user data directory for browser '{configuration.Browser}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode."); if (!Directory.Exists(userDataDirectory)) { - throw new InvalidOperationException($"Browser user data directory '{userDataDirectory}' was not found for browser '{settings.Browser}'."); + throw new InvalidOperationException($"Browser user data directory '{userDataDirectory}' was not found for browser '{configuration.Browser}'."); } - if (BrowserLogsRunningSession.IsGoogleChromeDefaultUserDataDirectory(settings.Browser, browserExecutable, userDataDirectory)) + if (BrowserLogsRunningSession.IsGoogleChromeDefaultUserDataDirectory(configuration.Browser, browserExecutable, userDataDirectory)) { throw new InvalidOperationException( $"Google Chrome blocks remote debugging against its default user data directory '{userDataDirectory}'. " + $"Use '{BrowserLogsBuilderExtensions.UserDataModeConfigurationKey}'='{BrowserUserDataMode.Isolated}' or select Microsoft Edge for shared browser state."); } - var profileDirectoryName = settings.Profile is { } profile + var profileDirectoryName = configuration.Profile is { } profile ? BrowserLogsRunningSession.ResolveBrowserProfileDirectory(userDataDirectory, profile) : null; return BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory, profileDirectoryName); diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs index 2c82702dc29..0445bb35625 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs @@ -1,8 +1,8 @@ // 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.CodeAnalysis; using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Resources; using Microsoft.Extensions.DependencyInjection; @@ -26,7 +26,7 @@ public static class BrowserLogsBuilderExtensions internal const string ProfilePropertyName = "Profile"; internal const string UserDataModeConfigurationKey = "UserDataMode"; internal const string UserDataModePropertyName = "User data mode"; - internal const BrowserUserDataMode DefaultUserDataMode = BrowserUserDataMode.Shared; + internal const BrowserUserDataMode DefaultUserDataMode = BrowserConfiguration.DefaultUserDataMode; internal const string TargetUrlPropertyName = "Target URL"; internal const string ActiveSessionsPropertyName = "Active sessions"; internal const string BrowserSessionsPropertyName = "Browser sessions"; @@ -113,11 +113,11 @@ public static IResourceBuilder WithBrowserLogs( builder.ApplicationBuilder.Services.TryAddSingleton(); var parentResource = builder.Resource; - var settings = ResolveSettings(builder.ApplicationBuilder.Configuration, parentResource.Name, browser, profile, userDataMode); + var initialConfiguration = BrowserConfiguration.Resolve(builder.ApplicationBuilder.Configuration, parentResource.Name, browser, profile, userDataMode); var browserLogsResource = new BrowserLogsResource( $"{parentResource.Name}-browser-logs", parentResource, - settings, + initialConfiguration, browser, profile, userDataMode); @@ -132,7 +132,7 @@ public static IResourceBuilder WithBrowserLogs( ResourceType = BrowserResourceType, CreationTimeStamp = DateTime.UtcNow, State = KnownResourceStates.NotStarted, - Properties = CreateInitialProperties(parentResource.Name, settings) + Properties = CreateInitialProperties(parentResource.Name, initialConfiguration) }) .WithCommand( OpenTrackedBrowserCommandName, @@ -142,10 +142,10 @@ public static IResourceBuilder WithBrowserLogs( try { var configuration = context.ServiceProvider.GetRequiredService(); - var currentSettings = browserLogsResource.ResolveCurrentSettings(configuration); + var currentConfiguration = browserLogsResource.ResolveCurrentConfiguration(configuration); var url = ResolveBrowserUrl(parentResource); var sessionManager = context.ServiceProvider.GetRequiredService(); - await sessionManager.StartSessionAsync(browserLogsResource, currentSettings, context.ResourceName, url, context.CancellationToken).ConfigureAwait(false); + await sessionManager.StartSessionAsync(browserLogsResource, currentConfiguration, context.ResourceName, url, context.CancellationToken).ConfigureAwait(false); return CommandResults.Success(); } catch (Exception ex) @@ -193,16 +193,16 @@ public static IResourceBuilder WithBrowserLogs( Task RefreshBrowserLogsResourceAsync(ResourceNotificationService notifications) => notifications.PublishUpdateAsync(browserLogsResource, snapshot => snapshot); - static ImmutableArray CreateInitialProperties(string resourceName, BrowserLogsSettings settings) + static ImmutableArray CreateInitialProperties(string resourceName, BrowserConfiguration configuration) { List properties = [ new(CustomResourceKnownProperties.Source, resourceName), - new(BrowserPropertyName, settings.Browser), - new(UserDataModePropertyName, settings.UserDataMode.ToString()) + new(BrowserPropertyName, configuration.Browser), + new(UserDataModePropertyName, configuration.UserDataMode.ToString()) ]; - if (settings.Profile is { } profile) + if (configuration.Profile is { } profile) { properties.Add(new ResourcePropertySnapshot(ProfilePropertyName, profile)); } @@ -250,87 +250,4 @@ static void ThrowIfBlankWhenSpecified(string? value, string paramName) } } - internal static BrowserLogsSettings ResolveSettings( - IConfiguration configuration, - string resourceName, - string? browser, - string? profile, - BrowserUserDataMode? userDataMode) - { - var browserLogsSection = configuration.GetSection(BrowserLogsConfigurationSectionName); - var resourceSection = browserLogsSection.GetSection(resourceName); - - var resolvedProfile = profile - ?? resourceSection[ProfileConfigurationKey] - ?? browserLogsSection[ProfileConfigurationKey]; - var resolvedUserDataMode = userDataMode - ?? ParseUserDataMode(resourceSection[UserDataModeConfigurationKey]) - ?? ParseUserDataMode(browserLogsSection[UserDataModeConfigurationKey]) - ?? DefaultUserDataMode; - var resolvedBrowser = browser - ?? resourceSection[BrowserConfigurationKey] - ?? browserLogsSection[BrowserConfigurationKey] - ?? GetDefaultBrowser(resolvedUserDataMode); - - if (string.IsNullOrWhiteSpace(resolvedBrowser)) - { - throw new InvalidOperationException("Tracked browser configuration resolved an empty browser value."); - } - - if (resolvedProfile is not null && string.IsNullOrWhiteSpace(resolvedProfile)) - { - throw new InvalidOperationException("Tracked browser configuration resolved an empty profile value."); - } - - if (resolvedUserDataMode == BrowserUserDataMode.Isolated && resolvedProfile is not null) - { - throw new InvalidOperationException( - $"Tracked browser configuration set '{ProfileConfigurationKey}' to '{resolvedProfile}' while '{UserDataModeConfigurationKey}' is '{BrowserUserDataMode.Isolated}'. " + - $"Profiles can only be selected when '{UserDataModeConfigurationKey}' is '{BrowserUserDataMode.Shared}'."); - } - - return new BrowserLogsSettings(resolvedBrowser, resolvedProfile, resolvedUserDataMode); - } - - private static BrowserUserDataMode? ParseUserDataMode(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return null; - } - - if (Enum.TryParse(value, ignoreCase: true, out var parsed)) - { - return parsed; - } - - throw new InvalidOperationException( - $"Tracked browser configuration value '{value}' is not a valid '{UserDataModeConfigurationKey}'. Expected '{BrowserUserDataMode.Shared}' or '{BrowserUserDataMode.Isolated}'."); - } - - internal static string GetDefaultBrowser(Func resolveBrowserExecutable) => - GetDefaultBrowser(DefaultUserDataMode, resolveBrowserExecutable); - - internal static string GetDefaultBrowser(BrowserUserDataMode userDataMode, Func resolveBrowserExecutable) - { - if (userDataMode == BrowserUserDataMode.Shared && - resolveBrowserExecutable("msedge") is not null) - { - return "msedge"; - } - - if (resolveBrowserExecutable("chrome") is not null) - { - return "chrome"; - } - - if (resolveBrowserExecutable("msedge") is not null) - { - return "msedge"; - } - - return "chrome"; - } - - private static string GetDefaultBrowser(BrowserUserDataMode userDataMode) => GetDefaultBrowser(userDataMode, BrowserLogsRunningSession.TryResolveBrowserExecutable); } diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsResource.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsResource.cs index f107d55694f..35de8192020 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsResource.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsResource.cs @@ -6,37 +6,10 @@ namespace Aspire.Hosting; -/// -/// Selects the Chromium user data directory used by tracked browser sessions. -/// -public enum BrowserUserDataMode -{ - /// - /// Use the browser's real user data directory so the tracked session behaves like a persistent browser context - /// with real cookies, sessions, extensions, and profile selection. - /// - /// - /// NOTE: Aspire can adopt a shared browser only when it previously launched that browser with remote debugging - /// enabled. If a normal non-debuggable browser is already using the selected user data directory, the tracked - /// session fails with guidance instead of opening a second browser against the same profile store. Google Chrome - /// also blocks remote debugging against its default user data directory; use Microsoft Edge or - /// mode when Chrome is selected. - /// - Shared, - - /// - /// Launch the tracked browser against a temporary user data directory, like a disposable persistent browser - /// context, so the session starts from clean state and does not affect the user's normal browser profiles. - /// - Isolated, -} - -internal readonly record struct BrowserLogsSettings(string Browser, string? Profile, BrowserUserDataMode UserDataMode); - internal sealed class BrowserLogsResource( string name, IResourceWithEndpoints parentResource, - BrowserLogsSettings initialSettings, + BrowserConfiguration initialConfiguration, string? browserOverride, string? profileOverride, BrowserUserDataMode? userDataModeOverride) @@ -44,11 +17,11 @@ internal sealed class BrowserLogsResource( { public IResourceWithEndpoints ParentResource { get; } = parentResource; - public string Browser { get; } = initialSettings.Browser; + public string Browser { get; } = initialConfiguration.Browser; - public string? Profile { get; } = initialSettings.Profile; + public string? Profile { get; } = initialConfiguration.Profile; - public BrowserUserDataMode UserDataMode { get; } = initialSettings.UserDataMode; + public BrowserUserDataMode UserDataMode { get; } = initialConfiguration.UserDataMode; public string? BrowserOverride { get; } = browserOverride; @@ -56,6 +29,6 @@ internal sealed class BrowserLogsResource( public BrowserUserDataMode? UserDataModeOverride { get; } = userDataModeOverride; - public BrowserLogsSettings ResolveCurrentSettings(IConfiguration configuration) => - BrowserLogsBuilderExtensions.ResolveSettings(configuration, ParentResource.Name, BrowserOverride, ProfileOverride, UserDataModeOverride); + public BrowserConfiguration ResolveCurrentConfiguration(IConfiguration configuration) => + BrowserConfiguration.Resolve(configuration, ParentResource.Name, BrowserOverride, ProfileOverride, UserDataModeOverride); } diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs index 8760e073e75..913a7dcbcfd 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs @@ -34,7 +34,7 @@ internal interface IBrowserLogsRunningSession internal interface IBrowserLogsRunningSessionFactory { Task StartSessionAsync( - BrowserLogsSettings settings, + BrowserConfiguration configuration, string resourceName, Uri url, string sessionId, @@ -56,7 +56,7 @@ public BrowserLogsRunningSessionFactory(IFileSystemService fileSystemService, IL } public async Task StartSessionAsync( - BrowserLogsSettings settings, + BrowserConfiguration configuration, string resourceName, Uri url, string sessionId, @@ -64,7 +64,7 @@ public async Task StartSessionAsync( CancellationToken cancellationToken) { return await BrowserLogsRunningSession.StartAsync( - settings, + configuration, resourceName, sessionId, url, @@ -78,8 +78,8 @@ public async Task StartSessionAsync( public ValueTask DisposeAsync() => _browserHostRegistry.DisposeAsync(); } -// Owns one launched browser instance and its attached CDP target. The manager keeps aggregate dashboard state; -// this type keeps per-browser lifecycle, diagnostics, and recovery. +// Owns one tracked browser page session. The browser host may be shared with other sessions; this type keeps the +// per-resource page lifecycle, diagnostics, and recovery. internal sealed class BrowserLogsRunningSession : IBrowserLogsRunningSession { private readonly BrowserEventLogger _eventLogger; @@ -88,7 +88,7 @@ internal sealed class BrowserLogsRunningSession : IBrowserLogsRunningSession private readonly ILogger _logger; private readonly ILogger _resourceLogger; private readonly string _resourceName; - private readonly BrowserLogsSettings _settings; + private readonly BrowserConfiguration _configuration; private readonly string _sessionId; private readonly CancellationTokenSource _stopCts = new(); private readonly TimeProvider _timeProvider; @@ -106,7 +106,7 @@ internal sealed class BrowserLogsRunningSession : IBrowserLogsRunningSession private string? _targetSessionId; private BrowserLogsRunningSession( - BrowserLogsSettings settings, + BrowserConfiguration configuration, string resourceName, string sessionId, Uri url, @@ -121,7 +121,7 @@ private BrowserLogsRunningSession( _logger = logger; _resourceLogger = resourceLogger; _resourceName = resourceName; - _settings = settings; + _configuration = configuration; _sessionId = sessionId; _timeProvider = timeProvider; _url = url; @@ -144,7 +144,7 @@ private BrowserLogsRunningSession( private Task Completion => _completion ?? throw new InvalidOperationException("Session has not been started."); public static async Task StartAsync( - BrowserLogsSettings settings, + BrowserConfiguration configuration, string resourceName, string sessionId, Uri url, @@ -154,7 +154,7 @@ public static async Task StartAsync( TimeProvider timeProvider, CancellationToken cancellationToken) { - var session = new BrowserLogsRunningSession(settings, resourceName, sessionId, url, browserHostRegistry, resourceLogger, logger, timeProvider); + var session = new BrowserLogsRunningSession(configuration, resourceName, sessionId, url, browserHostRegistry, resourceLogger, logger, timeProvider); try { @@ -197,13 +197,13 @@ private async Task InitializeAsync(CancellationToken cancellationToken) _resourceLogger.LogInformation( "[{SessionId}] Resolving tracked browser host. User data mode: {UserDataMode}; browser: '{Browser}'; profile: '{Profile}'.", _sessionId, - _settings.UserDataMode, - _settings.Browser, - _settings.Profile ?? "(default)"); + _configuration.UserDataMode, + _configuration.Browser, + _configuration.Profile ?? "(default)"); try { - _browserHostLease = await _browserHostRegistry.AcquireAsync(_settings, cancellationToken).ConfigureAwait(false); + _browserHostLease = await _browserHostRegistry.AcquireAsync(_configuration, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs index fb83a8ae724..1f6cb04e937 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs @@ -55,10 +55,10 @@ internal BrowserLogsSessionManager( _sessionFactory = sessionFactory; } - public async Task StartSessionAsync(BrowserLogsResource resource, BrowserLogsSettings settings, string resourceName, Uri url, CancellationToken cancellationToken) + public async Task StartSessionAsync(BrowserLogsResource resource, BrowserConfiguration configuration, string resourceName, Uri url, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(resource); - ArgumentNullException.ThrowIfNull(settings.Browser); + ArgumentNullException.ThrowIfNull(configuration.Browser); ArgumentException.ThrowIfNullOrWhiteSpace(resourceName); ArgumentNullException.ThrowIfNull(url); ThrowIfDisposing(); @@ -77,17 +77,17 @@ public async Task StartSessionAsync(BrowserLogsResource resource, BrowserLogsSet var sessionId = $"session-{sessionSequence:0000}"; resourceState.LastSessionId = sessionId; resourceState.LastTargetUrl = url.ToString(); - resourceState.LastBrowser = settings.Browser; - resourceState.LastBrowserExecutable = BrowserLogsRunningSession.TryResolveBrowserExecutable(settings.Browser); + resourceState.LastBrowser = configuration.Browser; + resourceState.LastBrowserExecutable = BrowserLogsRunningSession.TryResolveBrowserExecutable(configuration.Browser); if (resourceState.ActiveSessions.Count == 0) { resourceState.LastBrowserHostOwnership = null; } resourceState.LastError = null; - resourceState.LastProfile = settings.Profile; + resourceState.LastProfile = configuration.Profile; var resourceLogger = _resourceLoggerService.GetLogger(resourceName); - resourceLogger.LogInformation("[{SessionId}] Opening tracked browser for '{Url}' using '{Browser}'.", sessionId, url, settings.Browser); + resourceLogger.LogInformation("[{SessionId}] Opening tracked browser for '{Url}' using '{Browser}'.", sessionId, url, configuration.Browser); var launchStartedAt = _timeProvider.GetUtcNow().UtcDateTime; var pendingSession = new PendingBrowserSession(sessionId, launchStartedAt, url); @@ -106,7 +106,7 @@ await PublishResourceSnapshotAsync( try { session = await _sessionFactory.StartSessionAsync( - settings, + configuration, resourceName, url, sessionId, @@ -145,9 +145,9 @@ await PublishResourceSnapshotAsync( // inspect the exact target that is producing resource logs. resourceState.ActiveSessions[session.SessionId] = new ActiveBrowserSession( session.SessionId, - settings.Browser, + configuration.Browser, session.BrowserExecutable, - settings.Profile, + configuration.Profile, session.BrowserDebugEndpoint, session.BrowserHostOwnership.ToString(), session.ProcessId, diff --git a/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs b/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs index 276d18c3079..f5a3b658209 100644 --- a/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs +++ b/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs @@ -94,7 +94,7 @@ public async ValueTask DisposeAsync() } } -// Stable identity used by the host registry to decide whether two requests can share a host. Two settings that +// 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: diff --git a/src/Aspire.Hosting/BrowserLogs/IBrowserLogsSessionManager.cs b/src/Aspire.Hosting/BrowserLogs/IBrowserLogsSessionManager.cs index 6015f98c3d3..ba5132c359c 100644 --- a/src/Aspire.Hosting/BrowserLogs/IBrowserLogsSessionManager.cs +++ b/src/Aspire.Hosting/BrowserLogs/IBrowserLogsSessionManager.cs @@ -5,5 +5,5 @@ namespace Aspire.Hosting; internal interface IBrowserLogsSessionManager { - Task StartSessionAsync(BrowserLogsResource resource, BrowserLogsSettings settings, string resourceName, Uri url, CancellationToken cancellationToken); + Task StartSessionAsync(BrowserLogsResource resource, BrowserConfiguration configuration, string resourceName, Uri url, CancellationToken cancellationToken); } diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs index ce7fa307623..74523594faf 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs @@ -103,7 +103,7 @@ public void WithBrowserLogs_UsesResourceSpecificConfigurationWhenArgumentsAreOmi [Fact] public void GetDefaultBrowser_PrefersEdgeWhenSharedModeAndEdgeIsInstalled() { - var browser = BrowserLogsBuilderExtensions.GetDefaultBrowser(BrowserUserDataMode.Shared, browser => + var browser = BrowserConfiguration.GetDefaultBrowser(BrowserUserDataMode.Shared, browser => browser switch { "chrome" => "/resolved/chrome", @@ -117,7 +117,7 @@ public void GetDefaultBrowser_PrefersEdgeWhenSharedModeAndEdgeIsInstalled() [Fact] public void GetDefaultBrowser_PrefersChromeWhenIsolatedModeAndChromeIsInstalled() { - var browser = BrowserLogsBuilderExtensions.GetDefaultBrowser(BrowserUserDataMode.Isolated, browser => + var browser = BrowserConfiguration.GetDefaultBrowser(BrowserUserDataMode.Isolated, browser => browser switch { "chrome" => "/resolved/chrome", @@ -131,7 +131,7 @@ public void GetDefaultBrowser_PrefersChromeWhenIsolatedModeAndChromeIsInstalled( [Fact] public void GetDefaultBrowser_FallsBackToEdgeWhenChromeIsMissing() { - var browser = BrowserLogsBuilderExtensions.GetDefaultBrowser(browser => + var browser = BrowserConfiguration.GetDefaultBrowser(browser => browser switch { "msedge" => "/resolved/edge", @@ -144,7 +144,7 @@ public void GetDefaultBrowser_FallsBackToEdgeWhenChromeIsMissing() [Fact] public void GetDefaultBrowser_FallsBackToChromeWhenKnownBrowsersAreMissing() { - var browser = BrowserLogsBuilderExtensions.GetDefaultBrowser(static _ => null); + var browser = BrowserConfiguration.GetDefaultBrowser(static _ => null); Assert.Equal("chrome", browser); } @@ -169,7 +169,7 @@ public void WithBrowserLogs_UsesDetectedDefaultBrowserWhenConfigurationIsMissing using var app = builder.Build(); var browserLogsResource = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); - Assert.Equal(BrowserLogsBuilderExtensions.GetDefaultBrowser(BrowserLogsRunningSession.TryResolveBrowserExecutable), browserLogsResource.Browser); + Assert.Equal(BrowserConfiguration.GetDefaultBrowser(BrowserLogsRunningSession.TryResolveBrowserExecutable), browserLogsResource.Browser); Assert.Null(browserLogsResource.Profile); } @@ -321,8 +321,8 @@ public async Task WithBrowserLogs_CommandStartsTrackedSession() var call = Assert.Single(sessionManager.Calls); Assert.Same(browserLogsResource, call.Resource); Assert.Equal(browserLogsResource.Name, call.ResourceName); - Assert.Equal("chrome", call.Settings.Browser); - Assert.Null(call.Settings.Profile); + Assert.Equal("chrome", call.Configuration.Browser); + Assert.Null(call.Configuration.Profile); Assert.Equal(new Uri("http://localhost:8080", UriKind.Absolute), call.Url); } @@ -367,9 +367,9 @@ public async Task WithBrowserLogs_CommandUsesLatestConfiguredSettingsAndRefreshe Assert.True(result.Success); - var launchSettings = Assert.Single(sessionFactory.Settings); - Assert.Equal("msedge", launchSettings.Browser); - Assert.Null(launchSettings.Profile); + var launchConfiguration = Assert.Single(sessionFactory.Configurations); + Assert.Equal("msedge", launchConfiguration.Browser); + Assert.Null(launchConfiguration.Profile); var runningEvent = await app.ResourceNotifications.WaitForResourceAsync( browserLogsResource.Name, @@ -1162,33 +1162,33 @@ private sealed class FakeBrowserLogsSessionManager : IBrowserLogsSessionManager { public List Calls { get; } = []; - public Task StartSessionAsync(BrowserLogsResource resource, BrowserLogsSettings settings, string resourceName, Uri url, CancellationToken cancellationToken) + public Task StartSessionAsync(BrowserLogsResource resource, BrowserConfiguration configuration, string resourceName, Uri url, CancellationToken cancellationToken) { - Calls.Add(new SessionStartCall(resource, settings, resourceName, url)); + Calls.Add(new SessionStartCall(resource, configuration, resourceName, url)); return Task.CompletedTask; } } - private sealed record SessionStartCall(BrowserLogsResource Resource, BrowserLogsSettings Settings, string ResourceName, Uri Url); + private sealed record SessionStartCall(BrowserLogsResource Resource, BrowserConfiguration Configuration, string ResourceName, Uri Url); private sealed class FakeBrowserLogsRunningSessionFactory : IBrowserLogsRunningSessionFactory { public List Sessions { get; } = []; - public List Settings { get; } = []; + public List Configurations { get; } = []; public Exception? NextStartException { get; set; } public BrowserHostOwnership NextBrowserHostOwnership { get; set; } = BrowserHostOwnership.Owned; public int? NextProcessId { get; set; } public bool NextProcessIdIsNull { get; set; } public Task StartSessionAsync( - BrowserLogsSettings settings, + BrowserConfiguration configuration, string resourceName, Uri url, string sessionId, ILogger resourceLogger, CancellationToken cancellationToken) { - Settings.Add(settings); + Configurations.Add(configuration); if (NextStartException is { } exception) { diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs index 03b217c3b53..ebe1d58c315 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs @@ -90,17 +90,17 @@ public async Task BrowserHostRegistry_ReusesHostUntilFinalLeaseReleasesIt() fileSystemService: null!, NullLogger.Instance, TimeProvider.System, - createUserDataDirectory: (settings, _) => BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory.FullName, settings.Profile), - createHostAsync: (settings, identity, _, _) => + createUserDataDirectory: (configuration, _) => BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory.FullName, configuration.Profile), + createHostAsync: (configuration, identity, _, _) => { - var host = new TestBrowserHost(identity, settings.Profile); + var host = new TestBrowserHost(identity, configuration.Profile); createdHosts.Add(host); return Task.FromResult(host); }); - var settings = new BrowserLogsSettings(browserExecutable, Profile: null, BrowserUserDataMode.Shared); + var configuration = new BrowserConfiguration(browserExecutable, Profile: null, BrowserUserDataMode.Shared); - var firstLease = await registry.AcquireAsync(settings, CancellationToken.None); - var secondLease = await registry.AcquireAsync(settings, CancellationToken.None); + var firstLease = await registry.AcquireAsync(configuration, CancellationToken.None); + var secondLease = await registry.AcquireAsync(configuration, CancellationToken.None); Assert.Single(createdHosts); Assert.Same(firstLease.Host, secondLease.Host); @@ -130,16 +130,16 @@ public async Task BrowserHostRegistry_LateLeaseReleaseAfterRegistryDisposeNoOps( fileSystemService: null!, NullLogger.Instance, TimeProvider.System, - createUserDataDirectory: (settings, _) => BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory.FullName, settings.Profile), - createHostAsync: (settings, identity, _, _) => + createUserDataDirectory: (configuration, _) => BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory.FullName, configuration.Profile), + createHostAsync: (configuration, identity, _, _) => { - var host = new TestBrowserHost(identity, settings.Profile); + var host = new TestBrowserHost(identity, configuration.Profile); createdHosts.Add(host); return Task.FromResult(host); }); - var settings = new BrowserLogsSettings(browserExecutable, Profile: null, BrowserUserDataMode.Shared); + var configuration = new BrowserConfiguration(browserExecutable, Profile: null, BrowserUserDataMode.Shared); - var lease = await registry.AcquireAsync(settings, CancellationToken.None); + var lease = await registry.AcquireAsync(configuration, CancellationToken.None); await registry.DisposeAsync(); await lease.DisposeAsync(); @@ -164,16 +164,16 @@ public async Task BrowserHostRegistry_RejectsDifferentProfileForSharedHost() fileSystemService: null!, NullLogger.Instance, TimeProvider.System, - createUserDataDirectory: (settings, _) => BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory.FullName, settings.Profile), - createHostAsync: (settings, identity, _, _) => Task.FromResult(new TestBrowserHost(identity, settings.Profile))); + createUserDataDirectory: (configuration, _) => BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory.FullName, configuration.Profile), + createHostAsync: (configuration, identity, _, _) => Task.FromResult(new TestBrowserHost(identity, configuration.Profile))); var firstLease = await registry.AcquireAsync( - new BrowserLogsSettings(browserExecutable, Profile: "Profile 1", BrowserUserDataMode.Shared), + new BrowserConfiguration(browserExecutable, Profile: "Profile 1", BrowserUserDataMode.Shared), CancellationToken.None); var exception = await Assert.ThrowsAsync(() => registry.AcquireAsync( - new BrowserLogsSettings(browserExecutable, Profile: "Profile 2", BrowserUserDataMode.Shared), + new BrowserConfiguration(browserExecutable, Profile: "Profile 2", BrowserUserDataMode.Shared), CancellationToken.None)); await firstLease.DisposeAsync(); @@ -574,7 +574,7 @@ public async Task StartSessionAsync_ThrowsWhenManagerIsDisposing() var resource = new BrowserLogsResource( "web-browser-logs", new TestResourceWithEndpoints("web"), - new BrowserLogsSettings("chrome", null, BrowserUserDataMode.Isolated), + new BrowserConfiguration("chrome", null, BrowserUserDataMode.Isolated), browserOverride: null, profileOverride: null, userDataModeOverride: null); @@ -583,7 +583,7 @@ public async Task StartSessionAsync_ThrowsWhenManagerIsDisposing() await Assert.ThrowsAsync(() => manager.StartSessionAsync( resource, - new BrowserLogsSettings("chrome", null, BrowserUserDataMode.Isolated), + new BrowserConfiguration("chrome", null, BrowserUserDataMode.Isolated), resource.Name, new Uri("https://localhost"), CancellationToken.None)); @@ -646,7 +646,7 @@ private sealed class ThrowIfCalledSessionFactory : IBrowserLogsRunningSessionFac public bool WasCalled { get; private set; } public Task StartSessionAsync( - BrowserLogsSettings settings, + BrowserConfiguration configuration, string resourceName, Uri url, string sessionId, From 960ef62f60b43a9b5fb64076236495f8523a75b5 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 25 Apr 2026 16:24:20 -0700 Subject: [PATCH 24/36] Clean up browser configuration state Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogs/BrowserConfiguration.cs | 19 ++++++++------- .../BrowserLogsBuilderExtensions.cs | 7 +++--- .../BrowserLogs/BrowserLogsResource.cs | 18 ++++---------- .../BrowserLogsBuilderExtensionsTests.cs | 24 +++++++++---------- .../BrowserLogsSessionManagerTests.cs | 4 +--- 5 files changed, 31 insertions(+), 41 deletions(-) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserConfiguration.cs b/src/Aspire.Hosting/BrowserLogs/BrowserConfiguration.cs index 6f5f351f409..2c7250b480a 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserConfiguration.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserConfiguration.cs @@ -43,17 +43,15 @@ internal readonly record struct BrowserConfiguration(string Browser, string? Pro /// /// The default mode matches a normal browser launch by using the browser's real user data directory. /// - public const BrowserUserDataMode DefaultUserDataMode = BrowserUserDataMode.Shared; + internal const BrowserUserDataMode DefaultUserDataMode = BrowserUserDataMode.Shared; /// /// Resolves explicit method arguments, resource-scoped configuration, global configuration, and defaults. /// - public static BrowserConfiguration Resolve( + internal static BrowserConfiguration Resolve( IConfiguration configuration, string resourceName, - string? browserOverride, - string? profileOverride, - BrowserUserDataMode? userDataModeOverride) + BrowserConfigurationOverrides overrides) { ArgumentNullException.ThrowIfNull(configuration); ArgumentException.ThrowIfNullOrWhiteSpace(resourceName); @@ -64,14 +62,14 @@ public static BrowserConfiguration Resolve( // Resolution order is explicit argument -> resource-specific config -> global browser-log config -> default. // Resolve user-data mode before browser so the browser default can prefer Edge for shared state and Chrome for // disposable isolated state. - var resolvedProfile = profileOverride + var resolvedProfile = overrides.Profile ?? resourceSection[BrowserLogsBuilderExtensions.ProfileConfigurationKey] ?? browserLogsSection[BrowserLogsBuilderExtensions.ProfileConfigurationKey]; - var resolvedUserDataMode = userDataModeOverride + var resolvedUserDataMode = overrides.UserDataMode ?? ParseUserDataMode(resourceSection[BrowserLogsBuilderExtensions.UserDataModeConfigurationKey]) ?? ParseUserDataMode(browserLogsSection[BrowserLogsBuilderExtensions.UserDataModeConfigurationKey]) ?? DefaultUserDataMode; - var resolvedBrowser = browserOverride + var resolvedBrowser = overrides.Browser ?? resourceSection[BrowserLogsBuilderExtensions.BrowserConfigurationKey] ?? browserLogsSection[BrowserLogsBuilderExtensions.BrowserConfigurationKey] ?? GetDefaultBrowser(resolvedUserDataMode); @@ -145,3 +143,8 @@ internal static string GetDefaultBrowser(BrowserUserDataMode userDataMode, Func< private static string GetDefaultBrowser(BrowserUserDataMode userDataMode) => GetDefaultBrowser(userDataMode, BrowserLogsRunningSession.TryResolveBrowserExecutable); } + +/// +/// Explicit browser configuration values supplied by the resource builder. +/// +internal readonly record struct BrowserConfigurationOverrides(string? Browser, string? Profile, BrowserUserDataMode? UserDataMode); diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs index 0445bb35625..04443ca7d41 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs @@ -113,14 +113,13 @@ public static IResourceBuilder WithBrowserLogs( builder.ApplicationBuilder.Services.TryAddSingleton(); var parentResource = builder.Resource; - var initialConfiguration = BrowserConfiguration.Resolve(builder.ApplicationBuilder.Configuration, parentResource.Name, browser, profile, userDataMode); + var configurationOverrides = new BrowserConfigurationOverrides(browser, profile, userDataMode); + var initialConfiguration = BrowserConfiguration.Resolve(builder.ApplicationBuilder.Configuration, parentResource.Name, configurationOverrides); var browserLogsResource = new BrowserLogsResource( $"{parentResource.Name}-browser-logs", parentResource, initialConfiguration, - browser, - profile, - userDataMode); + configurationOverrides); browserLogsResource.Annotations.Add(NameValidationPolicyAnnotation.None); builder.ApplicationBuilder.AddResource(browserLogsResource) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsResource.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsResource.cs index 35de8192020..57181ae7348 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsResource.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsResource.cs @@ -10,25 +10,15 @@ internal sealed class BrowserLogsResource( string name, IResourceWithEndpoints parentResource, BrowserConfiguration initialConfiguration, - string? browserOverride, - string? profileOverride, - BrowserUserDataMode? userDataModeOverride) + BrowserConfigurationOverrides configurationOverrides) : Resource(name) { public IResourceWithEndpoints ParentResource { get; } = parentResource; - public string Browser { get; } = initialConfiguration.Browser; + public BrowserConfiguration InitialConfiguration { get; } = initialConfiguration; - public string? Profile { get; } = initialConfiguration.Profile; - - public BrowserUserDataMode UserDataMode { get; } = initialConfiguration.UserDataMode; - - public string? BrowserOverride { get; } = browserOverride; - - public string? ProfileOverride { get; } = profileOverride; - - public BrowserUserDataMode? UserDataModeOverride { get; } = userDataModeOverride; + public BrowserConfigurationOverrides ConfigurationOverrides { get; } = configurationOverrides; public BrowserConfiguration ResolveCurrentConfiguration(IConfiguration configuration) => - BrowserConfiguration.Resolve(configuration, ParentResource.Name, BrowserOverride, ProfileOverride, UserDataModeOverride); + BrowserConfiguration.Resolve(configuration, ParentResource.Name, ConfigurationOverrides); } diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs index 74523594faf..e4bde3852d2 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs @@ -42,8 +42,8 @@ public void WithBrowserLogs_CreatesChildResource() var browserLogsResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("web-browser-logs", browserLogsResource.Name); Assert.Equal(web.Resource.Name, browserLogsResource.ParentResource.Name); - Assert.Equal("chrome", browserLogsResource.Browser); - Assert.Null(browserLogsResource.Profile); + Assert.Equal("chrome", browserLogsResource.InitialConfiguration.Browser); + Assert.Null(browserLogsResource.InitialConfiguration.Profile); Assert.Contains(browserLogsResource.Annotations.OfType(), static annotation => annotation == NameValidationPolicyAnnotation.None); Assert.True(browserLogsResource.TryGetAnnotationsOfType(out var relationships)); @@ -92,8 +92,8 @@ public void WithBrowserLogs_UsesResourceSpecificConfigurationWhenArgumentsAreOmi using var app = builder.Build(); var browserLogsResource = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); - Assert.Equal("chrome", browserLogsResource.Browser); - Assert.Equal("Profile 1", browserLogsResource.Profile); + Assert.Equal("chrome", browserLogsResource.InitialConfiguration.Browser); + Assert.Equal("Profile 1", browserLogsResource.InitialConfiguration.Profile); var snapshot = browserLogsResource.Annotations.OfType().Single().InitialSnapshot; Assert.Contains(snapshot.Properties, property => property.Name == BrowserLogsBuilderExtensions.BrowserPropertyName && Equals(property.Value, "chrome")); @@ -169,8 +169,8 @@ public void WithBrowserLogs_UsesDetectedDefaultBrowserWhenConfigurationIsMissing using var app = builder.Build(); var browserLogsResource = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); - Assert.Equal(BrowserConfiguration.GetDefaultBrowser(BrowserLogsRunningSession.TryResolveBrowserExecutable), browserLogsResource.Browser); - Assert.Null(browserLogsResource.Profile); + Assert.Equal(BrowserConfiguration.GetDefaultBrowser(BrowserLogsRunningSession.TryResolveBrowserExecutable), browserLogsResource.InitialConfiguration.Browser); + Assert.Null(browserLogsResource.InitialConfiguration.Profile); } [Fact] @@ -196,9 +196,9 @@ public void WithBrowserLogs_ExplicitArgumentsOverrideConfiguration() using var app = builder.Build(); var browserLogsResource = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); - Assert.Equal("msedge", browserLogsResource.Browser); - Assert.Equal("Default", browserLogsResource.Profile); - Assert.Equal(BrowserUserDataMode.Shared, browserLogsResource.UserDataMode); + Assert.Equal("msedge", browserLogsResource.InitialConfiguration.Browser); + Assert.Equal("Default", browserLogsResource.InitialConfiguration.Profile); + Assert.Equal(BrowserUserDataMode.Shared, browserLogsResource.InitialConfiguration.UserDataMode); } [Fact] @@ -220,7 +220,7 @@ public void WithBrowserLogs_DefaultsToSharedUserDataMode() using var app = builder.Build(); var browserLogsResource = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); - Assert.Equal(BrowserUserDataMode.Shared, browserLogsResource.UserDataMode); + Assert.Equal(BrowserUserDataMode.Shared, browserLogsResource.InitialConfiguration.UserDataMode); var snapshot = browserLogsResource.Annotations.OfType().Single().InitialSnapshot; Assert.Contains(snapshot.Properties, property => property.Name == BrowserLogsBuilderExtensions.UserDataModePropertyName && Equals(property.Value, nameof(BrowserUserDataMode.Shared))); } @@ -246,7 +246,7 @@ public void WithBrowserLogs_ReadsUserDataModeFromConfiguration() using var app = builder.Build(); var browserLogsResource = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); - Assert.Equal(BrowserUserDataMode.Shared, browserLogsResource.UserDataMode); + Assert.Equal(BrowserUserDataMode.Shared, browserLogsResource.InitialConfiguration.UserDataMode); } [Fact] @@ -288,7 +288,7 @@ public void WithBrowserLogs_ExplicitUserDataModeOverridesConfiguration() using var app = builder.Build(); var browserLogsResource = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); - Assert.Equal(BrowserUserDataMode.Shared, browserLogsResource.UserDataMode); + Assert.Equal(BrowserUserDataMode.Shared, browserLogsResource.InitialConfiguration.UserDataMode); } [Fact] diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs index ebe1d58c315..00a6d443be3 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs @@ -575,9 +575,7 @@ public async Task StartSessionAsync_ThrowsWhenManagerIsDisposing() "web-browser-logs", new TestResourceWithEndpoints("web"), new BrowserConfiguration("chrome", null, BrowserUserDataMode.Isolated), - browserOverride: null, - profileOverride: null, - userDataModeOverride: null); + new BrowserConfigurationOverrides()); await manager.DisposeAsync(); From d39fc3abca31f6ab4649964edcf1842ba634f760 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 26 Apr 2026 07:17:38 -0700 Subject: [PATCH 25/36] Move Chromium browser resolution helpers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogs/BrowserConfiguration.cs | 2 +- .../BrowserLogs/BrowserHostRegistry.cs | 8 +- .../BrowserLogs/BrowserLogsRunningSession.cs | 288 --------------- .../BrowserLogs/BrowserLogsSessionManager.cs | 2 +- .../BrowserLogs/ChromiumBrowserResolver.cs | 335 ++++++++++++++++++ .../BrowserLogsBuilderExtensionsTests.cs | 2 +- .../BrowserLogsSessionManagerTests.cs | 32 +- 7 files changed, 358 insertions(+), 311 deletions(-) create mode 100644 src/Aspire.Hosting/BrowserLogs/ChromiumBrowserResolver.cs diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserConfiguration.cs b/src/Aspire.Hosting/BrowserLogs/BrowserConfiguration.cs index 2c7250b480a..3ac3170b764 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserConfiguration.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserConfiguration.cs @@ -141,7 +141,7 @@ internal static string GetDefaultBrowser(BrowserUserDataMode userDataMode, Func< } private static string GetDefaultBrowser(BrowserUserDataMode userDataMode) => - GetDefaultBrowser(userDataMode, BrowserLogsRunningSession.TryResolveBrowserExecutable); + GetDefaultBrowser(userDataMode, ChromiumBrowserResolver.TryResolveExecutable); } /// diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs b/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs index df80c1783e7..5da8d16106f 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs @@ -50,7 +50,7 @@ public async Task AcquireAsync(BrowserConfiguration configurat { ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); - var browserExecutable = BrowserLogsRunningSession.TryResolveBrowserExecutable(configuration.Browser) + var browserExecutable = ChromiumBrowserResolver.TryResolveExecutable(configuration.Browser) ?? throw new InvalidOperationException($"Unable to locate browser '{configuration.Browser}'. Specify an installed Chromium-based browser or an explicit executable path."); var userDataDirectory = _createUserDataDirectory(configuration, browserExecutable); var identity = new BrowserHostIdentity(browserExecutable, userDataDirectory.Path); @@ -330,7 +330,7 @@ private BrowserLogsUserDataDirectory CreateUserDataDirectory(BrowserConfiguratio // extensions, and profiles are available. Chromium puts singleton locks, DevToolsActivePort, and our Aspire // endpoint sidecar at that root; named profiles are subdirectories selected by command-line argument. The later // endpoint/probe logic decides whether that root is reusable, adoptable, or locked. - var userDataDirectory = BrowserLogsRunningSession.TryResolveBrowserUserDataDirectory(configuration.Browser, browserExecutable) + var userDataDirectory = ChromiumBrowserResolver.TryResolveUserDataDirectory(configuration.Browser, browserExecutable) ?? throw new InvalidOperationException($"Unable to resolve the user data directory for browser '{configuration.Browser}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode."); if (!Directory.Exists(userDataDirectory)) @@ -338,7 +338,7 @@ private BrowserLogsUserDataDirectory CreateUserDataDirectory(BrowserConfiguratio throw new InvalidOperationException($"Browser user data directory '{userDataDirectory}' was not found for browser '{configuration.Browser}'."); } - if (BrowserLogsRunningSession.IsGoogleChromeDefaultUserDataDirectory(configuration.Browser, browserExecutable, userDataDirectory)) + if (ChromiumBrowserResolver.IsGoogleChromeDefaultUserDataDirectory(configuration.Browser, browserExecutable, userDataDirectory)) { throw new InvalidOperationException( $"Google Chrome blocks remote debugging against its default user data directory '{userDataDirectory}'. " + @@ -346,7 +346,7 @@ private BrowserLogsUserDataDirectory CreateUserDataDirectory(BrowserConfiguratio } var profileDirectoryName = configuration.Profile is { } profile - ? BrowserLogsRunningSession.ResolveBrowserProfileDirectory(userDataDirectory, profile) + ? ChromiumBrowserResolver.ResolveProfileDirectory(userDataDirectory, profile) : null; return BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory, profileDirectoryName); } diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs index 913a7dcbcfd..988478f4db3 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs @@ -5,7 +5,6 @@ using System.Globalization; using System.Text; -using System.Text.Json; using Microsoft.Extensions.Logging; namespace Aspire.Hosting; @@ -327,254 +326,6 @@ private async Task DisposeBrowserHostLeaseAsync() } } - internal static bool IsGoogleChromeDefaultUserDataDirectory(string browser, string browserExecutable, string userDataDirectory) - { - if (GetBrowserKind(browser, browserExecutable) != BrowserKind.Chrome || - MatchesBrowser(browser, browserExecutable, "chromium", "chromium-browser") || - TryResolveBrowserUserDataDirectory(browser, browserExecutable) is not { } defaultUserDataDirectory) - { - return false; - } - - var comparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; - return comparer.Equals(NormalizePath(userDataDirectory), NormalizePath(defaultUserDataDirectory)); - - static string NormalizePath(string path) => Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); - } - - internal static string? TryResolveBrowserExecutable(string browser) - { - if (Path.IsPathRooted(browser) && File.Exists(browser)) - { - return browser; - } - - foreach (var candidate in GetBrowserCandidates(browser)) - { - if (Path.IsPathRooted(candidate)) - { - if (File.Exists(candidate)) - { - return candidate; - } - } - else if (PathLookupHelper.FindFullPathFromPath(candidate) is { } resolvedPath) - { - return resolvedPath; - } - } - - return PathLookupHelper.FindFullPathFromPath(browser); - } - - internal static string? TryResolveBrowserUserDataDirectory(string browser, string browserExecutable) - { - var browserKind = GetBrowserKind(browser, browserExecutable); - if (browserKind == BrowserKind.Unknown) - { - return null; - } - - if (OperatingSystem.IsMacOS()) - { - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - return browserKind switch - { - BrowserKind.Edge => Path.Combine(home, "Library", "Application Support", "Microsoft Edge"), - BrowserKind.Chrome => Path.Combine(home, "Library", "Application Support", "Google", "Chrome"), - _ => null - }; - } - - if (OperatingSystem.IsWindows()) - { - var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - return browserKind switch - { - BrowserKind.Edge => Path.Combine(localAppData, "Microsoft", "Edge", "User Data"), - BrowserKind.Chrome => Path.Combine(localAppData, "Google", "Chrome", "User Data"), - _ => null - }; - } - - var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - return browserKind switch - { - BrowserKind.Edge => Path.Combine(homeDirectory, ".config", "microsoft-edge"), - BrowserKind.Chrome => Path.Combine( - homeDirectory, - ".config", - MatchesBrowser(browser, browserExecutable, "chromium", "chromium-browser") ? "chromium" : "google-chrome"), - _ => null - }; - } - - internal static string ResolveBrowserProfileDirectory(string userDataDirectory, string profile) - { - ArgumentException.ThrowIfNullOrWhiteSpace(userDataDirectory); - ArgumentException.ThrowIfNullOrWhiteSpace(profile); - - if (!Directory.Exists(userDataDirectory)) - { - throw new InvalidOperationException($"Browser user data directory '{userDataDirectory}' was not found."); - } - - if (TryResolveBrowserProfileDirectoryFromDirectoryEntries(userDataDirectory, profile) is { } directMatch) - { - return directMatch; - } - - var localStatePath = Path.Combine(userDataDirectory, "Local State"); - if (File.Exists(localStatePath)) - { - try - { - using var localStateStream = File.OpenRead(localStatePath); - using var localStateDocument = JsonDocument.Parse(localStateStream); - if (TryResolveBrowserProfileDirectory(localStateDocument.RootElement, userDataDirectory, profile) is { } profileDirectory) - { - return profileDirectory; - } - } - catch (IOException ex) - { - throw new InvalidOperationException( - $"Unable to read Chromium profile metadata from '{localStatePath}' while resolving browser profile '{profile}'.", - ex); - } - catch (UnauthorizedAccessException ex) - { - throw new InvalidOperationException( - $"Unable to read Chromium profile metadata from '{localStatePath}' while resolving browser profile '{profile}'.", - ex); - } - catch (JsonException ex) - { - throw new InvalidOperationException( - $"Chromium profile metadata in '{localStatePath}' is invalid while resolving browser profile '{profile}'.", - ex); - } - } - - throw new InvalidOperationException( - $"Browser profile '{profile}' was not found under '{userDataDirectory}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata."); - } - - internal static string? TryResolveBrowserProfileDirectory(JsonElement localStateRoot, string userDataDirectory, string profile) - { - ArgumentException.ThrowIfNullOrWhiteSpace(userDataDirectory); - ArgumentException.ThrowIfNullOrWhiteSpace(profile); - - if (!localStateRoot.TryGetProperty("profile", out var profileElement) || - !profileElement.TryGetProperty("info_cache", out var infoCacheElement) || - infoCacheElement.ValueKind != JsonValueKind.Object) - { - return null; - } - - string? match = null; - - foreach (var profileEntry in infoCacheElement.EnumerateObject()) - { - if (!Directory.Exists(Path.Combine(userDataDirectory, profileEntry.Name)) || - !MatchesBrowserProfile(profileEntry, profile)) - { - continue; - } - - if (match is not null && !string.Equals(match, profileEntry.Name, StringComparison.Ordinal)) - { - throw new InvalidOperationException( - $"Browser profile '{profile}' matched multiple Chromium profiles under '{userDataDirectory}'. Specify the profile directory name instead."); - } - - match = profileEntry.Name; - } - - return match; - } - - private static IEnumerable GetBrowserCandidates(string browser) - { - if (OperatingSystem.IsMacOS()) - { - return browser.ToLowerInvariant() switch - { - "msedge" or "edge" => - [ - "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", - "msedge" - ], - "chrome" or "google-chrome" => - [ - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "google-chrome", - "chrome" - ], - _ => [browser] - }; - } - - if (OperatingSystem.IsWindows()) - { - var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); - var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); - - return browser.ToLowerInvariant() switch - { - "msedge" or "edge" => - [ - Path.Combine(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"), - Path.Combine(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"), - "msedge.exe" - ], - "chrome" or "google-chrome" => - [ - Path.Combine(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"), - Path.Combine(programFiles, "Google", "Chrome", "Application", "chrome.exe"), - "chrome.exe" - ], - _ => [browser] - }; - } - - return browser.ToLowerInvariant() switch - { - "msedge" or "edge" => ["microsoft-edge", "microsoft-edge-stable", "msedge"], - "chrome" or "google-chrome" => ["google-chrome", "google-chrome-stable", "chrome", "chromium-browser", "chromium"], - _ => [browser] - }; - } - - private static string? TryResolveBrowserProfileDirectoryFromDirectoryEntries(string userDataDirectory, string profile) - { - foreach (var directoryPath in Directory.EnumerateDirectories(userDataDirectory)) - { - var directoryName = Path.GetFileName(directoryPath); - if (string.Equals(directoryName, profile, StringComparison.OrdinalIgnoreCase)) - { - return directoryName; - } - } - - return null; - } - - private static BrowserKind GetBrowserKind(string browser, string browserExecutable) - { - if (MatchesBrowser(browser, browserExecutable, "msedge", "edge", "microsoft-edge")) - { - return BrowserKind.Edge; - } - - if (MatchesBrowser(browser, browserExecutable, "chrome", "google-chrome", "chromium", "chromium-browser")) - { - return BrowserKind.Chrome; - } - - return BrowserKind.Unknown; - } - internal static string? TrySelectTrackedTargetId(IReadOnlyList? targetInfos) { if (targetInfos is null) @@ -602,38 +353,6 @@ private static BrowserKind GetBrowserKind(string browser, string browserExecutab ?.TargetId; } - private static bool MatchesBrowser(string browser, string browserExecutable, params string[] names) - { - var browserLower = browser.ToLowerInvariant(); - var executableLower = browserExecutable.ToLowerInvariant(); - - foreach (var name in names) - { - if (browserLower == name || - Path.GetFileNameWithoutExtension(browserLower) == name || - executableLower.Contains(name, StringComparison.Ordinal)) - { - return true; - } - } - - return false; - } - - private static bool MatchesBrowserProfile(JsonProperty profileEntry, string profile) - { - return string.Equals(profileEntry.Name, profile, StringComparison.OrdinalIgnoreCase) || - MatchesBrowserProfileProperty(profileEntry.Value, "name", profile) || - MatchesBrowserProfileProperty(profileEntry.Value, "shortcut_name", profile); - } - - private static bool MatchesBrowserProfileProperty(JsonElement profileElement, string propertyName, string profile) - { - return profileElement.TryGetProperty(propertyName, out var propertyElement) && - propertyElement.ValueKind == JsonValueKind.String && - string.Equals(propertyElement.GetString(), profile, StringComparison.OrdinalIgnoreCase); - } - internal static string BuildCommandLine(IReadOnlyList arguments) { var builder = new StringBuilder(); @@ -708,13 +427,6 @@ private static void AppendCommandLineArgument(StringBuilder builder, string argu private sealed record BrowserSessionResult(int? ExitCode, Exception? Error); - private enum BrowserKind - { - Unknown, - Edge, - Chrome - } - } internal static class BrowserLogsDebugEndpointParser diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs index 1f6cb04e937..b1b7fc525a2 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs @@ -78,7 +78,7 @@ public async Task StartSessionAsync(BrowserLogsResource resource, BrowserConfigu resourceState.LastSessionId = sessionId; resourceState.LastTargetUrl = url.ToString(); resourceState.LastBrowser = configuration.Browser; - resourceState.LastBrowserExecutable = BrowserLogsRunningSession.TryResolveBrowserExecutable(configuration.Browser); + resourceState.LastBrowserExecutable = ChromiumBrowserResolver.TryResolveExecutable(configuration.Browser); if (resourceState.ActiveSessions.Count == 0) { resourceState.LastBrowserHostOwnership = null; diff --git a/src/Aspire.Hosting/BrowserLogs/ChromiumBrowserResolver.cs b/src/Aspire.Hosting/BrowserLogs/ChromiumBrowserResolver.cs new file mode 100644 index 00000000000..dd16e0f361c --- /dev/null +++ b/src/Aspire.Hosting/BrowserLogs/ChromiumBrowserResolver.cs @@ -0,0 +1,335 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; + +namespace Aspire.Hosting; + +/// +/// Resolves Chromium-based browser executables, user data directories, and profile directories. +/// +/// +/// This type translates the resolved browser-log configuration into local machine paths. Keep OS/browser probing here +/// so stays focused on configuration precedence and effective option values. +/// +internal static class ChromiumBrowserResolver +{ + /// + /// Returns whether the requested path is branded Google Chrome's default user data directory. + /// + internal static bool IsGoogleChromeDefaultUserDataDirectory(string browser, string browserExecutable, string userDataDirectory) + { + // Google Chrome rejects remote debugging against its real default profile root. Chromium builds can still use the + // same resolver path, so exclude Chromium aliases before comparing with Chrome's default user data directory. + if (GetBrowserKind(browser, browserExecutable) != BrowserKind.Chrome || + MatchesBrowser(browser, browserExecutable, "chromium", "chromium-browser") || + TryResolveUserDataDirectory(browser, browserExecutable) is not { } defaultUserDataDirectory) + { + return false; + } + + var comparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + return comparer.Equals(NormalizePath(userDataDirectory), NormalizePath(defaultUserDataDirectory)); + + static string NormalizePath(string path) => Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); + } + + /// + /// Resolves a logical browser name or explicit executable path to a runnable browser executable. + /// + internal static string? TryResolveExecutable(string browser) + { + if (Path.IsPathRooted(browser) && File.Exists(browser)) + { + return browser; + } + + // Probe well-known install paths before PATH lookup so logical names still work for browser app bundles or + // standard Windows installs that are not exposed on PATH. + foreach (var candidate in GetBrowserCandidates(browser)) + { + if (Path.IsPathRooted(candidate)) + { + if (File.Exists(candidate)) + { + return candidate; + } + } + else if (PathLookupHelper.FindFullPathFromPath(candidate) is { } resolvedPath) + { + return resolvedPath; + } + } + + return PathLookupHelper.FindFullPathFromPath(browser); + } + + /// + /// Resolves the browser's persistent user data root for shared browser-log sessions. + /// + internal static string? TryResolveUserDataDirectory(string browser, string browserExecutable) + { + var browserKind = GetBrowserKind(browser, browserExecutable); + if (browserKind == BrowserKind.Unknown) + { + return null; + } + + if (OperatingSystem.IsMacOS()) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return browserKind switch + { + BrowserKind.Edge => Path.Combine(home, "Library", "Application Support", "Microsoft Edge"), + BrowserKind.Chrome => Path.Combine(home, "Library", "Application Support", "Google", "Chrome"), + _ => null + }; + } + + if (OperatingSystem.IsWindows()) + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return browserKind switch + { + BrowserKind.Edge => Path.Combine(localAppData, "Microsoft", "Edge", "User Data"), + BrowserKind.Chrome => Path.Combine(localAppData, "Google", "Chrome", "User Data"), + _ => null + }; + } + + var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return browserKind switch + { + BrowserKind.Edge => Path.Combine(homeDirectory, ".config", "microsoft-edge"), + // Linux Chromium packages use a different root than Google Chrome even when the requested logical browser is + // "chrome" but the resolved executable is chromium/chromium-browser. + BrowserKind.Chrome => Path.Combine( + homeDirectory, + ".config", + MatchesBrowser(browser, browserExecutable, "chromium", "chromium-browser") ? "chromium" : "google-chrome"), + _ => null + }; + } + + /// + /// Resolves a Chromium profile directory name from a directory name, profile display name, or shortcut name. + /// + internal static string ResolveProfileDirectory(string userDataDirectory, string profile) + { + ArgumentException.ThrowIfNullOrWhiteSpace(userDataDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(profile); + + if (!Directory.Exists(userDataDirectory)) + { + throw new InvalidOperationException($"Browser user data directory '{userDataDirectory}' was not found."); + } + + if (TryResolveProfileDirectoryFromDirectoryEntries(userDataDirectory, profile) is { } directMatch) + { + return directMatch; + } + + // Chromium stores display names in the user-data-root "Local State" file under profile.info_cache. Directory + // names like "Default" or "Profile 1" are stable command-line values, while display names are user-facing. + var localStatePath = Path.Combine(userDataDirectory, "Local State"); + if (File.Exists(localStatePath)) + { + try + { + using var localStateStream = File.OpenRead(localStatePath); + using var localStateDocument = JsonDocument.Parse(localStateStream); + if (TryResolveProfileDirectory(localStateDocument.RootElement, userDataDirectory, profile) is { } profileDirectory) + { + return profileDirectory; + } + } + catch (IOException ex) + { + throw new InvalidOperationException( + $"Unable to read Chromium profile metadata from '{localStatePath}' while resolving browser profile '{profile}'.", + ex); + } + catch (UnauthorizedAccessException ex) + { + throw new InvalidOperationException( + $"Unable to read Chromium profile metadata from '{localStatePath}' while resolving browser profile '{profile}'.", + ex); + } + catch (JsonException ex) + { + throw new InvalidOperationException( + $"Chromium profile metadata in '{localStatePath}' is invalid while resolving browser profile '{profile}'.", + ex); + } + } + + throw new InvalidOperationException( + $"Browser profile '{profile}' was not found under '{userDataDirectory}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata."); + } + + /// + /// Resolves a profile directory from Chromium's parsed Local State metadata. + /// + internal static string? TryResolveProfileDirectory(JsonElement localStateRoot, string userDataDirectory, string profile) + { + ArgumentException.ThrowIfNullOrWhiteSpace(userDataDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(profile); + + if (!localStateRoot.TryGetProperty("profile", out var profileElement) || + !profileElement.TryGetProperty("info_cache", out var infoCacheElement) || + infoCacheElement.ValueKind != JsonValueKind.Object) + { + return null; + } + + string? match = null; + + foreach (var profileEntry in infoCacheElement.EnumerateObject()) + { + // Ignore stale metadata entries whose profile directories no longer exist. + if (!Directory.Exists(Path.Combine(userDataDirectory, profileEntry.Name)) || + !MatchesBrowserProfile(profileEntry, profile)) + { + continue; + } + + // Profile display names are not unique. Force the caller to use the stable directory name when ambiguity + // would otherwise select an arbitrary profile. + if (match is not null && !string.Equals(match, profileEntry.Name, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Browser profile '{profile}' matched multiple Chromium profiles under '{userDataDirectory}'. Specify the profile directory name instead."); + } + + match = profileEntry.Name; + } + + return match; + } + + /// + /// Gets platform-specific candidate executables for logical browser names. + /// + private static IEnumerable GetBrowserCandidates(string browser) + { + if (OperatingSystem.IsMacOS()) + { + return browser.ToLowerInvariant() switch + { + "msedge" or "edge" => + [ + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "msedge" + ], + "chrome" or "google-chrome" => + [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "google-chrome", + "chrome" + ], + _ => [browser] + }; + } + + if (OperatingSystem.IsWindows()) + { + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + + return browser.ToLowerInvariant() switch + { + "msedge" or "edge" => + [ + Path.Combine(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"), + Path.Combine(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"), + "msedge.exe" + ], + "chrome" or "google-chrome" => + [ + Path.Combine(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"), + Path.Combine(programFiles, "Google", "Chrome", "Application", "chrome.exe"), + "chrome.exe" + ], + _ => [browser] + }; + } + + return browser.ToLowerInvariant() switch + { + "msedge" or "edge" => ["microsoft-edge", "microsoft-edge-stable", "msedge"], + "chrome" or "google-chrome" => ["google-chrome", "google-chrome-stable", "chrome", "chromium-browser", "chromium"], + _ => [browser] + }; + } + + private static string? TryResolveProfileDirectoryFromDirectoryEntries(string userDataDirectory, string profile) + { + foreach (var directoryPath in Directory.EnumerateDirectories(userDataDirectory)) + { + var directoryName = Path.GetFileName(directoryPath); + if (string.Equals(directoryName, profile, StringComparison.OrdinalIgnoreCase)) + { + return directoryName; + } + } + + return null; + } + + /// + /// Classifies a browser from both the requested value and the resolved executable path. + /// + private static BrowserKind GetBrowserKind(string browser, string browserExecutable) + { + if (MatchesBrowser(browser, browserExecutable, "msedge", "edge", "microsoft-edge")) + { + return BrowserKind.Edge; + } + + if (MatchesBrowser(browser, browserExecutable, "chrome", "google-chrome", "chromium", "chromium-browser")) + { + return BrowserKind.Chrome; + } + + return BrowserKind.Unknown; + } + + private static bool MatchesBrowser(string browser, string browserExecutable, params string[] names) + { + var browserLower = browser.ToLowerInvariant(); + var executableLower = browserExecutable.ToLowerInvariant(); + + foreach (var name in names) + { + if (browserLower == name || + Path.GetFileNameWithoutExtension(browserLower) == name || + executableLower.Contains(name, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private static bool MatchesBrowserProfile(JsonProperty profileEntry, string profile) + { + return string.Equals(profileEntry.Name, profile, StringComparison.OrdinalIgnoreCase) || + MatchesBrowserProfileProperty(profileEntry.Value, "name", profile) || + MatchesBrowserProfileProperty(profileEntry.Value, "shortcut_name", profile); + } + + private static bool MatchesBrowserProfileProperty(JsonElement profileElement, string propertyName, string profile) + { + return profileElement.TryGetProperty(propertyName, out var propertyElement) && + propertyElement.ValueKind == JsonValueKind.String && + string.Equals(propertyElement.GetString(), profile, StringComparison.OrdinalIgnoreCase); + } + + private enum BrowserKind + { + Unknown, + Edge, + Chrome + } +} diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs index e4bde3852d2..715a0e4c5e3 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsBuilderExtensionsTests.cs @@ -169,7 +169,7 @@ public void WithBrowserLogs_UsesDetectedDefaultBrowserWhenConfigurationIsMissing using var app = builder.Build(); var browserLogsResource = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); - Assert.Equal(BrowserConfiguration.GetDefaultBrowser(BrowserLogsRunningSession.TryResolveBrowserExecutable), browserLogsResource.InitialConfiguration.Browser); + Assert.Equal(BrowserConfiguration.GetDefaultBrowser(ChromiumBrowserResolver.TryResolveExecutable), browserLogsResource.InitialConfiguration.Browser); Assert.Null(browserLogsResource.InitialConfiguration.Profile); } diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs index 00a6d443be3..894b89286b6 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs @@ -356,7 +356,7 @@ public void BrowserEndpointDiscovery_IgnoresPosixSingletonLockWithoutPidTarget() } [Fact] - public void TryResolveBrowserUserDataDirectory_ReturnsExpectedPathForKnownBrowser() + public void TryResolveUserDataDirectory_ReturnsExpectedPathForKnownBrowser() { var expectedPath = OperatingSystem.IsWindows() ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Google", "Chrome", "User Data") @@ -370,7 +370,7 @@ public void TryResolveBrowserUserDataDirectory_ReturnsExpectedPathForKnownBrowse ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" : "google-chrome"; - var userDataDirectory = BrowserLogsRunningSession.TryResolveBrowserUserDataDirectory("chrome", browserExecutable); + var userDataDirectory = ChromiumBrowserResolver.TryResolveUserDataDirectory("chrome", browserExecutable); Assert.Equal(expectedPath, userDataDirectory); } @@ -383,10 +383,10 @@ public void IsGoogleChromeDefaultUserDataDirectory_ReturnsTrueForGoogleChromeDef : OperatingSystem.IsMacOS() ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" : "/usr/bin/google-chrome"; - var userDataDirectory = BrowserLogsRunningSession.TryResolveBrowserUserDataDirectory("chrome", browserExecutable); + var userDataDirectory = ChromiumBrowserResolver.TryResolveUserDataDirectory("chrome", browserExecutable); Assert.NotNull(userDataDirectory); - Assert.True(BrowserLogsRunningSession.IsGoogleChromeDefaultUserDataDirectory("chrome", browserExecutable, userDataDirectory)); + Assert.True(ChromiumBrowserResolver.IsGoogleChromeDefaultUserDataDirectory("chrome", browserExecutable, userDataDirectory)); } [Fact] @@ -397,22 +397,22 @@ public void IsGoogleChromeDefaultUserDataDirectory_ReturnsFalseForChromium() : OperatingSystem.IsMacOS() ? "/Applications/Chromium.app/Contents/MacOS/Chromium" : "/usr/bin/chromium"; - var userDataDirectory = BrowserLogsRunningSession.TryResolveBrowserUserDataDirectory("chromium", browserExecutable); + var userDataDirectory = ChromiumBrowserResolver.TryResolveUserDataDirectory("chromium", browserExecutable); Assert.NotNull(userDataDirectory); - Assert.False(BrowserLogsRunningSession.IsGoogleChromeDefaultUserDataDirectory("chromium", browserExecutable, userDataDirectory)); + Assert.False(ChromiumBrowserResolver.IsGoogleChromeDefaultUserDataDirectory("chromium", browserExecutable, userDataDirectory)); } [Fact] - public void TryResolveBrowserUserDataDirectory_ReturnsNullForUnknownBrowser() + public void TryResolveUserDataDirectory_ReturnsNullForUnknownBrowser() { - var userDataDirectory = BrowserLogsRunningSession.TryResolveBrowserUserDataDirectory("custom-browser", "/opt/custom-browser"); + var userDataDirectory = ChromiumBrowserResolver.TryResolveUserDataDirectory("custom-browser", "/opt/custom-browser"); Assert.Null(userDataDirectory); } [Fact] - public void TryResolveBrowserUserDataDirectory_UsesChromiumPathOnLinux() + public void TryResolveUserDataDirectory_UsesChromiumPathOnLinux() { if (!OperatingSystem.IsLinux()) { @@ -421,26 +421,26 @@ public void TryResolveBrowserUserDataDirectory_UsesChromiumPathOnLinux() var expectedPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "chromium"); - var userDataDirectory = BrowserLogsRunningSession.TryResolveBrowserUserDataDirectory("chrome", "/usr/bin/chromium"); + var userDataDirectory = ChromiumBrowserResolver.TryResolveUserDataDirectory("chrome", "/usr/bin/chromium"); Assert.Equal(expectedPath, userDataDirectory); } [Fact] - public void ResolveBrowserProfileDirectory_MatchesDirectoryNameCaseInsensitively() + public void ResolveProfileDirectory_MatchesDirectoryNameCaseInsensitively() { WithTempUserDataDirectory(userDataDirectory => { Directory.CreateDirectory(Path.Combine(userDataDirectory, "Profile 1")); - var profileDirectory = BrowserLogsRunningSession.ResolveBrowserProfileDirectory(userDataDirectory, "profile 1"); + var profileDirectory = ChromiumBrowserResolver.ResolveProfileDirectory(userDataDirectory, "profile 1"); Assert.Equal("Profile 1", profileDirectory); }); } [Fact] - public void ResolveBrowserProfileDirectory_MatchesProfileDisplayNameFromLocalState() + public void ResolveProfileDirectory_MatchesProfileDisplayNameFromLocalState() { WithTempUserDataDirectory(userDataDirectory => { @@ -463,14 +463,14 @@ public void ResolveBrowserProfileDirectory_MatchesProfileDisplayNameFromLocalSta } """); - var profileDirectory = BrowserLogsRunningSession.ResolveBrowserProfileDirectory(userDataDirectory, "Profile 2"); + var profileDirectory = ChromiumBrowserResolver.ResolveProfileDirectory(userDataDirectory, "Profile 2"); Assert.Equal("Profile 1", profileDirectory); }); } [Fact] - public void ResolveBrowserProfileDirectory_ThrowsWhenDisplayNameIsAmbiguous() + public void ResolveProfileDirectory_ThrowsWhenDisplayNameIsAmbiguous() { WithTempUserDataDirectory(userDataDirectory => { @@ -493,7 +493,7 @@ public void ResolveBrowserProfileDirectory_ThrowsWhenDisplayNameIsAmbiguous() } """); - var exception = Assert.Throws(() => BrowserLogsRunningSession.ResolveBrowserProfileDirectory(userDataDirectory, "Shared profile")); + var exception = Assert.Throws(() => ChromiumBrowserResolver.ResolveProfileDirectory(userDataDirectory, "Shared profile")); Assert.Contains("matched multiple Chromium profiles", exception.Message); }); From 5a502019d57d683188e8ed58d071ea6cb0281800 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 26 Apr 2026 07:21:52 -0700 Subject: [PATCH 26/36] Document browser logs JSON formats Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogs/BrowserEndpointDiscovery.cs | 16 ++++++++++++++++ .../BrowserLogs/ChromiumBrowserResolver.cs | 10 ++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs index 31d7f1a8aa6..e7ac16613d4 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs @@ -52,6 +52,16 @@ public static string GetEndpointMetadataFilePath(string userDataDirectory) => // 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/", + // "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, BrowserEndpointJsonContext.Default.BrowserDebugEndpointMetadata, cancellationToken).ConfigureAwait(false); } @@ -234,6 +244,12 @@ private static async Task ProbeBrowserEndpointAsync(Uri browserEndpoint, C 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/" + // } using var stream = await response.Content.ReadAsStreamAsync(probeCts.Token).ConfigureAwait(false); var version = await JsonSerializer.DeserializeAsync(stream, BrowserEndpointJsonContext.Default.BrowserJsonVersionResponse, probeCts.Token).ConfigureAwait(false); return Uri.TryCreate(version?.WebSocketDebuggerUrl, UriKind.Absolute, out _); diff --git a/src/Aspire.Hosting/BrowserLogs/ChromiumBrowserResolver.cs b/src/Aspire.Hosting/BrowserLogs/ChromiumBrowserResolver.cs index dd16e0f361c..750c6a017ae 100644 --- a/src/Aspire.Hosting/BrowserLogs/ChromiumBrowserResolver.cs +++ b/src/Aspire.Hosting/BrowserLogs/ChromiumBrowserResolver.cs @@ -131,6 +131,16 @@ internal static string ResolveProfileDirectory(string userDataDirectory, string // Chromium stores display names in the user-data-root "Local State" file under profile.info_cache. Directory // names like "Default" or "Profile 1" are stable command-line values, while display names are user-facing. + // + // Relevant Local State shape: + // { + // "profile": { + // "info_cache": { + // "Default": { "name": "Person 1", "shortcut_name": "Person 1" }, + // "Profile 1": { "name": "Work", "shortcut_name": "Work" } + // } + // } + // } var localStatePath = Path.Combine(userDataDirectory, "Local State"); if (File.Exists(localStatePath)) { From 57cea6f4073090c00728a3ab0120912a5358faf1 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 26 Apr 2026 07:28:03 -0700 Subject: [PATCH 27/36] Localize browser logs failure messages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogs/BrowserConfiguration.cs | 24 +- .../BrowserLogs/BrowserEndpointDiscovery.cs | 10 +- src/Aspire.Hosting/BrowserLogs/BrowserHost.cs | 6 +- .../BrowserLogs/BrowserHostRegistry.cs | 32 ++- .../BrowserLogsBuilderExtensions.cs | 7 +- .../BrowserLogs/ChromiumBrowserResolver.cs | 14 +- .../Resources/MessageStrings.Designer.cs | 220 ++++++++++++++++-- .../Resources/MessageStrings.resx | 79 ++++++- .../Resources/xlf/MessageStrings.cs.xlf | 100 ++++++++ .../Resources/xlf/MessageStrings.de.xlf | 100 ++++++++ .../Resources/xlf/MessageStrings.es.xlf | 100 ++++++++ .../Resources/xlf/MessageStrings.fr.xlf | 100 ++++++++ .../Resources/xlf/MessageStrings.it.xlf | 100 ++++++++ .../Resources/xlf/MessageStrings.ja.xlf | 100 ++++++++ .../Resources/xlf/MessageStrings.ko.xlf | 100 ++++++++ .../Resources/xlf/MessageStrings.pl.xlf | 100 ++++++++ .../Resources/xlf/MessageStrings.pt-BR.xlf | 100 ++++++++ .../Resources/xlf/MessageStrings.ru.xlf | 100 ++++++++ .../Resources/xlf/MessageStrings.tr.xlf | 100 ++++++++ .../Resources/xlf/MessageStrings.zh-Hans.xlf | 100 ++++++++ .../Resources/xlf/MessageStrings.zh-Hant.xlf | 100 ++++++++ 21 files changed, 1644 insertions(+), 48 deletions(-) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserConfiguration.cs b/src/Aspire.Hosting/BrowserLogs/BrowserConfiguration.cs index 3ac3170b764..cb1e3f9f161 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserConfiguration.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserConfiguration.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Globalization; +using Aspire.Hosting.Resources; using Microsoft.Extensions.Configuration; namespace Aspire.Hosting; @@ -76,19 +78,25 @@ internal static BrowserConfiguration Resolve( if (string.IsNullOrWhiteSpace(resolvedBrowser)) { - throw new InvalidOperationException("Tracked browser configuration resolved an empty browser value."); + throw new InvalidOperationException(MessageStrings.BrowserLogsEmptyBrowserConfiguration); } if (resolvedProfile is not null && string.IsNullOrWhiteSpace(resolvedProfile)) { - throw new InvalidOperationException("Tracked browser configuration resolved an empty profile value."); + throw new InvalidOperationException(MessageStrings.BrowserLogsEmptyProfileConfiguration); } if (resolvedUserDataMode == BrowserUserDataMode.Isolated && resolvedProfile is not null) { throw new InvalidOperationException( - $"Tracked browser configuration set '{BrowserLogsBuilderExtensions.ProfileConfigurationKey}' to '{resolvedProfile}' while '{BrowserLogsBuilderExtensions.UserDataModeConfigurationKey}' is '{BrowserUserDataMode.Isolated}'. " + - $"Profiles can only be selected when '{BrowserLogsBuilderExtensions.UserDataModeConfigurationKey}' is '{BrowserUserDataMode.Shared}'."); + string.Format( + CultureInfo.CurrentCulture, + MessageStrings.BrowserLogsProfileRequiresSharedUserDataMode, + BrowserLogsBuilderExtensions.ProfileConfigurationKey, + resolvedProfile, + BrowserLogsBuilderExtensions.UserDataModeConfigurationKey, + BrowserUserDataMode.Isolated, + BrowserUserDataMode.Shared)); } return new BrowserConfiguration(resolvedBrowser, resolvedProfile, resolvedUserDataMode); @@ -137,7 +145,13 @@ internal static string GetDefaultBrowser(BrowserUserDataMode userDataMode, Func< } throw new InvalidOperationException( - $"Tracked browser configuration value '{value}' is not a valid '{BrowserLogsBuilderExtensions.UserDataModeConfigurationKey}'. Expected '{BrowserUserDataMode.Shared}' or '{BrowserUserDataMode.Isolated}'."); + string.Format( + CultureInfo.CurrentCulture, + MessageStrings.BrowserLogsInvalidUserDataModeConfiguration, + value, + BrowserLogsBuilderExtensions.UserDataModeConfigurationKey, + BrowserUserDataMode.Shared, + BrowserUserDataMode.Isolated)); } private static string GetDefaultBrowser(BrowserUserDataMode userDataMode) => diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs index e7ac16613d4..50d21719a82 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs @@ -2,8 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; +using Aspire.Hosting.Resources; using Microsoft.Extensions.Logging; namespace Aspire.Hosting; @@ -125,8 +127,12 @@ metadataUserDataRootPath is null || if (!string.Equals(metadata.ProfileDirectoryName, profileDirectoryName, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException( - $"A tracked browser is already running for user data directory '{identity.UserDataRootPath}' with profile '{metadata.ProfileDirectoryName ?? "(default)"}'. " + - $"The requested profile is '{profileDirectoryName ?? "(default)"}'. Close the existing tracked browser session or use isolated user data mode."); + string.Format( + CultureInfo.CurrentCulture, + MessageStrings.BrowserLogsTrackedBrowserProfileConflict, + identity.UserDataRootPath, + metadata.ProfileDirectoryName ?? MessageStrings.BrowserLogsDefaultProfileName, + profileDirectoryName ?? MessageStrings.BrowserLogsDefaultProfileName)); } return metadata with { Endpoint = endpoint.ToString() }; diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs b/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs index c519ebb968d..d1179be8762 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs @@ -3,7 +3,9 @@ #pragma warning disable ASPIREFILESYSTEM001 // Type is for evaluation purposes only +using System.Globalization; using Aspire.Hosting.Dcp.Process; +using Aspire.Hosting.Resources; using Microsoft.Extensions.Logging; namespace Aspire.Hosting; @@ -223,7 +225,7 @@ private static async Task WaitForProcessStartAsync(Task processStarted } var result = await processTask.ConfigureAwait(false); - throw new InvalidOperationException($"Tracked browser process exited with code {result.ExitCode} before reporting its process id."); + throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, MessageStrings.BrowserLogsProcessExitedBeforeProcessId, result.ExitCode)); } private static async Task WaitForBrowserEndpointAsync( @@ -243,7 +245,7 @@ private static async Task WaitForBrowserEndpointAsync( { var result = await processTask.ConfigureAwait(false); throw new InvalidOperationException( - $"Tracked browser process exited with code {result.ExitCode} before the debug endpoint metadata was written to '{devToolsActivePortFilePath}'."); + string.Format(CultureInfo.CurrentCulture, MessageStrings.BrowserLogsProcessExitedBeforeDebugEndpoint, result.ExitCode, devToolsActivePortFilePath)); } try diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs b/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs index 5da8d16106f..bcb037202c1 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs @@ -3,6 +3,8 @@ #pragma warning disable ASPIREFILESYSTEM001 // Type is for evaluation purposes only +using System.Globalization; +using Aspire.Hosting.Resources; using Microsoft.Extensions.Logging; namespace Aspire.Hosting; @@ -51,7 +53,7 @@ public async Task AcquireAsync(BrowserConfiguration configurat ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); var browserExecutable = ChromiumBrowserResolver.TryResolveExecutable(configuration.Browser) - ?? throw new InvalidOperationException($"Unable to locate browser '{configuration.Browser}'. Specify an installed Chromium-based browser or an explicit executable path."); + ?? throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, MessageStrings.BrowserLogsUnableToLocateBrowser, configuration.Browser)); var userDataDirectory = _createUserDataDirectory(configuration, browserExecutable); var identity = new BrowserHostIdentity(browserExecutable, userDataDirectory.Path); @@ -308,8 +310,12 @@ private async Task CreateHostCoreAsync( { userDataDirectory.Dispose(); throw new InvalidOperationException( - $"Browser user data directory '{identity.UserDataRootPath}' is already in use by a non-debuggable browser. " + - $"Close that browser, use '{BrowserLogsBuilderExtensions.UserDataModeConfigurationKey}'='{BrowserUserDataMode.Isolated}', or start the browser from Aspire first."); + string.Format( + CultureInfo.CurrentCulture, + MessageStrings.BrowserLogsNonDebuggableBrowserRunning, + identity.UserDataRootPath, + BrowserLogsBuilderExtensions.UserDataModeConfigurationKey, + BrowserUserDataMode.Isolated)); } } @@ -331,18 +337,22 @@ private BrowserLogsUserDataDirectory CreateUserDataDirectory(BrowserConfiguratio // endpoint sidecar at that root; named profiles are subdirectories selected by command-line argument. The later // endpoint/probe logic decides whether that root is reusable, adoptable, or locked. var userDataDirectory = ChromiumBrowserResolver.TryResolveUserDataDirectory(configuration.Browser, browserExecutable) - ?? throw new InvalidOperationException($"Unable to resolve the user data directory for browser '{configuration.Browser}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode."); + ?? throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, MessageStrings.BrowserLogsUnableToResolveUserDataDirectory, configuration.Browser)); if (!Directory.Exists(userDataDirectory)) { - throw new InvalidOperationException($"Browser user data directory '{userDataDirectory}' was not found for browser '{configuration.Browser}'."); + throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, MessageStrings.BrowserLogsUserDataDirectoryNotFoundForBrowser, userDataDirectory, configuration.Browser)); } if (ChromiumBrowserResolver.IsGoogleChromeDefaultUserDataDirectory(configuration.Browser, browserExecutable, userDataDirectory)) { throw new InvalidOperationException( - $"Google Chrome blocks remote debugging against its default user data directory '{userDataDirectory}'. " + - $"Use '{BrowserLogsBuilderExtensions.UserDataModeConfigurationKey}'='{BrowserUserDataMode.Isolated}' or select Microsoft Edge for shared browser state."); + string.Format( + CultureInfo.CurrentCulture, + MessageStrings.BrowserLogsGoogleChromeDefaultUserDataDirectoryNotSupported, + userDataDirectory, + BrowserLogsBuilderExtensions.UserDataModeConfigurationKey, + BrowserUserDataMode.Isolated)); } var profileDirectoryName = configuration.Profile is { } profile @@ -363,8 +373,12 @@ private static void ValidateProfileCompatibility(BrowserHostIdentity identity, s } throw new InvalidOperationException( - $"A tracked browser is already running for user data directory '{identity.UserDataRootPath}' with profile '{existingProfileDirectoryName ?? "(default)"}'. " + - $"The requested profile is '{requestedProfileDirectoryName}'. Close the existing tracked browser session or use isolated user data mode."); + string.Format( + CultureInfo.CurrentCulture, + MessageStrings.BrowserLogsTrackedBrowserProfileConflict, + identity.UserDataRootPath, + existingProfileDirectoryName ?? MessageStrings.BrowserLogsDefaultProfileName, + requestedProfileDirectoryName)); } private sealed class BrowserHostEntry(IBrowserHost host, string? profileDirectoryName, int ReferenceCount) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs index 04443ca7d41..5d320e1026e 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs @@ -3,11 +3,12 @@ using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Resources; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Configuration; namespace Aspire.Hosting; @@ -228,13 +229,13 @@ static Uri ResolveBrowserUrl(T resource) if (endpointAnnotation is null) { - throw new InvalidOperationException($"Resource '{resource.Name}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to."); + throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, MessageStrings.BrowserLogsResourceMissingHttpEndpoint, resource.Name)); } var endpointReference = resource.GetEndpoint(endpointAnnotation.Name); if (!endpointReference.IsAllocated) { - throw new InvalidOperationException($"Endpoint '{endpointAnnotation.Name}' for resource '{resource.Name}' has not been allocated yet."); + throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, MessageStrings.BrowserLogsEndpointNotAllocated, endpointAnnotation.Name, resource.Name)); } return new Uri(endpointReference.Url, UriKind.Absolute); diff --git a/src/Aspire.Hosting/BrowserLogs/ChromiumBrowserResolver.cs b/src/Aspire.Hosting/BrowserLogs/ChromiumBrowserResolver.cs index 750c6a017ae..aff8073a8cc 100644 --- a/src/Aspire.Hosting/BrowserLogs/ChromiumBrowserResolver.cs +++ b/src/Aspire.Hosting/BrowserLogs/ChromiumBrowserResolver.cs @@ -1,7 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Globalization; using System.Text.Json; +using Aspire.Hosting.Resources; namespace Aspire.Hosting; @@ -121,7 +123,7 @@ internal static string ResolveProfileDirectory(string userDataDirectory, string if (!Directory.Exists(userDataDirectory)) { - throw new InvalidOperationException($"Browser user data directory '{userDataDirectory}' was not found."); + throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, MessageStrings.BrowserLogsUserDataDirectoryNotFound, userDataDirectory)); } if (TryResolveProfileDirectoryFromDirectoryEntries(userDataDirectory, profile) is { } directMatch) @@ -156,25 +158,25 @@ internal static string ResolveProfileDirectory(string userDataDirectory, string catch (IOException ex) { throw new InvalidOperationException( - $"Unable to read Chromium profile metadata from '{localStatePath}' while resolving browser profile '{profile}'.", + string.Format(CultureInfo.CurrentCulture, MessageStrings.BrowserLogsUnableToReadProfileMetadata, localStatePath, profile), ex); } catch (UnauthorizedAccessException ex) { throw new InvalidOperationException( - $"Unable to read Chromium profile metadata from '{localStatePath}' while resolving browser profile '{profile}'.", + string.Format(CultureInfo.CurrentCulture, MessageStrings.BrowserLogsUnableToReadProfileMetadata, localStatePath, profile), ex); } catch (JsonException ex) { throw new InvalidOperationException( - $"Chromium profile metadata in '{localStatePath}' is invalid while resolving browser profile '{profile}'.", + string.Format(CultureInfo.CurrentCulture, MessageStrings.BrowserLogsInvalidProfileMetadata, localStatePath, profile), ex); } } throw new InvalidOperationException( - $"Browser profile '{profile}' was not found under '{userDataDirectory}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata."); + string.Format(CultureInfo.CurrentCulture, MessageStrings.BrowserLogsProfileNotFound, profile, userDataDirectory)); } /// @@ -208,7 +210,7 @@ internal static string ResolveProfileDirectory(string userDataDirectory, string if (match is not null && !string.Equals(match, profileEntry.Name, StringComparison.Ordinal)) { throw new InvalidOperationException( - $"Browser profile '{profile}' matched multiple Chromium profiles under '{userDataDirectory}'. Specify the profile directory name instead."); + string.Format(CultureInfo.CurrentCulture, MessageStrings.BrowserLogsAmbiguousProfile, profile, userDataDirectory)); } match = profileEntry.Name; diff --git a/src/Aspire.Hosting/Resources/MessageStrings.Designer.cs b/src/Aspire.Hosting/Resources/MessageStrings.Designer.cs index 1776f26b405..1fcd3e4966e 100644 --- a/src/Aspire.Hosting/Resources/MessageStrings.Designer.cs +++ b/src/Aspire.Hosting/Resources/MessageStrings.Designer.cs @@ -10,8 +10,8 @@ namespace Aspire.Hosting.Resources { using System; - - + + /// /// A strongly-typed resource class, for looking up localized strings, etc. /// @@ -23,15 +23,15 @@ namespace Aspire.Hosting.Resources { [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class MessageStrings { - + private static global::System.Resources.ResourceManager resourceMan; - + private static global::System.Globalization.CultureInfo resourceCulture; - + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] internal MessageStrings() { } - + /// /// Returns the cached ResourceManager instance used by this class. /// @@ -45,7 +45,7 @@ internal MessageStrings() { return resourceMan; } } - + /// /// Overrides the current thread's CurrentUICulture property for all /// resource lookups using this strongly typed resource class. @@ -59,7 +59,7 @@ internal MessageStrings() { resourceCulture = value; } } - + /// /// Looks up a localized string similar to Anonymous volumes cannot be read-only.. /// @@ -68,7 +68,7 @@ internal static string ContainerMountAnonymousVolumesReadOnlyExceptionMessage { return ResourceManager.GetString("ContainerMountAnonymousVolumesReadOnlyExceptionMessage", resourceCulture); } } - + /// /// Looks up a localized string similar to Bind mounts must specify an absolute path.. /// @@ -77,7 +77,7 @@ internal static string ContainerMountBindMountsRequireRootedPaths { return ResourceManager.GetString("ContainerMountBindMountsRequireRootedPaths", resourceCulture); } } - + /// /// Looks up a localized string similar to Bind mounts must specify a source path.. /// @@ -86,7 +86,7 @@ internal static string ContainerMountBindMountsRequireSourceExceptionMessage { return ResourceManager.GetString("ContainerMountBindMountsRequireSourceExceptionMessage", resourceCulture); } } - + /// /// Looks up a localized string similar to Application orchestrator dependency check returned an error: {0}. /// @@ -95,7 +95,7 @@ internal static string DcpDependencyCheckFailedMessage { return ResourceManager.GetString("DcpDependencyCheckFailedMessage", resourceCulture); } } - + /// /// Looks up a localized string similar to Newer version of the Aspire.Hosting.AppHost package is required to run the application. Ensure you are referencing at least version '{0}'.. /// @@ -104,7 +104,7 @@ internal static string DcpVersionCheckTooLowMessage { return ResourceManager.GetString("DcpVersionCheckTooLowMessage", resourceCulture); } } - + /// /// Looks up a localized string similar to Installation instructions. /// @@ -113,7 +113,7 @@ internal static string InstallationInstructions { return ResourceManager.GetString("InstallationInstructions", resourceCulture); } } - + /// /// Looks up a localized string similar to Missing command. /// @@ -122,7 +122,7 @@ internal static string MissingCommandNotificationTitle { return ResourceManager.GetString("MissingCommandNotificationTitle", resourceCulture); } } - + /// /// Looks up a localized string similar to Required command '{0}' was not found on PATH or at the specified location.. /// @@ -131,7 +131,7 @@ internal static string RequiredCommandNotFound { return ResourceManager.GetString("RequiredCommandNotFound", resourceCulture); } } - + /// /// Looks up a localized string similar to Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1}. /// @@ -140,7 +140,7 @@ internal static string RequiredCommandNotFoundWithLink { return ResourceManager.GetString("RequiredCommandNotFoundWithLink", resourceCulture); } } - + /// /// Looks up a localized string similar to Command '{0}' validation failed: {1}. /// @@ -149,7 +149,7 @@ internal static string RequiredCommandValidationFailed { return ResourceManager.GetString("RequiredCommandValidationFailed", resourceCulture); } } - + /// /// Looks up a localized string similar to Command '{0}' validation failed: {1}. For installation instructions, see: {2}. /// @@ -158,7 +158,7 @@ internal static string RequiredCommandValidationFailedWithLink { return ResourceManager.GetString("RequiredCommandValidationFailedWithLink", resourceCulture); } } - + /// /// Looks up a localized string similar to Resource '{0}' has a persistent lifetime but the AppHost project does not have user secrets configured. Generated parameter values (such as passwords) may change on each restart, causing persistent containers to be recreated. Run 'aspire secret set' to initialize user secrets.. /// @@ -167,7 +167,7 @@ internal static string PersistentContainerWithoutUserSecrets { return ResourceManager.GetString("PersistentContainerWithoutUserSecrets", resourceCulture); } } - + /// /// Looks up a localized string similar to Resource '{0}' may fail to start: {1}. /// @@ -176,5 +176,185 @@ internal static string ResourceMayFailToStart { return ResourceManager.GetString("ResourceMayFailToStart", resourceCulture); } } + + /// + /// Looks up a localized string similar to (default). + /// + internal static string BrowserLogsDefaultProfileName { + get { + return ResourceManager.GetString("BrowserLogsDefaultProfileName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Tracked browser configuration resolved an empty browser value.. + /// + internal static string BrowserLogsEmptyBrowserConfiguration { + get { + return ResourceManager.GetString("BrowserLogsEmptyBrowserConfiguration", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Tracked browser configuration resolved an empty profile value.. + /// + internal static string BrowserLogsEmptyProfileConfiguration { + get { + return ResourceManager.GetString("BrowserLogsEmptyProfileConfiguration", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'.. + /// + internal static string BrowserLogsProfileRequiresSharedUserDataMode { + get { + return ResourceManager.GetString("BrowserLogsProfileRequiresSharedUserDataMode", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'.. + /// + internal static string BrowserLogsInvalidUserDataModeConfiguration { + get { + return ResourceManager.GetString("BrowserLogsInvalidUserDataModeConfiguration", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path.. + /// + internal static string BrowserLogsUnableToLocateBrowser { + get { + return ResourceManager.GetString("BrowserLogsUnableToLocateBrowser", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first.. + /// + internal static string BrowserLogsNonDebuggableBrowserRunning { + get { + return ResourceManager.GetString("BrowserLogsNonDebuggableBrowserRunning", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode.. + /// + internal static string BrowserLogsUnableToResolveUserDataDirectory { + get { + return ResourceManager.GetString("BrowserLogsUnableToResolveUserDataDirectory", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Browser user data directory '{0}' was not found.. + /// + internal static string BrowserLogsUserDataDirectoryNotFound { + get { + return ResourceManager.GetString("BrowserLogsUserDataDirectoryNotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Browser user data directory '{0}' was not found for browser '{1}'.. + /// + internal static string BrowserLogsUserDataDirectoryNotFoundForBrowser { + get { + return ResourceManager.GetString("BrowserLogsUserDataDirectoryNotFoundForBrowser", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state.. + /// + internal static string BrowserLogsGoogleChromeDefaultUserDataDirectoryNotSupported { + get { + return ResourceManager.GetString("BrowserLogsGoogleChromeDefaultUserDataDirectoryNotSupported", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode.. + /// + internal static string BrowserLogsTrackedBrowserProfileConflict { + get { + return ResourceManager.GetString("BrowserLogsTrackedBrowserProfileConflict", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'.. + /// + internal static string BrowserLogsUnableToReadProfileMetadata { + get { + return ResourceManager.GetString("BrowserLogsUnableToReadProfileMetadata", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'.. + /// + internal static string BrowserLogsInvalidProfileMetadata { + get { + return ResourceManager.GetString("BrowserLogsInvalidProfileMetadata", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata.. + /// + internal static string BrowserLogsProfileNotFound { + get { + return ResourceManager.GetString("BrowserLogsProfileNotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead.. + /// + internal static string BrowserLogsAmbiguousProfile { + get { + return ResourceManager.GetString("BrowserLogsAmbiguousProfile", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to.. + /// + internal static string BrowserLogsResourceMissingHttpEndpoint { + get { + return ResourceManager.GetString("BrowserLogsResourceMissingHttpEndpoint", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Endpoint '{0}' for resource '{1}' has not been allocated yet.. + /// + internal static string BrowserLogsEndpointNotAllocated { + get { + return ResourceManager.GetString("BrowserLogsEndpointNotAllocated", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Tracked browser process exited with code {0} before reporting its process id.. + /// + internal static string BrowserLogsProcessExitedBeforeProcessId { + get { + return ResourceManager.GetString("BrowserLogsProcessExitedBeforeProcessId", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'.. + /// + internal static string BrowserLogsProcessExitedBeforeDebugEndpoint { + get { + return ResourceManager.GetString("BrowserLogsProcessExitedBeforeDebugEndpoint", resourceCulture); + } + } } } diff --git a/src/Aspire.Hosting/Resources/MessageStrings.resx b/src/Aspire.Hosting/Resources/MessageStrings.resx index 507d72c2cef..fc160c92693 100644 --- a/src/Aspire.Hosting/Resources/MessageStrings.resx +++ b/src/Aspire.Hosting/Resources/MessageStrings.resx @@ -156,4 +156,81 @@ Resource '{0}' may fail to start: {1} - \ No newline at end of file + + (default) + + + Tracked browser configuration resolved an empty browser value. + + + Tracked browser configuration resolved an empty profile value. + + + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + {0} is the profile configuration key, {1} is the configured profile, {2} is the user data mode configuration key, {3} is Isolated, {4} is Shared. + + + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. + + + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + {0} is the requested browser name or path. + + + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + {0} is the requested browser name or path. + + + Browser user data directory '{0}' was not found. + {0} is the user data directory. + + + Browser user data directory '{0}' was not found for browser '{1}'. + {0} is the user data directory, {1} is the browser name. + + + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + {0} is the user data directory, {1} is the existing profile, {2} is the requested profile. + + + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + {0} is the requested profile, {1} is the user data directory. + + + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + {0} is the requested profile, {1} is the user data directory. + + + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + {0} is the resource name. + + + Endpoint '{0}' for resource '{1}' has not been allocated yet. + {0} is the endpoint name, {1} is the resource name. + + + Tracked browser process exited with code {0} before reporting its process id. + {0} is the browser process exit code. + + + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + {0} is the browser process exit code, {1} is the DevToolsActivePort file path. + + diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf index 543d1a3979f..c8f5d24aa80 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf @@ -2,6 +2,106 @@ + + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + {0} is the requested profile, {1} is the user data directory. + + + (default) + (default) + + + + Tracked browser configuration resolved an empty browser value. + Tracked browser configuration resolved an empty browser value. + + + + Tracked browser configuration resolved an empty profile value. + Tracked browser configuration resolved an empty profile value. + + + + Endpoint '{0}' for resource '{1}' has not been allocated yet. + Endpoint '{0}' for resource '{1}' has not been allocated yet. + {0} is the endpoint name, {1} is the resource name. + + + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. + + + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + {0} is the browser process exit code, {1} is the DevToolsActivePort file path. + + + Tracked browser process exited with code {0} before reporting its process id. + Tracked browser process exited with code {0} before reporting its process id. + {0} is the browser process exit code. + + + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + {0} is the requested profile, {1} is the user data directory. + + + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + {0} is the profile configuration key, {1} is the configured profile, {2} is the user data mode configuration key, {3} is Isolated, {4} is Shared. + + + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + {0} is the resource name. + + + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + {0} is the user data directory, {1} is the existing profile, {2} is the requested profile. + + + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + {0} is the requested browser name or path. + + + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + {0} is the requested browser name or path. + + + Browser user data directory '{0}' was not found. + Browser user data directory '{0}' was not found. + {0} is the user data directory. + + + Browser user data directory '{0}' was not found for browser '{1}'. + Browser user data directory '{0}' was not found for browser '{1}'. + {0} is the user data directory, {1} is the browser name. + Anonymous volumes cannot be read-only. Anonymní svazky nemůžou být jen pro čtení. diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf index 9b298673e5a..39f09186a23 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf @@ -2,6 +2,106 @@ + + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + {0} is the requested profile, {1} is the user data directory. + + + (default) + (default) + + + + Tracked browser configuration resolved an empty browser value. + Tracked browser configuration resolved an empty browser value. + + + + Tracked browser configuration resolved an empty profile value. + Tracked browser configuration resolved an empty profile value. + + + + Endpoint '{0}' for resource '{1}' has not been allocated yet. + Endpoint '{0}' for resource '{1}' has not been allocated yet. + {0} is the endpoint name, {1} is the resource name. + + + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. + + + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + {0} is the browser process exit code, {1} is the DevToolsActivePort file path. + + + Tracked browser process exited with code {0} before reporting its process id. + Tracked browser process exited with code {0} before reporting its process id. + {0} is the browser process exit code. + + + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + {0} is the requested profile, {1} is the user data directory. + + + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + {0} is the profile configuration key, {1} is the configured profile, {2} is the user data mode configuration key, {3} is Isolated, {4} is Shared. + + + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + {0} is the resource name. + + + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + {0} is the user data directory, {1} is the existing profile, {2} is the requested profile. + + + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + {0} is the requested browser name or path. + + + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + {0} is the requested browser name or path. + + + Browser user data directory '{0}' was not found. + Browser user data directory '{0}' was not found. + {0} is the user data directory. + + + Browser user data directory '{0}' was not found for browser '{1}'. + Browser user data directory '{0}' was not found for browser '{1}'. + {0} is the user data directory, {1} is the browser name. + Anonymous volumes cannot be read-only. Anonyme Volumes können nicht schreibgeschützt sein. diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf index f458d0e0774..bd910a7b579 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf @@ -2,6 +2,106 @@ + + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + {0} is the requested profile, {1} is the user data directory. + + + (default) + (default) + + + + Tracked browser configuration resolved an empty browser value. + Tracked browser configuration resolved an empty browser value. + + + + Tracked browser configuration resolved an empty profile value. + Tracked browser configuration resolved an empty profile value. + + + + Endpoint '{0}' for resource '{1}' has not been allocated yet. + Endpoint '{0}' for resource '{1}' has not been allocated yet. + {0} is the endpoint name, {1} is the resource name. + + + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. + + + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + {0} is the browser process exit code, {1} is the DevToolsActivePort file path. + + + Tracked browser process exited with code {0} before reporting its process id. + Tracked browser process exited with code {0} before reporting its process id. + {0} is the browser process exit code. + + + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + {0} is the requested profile, {1} is the user data directory. + + + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + {0} is the profile configuration key, {1} is the configured profile, {2} is the user data mode configuration key, {3} is Isolated, {4} is Shared. + + + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + {0} is the resource name. + + + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + {0} is the user data directory, {1} is the existing profile, {2} is the requested profile. + + + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + {0} is the requested browser name or path. + + + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + {0} is the requested browser name or path. + + + Browser user data directory '{0}' was not found. + Browser user data directory '{0}' was not found. + {0} is the user data directory. + + + Browser user data directory '{0}' was not found for browser '{1}'. + Browser user data directory '{0}' was not found for browser '{1}'. + {0} is the user data directory, {1} is the browser name. + Anonymous volumes cannot be read-only. Los volúmenes anónimos no pueden ser de solo lectura. diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf index ee369e66477..3fc089f94a0 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf @@ -2,6 +2,106 @@ + + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + {0} is the requested profile, {1} is the user data directory. + + + (default) + (default) + + + + Tracked browser configuration resolved an empty browser value. + Tracked browser configuration resolved an empty browser value. + + + + Tracked browser configuration resolved an empty profile value. + Tracked browser configuration resolved an empty profile value. + + + + Endpoint '{0}' for resource '{1}' has not been allocated yet. + Endpoint '{0}' for resource '{1}' has not been allocated yet. + {0} is the endpoint name, {1} is the resource name. + + + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. + + + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + {0} is the browser process exit code, {1} is the DevToolsActivePort file path. + + + Tracked browser process exited with code {0} before reporting its process id. + Tracked browser process exited with code {0} before reporting its process id. + {0} is the browser process exit code. + + + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + {0} is the requested profile, {1} is the user data directory. + + + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + {0} is the profile configuration key, {1} is the configured profile, {2} is the user data mode configuration key, {3} is Isolated, {4} is Shared. + + + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + {0} is the resource name. + + + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + {0} is the user data directory, {1} is the existing profile, {2} is the requested profile. + + + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + {0} is the requested browser name or path. + + + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + {0} is the requested browser name or path. + + + Browser user data directory '{0}' was not found. + Browser user data directory '{0}' was not found. + {0} is the user data directory. + + + Browser user data directory '{0}' was not found for browser '{1}'. + Browser user data directory '{0}' was not found for browser '{1}'. + {0} is the user data directory, {1} is the browser name. + Anonymous volumes cannot be read-only. Les volumes anonymes ne peuvent pas être en lecture seule. diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf index c13f0da17d1..d12562d4ead 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf @@ -2,6 +2,106 @@ + + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + {0} is the requested profile, {1} is the user data directory. + + + (default) + (default) + + + + Tracked browser configuration resolved an empty browser value. + Tracked browser configuration resolved an empty browser value. + + + + Tracked browser configuration resolved an empty profile value. + Tracked browser configuration resolved an empty profile value. + + + + Endpoint '{0}' for resource '{1}' has not been allocated yet. + Endpoint '{0}' for resource '{1}' has not been allocated yet. + {0} is the endpoint name, {1} is the resource name. + + + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. + + + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + {0} is the browser process exit code, {1} is the DevToolsActivePort file path. + + + Tracked browser process exited with code {0} before reporting its process id. + Tracked browser process exited with code {0} before reporting its process id. + {0} is the browser process exit code. + + + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + {0} is the requested profile, {1} is the user data directory. + + + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + {0} is the profile configuration key, {1} is the configured profile, {2} is the user data mode configuration key, {3} is Isolated, {4} is Shared. + + + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + {0} is the resource name. + + + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + {0} is the user data directory, {1} is the existing profile, {2} is the requested profile. + + + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + {0} is the requested browser name or path. + + + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + {0} is the requested browser name or path. + + + Browser user data directory '{0}' was not found. + Browser user data directory '{0}' was not found. + {0} is the user data directory. + + + Browser user data directory '{0}' was not found for browser '{1}'. + Browser user data directory '{0}' was not found for browser '{1}'. + {0} is the user data directory, {1} is the browser name. + Anonymous volumes cannot be read-only. I volumi anonimi non possono essere di sola lettura. diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf index b268c42a2b4..4ec839bc953 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf @@ -2,6 +2,106 @@ + + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + {0} is the requested profile, {1} is the user data directory. + + + (default) + (default) + + + + Tracked browser configuration resolved an empty browser value. + Tracked browser configuration resolved an empty browser value. + + + + Tracked browser configuration resolved an empty profile value. + Tracked browser configuration resolved an empty profile value. + + + + Endpoint '{0}' for resource '{1}' has not been allocated yet. + Endpoint '{0}' for resource '{1}' has not been allocated yet. + {0} is the endpoint name, {1} is the resource name. + + + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. + + + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + {0} is the browser process exit code, {1} is the DevToolsActivePort file path. + + + Tracked browser process exited with code {0} before reporting its process id. + Tracked browser process exited with code {0} before reporting its process id. + {0} is the browser process exit code. + + + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + {0} is the requested profile, {1} is the user data directory. + + + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + {0} is the profile configuration key, {1} is the configured profile, {2} is the user data mode configuration key, {3} is Isolated, {4} is Shared. + + + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + {0} is the resource name. + + + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + {0} is the user data directory, {1} is the existing profile, {2} is the requested profile. + + + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + {0} is the requested browser name or path. + + + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + {0} is the requested browser name or path. + + + Browser user data directory '{0}' was not found. + Browser user data directory '{0}' was not found. + {0} is the user data directory. + + + Browser user data directory '{0}' was not found for browser '{1}'. + Browser user data directory '{0}' was not found for browser '{1}'. + {0} is the user data directory, {1} is the browser name. + Anonymous volumes cannot be read-only. 匿名ボリュームを読み取り専用にすることはできません。 diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf index d5947778ca9..44c60e47f2c 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf @@ -2,6 +2,106 @@ + + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + {0} is the requested profile, {1} is the user data directory. + + + (default) + (default) + + + + Tracked browser configuration resolved an empty browser value. + Tracked browser configuration resolved an empty browser value. + + + + Tracked browser configuration resolved an empty profile value. + Tracked browser configuration resolved an empty profile value. + + + + Endpoint '{0}' for resource '{1}' has not been allocated yet. + Endpoint '{0}' for resource '{1}' has not been allocated yet. + {0} is the endpoint name, {1} is the resource name. + + + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. + + + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + {0} is the browser process exit code, {1} is the DevToolsActivePort file path. + + + Tracked browser process exited with code {0} before reporting its process id. + Tracked browser process exited with code {0} before reporting its process id. + {0} is the browser process exit code. + + + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + {0} is the requested profile, {1} is the user data directory. + + + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + {0} is the profile configuration key, {1} is the configured profile, {2} is the user data mode configuration key, {3} is Isolated, {4} is Shared. + + + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + {0} is the resource name. + + + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + {0} is the user data directory, {1} is the existing profile, {2} is the requested profile. + + + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + {0} is the requested browser name or path. + + + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + {0} is the requested browser name or path. + + + Browser user data directory '{0}' was not found. + Browser user data directory '{0}' was not found. + {0} is the user data directory. + + + Browser user data directory '{0}' was not found for browser '{1}'. + Browser user data directory '{0}' was not found for browser '{1}'. + {0} is the user data directory, {1} is the browser name. + Anonymous volumes cannot be read-only. 익명 볼륨은 읽기 전용일 수 없습니다. diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf index edbe59b5248..c6933ebfa7f 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf @@ -2,6 +2,106 @@ + + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + {0} is the requested profile, {1} is the user data directory. + + + (default) + (default) + + + + Tracked browser configuration resolved an empty browser value. + Tracked browser configuration resolved an empty browser value. + + + + Tracked browser configuration resolved an empty profile value. + Tracked browser configuration resolved an empty profile value. + + + + Endpoint '{0}' for resource '{1}' has not been allocated yet. + Endpoint '{0}' for resource '{1}' has not been allocated yet. + {0} is the endpoint name, {1} is the resource name. + + + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. + + + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + {0} is the browser process exit code, {1} is the DevToolsActivePort file path. + + + Tracked browser process exited with code {0} before reporting its process id. + Tracked browser process exited with code {0} before reporting its process id. + {0} is the browser process exit code. + + + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + {0} is the requested profile, {1} is the user data directory. + + + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + {0} is the profile configuration key, {1} is the configured profile, {2} is the user data mode configuration key, {3} is Isolated, {4} is Shared. + + + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + {0} is the resource name. + + + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + {0} is the user data directory, {1} is the existing profile, {2} is the requested profile. + + + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + {0} is the requested browser name or path. + + + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + {0} is the requested browser name or path. + + + Browser user data directory '{0}' was not found. + Browser user data directory '{0}' was not found. + {0} is the user data directory. + + + Browser user data directory '{0}' was not found for browser '{1}'. + Browser user data directory '{0}' was not found for browser '{1}'. + {0} is the user data directory, {1} is the browser name. + Anonymous volumes cannot be read-only. Woluminy anonimowe nie mogą być tylko do odczytu. diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf index 4bd628aab1c..816f38c4e3f 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf @@ -2,6 +2,106 @@ + + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + {0} is the requested profile, {1} is the user data directory. + + + (default) + (default) + + + + Tracked browser configuration resolved an empty browser value. + Tracked browser configuration resolved an empty browser value. + + + + Tracked browser configuration resolved an empty profile value. + Tracked browser configuration resolved an empty profile value. + + + + Endpoint '{0}' for resource '{1}' has not been allocated yet. + Endpoint '{0}' for resource '{1}' has not been allocated yet. + {0} is the endpoint name, {1} is the resource name. + + + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. + + + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + {0} is the browser process exit code, {1} is the DevToolsActivePort file path. + + + Tracked browser process exited with code {0} before reporting its process id. + Tracked browser process exited with code {0} before reporting its process id. + {0} is the browser process exit code. + + + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + {0} is the requested profile, {1} is the user data directory. + + + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + {0} is the profile configuration key, {1} is the configured profile, {2} is the user data mode configuration key, {3} is Isolated, {4} is Shared. + + + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + {0} is the resource name. + + + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + {0} is the user data directory, {1} is the existing profile, {2} is the requested profile. + + + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + {0} is the requested browser name or path. + + + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + {0} is the requested browser name or path. + + + Browser user data directory '{0}' was not found. + Browser user data directory '{0}' was not found. + {0} is the user data directory. + + + Browser user data directory '{0}' was not found for browser '{1}'. + Browser user data directory '{0}' was not found for browser '{1}'. + {0} is the user data directory, {1} is the browser name. + Anonymous volumes cannot be read-only. Volumes anônimos não podem ser somente leitura. diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf index f976374fb51..911a06d342a 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf @@ -2,6 +2,106 @@ + + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + {0} is the requested profile, {1} is the user data directory. + + + (default) + (default) + + + + Tracked browser configuration resolved an empty browser value. + Tracked browser configuration resolved an empty browser value. + + + + Tracked browser configuration resolved an empty profile value. + Tracked browser configuration resolved an empty profile value. + + + + Endpoint '{0}' for resource '{1}' has not been allocated yet. + Endpoint '{0}' for resource '{1}' has not been allocated yet. + {0} is the endpoint name, {1} is the resource name. + + + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. + + + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + {0} is the browser process exit code, {1} is the DevToolsActivePort file path. + + + Tracked browser process exited with code {0} before reporting its process id. + Tracked browser process exited with code {0} before reporting its process id. + {0} is the browser process exit code. + + + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + {0} is the requested profile, {1} is the user data directory. + + + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + {0} is the profile configuration key, {1} is the configured profile, {2} is the user data mode configuration key, {3} is Isolated, {4} is Shared. + + + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + {0} is the resource name. + + + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + {0} is the user data directory, {1} is the existing profile, {2} is the requested profile. + + + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + {0} is the requested browser name or path. + + + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + {0} is the requested browser name or path. + + + Browser user data directory '{0}' was not found. + Browser user data directory '{0}' was not found. + {0} is the user data directory. + + + Browser user data directory '{0}' was not found for browser '{1}'. + Browser user data directory '{0}' was not found for browser '{1}'. + {0} is the user data directory, {1} is the browser name. + Anonymous volumes cannot be read-only. Анонимные тома не могут быть доступны только для чтения. diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf index 432ed7ef707..d2baac494c4 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf @@ -2,6 +2,106 @@ + + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + {0} is the requested profile, {1} is the user data directory. + + + (default) + (default) + + + + Tracked browser configuration resolved an empty browser value. + Tracked browser configuration resolved an empty browser value. + + + + Tracked browser configuration resolved an empty profile value. + Tracked browser configuration resolved an empty profile value. + + + + Endpoint '{0}' for resource '{1}' has not been allocated yet. + Endpoint '{0}' for resource '{1}' has not been allocated yet. + {0} is the endpoint name, {1} is the resource name. + + + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. + + + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + {0} is the browser process exit code, {1} is the DevToolsActivePort file path. + + + Tracked browser process exited with code {0} before reporting its process id. + Tracked browser process exited with code {0} before reporting its process id. + {0} is the browser process exit code. + + + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + {0} is the requested profile, {1} is the user data directory. + + + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + {0} is the profile configuration key, {1} is the configured profile, {2} is the user data mode configuration key, {3} is Isolated, {4} is Shared. + + + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + {0} is the resource name. + + + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + {0} is the user data directory, {1} is the existing profile, {2} is the requested profile. + + + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + {0} is the requested browser name or path. + + + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + {0} is the requested browser name or path. + + + Browser user data directory '{0}' was not found. + Browser user data directory '{0}' was not found. + {0} is the user data directory. + + + Browser user data directory '{0}' was not found for browser '{1}'. + Browser user data directory '{0}' was not found for browser '{1}'. + {0} is the user data directory, {1} is the browser name. + Anonymous volumes cannot be read-only. Anonim birimler salt okunur olamaz. diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf index d83606fb8db..442273ca182 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf @@ -2,6 +2,106 @@ + + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + {0} is the requested profile, {1} is the user data directory. + + + (default) + (default) + + + + Tracked browser configuration resolved an empty browser value. + Tracked browser configuration resolved an empty browser value. + + + + Tracked browser configuration resolved an empty profile value. + Tracked browser configuration resolved an empty profile value. + + + + Endpoint '{0}' for resource '{1}' has not been allocated yet. + Endpoint '{0}' for resource '{1}' has not been allocated yet. + {0} is the endpoint name, {1} is the resource name. + + + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. + + + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + {0} is the browser process exit code, {1} is the DevToolsActivePort file path. + + + Tracked browser process exited with code {0} before reporting its process id. + Tracked browser process exited with code {0} before reporting its process id. + {0} is the browser process exit code. + + + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + {0} is the requested profile, {1} is the user data directory. + + + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + {0} is the profile configuration key, {1} is the configured profile, {2} is the user data mode configuration key, {3} is Isolated, {4} is Shared. + + + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + {0} is the resource name. + + + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + {0} is the user data directory, {1} is the existing profile, {2} is the requested profile. + + + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + {0} is the requested browser name or path. + + + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + {0} is the requested browser name or path. + + + Browser user data directory '{0}' was not found. + Browser user data directory '{0}' was not found. + {0} is the user data directory. + + + Browser user data directory '{0}' was not found for browser '{1}'. + Browser user data directory '{0}' was not found for browser '{1}'. + {0} is the user data directory, {1} is the browser name. + Anonymous volumes cannot be read-only. 匿名卷不能为只读。 diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf index 2358ea63c17..1cd681cc064 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf @@ -2,6 +2,106 @@ + + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. + {0} is the requested profile, {1} is the user data directory. + + + (default) + (default) + + + + Tracked browser configuration resolved an empty browser value. + Tracked browser configuration resolved an empty browser value. + + + + Tracked browser configuration resolved an empty profile value. + Tracked browser configuration resolved an empty profile value. + + + + Endpoint '{0}' for resource '{1}' has not been allocated yet. + Endpoint '{0}' for resource '{1}' has not been allocated yet. + {0} is the endpoint name, {1} is the resource name. + + + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. + {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. + + + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. + {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. + + + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. + {0} is the browser process exit code, {1} is the DevToolsActivePort file path. + + + Tracked browser process exited with code {0} before reporting its process id. + Tracked browser process exited with code {0} before reporting its process id. + {0} is the browser process exit code. + + + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + Browser profile '{0}' was not found under '{1}'. Specify the profile directory name (for example 'Default' or 'Profile 1') or a browser profile name from Chromium's profile metadata. + {0} is the requested profile, {1} is the user data directory. + + + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + Tracked browser configuration set '{0}' to '{1}' while '{2}' is '{3}'. Profiles can only be selected when '{2}' is '{4}'. + {0} is the profile configuration key, {1} is the configured profile, {2} is the user data mode configuration key, {3} is Isolated, {4} is Shared. + + + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + Resource '{0}' does not have an HTTP or HTTPS endpoint. Browser logs require an endpoint to navigate to. + {0} is the resource name. + + + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. + {0} is the user data directory, {1} is the existing profile, {2} is the requested profile. + + + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. + {0} is the requested browser name or path. + + + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. + {0} is the Local State file path, {1} is the requested profile. + + + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. + {0} is the requested browser name or path. + + + Browser user data directory '{0}' was not found. + Browser user data directory '{0}' was not found. + {0} is the user data directory. + + + Browser user data directory '{0}' was not found for browser '{1}'. + Browser user data directory '{0}' was not found for browser '{1}'. + {0} is the user data directory, {1} is the browser name. + Anonymous volumes cannot be read-only. 匿名磁碟區不可為唯讀。 From ce82bc8a3f1ef49c3c7096181e4b4f29017fb991 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 26 Apr 2026 07:37:46 -0700 Subject: [PATCH 28/36] Move browser launch helpers out of running session Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting/BrowserLogs/BrowserHost.cs | 77 ++++++++++++- .../BrowserLogs/BrowserLogsRunningSession.cs | 109 ------------------ .../ChromiumDevToolsActivePortParser.cs | 47 ++++++++ .../BrowserLogsSessionManagerTests.cs | 23 ---- .../ChromiumDevToolsActivePortParserTests.cs | 31 +++++ 5 files changed, 153 insertions(+), 134 deletions(-) create mode 100644 src/Aspire.Hosting/BrowserLogs/ChromiumDevToolsActivePortParser.cs create mode 100644 tests/Aspire.Hosting.Tests/ChromiumDevToolsActivePortParserTests.cs diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs b/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs index d1179be8762..883892658d4 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs @@ -4,6 +4,7 @@ #pragma warning disable ASPIREFILESYSTEM001 // Type is for evaluation purposes only using System.Globalization; +using System.Text; using Aspire.Hosting.Dcp.Process; using Aspire.Hosting.Resources; using Microsoft.Extensions.Logging; @@ -73,7 +74,79 @@ protected static string BuildBrowserArguments(BrowserLogsUserDataDirectory userD arguments.Add("about:blank"); - return BrowserLogsRunningSession.BuildCommandLine(arguments); + return BuildCommandLine(arguments); + } + + private static string BuildCommandLine(IReadOnlyList arguments) + { + var builder = new StringBuilder(); + + for (var i = 0; i < arguments.Count; i++) + { + if (i > 0) + { + builder.Append(' '); + } + + AppendCommandLineArgument(builder, arguments[i]); + } + + return builder.ToString(); + } + + // Adapted from dotnet/runtime PasteArguments.AppendArgument so ProcessSpec can safely represent Chromium flags. + private static void AppendCommandLineArgument(StringBuilder builder, string argument) + { + if (argument.Length != 0 && !argument.AsSpan().ContainsAny(' ', '\t', '"')) + { + builder.Append(argument); + return; + } + + builder.Append('"'); + + var index = 0; + while (index < argument.Length) + { + var character = argument[index++]; + if (character == '\\') + { + var backslashCount = 1; + while (index < argument.Length && argument[index] == '\\') + { + index++; + backslashCount++; + } + + if (index == argument.Length) + { + builder.Append('\\', backslashCount * 2); + } + else if (argument[index] == '"') + { + builder.Append('\\', backslashCount * 2 + 1); + builder.Append('"'); + index++; + } + else + { + builder.Append('\\', backslashCount); + } + + continue; + } + + if (character == '"') + { + builder.Append('\\'); + builder.Append('"'); + continue; + } + + builder.Append(character); + } + + builder.Append('"'); } private async Task CreatePageSessionCoreAsync( @@ -260,7 +333,7 @@ private static async Task WaitForBrowserEndpointAsync( } var contents = await File.ReadAllTextAsync(devToolsActivePortFilePath, cancellationToken).ConfigureAwait(false); - if (BrowserLogsDebugEndpointParser.TryParseBrowserDebugEndpoint(contents) is { } browserEndpoint) + if (ChromiumDevToolsActivePortParser.TryParseBrowserDebugEndpoint(contents) is { } browserEndpoint) { return browserEndpoint; } diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs index 988478f4db3..9e1b6381f26 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs @@ -3,8 +3,6 @@ #pragma warning disable ASPIREFILESYSTEM001 // Type is for evaluation purposes only -using System.Globalization; -using System.Text; using Microsoft.Extensions.Logging; namespace Aspire.Hosting; @@ -353,112 +351,5 @@ private async Task DisposeBrowserHostLeaseAsync() ?.TargetId; } - internal static string BuildCommandLine(IReadOnlyList arguments) - { - var builder = new StringBuilder(); - - for (var i = 0; i < arguments.Count; i++) - { - if (i > 0) - { - builder.Append(' '); - } - - AppendCommandLineArgument(builder, arguments[i]); - } - - return builder.ToString(); - } - - // Adapted from dotnet/runtime PasteArguments.AppendArgument so ProcessSpec can safely represent Chromium flags. - private static void AppendCommandLineArgument(StringBuilder builder, string argument) - { - if (argument.Length != 0 && !argument.AsSpan().ContainsAny(' ', '\t', '"')) - { - builder.Append(argument); - return; - } - - builder.Append('"'); - - var index = 0; - while (index < argument.Length) - { - var character = argument[index++]; - if (character == '\\') - { - var backslashCount = 1; - while (index < argument.Length && argument[index] == '\\') - { - index++; - backslashCount++; - } - - if (index == argument.Length) - { - builder.Append('\\', backslashCount * 2); - } - else if (argument[index] == '"') - { - builder.Append('\\', backslashCount * 2 + 1); - builder.Append('"'); - index++; - } - else - { - builder.Append('\\', backslashCount); - } - - continue; - } - - if (character == '"') - { - builder.Append('\\'); - builder.Append('"'); - continue; - } - - builder.Append(character); - } - - builder.Append('"'); - } - private sealed record BrowserSessionResult(int? ExitCode, Exception? Error); - -} - -internal static class BrowserLogsDebugEndpointParser -{ - internal static Uri? TryParseBrowserDebugEndpoint(string activePortFileContents) - { - if (string.IsNullOrWhiteSpace(activePortFileContents)) - { - return null; - } - - using var reader = new StringReader(activePortFileContents); - var portLine = reader.ReadLine(); - var browserPathLine = reader.ReadLine(); - - if (!int.TryParse(portLine, NumberStyles.None, CultureInfo.InvariantCulture, out var port) || port <= 0) - { - return null; - } - - if (string.IsNullOrWhiteSpace(browserPathLine)) - { - return null; - } - - if (!browserPathLine.StartsWith("/", StringComparison.Ordinal)) - { - browserPathLine = $"/{browserPathLine}"; - } - - return Uri.TryCreate($"ws://127.0.0.1:{port}{browserPathLine}", UriKind.Absolute, out var browserEndpoint) - ? browserEndpoint - : null; - } } diff --git a/src/Aspire.Hosting/BrowserLogs/ChromiumDevToolsActivePortParser.cs b/src/Aspire.Hosting/BrowserLogs/ChromiumDevToolsActivePortParser.cs new file mode 100644 index 00000000000..2a5ca3f8cb9 --- /dev/null +++ b/src/Aspire.Hosting/BrowserLogs/ChromiumDevToolsActivePortParser.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; + +namespace Aspire.Hosting; + +/// +/// Parses Chromium's DevToolsActivePort file into a browser-level CDP endpoint. +/// +internal static class ChromiumDevToolsActivePortParser +{ + internal static Uri? TryParseBrowserDebugEndpoint(string activePortFileContents) + { + if (string.IsNullOrWhiteSpace(activePortFileContents)) + { + return null; + } + + // Chromium writes DevToolsActivePort as two lines: + // + // 51943 + // /devtools/browser/4c8404fb-06f8-45f0-9d89-112233445566 + using var reader = new StringReader(activePortFileContents); + var portLine = reader.ReadLine(); + var browserPathLine = reader.ReadLine(); + + if (!int.TryParse(portLine, NumberStyles.None, CultureInfo.InvariantCulture, out var port) || port <= 0) + { + return null; + } + + if (string.IsNullOrWhiteSpace(browserPathLine)) + { + return null; + } + + if (!browserPathLine.StartsWith("/", StringComparison.Ordinal)) + { + browserPathLine = $"/{browserPathLine}"; + } + + return Uri.TryCreate($"ws://127.0.0.1:{port}{browserPathLine}", UriKind.Absolute, out var browserEndpoint) + ? browserEndpoint + : null; + } +} diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs index 894b89286b6..56bea9f6094 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs @@ -16,29 +16,6 @@ namespace Aspire.Hosting.Tests; [Trait("Partition", "2")] public class BrowserLogsSessionManagerTests { - [Fact] - public void TryParseBrowserDebugEndpoint_ReturnsBrowserWebSocketUri() - { - var endpoint = BrowserLogsDebugEndpointParser.TryParseBrowserDebugEndpoint(""" - 51943 - /devtools/browser/4c8404fb-06f8-45f0-9d89-112233445566 - """); - - Assert.NotNull(endpoint); - Assert.Equal("ws://127.0.0.1:51943/devtools/browser/4c8404fb-06f8-45f0-9d89-112233445566", endpoint.AbsoluteUri); - } - - [Theory] - [InlineData("")] - [InlineData("not-a-port")] - [InlineData("51943")] - public void TryParseBrowserDebugEndpoint_ReturnsNullForInvalidMetadata(string metadata) - { - var endpoint = BrowserLogsDebugEndpointParser.TryParseBrowserDebugEndpoint(metadata); - - Assert.Null(endpoint); - } - [Fact] public void BrowserHostIdentity_NormalizesTrailingDirectorySeparators() { diff --git a/tests/Aspire.Hosting.Tests/ChromiumDevToolsActivePortParserTests.cs b/tests/Aspire.Hosting.Tests/ChromiumDevToolsActivePortParserTests.cs new file mode 100644 index 00000000000..d2e7571b6e5 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/ChromiumDevToolsActivePortParserTests.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Tests; + +[Trait("Partition", "2")] +public class ChromiumDevToolsActivePortParserTests +{ + [Fact] + public void TryParseBrowserDebugEndpoint_ReturnsBrowserWebSocketUri() + { + var endpoint = ChromiumDevToolsActivePortParser.TryParseBrowserDebugEndpoint(""" + 51943 + /devtools/browser/4c8404fb-06f8-45f0-9d89-112233445566 + """); + + Assert.NotNull(endpoint); + Assert.Equal("ws://127.0.0.1:51943/devtools/browser/4c8404fb-06f8-45f0-9d89-112233445566", endpoint.AbsoluteUri); + } + + [Theory] + [InlineData("")] + [InlineData("not-a-port")] + [InlineData("51943")] + public void TryParseBrowserDebugEndpoint_ReturnsNullForInvalidMetadata(string metadata) + { + var endpoint = ChromiumDevToolsActivePortParser.TryParseBrowserDebugEndpoint(metadata); + + Assert.Null(endpoint); + } +} From 37573bbda2b08d94719c8a7d7ca4395691925956 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 26 Apr 2026 07:48:35 -0700 Subject: [PATCH 29/36] Clean up browser logs helper ownership Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserConnectionDiagnosticsLogger.cs | 57 +++++++++ .../BrowserLogs/BrowserEndpointDiscovery.cs | 3 - src/Aspire.Hosting/BrowserLogs/BrowserHost.cs | 116 +++++++++--------- .../BrowserLogs/BrowserLogsEventLogger.cs | 51 -------- .../BrowserLogs/BrowserLogsRunningSession.cs | 27 ---- .../BrowserLogs/BrowserPageSession.cs | 31 ++++- ...BrowserConnectionDiagnosticsLoggerTests.cs | 48 ++++++++ .../BrowserLogsSessionManagerTests.cs | 66 ---------- .../BrowserPageSessionTests.cs | 33 +++++ 9 files changed, 226 insertions(+), 206 deletions(-) create mode 100644 src/Aspire.Hosting/BrowserLogs/BrowserConnectionDiagnosticsLogger.cs create mode 100644 tests/Aspire.Hosting.Tests/BrowserConnectionDiagnosticsLoggerTests.cs create mode 100644 tests/Aspire.Hosting.Tests/BrowserPageSessionTests.cs diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserConnectionDiagnosticsLogger.cs b/src/Aspire.Hosting/BrowserLogs/BrowserConnectionDiagnosticsLogger.cs new file mode 100644 index 00000000000..c511fc54077 --- /dev/null +++ b/src/Aspire.Hosting/BrowserLogs/BrowserConnectionDiagnosticsLogger.cs @@ -0,0 +1,57 @@ +// 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; + +// Keep message composition separate from the runtime so tests can pin the diagnostics without a live websocket failure. +internal sealed class BrowserConnectionDiagnosticsLogger(string sessionId, ILogger resourceLogger) +{ + private readonly ILogger _resourceLogger = resourceLogger; + private readonly string _sessionId = sessionId; + + public void LogSetupFailure(string stage, Exception exception) + { + _resourceLogger.LogError("[{SessionId}] {Stage} failed: {Reason}", _sessionId, stage, DescribeConnectionProblem(exception)); + } + + public void LogConnectionLost(Exception exception) + { + _resourceLogger.LogWarning("[{SessionId}] Tracked browser debug connection lost: {Reason}. Attempting to reconnect.", _sessionId, DescribeConnectionProblem(exception)); + } + + public void LogReconnectAttemptFailed(int attempt, Exception exception) + { + _resourceLogger.LogWarning("[{SessionId}] Reconnect attempt {Attempt} failed: {Reason}", _sessionId, attempt, DescribeConnectionProblem(exception)); + } + + public void LogReconnectFailed(Exception exception) + { + _resourceLogger.LogError("[{SessionId}] Unable to reconnect tracked browser debug connection. Closing the tracked browser session. Last error: {Reason}", _sessionId, DescribeConnectionProblem(exception)); + } + + public void LogHostTerminated(Exception exception) + { + _resourceLogger.LogError("[{SessionId}] Tracked browser host ended before the tracked page session completed: {Reason}", _sessionId, DescribeConnectionProblem(exception)); + } + + internal static string DescribeConnectionProblem(Exception exception) + { + var messages = new List(); + + for (var current = exception; current is not null; current = current.InnerException) + { + var message = string.IsNullOrWhiteSpace(current.Message) + ? current.GetType().Name + : $"{current.GetType().Name}: {current.Message}"; + + if (!messages.Contains(message, StringComparer.Ordinal)) + { + messages.Add(message); + } + } + + return string.Join(" --> ", messages); + } +} diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs index 50d21719a82..61c69be92a1 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs @@ -186,9 +186,6 @@ public static async Task WriteAsync(BrowserHostIdentity identity, string? profil public static void DeleteEndpointMetadata(string userDataDirectory) => TryDelete(GetEndpointMetadataFilePath(userDataDirectory)); - public static void DeleteDevToolsActivePort(string userDataDirectory) => - TryDelete(Path.Combine(userDataDirectory, "DevToolsActivePort")); - public static bool IsNonDebuggableBrowserRunning(string userDataDirectory) => IsNonDebuggableBrowserRunning(userDataDirectory, OperatingSystem.IsWindows()); diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs b/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs index 883892658d4..54ced4e698f 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs @@ -50,7 +50,64 @@ public Task CreatePageSessionAsync( public abstract ValueTask DisposeAsync(); - protected static string BuildBrowserArguments(BrowserLogsUserDataDirectory userDataDirectory) + private async Task CreatePageSessionCoreAsync( + string sessionId, + Uri url, + BrowserConnectionDiagnosticsLogger connectionDiagnostics, + Func eventHandler, + CancellationToken cancellationToken) + { + return await BrowserPageSession.StartAsync( + this, + sessionId, + url, + connectionDiagnostics, + eventHandler, + _logger, + _timeProvider, + _reuseInitialBlankTarget, + cancellationToken).ConfigureAwait(false); + } +} + +// Host implementation for browsers Aspire starts itself. Owned hosts are responsible for spawning Chromium with a +// browser-level CDP endpoint, writing adoption metadata, and terminating the browser when the final lease is released. +internal sealed class OwnedBrowserHost : BrowserHost +{ + private static readonly TimeSpan s_browserEndpointTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan s_browserEndpointPollInterval = TimeSpan.FromMilliseconds(100); + private static readonly TimeSpan s_browserShutdownTimeout = TimeSpan.FromSeconds(5); + + private readonly BrowserLogsUserDataDirectory _userDataDirectory; + private readonly IAsyncDisposable _processLifetime; + private readonly Task _processTask; + private readonly Task _termination; + private int _disposed; + + private OwnedBrowserHost( + BrowserHostIdentity identity, + Uri debugEndpoint, + string browserDisplayName, + int processId, + BrowserLogsUserDataDirectory userDataDirectory, + IAsyncDisposable processLifetime, + Task processTask, + ILogger logger, + TimeProvider timeProvider) + : base(identity, BrowserHostOwnership.Owned, debugEndpoint, browserDisplayName, logger, timeProvider, reuseInitialBlankTarget: true) + { + _processLifetime = processLifetime; + _processTask = processTask; + _termination = processTask; + _userDataDirectory = userDataDirectory; + ProcessId = processId; + } + + public override int? ProcessId { get; } + + public override Task Termination => _termination; + + private static string BuildBrowserArguments(BrowserLogsUserDataDirectory userDataDirectory) { // Chromium writes DevToolsActivePort only when remote debugging is enabled. Let it choose the port so // playground runs do not collide with a user's existing browser or another AppHost. The initial about:blank @@ -149,63 +206,6 @@ private static void AppendCommandLineArgument(StringBuilder builder, string argu builder.Append('"'); } - private async Task CreatePageSessionCoreAsync( - string sessionId, - Uri url, - BrowserConnectionDiagnosticsLogger connectionDiagnostics, - Func eventHandler, - CancellationToken cancellationToken) - { - return await BrowserPageSession.StartAsync( - this, - sessionId, - url, - connectionDiagnostics, - eventHandler, - _logger, - _timeProvider, - _reuseInitialBlankTarget, - cancellationToken).ConfigureAwait(false); - } -} - -// Host implementation for browsers Aspire starts itself. Owned hosts are responsible for spawning Chromium with a -// browser-level CDP endpoint, writing adoption metadata, and terminating the browser when the final lease is released. -internal sealed class OwnedBrowserHost : BrowserHost -{ - private static readonly TimeSpan s_browserEndpointTimeout = TimeSpan.FromSeconds(30); - private static readonly TimeSpan s_browserEndpointPollInterval = TimeSpan.FromMilliseconds(100); - private static readonly TimeSpan s_browserShutdownTimeout = TimeSpan.FromSeconds(5); - - private readonly BrowserLogsUserDataDirectory _userDataDirectory; - private readonly IAsyncDisposable _processLifetime; - private readonly Task _processTask; - private readonly Task _termination; - private int _disposed; - - private OwnedBrowserHost( - BrowserHostIdentity identity, - Uri debugEndpoint, - string browserDisplayName, - int processId, - BrowserLogsUserDataDirectory userDataDirectory, - IAsyncDisposable processLifetime, - Task processTask, - ILogger logger, - TimeProvider timeProvider) - : base(identity, BrowserHostOwnership.Owned, debugEndpoint, browserDisplayName, logger, timeProvider, reuseInitialBlankTarget: true) - { - _processLifetime = processLifetime; - _processTask = processTask; - _termination = processTask; - _userDataDirectory = userDataDirectory; - ProcessId = processId; - } - - public override int? ProcessId { get; } - - public override Task Termination => _termination; - public static async Task StartAsync( BrowserHostIdentity identity, string browserDisplayName, diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsEventLogger.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsEventLogger.cs index 9bc97277e29..cc2064f4ce0 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsEventLogger.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsEventLogger.cs @@ -405,54 +405,3 @@ private sealed class BrowserNetworkRequestState public required string Url { get; set; } } } - -// Keep message composition separate from the runtime so tests can pin the diagnostics without a live websocket failure. -internal sealed class BrowserConnectionDiagnosticsLogger(string sessionId, ILogger resourceLogger) -{ - private readonly ILogger _resourceLogger = resourceLogger; - private readonly string _sessionId = sessionId; - - public void LogSetupFailure(string stage, Exception exception) - { - _resourceLogger.LogError("[{SessionId}] {Stage} failed: {Reason}", _sessionId, stage, DescribeConnectionProblem(exception)); - } - - public void LogConnectionLost(Exception exception) - { - _resourceLogger.LogWarning("[{SessionId}] Tracked browser debug connection lost: {Reason}. Attempting to reconnect.", _sessionId, DescribeConnectionProblem(exception)); - } - - public void LogReconnectAttemptFailed(int attempt, Exception exception) - { - _resourceLogger.LogWarning("[{SessionId}] Reconnect attempt {Attempt} failed: {Reason}", _sessionId, attempt, DescribeConnectionProblem(exception)); - } - - public void LogReconnectFailed(Exception exception) - { - _resourceLogger.LogError("[{SessionId}] Unable to reconnect tracked browser debug connection. Closing the tracked browser session. Last error: {Reason}", _sessionId, DescribeConnectionProblem(exception)); - } - - public void LogHostTerminated(Exception exception) - { - _resourceLogger.LogError("[{SessionId}] Tracked browser host ended before the tracked page session completed: {Reason}", _sessionId, DescribeConnectionProblem(exception)); - } - - internal static string DescribeConnectionProblem(Exception exception) - { - var messages = new List(); - - for (var current = exception; current is not null; current = current.InnerException) - { - var message = string.IsNullOrWhiteSpace(current.Message) - ? current.GetType().Name - : $"{current.GetType().Name}: {current.Message}"; - - if (!messages.Contains(message, StringComparer.Ordinal)) - { - messages.Add(message); - } - } - - return string.Join(" --> ", messages); - } -} diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs index 9e1b6381f26..a5e6c97b69f 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs @@ -324,32 +324,5 @@ private async Task DisposeBrowserHostLeaseAsync() } } - internal static string? TrySelectTrackedTargetId(IReadOnlyList? targetInfos) - { - if (targetInfos is null) - { - return null; - } - - // Owned Chromium launches start with the about:blank target from BuildBrowserArguments. Reusing it means the - // first tracked session navigates the visible empty tab instead of creating a second tab and leaving a blank one - // behind. If the browser reported a different unattached page first, fall back to that. - 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; - } - - return targetInfos.FirstOrDefault(static targetInfo => - string.Equals(targetInfo.Type, "page", StringComparison.Ordinal) && - targetInfo.Attached != true && - !string.IsNullOrWhiteSpace(targetInfo.TargetId)) - ?.TargetId; - } - private sealed record BrowserSessionResult(int? ExitCode, Exception? Error); } diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserPageSession.cs b/src/Aspire.Hosting/BrowserLogs/BrowserPageSession.cs index 08ff458e05e..7e51c79e3c2 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserPageSession.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserPageSession.cs @@ -193,7 +193,7 @@ private async Task CreateTargetAsync(CancellationToken cancellationToken if (_reuseInitialBlankTarget && _connection is not null) { var targets = await _connection.GetTargetsAsync(cancellationToken).ConfigureAwait(false); - if (BrowserLogsRunningSession.TrySelectTrackedTargetId(targets.TargetInfos) is { } targetId) + if (TrySelectReusableStartupPageTargetId(targets.TargetInfos) is { } targetId) { return targetId; } @@ -206,6 +206,35 @@ private async Task CreateTargetAsync(CancellationToken cancellationToken ?? throw new InvalidOperationException("Browser target creation did not return a target id."); } + internal static string? TrySelectReusableStartupPageTargetId(IReadOnlyList? 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 MonitorAsync() { try diff --git a/tests/Aspire.Hosting.Tests/BrowserConnectionDiagnosticsLoggerTests.cs b/tests/Aspire.Hosting.Tests/BrowserConnectionDiagnosticsLoggerTests.cs new file mode 100644 index 00000000000..d935ca18d2d --- /dev/null +++ b/tests/Aspire.Hosting.Tests/BrowserConnectionDiagnosticsLoggerTests.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.WebSockets; +using Aspire.Hosting.Tests.Utils; + +namespace Aspire.Hosting.Tests; + +[Trait("Partition", "2")] +public class BrowserConnectionDiagnosticsLoggerTests +{ + [Fact] + public async Task LogsConnectionProblems() + { + var resourceLoggerService = ConsoleLoggingTestHelpers.GetResourceLoggerService(); + var resourceName = "web-browser-logs"; + var diagnostics = new BrowserConnectionDiagnosticsLogger("session-0001", resourceLoggerService.GetLogger(resourceName)); + + var logs = await ConsoleLoggingTestHelpers.CaptureLogsAsync(resourceLoggerService, resourceName, targetLogCount: 4, () => + { + diagnostics.LogSetupFailure( + "Setting up the tracked browser debug connection", + new InvalidOperationException("Connecting to the tracked browser debug endpoint failed.", new TimeoutException("Timed out waiting for a tracked browser protocol response to 'Target.attachToTarget'."))); + diagnostics.LogConnectionLost( + new InvalidOperationException("Browser debug connection closed by the remote endpoint with status 'EndpointUnavailable' (1001): browser crashed")); + diagnostics.LogReconnectAttemptFailed( + 2, + new InvalidOperationException("Attaching to the tracked browser target failed.", new TimeoutException("Timed out waiting for a tracked browser protocol response to 'Target.attachToTarget'."))); + diagnostics.LogReconnectFailed( + new InvalidOperationException("Connecting to the tracked browser debug endpoint failed.", new WebSocketException("Connection refused"))); + }); + + Assert.Collection( + logs, + log => Assert.Equal( + "2000-12-29T20:59:59.0000000Z [session-0001] Setting up the tracked browser debug connection failed: InvalidOperationException: Connecting to the tracked browser debug endpoint failed. --> TimeoutException: Timed out waiting for a tracked browser protocol response to 'Target.attachToTarget'.", + log.Content), + log => Assert.Equal( + "2000-12-29T20:59:59.0000000Z [session-0001] Tracked browser debug connection lost: InvalidOperationException: Browser debug connection closed by the remote endpoint with status 'EndpointUnavailable' (1001): browser crashed. Attempting to reconnect.", + log.Content), + log => Assert.Equal( + "2000-12-29T20:59:59.0000000Z [session-0001] Reconnect attempt 2 failed: InvalidOperationException: Attaching to the tracked browser target failed. --> TimeoutException: Timed out waiting for a tracked browser protocol response to 'Target.attachToTarget'.", + log.Content), + log => Assert.Equal( + "2000-12-29T20:59:59.0000000Z [session-0001] Unable to reconnect tracked browser debug connection. Closing the tracked browser session. Last error: InvalidOperationException: Connecting to the tracked browser debug endpoint failed. --> WebSocketException: Connection refused", + log.Content)); + } +} diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs index 56bea9f6094..81c41d3251a 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs @@ -3,7 +3,6 @@ using System.Net; using System.Net.Sockets; -using System.Net.WebSockets; using System.Text; using Aspire.Hosting.Tests.Utils; using Microsoft.Extensions.Logging; @@ -476,68 +475,6 @@ public void ResolveProfileDirectory_ThrowsWhenDisplayNameIsAmbiguous() }); } - [Fact] - public void TrySelectTrackedTargetId_PrefersUnattachedBlankPage() - { - var targetId = BrowserLogsRunningSession.TrySelectTrackedTargetId( - [ - new BrowserLogsTargetInfo { TargetId = "restored-page", Type = "page", Url = "https://example.com", Attached = false }, - new BrowserLogsTargetInfo { TargetId = "service-worker", Type = "service_worker", Url = "https://example.com/sw.js", Attached = false }, - new BrowserLogsTargetInfo { TargetId = "launcher-page", Type = "page", Url = "about:blank", Attached = false } - ]); - - Assert.Equal("launcher-page", targetId); - } - - [Fact] - public void TrySelectTrackedTargetId_FallsBackToFirstUnattachedPage() - { - var targetId = BrowserLogsRunningSession.TrySelectTrackedTargetId( - [ - new BrowserLogsTargetInfo { TargetId = "attached-page", Type = "page", Url = "about:blank", Attached = true }, - new BrowserLogsTargetInfo { TargetId = "fallback-page", Type = "page", Url = "chrome://newtab/", Attached = false } - ]); - - Assert.Equal("fallback-page", targetId); - } - - [Fact] - public async Task BrowserConnectionDiagnosticsLogger_LogsConnectionProblems() - { - var resourceLoggerService = ConsoleLoggingTestHelpers.GetResourceLoggerService(); - var resourceName = "web-browser-logs"; - var diagnostics = new BrowserConnectionDiagnosticsLogger("session-0001", resourceLoggerService.GetLogger(resourceName)); - - var logs = await CaptureLogsAsync(resourceLoggerService, resourceName, targetLogCount: 4, () => - { - diagnostics.LogSetupFailure( - "Setting up the tracked browser debug connection", - new InvalidOperationException("Connecting to the tracked browser debug endpoint failed.", new TimeoutException("Timed out waiting for a tracked browser protocol response to 'Target.attachToTarget'."))); - diagnostics.LogConnectionLost( - new InvalidOperationException("Browser debug connection closed by the remote endpoint with status 'EndpointUnavailable' (1001): browser crashed")); - diagnostics.LogReconnectAttemptFailed( - 2, - new InvalidOperationException("Attaching to the tracked browser target failed.", new TimeoutException("Timed out waiting for a tracked browser protocol response to 'Target.attachToTarget'."))); - diagnostics.LogReconnectFailed( - new InvalidOperationException("Connecting to the tracked browser debug endpoint failed.", new WebSocketException("Connection refused"))); - }); - - Assert.Collection( - logs, - log => Assert.Equal( - "2000-12-29T20:59:59.0000000Z [session-0001] Setting up the tracked browser debug connection failed: InvalidOperationException: Connecting to the tracked browser debug endpoint failed. --> TimeoutException: Timed out waiting for a tracked browser protocol response to 'Target.attachToTarget'.", - log.Content), - log => Assert.Equal( - "2000-12-29T20:59:59.0000000Z [session-0001] Tracked browser debug connection lost: InvalidOperationException: Browser debug connection closed by the remote endpoint with status 'EndpointUnavailable' (1001): browser crashed. Attempting to reconnect.", - log.Content), - log => Assert.Equal( - "2000-12-29T20:59:59.0000000Z [session-0001] Reconnect attempt 2 failed: InvalidOperationException: Attaching to the tracked browser target failed. --> TimeoutException: Timed out waiting for a tracked browser protocol response to 'Target.attachToTarget'.", - log.Content), - log => Assert.Equal( - "2000-12-29T20:59:59.0000000Z [session-0001] Unable to reconnect tracked browser debug connection. Closing the tracked browser session. Last error: InvalidOperationException: Connecting to the tracked browser debug endpoint failed. --> WebSocketException: Connection refused", - log.Content)); - } - [Fact] public async Task StartSessionAsync_ThrowsWhenManagerIsDisposing() { @@ -566,9 +503,6 @@ await Assert.ThrowsAsync(() => manager.StartSessionAsyn Assert.False(sessionFactory.WasCalled); } - private static Task> CaptureLogsAsync(ResourceLoggerService resourceLoggerService, string resourceName, int targetLogCount, Action writeLogs) => - ConsoleLoggingTestHelpers.CaptureLogsAsync(resourceLoggerService, resourceName, targetLogCount, writeLogs); - private static void WithTempUserDataDirectory(Action action) { var userDataDirectory = Directory.CreateTempSubdirectory(); diff --git a/tests/Aspire.Hosting.Tests/BrowserPageSessionTests.cs b/tests/Aspire.Hosting.Tests/BrowserPageSessionTests.cs new file mode 100644 index 00000000000..2c245de5d19 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/BrowserPageSessionTests.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Tests; + +[Trait("Partition", "2")] +public class BrowserPageSessionTests +{ + [Fact] + public void TrySelectReusableStartupPageTargetId_PrefersUnattachedBlankPage() + { + var targetId = BrowserPageSession.TrySelectReusableStartupPageTargetId( + [ + new BrowserLogsTargetInfo { TargetId = "restored-page", Type = "page", Url = "https://example.com", Attached = false }, + new BrowserLogsTargetInfo { TargetId = "service-worker", Type = "service_worker", Url = "https://example.com/sw.js", Attached = false }, + new BrowserLogsTargetInfo { TargetId = "launcher-page", Type = "page", Url = "about:blank", Attached = false } + ]); + + Assert.Equal("launcher-page", targetId); + } + + [Fact] + public void TrySelectReusableStartupPageTargetId_FallsBackToFirstUnattachedPage() + { + var targetId = BrowserPageSession.TrySelectReusableStartupPageTargetId( + [ + new BrowserLogsTargetInfo { TargetId = "attached-page", Type = "page", Url = "about:blank", Attached = true }, + new BrowserLogsTargetInfo { TargetId = "fallback-page", Type = "page", Url = "chrome://newtab/", Attached = false } + ]); + + Assert.Equal("fallback-page", targetId); + } +} From 7de3f0790cd7eb316ae1c638b4f6a3fe764b871e Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 26 Apr 2026 07:57:35 -0700 Subject: [PATCH 30/36] Address browser logs review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogs/BrowserConfiguration.cs | 25 --- .../BrowserLogs/BrowserEndpointDiscovery.cs | 17 +- src/Aspire.Hosting/BrowserLogs/BrowserHost.cs | 16 +- .../BrowserLogs/BrowserHostRegistry.cs | 11 ++ .../BrowserLogs/BrowserLogsCdpConnection.cs | 3 + .../BrowserLogs/BrowserLogsCdpProtocol.cs | 152 +++++++++--------- .../BrowserLogs/BrowserPageSession.cs | 9 +- .../BrowserLogs/BrowserUserDataMode.cs | 29 ++++ .../BrowserLogs/ChromiumBrowserResolver.cs | 2 + .../BrowserLogsCdpProtocolTests.cs | 15 ++ .../BrowserLogsSessionManagerTests.cs | 30 ++++ 11 files changed, 201 insertions(+), 108 deletions(-) create mode 100644 src/Aspire.Hosting/BrowserLogs/BrowserUserDataMode.cs diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserConfiguration.cs b/src/Aspire.Hosting/BrowserLogs/BrowserConfiguration.cs index cb1e3f9f161..2f0e38a0a55 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserConfiguration.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserConfiguration.cs @@ -7,31 +7,6 @@ namespace Aspire.Hosting; -/// -/// Selects the Chromium user data directory used by tracked browser sessions. -/// -public enum BrowserUserDataMode -{ - /// - /// Use the browser's real user data directory so the tracked session behaves like a persistent browser context - /// with real cookies, sessions, extensions, and profile selection. - /// - /// - /// NOTE: Aspire can adopt a shared browser only when it previously launched that browser with remote debugging - /// enabled. If a normal non-debuggable browser is already using the selected user data directory, the tracked - /// session fails with guidance instead of opening a second browser against the same profile store. Google Chrome - /// also blocks remote debugging against its default user data directory; use Microsoft Edge or - /// mode when Chrome is selected. - /// - Shared, - - /// - /// Launch the tracked browser against a temporary user data directory, like a disposable persistent browser - /// context, so the session starts from clean state and does not affect the user's normal browser profiles. - /// - Isolated, -} - /// /// Resolved browser configuration used for one tracked browser session. /// diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs index 61c69be92a1..c56d94257fc 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Globalization; +using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; using Aspire.Hosting.Resources; @@ -23,6 +24,8 @@ namespace Aspire.Hosting; internal sealed class BrowserEndpointDiscovery(ILogger 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() { @@ -30,6 +33,12 @@ internal sealed class BrowserEndpointDiscovery(ILogger _logger = logger; @@ -65,7 +74,7 @@ public static string GetEndpointMetadataFilePath(string userDataDirectory) => // "createdAt": "2026-04-25T19:37:25Z" // } using var stream = File.OpenRead(metadataPath); - metadata = await JsonSerializer.DeserializeAsync(stream, BrowserEndpointJsonContext.Default.BrowserDebugEndpointMetadata, cancellationToken).ConfigureAwait(false); + metadata = await JsonSerializer.DeserializeAsync(stream, s_jsonContext.BrowserDebugEndpointMetadata, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) { @@ -172,7 +181,7 @@ public static async Task WriteAsync(BrowserHostIdentity identity, string? profil // 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, BrowserEndpointJsonContext.Default.BrowserDebugEndpointMetadata, cancellationToken).ConfigureAwait(false); + await JsonSerializer.SerializeAsync(stream, metadata, s_jsonContext.BrowserDebugEndpointMetadata, cancellationToken).ConfigureAwait(false); } File.Move(tempPath, metadataPath, overwrite: true); @@ -254,7 +263,7 @@ private static async Task ProbeBrowserEndpointAsync(Uri browserEndpoint, C // "webSocketDebuggerUrl": "ws://127.0.0.1:50981/devtools/browser/" // } using var stream = await response.Content.ReadAsStreamAsync(probeCts.Token).ConfigureAwait(false); - var version = await JsonSerializer.DeserializeAsync(stream, BrowserEndpointJsonContext.Default.BrowserJsonVersionResponse, 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 _); } @@ -277,7 +286,7 @@ private static async Task ProbeBrowserEndpointAsync(Uri browserEndpoint, C // The Chromium source describes the POSIX lock as a symlink to a non-existent destination shaped like // "-". The host segment can contain dashes, so parse from the final dash instead of splitting // on every dash. - return int.TryParse(linkTarget.AsSpan(separatorIndex + 1), out var pid) ? pid : null; + return int.TryParse(linkTarget.AsSpan(separatorIndex + 1), NumberStyles.None, CultureInfo.InvariantCulture, out var pid) ? pid : null; } catch (IOException) { diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs b/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs index 54ced4e698f..49e4aa94aaa 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs @@ -74,6 +74,9 @@ private async Task CreatePageSessionCoreAsync( // browser-level CDP endpoint, writing adoption metadata, and terminating the browser when the final lease is released. internal sealed class OwnedBrowserHost : BrowserHost { + // Browser startup is a local process + file hand-off. Give Chromium enough time to initialize under CI/dev-machine + // load, poll frequently enough for a responsive dashboard command, and cap shutdown so AppHost disposal cannot hang + // forever on a stuck browser process. private static readonly TimeSpan s_browserEndpointTimeout = TimeSpan.FromSeconds(30); private static readonly TimeSpan s_browserEndpointPollInterval = TimeSpan.FromMilliseconds(100); private static readonly TimeSpan s_browserShutdownTimeout = TimeSpan.FromSeconds(5); @@ -240,7 +243,7 @@ public static async Task StartAsync( try { processId = await WaitForProcessStartAsync(processStarted.Task, processTask, cancellationToken).ConfigureAwait(false); - browserEndpoint = await WaitForBrowserEndpointAsync(processTask, devToolsActivePortFilePath, previousWriteTimeUtc, timeProvider, cancellationToken).ConfigureAwait(false); + browserEndpoint = await WaitForBrowserEndpointAsync(processTask, devToolsActivePortFilePath, previousWriteTimeUtc, logger, timeProvider, cancellationToken).ConfigureAwait(false); // Once Chromium has written DevToolsActivePort and responded with a browser endpoint, write our sidecar so a // later AppHost run can adopt the same debug-enabled browser instead of opening a second window. await BrowserEndpointDiscovery.WriteAsync(identity, userDataDirectory.ProfileDirectoryName, browserEndpoint, processId, cancellationToken).ConfigureAwait(false); @@ -305,6 +308,7 @@ private static async Task WaitForBrowserEndpointAsync( Task processTask, string devToolsActivePortFilePath, DateTime? previousWriteTimeUtc, + ILogger logger, TimeProvider timeProvider, CancellationToken cancellationToken) { @@ -328,6 +332,7 @@ private static async Task WaitForBrowserEndpointAsync( if (previousWriteTimeUtc is { } previousWriteTime && File.GetLastWriteTimeUtc(devToolsActivePortFilePath) <= previousWriteTime) { + logger.LogTrace("Ignoring stale tracked browser endpoint metadata '{DevToolsActivePortFilePath}' while waiting for a fresh Chromium write.", devToolsActivePortFilePath); await Task.Delay(s_browserEndpointPollInterval, cancellationToken).ConfigureAwait(false); continue; } @@ -335,15 +340,20 @@ private static async Task WaitForBrowserEndpointAsync( var contents = await File.ReadAllTextAsync(devToolsActivePortFilePath, cancellationToken).ConfigureAwait(false); if (ChromiumDevToolsActivePortParser.TryParseBrowserDebugEndpoint(contents) is { } browserEndpoint) { + logger.LogTrace("Read tracked browser debug endpoint '{BrowserDebugEndpoint}' from '{DevToolsActivePortFilePath}'.", browserEndpoint, devToolsActivePortFilePath); return browserEndpoint; } + + logger.LogTrace("Tracked browser endpoint metadata '{DevToolsActivePortFilePath}' was present but not parseable yet.", devToolsActivePortFilePath); } } - catch (IOException) + catch (IOException ex) { + logger.LogTrace(ex, "Unable to read tracked browser endpoint metadata '{DevToolsActivePortFilePath}' yet.", devToolsActivePortFilePath); } - catch (UnauthorizedAccessException) + catch (UnauthorizedAccessException ex) { + logger.LogTrace(ex, "Unable to read tracked browser endpoint metadata '{DevToolsActivePortFilePath}' yet.", devToolsActivePortFilePath); } await Task.Delay(s_browserEndpointPollInterval, cancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs b/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs index bcb037202c1..7be3efc2439 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs @@ -3,6 +3,7 @@ #pragma warning disable ASPIREFILESYSTEM001 // Type is for evaluation purposes only +using System.Diagnostics; using System.Globalization; using Aspire.Hosting.Resources; using Microsoft.Extensions.Logging; @@ -126,6 +127,9 @@ public async ValueTask DisposeAsync() List hosts; var lockAcquired = await TryWaitForLockAsync(CancellationToken.None).ConfigureAwait(false); + // DisposeAsync is the only path that flips _lockDisposed, and the Interlocked guard above allows exactly one + // disposer through. Therefore the first disposer must be able to acquire the lock here; if a future refactor + // changes that lifetime ordering, throwing is safer than continuing with a partially-disposed registry. ObjectDisposedException.ThrowIf(!lockAcquired, this); try { @@ -176,6 +180,13 @@ private async ValueTask ReleaseAsync(BrowserHostIdentity identity, CancellationT if (_hosts.TryGetValue(identity, out var entry)) { + Debug.Assert(entry.ReferenceCount > 0, "BrowserHostRegistry reference count underflow."); + if (entry.ReferenceCount <= 0) + { + _logger.LogError("Tracked browser host '{BrowserExecutable}' for user data directory '{UserDataDirectory}' had an invalid reference count '{ReferenceCount}' during release.", identity.ExecutablePath, identity.UserDataRootPath, entry.ReferenceCount); + return; + } + entry.ReferenceCount--; if (entry.ReferenceCount == 0) { diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsCdpConnection.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsCdpConnection.cs index 46ecd9cf91e..838984bbc11 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsCdpConnection.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsCdpConnection.cs @@ -12,6 +12,9 @@ namespace Aspire.Hosting; // reconnection policy stay in BrowserPageSession. internal sealed class BrowserLogsCdpConnection : IAsyncDisposable { + // 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(10); private static readonly TimeSpan s_keepAliveInterval = TimeSpan.FromSeconds(15); diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsCdpProtocol.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsCdpProtocol.cs index fad1eecf2de..e4632ddc7c0 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsCdpProtocol.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsCdpProtocol.cs @@ -202,89 +202,83 @@ internal static string DescribeFrame(ReadOnlySpan framePayload, int maxLen } private static BrowserLogsConsoleApiCalledEvent? CreateConsoleApiCalledEvent(ReadOnlySpan framePayload) - { - var envelope = DeserializeFrame(framePayload, BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsConsoleApiCalledEnvelope); - return envelope.Params is null - ? null - : new BrowserLogsConsoleApiCalledEvent(envelope.SessionId, envelope.Params); - } + => CreateEvent( + framePayload, + BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsConsoleApiCalledEnvelope, + static (string? sessionId, BrowserLogsRuntimeConsoleApiCalledParameters parameters) => new BrowserLogsConsoleApiCalledEvent(sessionId, parameters)); private static BrowserLogsExceptionThrownEvent? CreateExceptionThrownEvent(ReadOnlySpan framePayload) - { - var envelope = DeserializeFrame(framePayload, BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsExceptionThrownEnvelope); - return envelope.Params is null - ? null - : new BrowserLogsExceptionThrownEvent(envelope.SessionId, envelope.Params); - } + => CreateEvent( + framePayload, + BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsExceptionThrownEnvelope, + static (string? sessionId, BrowserLogsExceptionThrownParameters parameters) => new BrowserLogsExceptionThrownEvent(sessionId, parameters)); private static BrowserLogsLogEntryAddedEvent? CreateLogEntryAddedEvent(ReadOnlySpan framePayload) - { - var envelope = DeserializeFrame(framePayload, BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsLogEntryAddedEnvelope); - return envelope.Params is null - ? null - : new BrowserLogsLogEntryAddedEvent(envelope.SessionId, envelope.Params); - } + => CreateEvent( + framePayload, + BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsLogEntryAddedEnvelope, + static (string? sessionId, BrowserLogsLogEntryAddedParameters parameters) => new BrowserLogsLogEntryAddedEvent(sessionId, parameters)); private static BrowserLogsRequestWillBeSentEvent? CreateRequestWillBeSentEvent(ReadOnlySpan framePayload) - { - var envelope = DeserializeFrame(framePayload, BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsRequestWillBeSentEnvelope); - return envelope.Params is null - ? null - : new BrowserLogsRequestWillBeSentEvent(envelope.SessionId, envelope.Params); - } + => CreateEvent( + framePayload, + BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsRequestWillBeSentEnvelope, + static (string? sessionId, BrowserLogsRequestWillBeSentParameters parameters) => new BrowserLogsRequestWillBeSentEvent(sessionId, parameters)); private static BrowserLogsResponseReceivedEvent? CreateResponseReceivedEvent(ReadOnlySpan framePayload) - { - var envelope = DeserializeFrame(framePayload, BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsResponseReceivedEnvelope); - return envelope.Params is null - ? null - : new BrowserLogsResponseReceivedEvent(envelope.SessionId, envelope.Params); - } + => CreateEvent( + framePayload, + BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsResponseReceivedEnvelope, + static (string? sessionId, BrowserLogsResponseReceivedParameters parameters) => new BrowserLogsResponseReceivedEvent(sessionId, parameters)); private static BrowserLogsLoadingFinishedEvent? CreateLoadingFinishedEvent(ReadOnlySpan framePayload) - { - var envelope = DeserializeFrame(framePayload, BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsLoadingFinishedEnvelope); - return envelope.Params is null - ? null - : new BrowserLogsLoadingFinishedEvent(envelope.SessionId, envelope.Params); - } + => CreateEvent( + framePayload, + BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsLoadingFinishedEnvelope, + static (string? sessionId, BrowserLogsLoadingFinishedParameters parameters) => new BrowserLogsLoadingFinishedEvent(sessionId, parameters)); private static BrowserLogsLoadingFailedEvent? CreateLoadingFailedEvent(ReadOnlySpan framePayload) - { - var envelope = DeserializeFrame(framePayload, BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsLoadingFailedEnvelope); - return envelope.Params is null - ? null - : new BrowserLogsLoadingFailedEvent(envelope.SessionId, envelope.Params); - } + => CreateEvent( + framePayload, + BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsLoadingFailedEnvelope, + static (string? sessionId, BrowserLogsLoadingFailedParameters parameters) => new BrowserLogsLoadingFailedEvent(sessionId, parameters)); private static BrowserLogsTargetDestroyedEvent? CreateTargetDestroyedEvent(ReadOnlySpan framePayload) - { - var envelope = DeserializeFrame(framePayload, BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsTargetDestroyedEnvelope); - return envelope.Params is null - ? null - : new BrowserLogsTargetDestroyedEvent(envelope.SessionId, envelope.Params); - } + => CreateEvent( + framePayload, + BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsTargetDestroyedEnvelope, + static (string? sessionId, BrowserLogsTargetDestroyedParameters parameters) => new BrowserLogsTargetDestroyedEvent(sessionId, parameters)); private static BrowserLogsTargetCrashedEvent? CreateTargetCrashedEvent(ReadOnlySpan framePayload) - { - var envelope = DeserializeFrame(framePayload, BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsTargetCrashedEnvelope); - return envelope.Params is null - ? null - : new BrowserLogsTargetCrashedEvent(envelope.SessionId, envelope.Params); - } + => CreateEvent( + framePayload, + BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsTargetCrashedEnvelope, + static (string? sessionId, BrowserLogsTargetCrashedParameters parameters) => new BrowserLogsTargetCrashedEvent(sessionId, parameters)); private static BrowserLogsDetachedFromTargetEvent? CreateDetachedFromTargetEvent(ReadOnlySpan framePayload) - { - var envelope = DeserializeFrame(framePayload, BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsDetachedFromTargetEnvelope); - return envelope.Params is null - ? null - : new BrowserLogsDetachedFromTargetEvent(envelope.SessionId, envelope.Params); - } + => CreateEvent( + framePayload, + BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsDetachedFromTargetEnvelope, + static (string? sessionId, BrowserLogsDetachedFromTargetParameters parameters) => new BrowserLogsDetachedFromTargetEvent(sessionId, parameters)); private static BrowserLogsInspectorDetachedEvent? CreateInspectorDetachedEvent(ReadOnlySpan framePayload) + => CreateEvent( + framePayload, + BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsInspectorDetachedEnvelope, + static (string? sessionId, BrowserLogsInspectorDetachedParameters parameters) => new BrowserLogsInspectorDetachedEvent(sessionId, parameters)); + + private static TEvent? CreateEvent( + ReadOnlySpan framePayload, + JsonTypeInfo jsonTypeInfo, + Func createEvent) + where TEnvelope : class, IBrowserLogsEventEnvelope + where TParameters : class + where TEvent : class { - var envelope = DeserializeFrame(framePayload, BrowserLogsCdpProtocolJsonContext.Default.BrowserLogsInspectorDetachedEnvelope); - return new BrowserLogsInspectorDetachedEvent(envelope.SessionId, envelope.Params); + var envelope = DeserializeFrame(framePayload, jsonTypeInfo); + return envelope.Params is null + ? null + : createEvent(envelope.SessionId, envelope.Params); } private static T DeserializeFrame(ReadOnlySpan framePayload, JsonTypeInfo jsonTypeInfo) @@ -368,10 +362,10 @@ internal sealed record BrowserLogsDetachedFromTargetEvent(string? SessionId, Bro public string? TargetId => Parameters.TargetId; } -internal sealed record BrowserLogsInspectorDetachedEvent(string? SessionId, BrowserLogsInspectorDetachedParameters? Parameters) +internal sealed record BrowserLogsInspectorDetachedEvent(string? SessionId, BrowserLogsInspectorDetachedParameters Parameters) : BrowserLogsCdpProtocolEvent(BrowserLogsCdpProtocol.InspectorDetachedMethod, SessionId) { - public string? Reason => Parameters?.Reason; + public string? Reason => Parameters.Reason; } internal sealed class BrowserLogsAttachToTargetResponseEnvelope @@ -410,7 +404,15 @@ internal sealed class BrowserLogsCommandAckResponseEnvelope public long Id { get; init; } } -internal sealed class BrowserLogsConsoleApiCalledEnvelope +internal interface IBrowserLogsEventEnvelope + where TParameters : class +{ + TParameters? Params { get; } + + string? SessionId { get; } +} + +internal sealed class BrowserLogsConsoleApiCalledEnvelope : IBrowserLogsEventEnvelope { [JsonPropertyName("params")] public BrowserLogsRuntimeConsoleApiCalledParameters? Params { get; init; } @@ -485,7 +487,7 @@ internal sealed class BrowserLogsExceptionObject public string? Description { get; init; } } -internal sealed class BrowserLogsExceptionThrownEnvelope +internal sealed class BrowserLogsExceptionThrownEnvelope : IBrowserLogsEventEnvelope { [JsonPropertyName("params")] public BrowserLogsExceptionThrownParameters? Params { get; init; } @@ -500,7 +502,7 @@ internal sealed class BrowserLogsExceptionThrownParameters public BrowserLogsExceptionDetails? ExceptionDetails { get; init; } } -internal sealed class BrowserLogsLoadingFailedEnvelope +internal sealed class BrowserLogsLoadingFailedEnvelope : IBrowserLogsEventEnvelope { [JsonPropertyName("params")] public BrowserLogsLoadingFailedParameters? Params { get; init; } @@ -527,7 +529,7 @@ internal sealed class BrowserLogsLoadingFailedParameters public double? Timestamp { get; init; } } -internal sealed class BrowserLogsLoadingFinishedEnvelope +internal sealed class BrowserLogsLoadingFinishedEnvelope : IBrowserLogsEventEnvelope { [JsonPropertyName("params")] public BrowserLogsLoadingFinishedParameters? Params { get; init; } @@ -557,7 +559,7 @@ internal sealed class BrowserLogsLogEntry : BrowserLogsSourceLocation public string? Text { get; init; } } -internal sealed class BrowserLogsLogEntryAddedEnvelope +internal sealed class BrowserLogsLogEntryAddedEnvelope : IBrowserLogsEventEnvelope { [JsonPropertyName("params")] public BrowserLogsLogEntryAddedParameters? Params { get; init; } @@ -602,7 +604,7 @@ internal sealed class BrowserLogsRequest public string? Url { get; init; } } -internal sealed class BrowserLogsRequestWillBeSentEnvelope +internal sealed class BrowserLogsRequestWillBeSentEnvelope : IBrowserLogsEventEnvelope { [JsonPropertyName("params")] public BrowserLogsRequestWillBeSentParameters? Params { get; init; } @@ -647,7 +649,7 @@ internal sealed class BrowserLogsResponse public string? Url { get; init; } } -internal sealed class BrowserLogsResponseReceivedEnvelope +internal sealed class BrowserLogsResponseReceivedEnvelope : IBrowserLogsEventEnvelope { [JsonPropertyName("params")] public BrowserLogsResponseReceivedParameters? Params { get; init; } @@ -689,7 +691,7 @@ internal class BrowserLogsSourceLocation public string? Url { get; init; } } -internal sealed class BrowserLogsTargetDestroyedEnvelope +internal sealed class BrowserLogsTargetDestroyedEnvelope : IBrowserLogsEventEnvelope { [JsonPropertyName("params")] public BrowserLogsTargetDestroyedParameters? Params { get; init; } @@ -704,7 +706,7 @@ internal sealed class BrowserLogsTargetDestroyedParameters public string? TargetId { get; init; } } -internal sealed class BrowserLogsTargetCrashedEnvelope +internal sealed class BrowserLogsTargetCrashedEnvelope : IBrowserLogsEventEnvelope { [JsonPropertyName("params")] public BrowserLogsTargetCrashedParameters? Params { get; init; } @@ -725,7 +727,7 @@ internal sealed class BrowserLogsTargetCrashedParameters public string? TargetId { get; init; } } -internal sealed class BrowserLogsDetachedFromTargetEnvelope +internal sealed class BrowserLogsDetachedFromTargetEnvelope : IBrowserLogsEventEnvelope { [JsonPropertyName("params")] public BrowserLogsDetachedFromTargetParameters? Params { get; init; } @@ -743,7 +745,7 @@ internal sealed class BrowserLogsDetachedFromTargetParameters public string? TargetId { get; init; } } -internal sealed class BrowserLogsInspectorDetachedEnvelope +internal sealed class BrowserLogsInspectorDetachedEnvelope : IBrowserLogsEventEnvelope { [JsonPropertyName("params")] public BrowserLogsInspectorDetachedParameters? Params { get; init; } diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserPageSession.cs b/src/Aspire.Hosting/BrowserLogs/BrowserPageSession.cs index 7e51c79e3c2..f238be3540c 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserPageSession.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserPageSession.cs @@ -11,6 +11,9 @@ namespace Aspire.Hosting; // reconnection loop. internal sealed class BrowserPageSession : IBrowserPageSession { + // Keep reconnects quick and local to transient websocket 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); @@ -121,7 +124,7 @@ public async ValueTask DisposeAsync() _stopCts.Cancel(); var connection = _connection; - if (connection is not null && _targetId is not null) + if (connection is not null && ReferenceEquals(connection, _connection) && _targetId is not null) { try { @@ -268,6 +271,7 @@ private async Task MonitorAsync() if (_stopCts.IsCancellationRequested) { + _logger.LogTrace("Stopping tracked browser page session '{SessionId}' after debug connection completed.", _sessionId); return new BrowserPageSessionResult(BrowserPageSessionCompletionKind.Stopped, Error: null); } @@ -302,12 +306,15 @@ private async Task TryReconnectAsync(Exception connectionError) { 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) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserUserDataMode.cs b/src/Aspire.Hosting/BrowserLogs/BrowserUserDataMode.cs new file mode 100644 index 00000000000..7efa52e1cc9 --- /dev/null +++ b/src/Aspire.Hosting/BrowserLogs/BrowserUserDataMode.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting; + +/// +/// Selects the Chromium user data directory used by tracked browser sessions. +/// +public enum BrowserUserDataMode +{ + /// + /// Use the browser's real user data directory so the tracked session behaves like a persistent browser context + /// with real cookies, sessions, extensions, and profile selection. + /// + /// + /// Aspire can adopt a shared browser only when it previously launched that browser with remote debugging enabled. + /// If a normal non-debuggable browser is already using the selected user data directory, the tracked session fails + /// with guidance instead of opening a second browser against the same profile store. Google Chrome also blocks + /// remote debugging against its default user data directory; use Microsoft Edge or mode when + /// Chrome is selected. + /// + Shared, + + /// + /// Launch the tracked browser against a temporary user data directory, like a disposable persistent browser + /// context, so the session starts from clean state and does not affect the user's normal browser profiles. + /// + Isolated, +} diff --git a/src/Aspire.Hosting/BrowserLogs/ChromiumBrowserResolver.cs b/src/Aspire.Hosting/BrowserLogs/ChromiumBrowserResolver.cs index aff8073a8cc..4ff01ca38f6 100644 --- a/src/Aspire.Hosting/BrowserLogs/ChromiumBrowserResolver.cs +++ b/src/Aspire.Hosting/BrowserLogs/ChromiumBrowserResolver.cs @@ -279,6 +279,8 @@ private static IEnumerable GetBrowserCandidates(string browser) foreach (var directoryPath in Directory.EnumerateDirectories(userDataDirectory)) { var directoryName = Path.GetFileName(directoryPath); + // Treat configured profile directory names as user input and match case-insensitively, then return the + // actual directory name so the Chromium command line preserves the filesystem casing. if (string.Equals(directoryName, profile, StringComparison.OrdinalIgnoreCase)) { return directoryName; diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsCdpProtocolTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsCdpProtocolTests.cs index 3043a014e7d..d07e1fdf56a 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsCdpProtocolTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsCdpProtocolTests.cs @@ -122,6 +122,21 @@ public void ParseEvent_TargetCrashed_ReturnsTargetStatusAndErrorCode() Assert.Equal(1337, @event.Parameters.ErrorCode); } + [Fact] + public void ParseEvent_InspectorDetachedWithoutParams_ReturnsNull() + { + var payload = Encoding.UTF8.GetBytes(""" + { + "method": "Inspector.detached", + "sessionId": "target-session-1" + } + """); + + var header = BrowserLogsCdpProtocol.ParseMessageHeader(payload); + + Assert.Null(BrowserLogsCdpProtocol.ParseEvent(header, payload)); + } + [Fact] public void CreateCommandFrame_DoesNotEscapeNonAsciiCharacters() { diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs index 81c41d3251a..cd9420b35e6 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs @@ -233,6 +233,36 @@ await BrowserEndpointDiscovery.WriteAsync( } } + [Fact] + public async Task BrowserEndpointDiscovery_DoesNotEscapeNonAsciiMetadata() + { + var userDataDirectory = Directory.CreateTempSubdirectory(); + try + { + var identity = new BrowserHostIdentity( + Path.Combine(userDataDirectory.FullName, "bröwser"), + userDataDirectory.FullName); + var metadataPath = BrowserEndpointDiscovery.GetEndpointMetadataFilePath(userDataDirectory.FullName); + + await BrowserEndpointDiscovery.WriteAsync( + identity, + profileDirectoryName: "Pröfile 1", + new Uri("ws://127.0.0.1:9/devtools/browser/stale"), + processId: int.MaxValue, + CancellationToken.None); + + var metadata = await File.ReadAllTextAsync(metadataPath); + + Assert.Contains("bröwser", metadata); + Assert.Contains("Pröfile 1", metadata); + Assert.DoesNotContain("\\u00f6", metadata); + } + finally + { + userDataDirectory.Delete(recursive: true); + } + } + [Fact] public async Task BrowserEndpointDiscovery_DeletesMalformedEndpointMetadata() { From 16bf2443a8a3efc12e1bce982c68ace31b598590 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 26 Apr 2026 08:14:09 -0700 Subject: [PATCH 31/36] Add browser logs baseline coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogs/BrowserLogsCdpConnection.cs | 22 +- .../BrowserLogs/BrowserPageSession.cs | 42 +++- .../BrowserLogsSessionManagerTests.cs | 43 ++++ .../BrowserPageSessionTests.cs | 223 ++++++++++++++++++ 4 files changed, 326 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsCdpConnection.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsCdpConnection.cs index 838984bbc11..8379254b3bf 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsCdpConnection.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsCdpConnection.cs @@ -8,9 +8,29 @@ namespace Aspire.Hosting; +// Browser-level CDP connection operations used by BrowserPageSession. +internal interface IBrowserLogsCdpConnection : IAsyncDisposable +{ + Task Completion { get; } + + Task CreateTargetAsync(CancellationToken cancellationToken); + + Task GetTargetsAsync(CancellationToken cancellationToken); + + Task AttachToTargetAsync(string targetId, CancellationToken cancellationToken); + + Task CloseTargetAsync(string targetId, CancellationToken cancellationToken); + + Task EnableTargetDiscoveryAsync(CancellationToken cancellationToken); + + Task EnablePageInstrumentationAsync(string sessionId, CancellationToken cancellationToken); + + Task NavigateAsync(string sessionId, Uri url, CancellationToken cancellationToken); +} + // Owns the browser-level websocket only. Protocol parsing stays in BrowserLogsCdpProtocol, while page lifecycle and // reconnection policy stay in BrowserPageSession. -internal sealed class BrowserLogsCdpConnection : IAsyncDisposable +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 diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserPageSession.cs b/src/Aspire.Hosting/BrowserLogs/BrowserPageSession.cs index f238be3540c..72a0dae9341 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserPageSession.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserPageSession.cs @@ -5,6 +5,13 @@ namespace Aspire.Hosting; +// Factory for browser-level CDP connections. +internal delegate Task BrowserLogsCdpConnectionFactory( + Uri webSocketUri, + Func eventHandler, + ILogger 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 @@ -20,6 +27,7 @@ internal sealed class BrowserPageSession : IBrowserPageSession private readonly TaskCompletionSource _completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly BrowserConnectionDiagnosticsLogger _connectionDiagnostics; + private readonly BrowserLogsCdpConnectionFactory _connectionFactory; private readonly Func _eventHandler; private readonly IBrowserHost _host; private readonly ILogger _logger; @@ -29,7 +37,7 @@ internal sealed class BrowserPageSession : IBrowserPageSession private readonly TimeProvider _timeProvider; private readonly Uri _url; - private BrowserLogsCdpConnection? _connection; + private IBrowserLogsCdpConnection? _connection; private Task? _monitorTask; private int _disposed; private string? _targetId; @@ -40,12 +48,14 @@ private BrowserPageSession( string sessionId, Uri url, BrowserConnectionDiagnosticsLogger connectionDiagnostics, + BrowserLogsCdpConnectionFactory connectionFactory, Func eventHandler, ILogger logger, TimeProvider timeProvider, bool reuseInitialBlankTarget) { _connectionDiagnostics = connectionDiagnostics; + _connectionFactory = connectionFactory; _eventHandler = eventHandler; _host = host; _logger = logger; @@ -100,7 +110,33 @@ public static async Task StartAsync( bool reuseInitialBlankTarget, CancellationToken cancellationToken) { - var pageSession = new BrowserPageSession(host, sessionId, url, connectionDiagnostics, eventHandler, logger, timeProvider, reuseInitialBlankTarget); + return await StartAsync( + host, + sessionId, + url, + connectionDiagnostics, + static async (webSocketUri, eventHandler, logger, cancellationToken) => + await BrowserLogsCdpConnection.ConnectAsync(webSocketUri, eventHandler, logger, cancellationToken).ConfigureAwait(false), + eventHandler, + logger, + timeProvider, + reuseInitialBlankTarget, + cancellationToken).ConfigureAwait(false); + } + + internal static async Task StartAsync( + IBrowserHost host, + string sessionId, + Uri url, + BrowserConnectionDiagnosticsLogger connectionDiagnostics, + BrowserLogsCdpConnectionFactory connectionFactory, + Func eventHandler, + ILogger 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); @@ -159,7 +195,7 @@ private async Task ConnectAsync(bool createTarget, CancellationToken cancellatio { await DisposeConnectionAsync().ConfigureAwait(false); - _connection = await BrowserLogsCdpConnection.ConnectAsync(_host.DebugEndpoint, HandleEventAsync, _logger, cancellationToken).ConfigureAwait(false); + _connection = await _connectionFactory(_host.DebugEndpoint, HandleEventAsync, _logger, cancellationToken).ConfigureAwait(false); // Target discovery must be re-enabled for every browser-level connection, including reconnects. The // subscription is attached to this websocket, 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. diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs index cd9420b35e6..1388c8cde7b 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs @@ -162,6 +162,49 @@ public async Task BrowserHostRegistry_RejectsDifferentProfileForSharedHost() } } + [Fact] + public async Task BrowserHostRegistry_AdoptsValidatedSharedEndpointMetadata() + { + var userDataDirectory = Directory.CreateTempSubdirectory(); + try + { + var browserExecutable = Path.Combine(userDataDirectory.FullName, "browser"); + File.WriteAllText(browserExecutable, string.Empty); + var identity = new BrowserHostIdentity(browserExecutable, userDataDirectory.FullName); + var browserEndpoint = StartBrowserVersionEndpoint(out var serverTask); + + await BrowserEndpointDiscovery.WriteAsync( + identity, + profileDirectoryName: null, + browserEndpoint, + Environment.ProcessId, + CancellationToken.None); + + await using var registry = new BrowserHostRegistry( + fileSystemService: null!, + NullLogger.Instance, + TimeProvider.System, + createUserDataDirectory: (configuration, _) => BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory.FullName, configuration.Profile), + createHostAsync: null); + + var lease = await registry.AcquireAsync( + new BrowserConfiguration(browserExecutable, Profile: null, BrowserUserDataMode.Shared), + CancellationToken.None); + + await serverTask.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(BrowserHostOwnership.Adopted, lease.Host.Ownership); + Assert.Equal(identity, lease.Host.Identity); + Assert.Equal(browserEndpoint, lease.Host.DebugEndpoint); + Assert.Null(lease.Host.ProcessId); + + await lease.DisposeAsync(); + } + finally + { + userDataDirectory.Delete(recursive: true); + } + } + [Fact] public void BrowserPageSession_MapsTargetLifecycleEventsToCompletion() { diff --git a/tests/Aspire.Hosting.Tests/BrowserPageSessionTests.cs b/tests/Aspire.Hosting.Tests/BrowserPageSessionTests.cs index 2c245de5d19..e2bcf01817d 100644 --- a/tests/Aspire.Hosting.Tests/BrowserPageSessionTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserPageSessionTests.cs @@ -1,6 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Logging.Abstractions; + namespace Aspire.Hosting.Tests; [Trait("Partition", "2")] @@ -30,4 +33,224 @@ public void TrySelectReusableStartupPageTargetId_FallsBackToFirstUnattachedPage( Assert.Equal("fallback-page", targetId); } + + [Fact] + public async Task StartAsync_ReusesStartupTargetAttachesInstrumentsNavigatesAndRoutesEvents() + { + var host = new TestBrowserHost(); + var connection = new FakeBrowserLogsCdpConnection + { + TargetInfos = + [ + new BrowserLogsTargetInfo { TargetId = "startup-target", Type = "page", Url = "about:blank", Attached = false } + ] + }; + var routedEvents = new List(); + + var session = await BrowserPageSession.StartAsync( + host, + "session-0001", + new Uri("https://localhost:5001/"), + new BrowserConnectionDiagnosticsLogger("session-0001", NullLogger.Instance), + CreateConnectionFactory(host, connection), + protocolEvent => + { + routedEvents.Add(protocolEvent); + return ValueTask.CompletedTask; + }, + NullLogger.Instance, + TimeProvider.System, + reuseInitialBlankTarget: true, + CancellationToken.None); + + Assert.Equal("startup-target", session.TargetId); + Assert.Equal("target-session-1", session.TargetSessionId); + Assert.Equal( + new[] + { + "EnableTargetDiscovery", + "GetTargets", + "Attach:startup-target", + "EnablePageInstrumentation:target-session-1", + "Navigate:target-session-1:https://localhost:5001/" + }, + connection.Calls); + + var unrelatedEvent = new BrowserLogsConsoleApiCalledEvent("other-session", new BrowserLogsRuntimeConsoleApiCalledParameters { Type = "log" }); + var routedEvent = new BrowserLogsConsoleApiCalledEvent("target-session-1", new BrowserLogsRuntimeConsoleApiCalledParameters { Type = "log" }); + await connection.RaiseEventAsync(unrelatedEvent); + await connection.RaiseEventAsync(routedEvent); + + var capturedEvent = Assert.Single(routedEvents); + Assert.Same(routedEvent, capturedEvent); + + await connection.RaiseEventAsync(new BrowserLogsTargetDestroyedEvent( + SessionId: null, + new BrowserLogsTargetDestroyedParameters { TargetId = "startup-target" })); + + var result = await session.Completion.DefaultTimeout(); + Assert.Equal(BrowserPageSessionCompletionKind.PageClosed, result.CompletionKind); + Assert.Null(result.Error); + Assert.True(connection.Disposed); + + await session.DisposeAsync(); + } + + [Fact] + public async Task DisposeAsync_ClosesTrackedTarget() + { + var host = new TestBrowserHost(); + var connection = new FakeBrowserLogsCdpConnection + { + CreatedTargetId = "created-target" + }; + + var session = await BrowserPageSession.StartAsync( + host, + "session-0001", + new Uri("https://localhost:5001/"), + new BrowserConnectionDiagnosticsLogger("session-0001", NullLogger.Instance), + CreateConnectionFactory(host, connection), + static _ => ValueTask.CompletedTask, + NullLogger.Instance, + TimeProvider.System, + reuseInitialBlankTarget: false, + CancellationToken.None); + + await session.DisposeAsync(); + + Assert.Equal( + new[] + { + "EnableTargetDiscovery", + "CreateTarget", + "Attach:created-target", + "EnablePageInstrumentation:target-session-1", + "Navigate:target-session-1:https://localhost:5001/", + "CloseTarget:created-target" + }, + connection.Calls); + Assert.True(connection.Disposed); + var result = await session.Completion.DefaultTimeout(); + Assert.Equal(BrowserPageSessionCompletionKind.Stopped, result.CompletionKind); + } + + private static BrowserLogsCdpConnectionFactory CreateConnectionFactory(TestBrowserHost host, FakeBrowserLogsCdpConnection connection) + { + return (webSocketUri, eventHandler, _, _) => + { + Assert.Equal(host.DebugEndpoint, webSocketUri); + connection.SetEventHandler(eventHandler); + return Task.FromResult(connection); + }; + } + + private sealed class TestBrowserHost : IBrowserHost + { + private readonly TaskCompletionSource _terminationSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public BrowserHostIdentity Identity { get; } = new( + Path.Combine(AppContext.BaseDirectory, "browser"), + Path.Combine(AppContext.BaseDirectory, "user-data")); + + public BrowserHostOwnership Ownership => BrowserHostOwnership.Owned; + + public Uri DebugEndpoint { get; } = new("ws://127.0.0.1/devtools/browser/test"); + + public int? ProcessId => 1; + + public string BrowserDisplayName => "Test"; + + public Task Termination => _terminationSource.Task; + + public Task CreatePageSessionAsync( + string sessionId, + Uri url, + BrowserConnectionDiagnosticsLogger connectionDiagnostics, + Func eventHandler, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public ValueTask DisposeAsync() + { + _terminationSource.TrySetResult(); + return ValueTask.CompletedTask; + } + } + + private sealed class FakeBrowserLogsCdpConnection : IBrowserLogsCdpConnection + { + private readonly TaskCompletionSource _completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + private Func? _eventHandler; + + public List Calls { get; } = []; + + public string CreatedTargetId { get; init; } = "target-1"; + + public bool Disposed { get; private set; } + + public BrowserLogsTargetInfo[]? TargetInfos { get; init; } + + public Task Completion => _completionSource.Task; + + public Task CreateTargetAsync(CancellationToken cancellationToken) + { + Calls.Add("CreateTarget"); + return Task.FromResult(new BrowserLogsCreateTargetResult { TargetId = CreatedTargetId }); + } + + public Task GetTargetsAsync(CancellationToken cancellationToken) + { + Calls.Add("GetTargets"); + return Task.FromResult(new BrowserLogsGetTargetsResult { TargetInfos = TargetInfos }); + } + + public Task AttachToTargetAsync(string targetId, CancellationToken cancellationToken) + { + Calls.Add($"Attach:{targetId}"); + return Task.FromResult(new BrowserLogsAttachToTargetResult { SessionId = "target-session-1" }); + } + + public Task CloseTargetAsync(string targetId, CancellationToken cancellationToken) + { + Calls.Add($"CloseTarget:{targetId}"); + return Task.FromResult(BrowserLogsCommandAck.Instance); + } + + public Task EnableTargetDiscoveryAsync(CancellationToken cancellationToken) + { + Calls.Add("EnableTargetDiscovery"); + return Task.FromResult(BrowserLogsCommandAck.Instance); + } + + public Task EnablePageInstrumentationAsync(string sessionId, CancellationToken cancellationToken) + { + Calls.Add($"EnablePageInstrumentation:{sessionId}"); + return Task.CompletedTask; + } + + public Task NavigateAsync(string sessionId, Uri url, CancellationToken cancellationToken) + { + Calls.Add($"Navigate:{sessionId}:{url}"); + return Task.FromResult(BrowserLogsCommandAck.Instance); + } + + public void SetEventHandler(Func eventHandler) + { + _eventHandler = eventHandler; + } + + public ValueTask RaiseEventAsync(BrowserLogsCdpProtocolEvent protocolEvent) + { + return _eventHandler is null + ? throw new InvalidOperationException("The fake connection is not connected.") + : _eventHandler(protocolEvent); + } + + public ValueTask DisposeAsync() + { + Disposed = true; + return ValueTask.CompletedTask; + } + } } From 92ae5833c307aa77c3b62bb7cf649386b563d08b Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 26 Apr 2026 08:24:58 -0700 Subject: [PATCH 32/36] Test browser logs CDP connection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogs/BrowserLogsCdpConnection.cs | 8 +- .../BrowserLogsCdpConnectionTests.cs | 297 +++++++++++++++++- 2 files changed, 300 insertions(+), 5 deletions(-) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsCdpConnection.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsCdpConnection.cs index 8379254b3bf..ed389958f0a 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsCdpConnection.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsCdpConnection.cs @@ -45,10 +45,10 @@ internal sealed class BrowserLogsCdpConnection : IBrowserLogsCdpConnection private readonly ConcurrentDictionary _pendingCommands = new(); private readonly Task _receiveLoop; private readonly SemaphoreSlim _sendLock = new(1, 1); - private readonly ClientWebSocket _webSocket; + private readonly WebSocket _webSocket; private long _nextCommandId; - private BrowserLogsCdpConnection(ClientWebSocket webSocket, Func eventHandler, ILogger logger) + private BrowserLogsCdpConnection(WebSocket webSocket, Func eventHandler, ILogger logger) { _eventHandler = eventHandler; _logger = logger; @@ -403,7 +403,7 @@ internal interface IClientWebSocketConnector : IDisposable Task ConnectAsync(Uri webSocketUri, CancellationToken cancellationToken); - ClientWebSocket DetachConnectedWebSocket(); + WebSocket DetachConnectedWebSocket(); } // Thin ownership wrapper around ClientWebSocket. It lets BrowserLogsCdpConnection transfer the connected socket into @@ -422,7 +422,7 @@ public Task ConnectAsync(Uri webSocketUri, CancellationToken cancellationToken) return GetWebSocket().ConnectAsync(webSocketUri, cancellationToken); } - public ClientWebSocket DetachConnectedWebSocket() + public WebSocket DetachConnectedWebSocket() { var webSocket = GetWebSocket(); _webSocket = null; diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsCdpConnectionTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsCdpConnectionTests.cs index 4fd6d717a43..f43755a028c 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsCdpConnectionTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsCdpConnectionTests.cs @@ -1,7 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Buffers; +using System.IO.Pipelines; using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Hosting.Tests; @@ -27,6 +32,162 @@ public async Task ConnectAsync_DisposesConnectorWhenConnectFails() Assert.Equal(TimeSpan.FromSeconds(15), connector.KeepAliveInterval); } + [Fact] + public async Task ConnectAsync_CorrelatesOutOfOrderResponsesAndRoutesEventsWhileCommandIsPending() + { + await using var pair = InMemoryWebSocketPair.Create(); + var connector = new ConnectedClientWebSocketConnector(pair.ClientSocket); + var routedEventSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var connection = await BrowserLogsCdpConnection.ConnectAsync( + new Uri("ws://127.0.0.1/devtools/browser/test"), + protocolEvent => + { + routedEventSource.TrySetResult(protocolEvent); + return ValueTask.CompletedTask; + }, + NullLogger.Instance, + CancellationToken.None, + () => connector); + + var createTargetTask = connection.CreateTargetAsync(CancellationToken.None); + var attachToTargetTask = connection.AttachToTargetAsync("target-1", CancellationToken.None); + + var firstCommand = await ReceiveCommandAsync(pair.ServerSocket).DefaultTimeout(); + var secondCommand = await ReceiveCommandAsync(pair.ServerSocket).DefaultTimeout(); + var createTargetCommand = Assert.Single(new[] { firstCommand, secondCommand }, static command => command.Method == BrowserLogsCdpProtocol.TargetCreateTargetMethod); + var attachToTargetCommand = Assert.Single(new[] { firstCommand, secondCommand }, static command => command.Method == BrowserLogsCdpProtocol.TargetAttachToTargetMethod); + Assert.Null(createTargetCommand.SessionId); + Assert.Equal("about:blank", createTargetCommand.Url); + Assert.Null(attachToTargetCommand.SessionId); + Assert.Equal("target-1", attachToTargetCommand.TargetId); + + await SendTextAsync( + pair.ServerSocket, + """ + { + "method": "Runtime.consoleAPICalled", + "sessionId": "target-session-1", + "params": { + "type": "log", + "args": [] + } + } + """).DefaultTimeout(); + + var routedEvent = Assert.IsType(await routedEventSource.Task.DefaultTimeout()); + Assert.Equal("target-session-1", routedEvent.SessionId); + Assert.Equal("log", routedEvent.Parameters.Type); + + await SendTextAsync( + pair.ServerSocket, + $$""" + { + "id": {{attachToTargetCommand.Id}}, + "result": { + "sessionId": "attached-session" + } + } + """).DefaultTimeout(); + await SendTextAsync( + pair.ServerSocket, + $$""" + { + "id": {{createTargetCommand.Id}}, + "result": { + "targetId": "created-target" + } + } + """).DefaultTimeout(); + + var createTargetResult = await createTargetTask.DefaultTimeout(); + var attachToTargetResult = await attachToTargetTask.DefaultTimeout(); + Assert.Equal("created-target", createTargetResult.TargetId); + Assert.Equal("attached-session", attachToTargetResult.SessionId); + Assert.True(connector.Disposed); + + await pair.ServerSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Done", CancellationToken.None).DefaultTimeout(); + } + + private static async Task ReceiveCommandAsync(WebSocket socket) + { + using var document = await ReceiveJsonDocumentAsync(socket).DefaultTimeout(); + var root = document.RootElement; + var id = root.GetProperty("id").GetInt64(); + var method = root.GetProperty("method").GetString()!; + var sessionId = root.TryGetProperty("sessionId", out var sessionIdElement) + ? sessionIdElement.GetString() + : null; + JsonElement? parameters = root.TryGetProperty("params", out var parametersElement) + ? parametersElement + : null; + var targetId = parameters?.TryGetProperty("targetId", out var targetIdElement) == true + ? targetIdElement.GetString() + : null; + var url = parameters?.TryGetProperty("url", out var urlElement) == true + ? urlElement.GetString() + : null; + + return new ReceivedCommand(id, method, sessionId, targetId, url); + } + + private static async Task ReceiveJsonDocumentAsync(WebSocket socket) + { + var buffer = new byte[1024]; + using var messageBuffer = new MemoryStream(); + + while (true) + { + var result = await socket.ReceiveAsync(buffer, CancellationToken.None).DefaultTimeout(); + if (result.MessageType == WebSocketMessageType.Close) + { + throw new InvalidOperationException("The in-memory websocket closed before a JSON message was received."); + } + + messageBuffer.Write(buffer, 0, result.Count); + if (result.EndOfMessage) + { + return JsonDocument.Parse(messageBuffer.ToArray()); + } + } + } + + private static Task SendTextAsync(WebSocket socket, string text) + { + return socket.SendAsync(Encoding.UTF8.GetBytes(text), WebSocketMessageType.Text, endOfMessage: true, CancellationToken.None); + } + + private sealed record ReceivedCommand(long Id, string Method, string? SessionId, string? TargetId, string? Url); + + private sealed class ConnectedClientWebSocketConnector(WebSocket webSocket) : IClientWebSocketConnector + { + private readonly WebSocket _webSocket = webSocket; + + public bool Disposed { get; private set; } + + public TimeSpan? KeepAliveInterval { get; private set; } + + public void SetKeepAliveInterval(TimeSpan interval) + { + KeepAliveInterval = interval; + } + + public Task ConnectAsync(Uri webSocketUri, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public WebSocket DetachConnectedWebSocket() + { + return _webSocket; + } + + public void Dispose() + { + Disposed = true; + } + } + private sealed class ThrowingClientWebSocketConnector(Exception connectException) : IClientWebSocketConnector { public bool Disposed { get; private set; } @@ -43,7 +204,7 @@ public Task ConnectAsync(Uri webSocketUri, CancellationToken cancellationToken) return Task.FromException(connectException); } - public ClientWebSocket DetachConnectedWebSocket() + public WebSocket DetachConnectedWebSocket() { throw new InvalidOperationException("A failed connect should not detach a websocket."); } @@ -53,4 +214,138 @@ public void Dispose() Disposed = true; } } + + private sealed class InMemoryWebSocketPair : IAsyncDisposable + { + private readonly DuplexPipeStream _clientStream; + private readonly DuplexPipeStream _serverStream; + + private InMemoryWebSocketPair(DuplexPipeStream clientStream, DuplexPipeStream serverStream) + { + _clientStream = clientStream; + _serverStream = serverStream; + ClientSocket = WebSocket.CreateFromStream(clientStream, isServer: false, subProtocol: null, keepAliveInterval: TimeSpan.FromSeconds(15)); + ServerSocket = WebSocket.CreateFromStream(serverStream, isServer: true, subProtocol: null, keepAliveInterval: TimeSpan.FromSeconds(15)); + } + + public WebSocket ClientSocket { get; } + + public WebSocket ServerSocket { get; } + + public static InMemoryWebSocketPair Create() + { + var clientToServer = new Pipe(); + var serverToClient = new Pipe(); + return new InMemoryWebSocketPair( + new DuplexPipeStream(serverToClient.Reader, clientToServer.Writer), + new DuplexPipeStream(clientToServer.Reader, serverToClient.Writer)); + } + + public async ValueTask DisposeAsync() + { + ClientSocket.Dispose(); + ServerSocket.Dispose(); + await _clientStream.DisposeAsync(); + await _serverStream.DisposeAsync(); + } + } + + private sealed class DuplexPipeStream(PipeReader reader, PipeWriter writer) : Stream + { + private int _disposed; + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => true; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + writer.FlushAsync().AsTask().GetAwaiter().GetResult(); + } + + public override async Task FlushAsync(CancellationToken cancellationToken) + { + await writer.FlushAsync(cancellationToken); + } + + public override int Read(byte[] buffer, int offset, int count) + { + return ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + while (true) + { + var result = await reader.ReadAsync(cancellationToken); + var readableBuffer = result.Buffer; + if (readableBuffer.Length > 0) + { + var count = (int)Math.Min(readableBuffer.Length, buffer.Length); + var consumed = readableBuffer.GetPosition(count); + readableBuffer.Slice(0, count).CopyTo(buffer.Span); + reader.AdvanceTo(consumed); + return count; + } + + reader.AdvanceTo(readableBuffer.Start, readableBuffer.End); + if (result.IsCompleted) + { + return 0; + } + } + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException(); + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + WriteAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + await writer.WriteAsync(buffer, cancellationToken); + } + + protected override void Dispose(bool disposing) + { + if (disposing && Interlocked.Exchange(ref _disposed, 1) == 0) + { + reader.Complete(); + writer.Complete(); + } + + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + { + await reader.CompleteAsync(); + await writer.CompleteAsync(); + } + + await base.DisposeAsync(); + } + } } From 1e0574bd726cfbefce9d626beaac99a956d7610a Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 26 Apr 2026 08:41:15 -0700 Subject: [PATCH 33/36] Test browser logs running session glue Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogsRunningSessionTests.cs | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 tests/Aspire.Hosting.Tests/BrowserLogsRunningSessionTests.cs diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsRunningSessionTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsRunningSessionTests.cs new file mode 100644 index 00000000000..8d8a0d156e7 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/BrowserLogsRunningSessionTests.cs @@ -0,0 +1,175 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREFILESYSTEM001 // Type is for evaluation purposes only + +using Aspire.Hosting.Tests.Utils; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Hosting.Tests; + +[Trait("Partition", "2")] +public class BrowserLogsRunningSessionTests +{ + [Fact] + public async Task RunningSessionRoutesPageEventsToResourceLogsAndReleasesHostOnCompletion() + { + var userDataDirectory = Directory.CreateTempSubdirectory(); + try + { + var browserExecutable = Path.Combine(userDataDirectory.FullName, "browser"); + File.WriteAllText(browserExecutable, string.Empty); + + TestBrowserHost? host = null; + await using var registry = new BrowserHostRegistry( + fileSystemService: null!, + NullLogger.Instance, + TimeProvider.System, + createUserDataDirectory: (configuration, _) => BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory.FullName, configuration.Profile), + createHostAsync: (configuration, identity, _, _) => + { + host = new TestBrowserHost(identity); + return Task.FromResult(host); + }); + + var resourceLoggerService = ConsoleLoggingTestHelpers.GetResourceLoggerService(); + var resourceName = "web-browser-logs"; + BrowserLogsRunningSession? session = null; + var logs = await ConsoleLoggingTestHelpers.CaptureLogsAsync(resourceLoggerService, resourceName, targetLogCount: 5, () => + { + session = BrowserLogsRunningSession.StartAsync( + new BrowserConfiguration(browserExecutable, Profile: null, BrowserUserDataMode.Shared), + resourceName, + "session-0001", + new Uri("https://localhost:5001/"), + registry, + resourceLoggerService.GetLogger(resourceName), + NullLogger.Instance, + TimeProvider.System, + CancellationToken.None).GetAwaiter().GetResult(); + + host!.PageSession!.RaiseEventAsync(new BrowserLogsConsoleApiCalledEvent( + SessionId: "target-session-1", + new BrowserLogsRuntimeConsoleApiCalledParameters + { + Type = "log", + Args = + [ + new BrowserLogsCdpProtocolRemoteObject + { + Value = new BrowserLogsCdpProtocolStringValue("hello from tracked browser") + } + ] + })).AsTask().GetAwaiter().GetResult(); + }); + + Assert.NotNull(session); + Assert.NotNull(host); + Assert.Equal(browserExecutable, session.BrowserExecutable); + Assert.Equal(host.DebugEndpoint, session.BrowserDebugEndpoint); + Assert.Equal(BrowserHostOwnership.Owned, session.BrowserHostOwnership); + Assert.Equal(42, session.ProcessId); + Assert.Equal("target-1", session.TargetId); + Assert.Equal("session-0001", host.PageSession?.SessionId); + Assert.Equal(new Uri("https://localhost:5001/"), host.PageSession?.Url); + Assert.Contains(logs, log => log.Content.EndsWith("[session-0001] [console.log] hello from tracked browser", StringComparison.Ordinal)); + + var completed = new TaskCompletionSource<(int? ExitCode, Exception? Error)>(TaskCreationOptions.RunContinuationsAsynchronously); + var observerTask = session.StartCompletionObserver((exitCode, error) => + { + completed.TrySetResult((exitCode, error)); + return Task.CompletedTask; + }); + + host.PageSession!.Complete(new BrowserPageSessionResult(BrowserPageSessionCompletionKind.PageClosed, Error: null)); + + var result = await completed.Task.DefaultTimeout(); + await observerTask.DefaultTimeout(); + + Assert.Null(result.ExitCode); + Assert.Null(result.Error); + Assert.True(host.Disposed); + Assert.Equal(1, host.PageSession.DisposeCount); + } + finally + { + userDataDirectory.Delete(recursive: true); + } + } + + private sealed class TestBrowserHost(BrowserHostIdentity identity) : IBrowserHost + { + private readonly TaskCompletionSource _terminationSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public BrowserHostIdentity Identity { get; } = identity; + + public BrowserHostOwnership Ownership => BrowserHostOwnership.Owned; + + public Uri DebugEndpoint { get; } = new("ws://127.0.0.1/devtools/browser/test"); + + public int? ProcessId => 42; + + public string BrowserDisplayName => "Test Browser"; + + public Task Termination => _terminationSource.Task; + + public bool Disposed { get; private set; } + + public TestBrowserPageSession? PageSession { get; private set; } + + public Task CreatePageSessionAsync( + string sessionId, + Uri url, + BrowserConnectionDiagnosticsLogger connectionDiagnostics, + Func eventHandler, + CancellationToken cancellationToken) + { + PageSession = new TestBrowserPageSession(sessionId, url, eventHandler); + return Task.FromResult(PageSession); + } + + public ValueTask DisposeAsync() + { + Disposed = true; + _terminationSource.TrySetResult(); + return ValueTask.CompletedTask; + } + } + + private sealed class TestBrowserPageSession( + string sessionId, + Uri url, + Func eventHandler) : IBrowserPageSession + { + private readonly TaskCompletionSource _completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public string SessionId { get; } = sessionId; + + public Uri Url { get; } = url; + + public string TargetId => "target-1"; + + public string TargetSessionId => "target-session-1"; + + public Task Completion => _completionSource.Task; + + public int DisposeCount { get; private set; } + + public ValueTask RaiseEventAsync(BrowserLogsCdpProtocolEvent protocolEvent) + { + return eventHandler(protocolEvent); + } + + public void Complete(BrowserPageSessionResult result) + { + _completionSource.TrySetResult(result); + } + + public ValueTask DisposeAsync() + { + DisposeCount++; + return ValueTask.CompletedTask; + } + } +} From faa9cbb57cc0255b11ce76c3fcf04a43b48f1f27 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 26 Apr 2026 10:13:02 -0700 Subject: [PATCH 34/36] Test browser page session reconnect Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserPageSessionTests.cs | 87 ++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/tests/Aspire.Hosting.Tests/BrowserPageSessionTests.cs b/tests/Aspire.Hosting.Tests/BrowserPageSessionTests.cs index e2bcf01817d..9166e1884aa 100644 --- a/tests/Aspire.Hosting.Tests/BrowserPageSessionTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserPageSessionTests.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; namespace Aspire.Hosting.Tests; @@ -135,11 +136,82 @@ public async Task DisposeAsync_ClosesTrackedTarget() Assert.Equal(BrowserPageSessionCompletionKind.Stopped, result.CompletionKind); } - private static BrowserLogsCdpConnectionFactory CreateConnectionFactory(TestBrowserHost host, FakeBrowserLogsCdpConnection connection) + [Fact] + public async Task MonitorAsync_ReconnectsToExistingTargetAfterConnectionLoss() { + var host = new TestBrowserHost(); + var firstConnection = new FakeBrowserLogsCdpConnection + { + CreatedTargetId = "target-1", + AttachSessionId = "target-session-1" + }; + var secondConnection = new FakeBrowserLogsCdpConnection + { + AttachSessionId = "target-session-2" + }; + var routedEvents = new List(); + var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 4, 26, 0, 0, 0, TimeSpan.Zero)); + + var session = await BrowserPageSession.StartAsync( + host, + "session-0001", + new Uri("https://localhost:5001/"), + new BrowserConnectionDiagnosticsLogger("session-0001", NullLogger.Instance), + CreateConnectionFactory(host, firstConnection, secondConnection), + protocolEvent => + { + routedEvents.Add(protocolEvent); + return ValueTask.CompletedTask; + }, + NullLogger.Instance, + timeProvider, + reuseInitialBlankTarget: false, + CancellationToken.None); + + Assert.Equal("target-1", session.TargetId); + Assert.Equal("target-session-1", session.TargetSessionId); + + firstConnection.FailCompletion(new InvalidOperationException("Socket reset.")); + + await secondConnection.InstrumentationEnabled.DefaultTimeout(); + + Assert.True(firstConnection.Disposed); + Assert.Equal("target-1", session.TargetId); + Assert.Equal("target-session-2", session.TargetSessionId); + Assert.Equal( + new[] + { + "EnableTargetDiscovery", + "Attach:target-1", + "EnablePageInstrumentation:target-session-2" + }, + secondConnection.Calls); + + var routedEvent = new BrowserLogsConsoleApiCalledEvent("target-session-2", new BrowserLogsRuntimeConsoleApiCalledParameters { Type = "log" }); + await secondConnection.RaiseEventAsync(routedEvent); + + Assert.Same(routedEvent, Assert.Single(routedEvents)); + + await secondConnection.RaiseEventAsync(new BrowserLogsTargetDestroyedEvent( + SessionId: null, + new BrowserLogsTargetDestroyedParameters { TargetId = "target-1" })); + + var result = await session.Completion.DefaultTimeout(); + Assert.Equal(BrowserPageSessionCompletionKind.PageClosed, result.CompletionKind); + Assert.Null(result.Error); + Assert.True(secondConnection.Disposed); + + await session.DisposeAsync(); + } + + private static BrowserLogsCdpConnectionFactory CreateConnectionFactory(TestBrowserHost host, params FakeBrowserLogsCdpConnection[] connections) + { + var nextConnectionIndex = 0; return (webSocketUri, eventHandler, _, _) => { Assert.Equal(host.DebugEndpoint, webSocketUri); + Assert.True(nextConnectionIndex < connections.Length); + var connection = connections[nextConnectionIndex++]; connection.SetEventHandler(eventHandler); return Task.FromResult(connection); }; @@ -181,18 +253,23 @@ public ValueTask DisposeAsync() private sealed class FakeBrowserLogsCdpConnection : IBrowserLogsCdpConnection { private readonly TaskCompletionSource _completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _instrumentationEnabled = new(TaskCreationOptions.RunContinuationsAsynchronously); private Func? _eventHandler; public List Calls { get; } = []; public string CreatedTargetId { get; init; } = "target-1"; + public string AttachSessionId { get; init; } = "target-session-1"; + public bool Disposed { get; private set; } public BrowserLogsTargetInfo[]? TargetInfos { get; init; } public Task Completion => _completionSource.Task; + public Task InstrumentationEnabled => _instrumentationEnabled.Task; + public Task CreateTargetAsync(CancellationToken cancellationToken) { Calls.Add("CreateTarget"); @@ -208,7 +285,7 @@ public Task GetTargetsAsync(CancellationToken cance public Task AttachToTargetAsync(string targetId, CancellationToken cancellationToken) { Calls.Add($"Attach:{targetId}"); - return Task.FromResult(new BrowserLogsAttachToTargetResult { SessionId = "target-session-1" }); + return Task.FromResult(new BrowserLogsAttachToTargetResult { SessionId = AttachSessionId }); } public Task CloseTargetAsync(string targetId, CancellationToken cancellationToken) @@ -226,6 +303,7 @@ public Task EnableTargetDiscoveryAsync(CancellationToken public Task EnablePageInstrumentationAsync(string sessionId, CancellationToken cancellationToken) { Calls.Add($"EnablePageInstrumentation:{sessionId}"); + _instrumentationEnabled.TrySetResult(); return Task.CompletedTask; } @@ -247,6 +325,11 @@ public ValueTask RaiseEventAsync(BrowserLogsCdpProtocolEvent protocolEvent) : _eventHandler(protocolEvent); } + public void FailCompletion(Exception exception) + { + _completionSource.TrySetException(exception); + } + public ValueTask DisposeAsync() { Disposed = true; From 2a0694b4ae9a1d7d391400916f5d3570b5e52056 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 26 Apr 2026 20:38:11 -0700 Subject: [PATCH 35/36] Redefine Shared/Isolated as Aspire-managed persistent profiles Both BrowserUserDataMode.Shared and Isolated now use Aspire-managed persistent user data directories under %LocalAppData%\Aspire\BrowserData (Windows) or platform equivalents. Shared is machine-wide (per-browser); Isolated is keyed on AppHost:PathSha256. - Shared no longer points at the user's real Chromium profile; it uses ...\BrowserData\shared\. - Isolated uses ...\BrowserData\isolated\\; replaces the previous temp-dir-per-session model. - Cross-process CDP adoption now runs for both modes via the existing aspire-debug-endpoint.json sidecar. - The browser process is no longer killed when the AppHost shuts down; it lives until the user closes it. - BrowserConfiguration carries the AppHost path SHA, captured once at Resolve time. - Removed dead helpers: IsNonDebuggableBrowserRunning, IsGoogleChromeDefaultUserDataDirectory, TryResolveUserDataDirectory and their resx strings/tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserLogs/BrowserConfiguration.cs | 13 +- .../BrowserLogs/BrowserEndpointDiscovery.cs | 72 ---------- src/Aspire.Hosting/BrowserLogs/BrowserHost.cs | 29 ++-- .../BrowserLogs/BrowserHostRegistry.cs | 105 ++++++--------- .../BrowserLogsBuilderExtensions.cs | 10 +- .../BrowserLogs/BrowserLogsRunningSession.cs | 4 +- .../BrowserLogs/BrowserLogsSessionManager.cs | 3 +- .../BrowserLogs/BrowserUserDataMode.cs | 22 +-- .../BrowserUserDataPathResolver.cs | 127 ++++++++++++++++++ .../BrowserLogs/ChromiumBrowserResolver.cs | 110 --------------- .../Resources/MessageStrings.Designer.cs | 33 +---- .../Resources/MessageStrings.resx | 18 +-- .../Resources/xlf/MessageStrings.cs.xlf | 25 +--- .../Resources/xlf/MessageStrings.de.xlf | 25 +--- .../Resources/xlf/MessageStrings.es.xlf | 25 +--- .../Resources/xlf/MessageStrings.fr.xlf | 25 +--- .../Resources/xlf/MessageStrings.it.xlf | 25 +--- .../Resources/xlf/MessageStrings.ja.xlf | 25 +--- .../Resources/xlf/MessageStrings.ko.xlf | 25 +--- .../Resources/xlf/MessageStrings.pl.xlf | 25 +--- .../Resources/xlf/MessageStrings.pt-BR.xlf | 25 +--- .../Resources/xlf/MessageStrings.ru.xlf | 25 +--- .../Resources/xlf/MessageStrings.tr.xlf | 25 +--- .../Resources/xlf/MessageStrings.zh-Hans.xlf | 25 +--- .../Resources/xlf/MessageStrings.zh-Hant.xlf | 25 +--- .../BrowserLogsRunningSessionTests.cs | 4 +- .../BrowserLogsSessionManagerTests.cs | 126 ++--------------- 27 files changed, 295 insertions(+), 706 deletions(-) create mode 100644 src/Aspire.Hosting/BrowserLogs/BrowserUserDataPathResolver.cs diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserConfiguration.cs b/src/Aspire.Hosting/BrowserLogs/BrowserConfiguration.cs index 2f0e38a0a55..6e1c1d8586b 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserConfiguration.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserConfiguration.cs @@ -15,10 +15,11 @@ namespace Aspire.Hosting; /// does that imply?". The later user-data-directory decision belongs to , where /// the resolved browser executable path is available. /// -internal readonly record struct BrowserConfiguration(string Browser, string? Profile, BrowserUserDataMode UserDataMode) +internal readonly record struct BrowserConfiguration(string Browser, string? Profile, BrowserUserDataMode UserDataMode, string? AppHostKey) { /// - /// The default mode matches a normal browser launch by using the browser's real user data directory. + /// The default mode points at an Aspire-managed persistent user data directory shared across every Aspire + /// AppHost on the machine, so cookies, sign-ins, and extensions persist between runs. /// internal const BrowserUserDataMode DefaultUserDataMode = BrowserUserDataMode.Shared; @@ -74,7 +75,13 @@ internal static BrowserConfiguration Resolve( BrowserUserDataMode.Shared)); } - return new BrowserConfiguration(resolvedBrowser, resolvedProfile, resolvedUserDataMode); + // Stable per-AppHost key sourced from DistributedApplicationBuilder. Only Isolated mode actually needs it + // (its user-data path includes the AppHost segment), but it is always captured here so the registry never + // has to re-read configuration. Same SHA value is used by FileDeploymentStateManager for analogous + // per-AppHost persistent state. + var appHostKey = configuration["AppHost:PathSha256"]; + + return new BrowserConfiguration(resolvedBrowser, resolvedProfile, resolvedUserDataMode, appHostKey); } /// diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs index c56d94257fc..5add159a5bf 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserEndpointDiscovery.cs @@ -195,47 +195,6 @@ public static async Task WriteAsync(BrowserHostIdentity identity, string? profil public static void DeleteEndpointMetadata(string userDataDirectory) => TryDelete(GetEndpointMetadataFilePath(userDataDirectory)); - public static bool IsNonDebuggableBrowserRunning(string userDataDirectory) => - IsNonDebuggableBrowserRunning(userDataDirectory, OperatingSystem.IsWindows()); - - internal static bool IsNonDebuggableBrowserRunning(string userDataDirectory, bool isWindows) - { - // Chromium uses different singleton lock files by platform: - // - POSIX/macOS: SingletonLock is a symlink shaped like "-". - // - Windows: lockfile is held open with FILE_FLAG_DELETE_ON_CLOSE and has no PID payload. - // See Chromium's process_singleton_posix.cc and process_singleton_win.cc for the platform-specific details. - var lockPath = Path.Combine(userDataDirectory, isWindows ? "lockfile" : "SingletonLock"); - FileInfo lockFile; - try - { - lockFile = new FileInfo(lockPath); - // Broken Unix symlinks can report Exists=false while still exposing the host-pid LinkTarget we need. - if (!lockFile.Exists && string.IsNullOrWhiteSpace(lockFile.LinkTarget)) - { - return false; - } - } - catch (IOException) - { - return false; - } - catch (UnauthorizedAccessException) - { - return false; - } - - if (TryGetSingletonLockProcessId(lockFile) is { } pid) - { - return IsProcessAlive(pid); - } - - // Chromium documents this singleton behavior in process_singleton_posix.cc: - // https://chromium.googlesource.com/chromium/src/+/main/chrome/browser/process_singleton_posix.cc - // On Windows the singleton is a locked file rather than a host-pid symlink, so the best available signal is the - // presence of the lock path. On Unix we avoid treating old broken symlinks as an active browser. - return isWindows; - } - private static async Task ProbeBrowserEndpointAsync(Uri browserEndpoint, CancellationToken cancellationToken) { // BrowserHost.DebugEndpoint is a websocket URL. Chromium exposes the matching HTTP endpoint by swapping @@ -267,37 +226,6 @@ private static async Task ProbeBrowserEndpointAsync(Uri browserEndpoint, C return Uri.TryCreate(version?.WebSocketDebuggerUrl, UriKind.Absolute, out _); } - private static int? TryGetSingletonLockProcessId(FileInfo singletonLock) - { - try - { - var linkTarget = singletonLock.LinkTarget; - if (string.IsNullOrWhiteSpace(linkTarget)) - { - return null; - } - - var separatorIndex = linkTarget.LastIndexOf('-'); - if (separatorIndex < 0 || separatorIndex == linkTarget.Length - 1) - { - return null; - } - - // The Chromium source describes the POSIX lock as a symlink to a non-existent destination shaped like - // "-". The host segment can contain dashes, so parse from the final dash instead of splitting - // on every dash. - return int.TryParse(linkTarget.AsSpan(separatorIndex + 1), NumberStyles.None, CultureInfo.InvariantCulture, out var pid) ? pid : null; - } - catch (IOException) - { - return null; - } - catch (UnauthorizedAccessException) - { - return null; - } - } - private static bool IsProcessAlive(int processId) { try diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs b/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs index 49e4aa94aaa..e3f33287842 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs @@ -79,7 +79,6 @@ internal sealed class OwnedBrowserHost : BrowserHost // forever on a stuck browser process. private static readonly TimeSpan s_browserEndpointTimeout = TimeSpan.FromSeconds(30); private static readonly TimeSpan s_browserEndpointPollInterval = TimeSpan.FromMilliseconds(100); - private static readonly TimeSpan s_browserShutdownTimeout = TimeSpan.FromSeconds(5); private readonly BrowserLogsUserDataDirectory _userDataDirectory; private readonly IAsyncDisposable _processLifetime; @@ -274,22 +273,18 @@ public override async ValueTask DisposeAsync() return; } - // Remove the adoption sidecar before tearing down the process so a subsequent AppHost does not adopt a browser - // that is already exiting. - BrowserEndpointDiscovery.DeleteEndpointMetadata(_userDataDirectory.Path); - - await _processLifetime.DisposeAsync().ConfigureAwait(false); - - using var shutdownCts = new CancellationTokenSource(s_browserShutdownTimeout); - try - { - await _processTask.WaitAsync(shutdownCts.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - } - - _userDataDirectory.Dispose(); + // Both Shared and Isolated point at a persistent Aspire-managed user data directory. AppHost shutdown does + // not close the browser, does not delete the adoption sidecar, and does not delete the user data directory. + // The next AppHost run reads the sidecar and connects to this browser via CDP. The user closes the browser + // when they are done with it. + // + // We deliberately do not dispose _processLifetime, which would terminate the browser process. The Process + // handle leaks until the AppHost exits; ProcessDisposable has no finalizer that would kill the process on + // GC, so the browser keeps running. + _ = _processLifetime; + _ = _processTask; + _ = _userDataDirectory; + await Task.CompletedTask.ConfigureAwait(false); } private static async Task WaitForProcessStartAsync(Task processStarted, Task processTask, CancellationToken cancellationToken) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs b/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs index 7be3efc2439..4f45f2eb96c 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs @@ -18,7 +18,6 @@ internal sealed class BrowserHostRegistry : IAsyncDisposable private readonly BrowserEndpointDiscovery _endpointDiscovery; private readonly Func _createUserDataDirectory; private readonly Func> _createHostAsync; - private readonly IFileSystemService _fileSystemService; private readonly Dictionary _hosts = new(); private readonly SemaphoreSlim _lock = new(1, 1); private readonly object _lockLifetimeGate = new(); @@ -29,13 +28,12 @@ internal sealed class BrowserHostRegistry : IAsyncDisposable private int _disposed; private bool _lockDisposed; - public BrowserHostRegistry(IFileSystemService fileSystemService, ILogger logger, TimeProvider timeProvider) - : this(fileSystemService, logger, timeProvider, createUserDataDirectory: null, createHostAsync: null) + public BrowserHostRegistry(ILogger logger, TimeProvider timeProvider) + : this(logger, timeProvider, createUserDataDirectory: null, createHostAsync: null) { } internal BrowserHostRegistry( - IFileSystemService fileSystemService, ILogger logger, TimeProvider timeProvider, Func? createUserDataDirectory, @@ -44,7 +42,6 @@ internal BrowserHostRegistry( _endpointDiscovery = new BrowserEndpointDiscovery(logger); _createUserDataDirectory = createUserDataDirectory ?? CreateUserDataDirectory; _createHostAsync = createHostAsync ?? CreateHostCoreAsync; - _fileSystemService = fileSystemService; _logger = logger; _timeProvider = timeProvider; } @@ -296,38 +293,22 @@ private async Task CreateHostCoreAsync( BrowserLogsUserDataDirectory userDataDirectory, CancellationToken cancellationToken) { - if (configuration.UserDataMode == BrowserUserDataMode.Shared) + // Both Shared and Isolated point at an Aspire-managed persistent user data directory, so the same + // adoption-or-launch decision applies to both: + // + // 1. If a previous AppHost run for this identity wrote an adoption sidecar and the recorded process is + // still alive with a live /json/version endpoint, connect to that browser via CDP. + // 2. Otherwise, launch a new debug-enabled browser against the same user data directory and write the + // sidecar so the next AppHost run can connect. + // + // Aspire never points at the user's real browser profile, so a "non-debuggable browser is using this + // user data dir" failure mode no longer applies. + if (await _endpointDiscovery.TryReadAndValidateAsync(identity, userDataDirectory.ProfileDirectoryName, cancellationToken).ConfigureAwait(false) is { } metadata) { - // Shared mode has three outcomes, in this order: - // - // 1. Adopt a browser that Aspire previously launched for this user data root and profile. The endpoint file - // must validate against this browser identity, which protects us from stale metadata left behind by a - // different browser or profile. Real browser sessions can leave sidecar files behind if they are closed - // externally or crash, so the file is only useful after the process and /json/version endpoint respond. - // 2. If the profile is locked but no valid Aspire endpoint exists, fail with guidance. That means a normal - // browser is using the profile without remote debugging, so we cannot attach and must not start a second - // browser against the same locked user data directory. On real Chromium profiles that second launch tends - // to hand off to the already-running browser or fail before writing a usable DevTools endpoint. - // 3. If nothing is running, fall through and start an owned debug-enabled browser. - if (await _endpointDiscovery.TryReadAndValidateAsync(identity, userDataDirectory.ProfileDirectoryName, cancellationToken).ConfigureAwait(false) is { } metadata) - { - var endpoint = new Uri(metadata.Endpoint, UriKind.Absolute); - _logger.LogInformation("Adopting tracked browser host '{BrowserExecutable}' at '{Endpoint}'.", identity.ExecutablePath, endpoint); - userDataDirectory.Dispose(); - return new AdoptedBrowserHost(identity, endpoint, configuration.Browser, _logger, _timeProvider); - } - - if (BrowserEndpointDiscovery.IsNonDebuggableBrowserRunning(identity.UserDataRootPath)) - { - userDataDirectory.Dispose(); - throw new InvalidOperationException( - string.Format( - CultureInfo.CurrentCulture, - MessageStrings.BrowserLogsNonDebuggableBrowserRunning, - identity.UserDataRootPath, - BrowserLogsBuilderExtensions.UserDataModeConfigurationKey, - BrowserUserDataMode.Isolated)); - } + var endpoint = new Uri(metadata.Endpoint, UriKind.Absolute); + _logger.LogInformation("Adopting tracked browser host '{BrowserExecutable}' at '{Endpoint}'.", identity.ExecutablePath, endpoint); + userDataDirectory.Dispose(); + return new AdoptedBrowserHost(identity, endpoint, configuration.Browser, _logger, _timeProvider); } _logger.LogInformation("Starting tracked browser host '{BrowserExecutable}'.", identity.ExecutablePath); @@ -336,40 +317,34 @@ private async Task CreateHostCoreAsync( private BrowserLogsUserDataDirectory CreateUserDataDirectory(BrowserConfiguration configuration, string browserExecutable) { - if (configuration.UserDataMode == BrowserUserDataMode.Isolated) - { - // Isolated mode never reuses the user's normal profile. Each host gets a temp user data directory that can - // be safely deleted when the last lease releases the owned browser. - return BrowserLogsUserDataDirectory.CreateTemporary(_fileSystemService.TempDirectory.CreateTempSubdirectory("aspire-browser-logs")); - } - - // Shared mode is a persistent browser context over the browser's real user data root, so user state, - // extensions, and profiles are available. Chromium puts singleton locks, DevToolsActivePort, and our Aspire - // endpoint sidecar at that root; named profiles are subdirectories selected by command-line argument. The later - // endpoint/probe logic decides whether that root is reusable, adoptable, or locked. - var userDataDirectory = ChromiumBrowserResolver.TryResolveUserDataDirectory(configuration.Browser, browserExecutable) - ?? throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, MessageStrings.BrowserLogsUnableToResolveUserDataDirectory, configuration.Browser)); + // Both modes use a persistent Aspire-managed user data directory. The mode picks the path scope: + // Shared -> machine-wide, shared across every Aspire AppHost + // Isolated -> per-AppHost (keyed on AppHost:PathSha256) + // + // The directory is created on demand and never deleted by AppHost shutdown. The browser process is left + // running across AppHost runs and adopted via the endpoint sidecar. + var path = BrowserUserDataPathResolver.Resolve(configuration); - if (!Directory.Exists(userDataDirectory)) - { - throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, MessageStrings.BrowserLogsUserDataDirectoryNotFoundForBrowser, userDataDirectory, configuration.Browser)); - } + // Profile resolution requires Local State to exist (Chromium writes it on first launch). Skip resolution + // when the directory is fresh and treat the supplied profile as the literal --profile-directory value; + // Chromium creates the sub-directory on first use. + var profileDirectoryName = configuration.Profile is { } profile + ? ResolveProfileDirectoryName(path, profile) + : null; + return BrowserLogsUserDataDirectory.CreatePersistent(path, profileDirectoryName); + } - if (ChromiumBrowserResolver.IsGoogleChromeDefaultUserDataDirectory(configuration.Browser, browserExecutable, userDataDirectory)) + private static string ResolveProfileDirectoryName(string userDataDirectory, string profile) + { + var localStatePath = Path.Combine(userDataDirectory, "Local State"); + if (File.Exists(localStatePath)) { - throw new InvalidOperationException( - string.Format( - CultureInfo.CurrentCulture, - MessageStrings.BrowserLogsGoogleChromeDefaultUserDataDirectoryNotSupported, - userDataDirectory, - BrowserLogsBuilderExtensions.UserDataModeConfigurationKey, - BrowserUserDataMode.Isolated)); + return ChromiumBrowserResolver.ResolveProfileDirectory(userDataDirectory, profile); } - var profileDirectoryName = configuration.Profile is { } profile - ? ChromiumBrowserResolver.ResolveProfileDirectory(userDataDirectory, profile) - : null; - return BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory, profileDirectoryName); + // Fresh user data directory: no Local State to map display names through. Use the supplied profile string + // as the literal directory name. Chromium creates it on launch. + return profile; } private static void ValidateProfileCompatibility(BrowserHostIdentity identity, string? existingProfileDirectoryName, string? requestedProfileDirectoryName) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs index 5d320e1026e..d56a423506d 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsBuilderExtensions.cs @@ -57,10 +57,12 @@ public static class BrowserLogsBuilderExtensions /// /// /// Optional that selects whether the tracked browser launches against - /// the browser's real user data directory (, the default) or a - /// temporary user data directory (). When not specified, the - /// tracked browser uses the configured value from Aspire:Hosting:BrowserLogs and otherwise - /// defaults to . + /// a persistent Aspire-managed user data directory shared across all AppHosts on the machine + /// (, the default) or a per-AppHost persistent user data directory + /// (). Both modes use Aspire-managed paths under + /// %LocalAppData%\Aspire\BrowserData on Windows (or platform equivalents); the user's normal browser + /// profile is never used. When not specified, the tracked browser uses the configured value from + /// Aspire:Hosting:BrowserLogs and otherwise defaults to . /// /// A reference to the original for further chaining. /// diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs index a5e6c97b69f..1026b22a61b 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs @@ -45,9 +45,9 @@ internal sealed class BrowserLogsRunningSessionFactory : IBrowserLogsRunningSess private readonly ILogger _logger; private readonly TimeProvider _timeProvider; - public BrowserLogsRunningSessionFactory(IFileSystemService fileSystemService, ILogger logger, TimeProvider timeProvider) + public BrowserLogsRunningSessionFactory(ILogger logger, TimeProvider timeProvider) { - _browserHostRegistry = new BrowserHostRegistry(fileSystemService, logger, timeProvider); + _browserHostRegistry = new BrowserHostRegistry(logger, timeProvider); _logger = logger; _timeProvider = timeProvider; } diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs index b1b7fc525a2..1a136d72360 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs @@ -27,7 +27,6 @@ internal sealed class BrowserLogsSessionManager : IBrowserLogsSessionManager, IA private int _disposing; public BrowserLogsSessionManager( - IFileSystemService fileSystemService, ResourceLoggerService resourceLoggerService, ResourceNotificationService resourceNotificationService, TimeProvider timeProvider, @@ -37,7 +36,7 @@ public BrowserLogsSessionManager( resourceNotificationService, timeProvider, logger, - new BrowserLogsRunningSessionFactory(fileSystemService, logger, timeProvider)) + new BrowserLogsRunningSessionFactory(logger, timeProvider)) { } diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserUserDataMode.cs b/src/Aspire.Hosting/BrowserLogs/BrowserUserDataMode.cs index 7efa52e1cc9..6b1df8e5838 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserUserDataMode.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserUserDataMode.cs @@ -9,21 +9,25 @@ namespace Aspire.Hosting; public enum BrowserUserDataMode { /// - /// Use the browser's real user data directory so the tracked session behaves like a persistent browser context - /// with real cookies, sessions, extensions, and profile selection. + /// Use a persistent Aspire-managed user data directory shared across all AppHosts on the machine. State such as + /// cookies, sign-ins, and extensions persist across runs and are visible to every AppHost using the same browser. /// /// - /// Aspire can adopt a shared browser only when it previously launched that browser with remote debugging enabled. - /// If a normal non-debuggable browser is already using the selected user data directory, the tracked session fails - /// with guidance instead of opening a second browser against the same profile store. Google Chrome also blocks - /// remote debugging against its default user data directory; use Microsoft Edge or mode when - /// Chrome is selected. + /// The directory lives under a well-known path (for example %LocalAppData%\Aspire\BrowserData\shared\<browser> + /// on Windows). When multiple AppHosts run concurrently, the second AppHost adopts the existing browser via the + /// Chrome DevTools Protocol instead of launching a new one. The browser is never closed automatically when an + /// AppHost exits. /// Shared, /// - /// Launch the tracked browser against a temporary user data directory, like a disposable persistent browser - /// context, so the session starts from clean state and does not affect the user's normal browser profiles. + /// Use a persistent Aspire-managed user data directory scoped to the current AppHost project. Each AppHost gets + /// its own state that persists across runs but is not shared with other AppHosts. /// + /// + /// The directory is keyed on a stable hash of the AppHost project path (for example + /// %LocalAppData%\Aspire\BrowserData\isolated\<hash>\<browser> on Windows). The browser is never + /// closed automatically when the AppHost exits. + /// Isolated, } diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserUserDataPathResolver.cs b/src/Aspire.Hosting/BrowserLogs/BrowserUserDataPathResolver.cs new file mode 100644 index 00000000000..d6c5de172bf --- /dev/null +++ b/src/Aspire.Hosting/BrowserLogs/BrowserUserDataPathResolver.cs @@ -0,0 +1,127 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using Aspire.Hosting.Resources; + +namespace Aspire.Hosting; + +// Resolves the local filesystem location used as a Chromium --user-data-dir for a tracked browser session. +// +// The resolver intentionally never points at the user's real browser profile root. Pointing Aspire at the real +// profile is fragile in practice: Chromium's per-user-data-dir singleton means a second launch silently hands +// off to the already-running browser, App-Bound Encryption (Chrome/Edge 127+) ties cookies to the launching +// process identity, and a normal user browser holds locks that prevent us from ever opening a debug-enabled +// instance. Both Shared and Isolated therefore live under an Aspire-managed root that can always be opened with +// remote debugging enabled. +// +// Layout (Windows shown; macOS/Linux mirror the same shape under the OS-appropriate data root): +// %LocalAppData%\Aspire\BrowserData\shared\ (Shared) +// %LocalAppData%\Aspire\BrowserData\isolated\\ (Isolated) +// +// Both paths are persistent. AppHost shutdown does not delete them and does not close the browser. The next +// AppHost run reads the adoption sidecar and connects to the existing browser via CDP. +internal static class BrowserUserDataPathResolver +{ + // SHA-256 prefix length used for the per-AppHost segment. AppHost:PathSha256 is the full hex-encoded + // SHA-256 of the AppHost project path; the full hash is unnecessary for a filesystem segment and a + // shorter prefix keeps the resulting paths well under Windows' MAX_PATH for nested Chromium profile + // sub-directories. + private const int AppHostShaSegmentLength = 16; + + public static string Resolve(BrowserConfiguration configuration) + { + ArgumentException.ThrowIfNullOrWhiteSpace(configuration.Browser); + + var root = GetAspireBrowserDataRoot(); + var browserSegment = NormalizeBrowserSegment(configuration.Browser); + + var path = configuration.UserDataMode switch + { + BrowserUserDataMode.Shared => Path.Combine(root, "shared", browserSegment), + BrowserUserDataMode.Isolated => Path.Combine(root, "isolated", GetAppHostSegment(configuration), browserSegment), + _ => throw new ArgumentOutOfRangeException(nameof(configuration), configuration.UserDataMode, null) + }; + + Directory.CreateDirectory(path); + return path; + } + + private static string GetAspireBrowserDataRoot() + { + if (OperatingSystem.IsWindows()) + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Aspire", + "BrowserData"); + } + + if (OperatingSystem.IsMacOS()) + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Library", + "Application Support", + "Aspire", + "BrowserData"); + } + + // XDG: prefer XDG_DATA_HOME, fall back to ~/.local/share. Lower-case segment names match the + // conventional XDG layout (e.g. ~/.config/google-chrome). + var xdgDataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); + var dataHome = !string.IsNullOrEmpty(xdgDataHome) + ? xdgDataHome + : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "share"); + return Path.Combine(dataHome, "aspire", "browser-data"); + } + + private static string GetAppHostSegment(BrowserConfiguration configuration) + { + if (string.IsNullOrWhiteSpace(configuration.AppHostKey)) + { + throw new InvalidOperationException( + string.Format( + CultureInfo.CurrentCulture, + MessageStrings.BrowserLogsAppHostPathShaNotAvailable, + BrowserUserDataMode.Isolated)); + } + + return configuration.AppHostKey.Length > AppHostShaSegmentLength + ? configuration.AppHostKey[..AppHostShaSegmentLength] + : configuration.AppHostKey; + } + + // Maps a logical browser name or executable path to a stable lower-case folder segment so a Chrome -> Edge + // configuration flip never silently shares state with the previous browser. Unknown executables fall back to + // a sanitized form of the file name without extension. + private static string NormalizeBrowserSegment(string browser) + { + var name = Path.IsPathRooted(browser) || Path.IsPathFullyQualified(browser) + ? Path.GetFileNameWithoutExtension(browser) + : browser; + + var lower = name.ToLowerInvariant(); + return lower switch + { + "msedge" or "edge" or "microsoft-edge" or "microsoft-edge-stable" or "microsoft edge" => "msedge", + "chrome" or "google-chrome" or "google-chrome-stable" or "google chrome" => "chrome", + "chromium" or "chromium-browser" => "chromium", + _ => Sanitize(lower) + }; + } + + private static string Sanitize(string value) + { + var invalidChars = Path.GetInvalidFileNameChars(); + var buffer = new char[value.Length]; + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + buffer[i] = Array.IndexOf(invalidChars, c) >= 0 || c == ' ' ? '_' : c; + } + + var result = new string(buffer); + return string.IsNullOrEmpty(result) ? "browser" : result; + } +} diff --git a/src/Aspire.Hosting/BrowserLogs/ChromiumBrowserResolver.cs b/src/Aspire.Hosting/BrowserLogs/ChromiumBrowserResolver.cs index 4ff01ca38f6..0f5db60b581 100644 --- a/src/Aspire.Hosting/BrowserLogs/ChromiumBrowserResolver.cs +++ b/src/Aspire.Hosting/BrowserLogs/ChromiumBrowserResolver.cs @@ -16,26 +16,6 @@ namespace Aspire.Hosting; /// internal static class ChromiumBrowserResolver { - /// - /// Returns whether the requested path is branded Google Chrome's default user data directory. - /// - internal static bool IsGoogleChromeDefaultUserDataDirectory(string browser, string browserExecutable, string userDataDirectory) - { - // Google Chrome rejects remote debugging against its real default profile root. Chromium builds can still use the - // same resolver path, so exclude Chromium aliases before comparing with Chrome's default user data directory. - if (GetBrowserKind(browser, browserExecutable) != BrowserKind.Chrome || - MatchesBrowser(browser, browserExecutable, "chromium", "chromium-browser") || - TryResolveUserDataDirectory(browser, browserExecutable) is not { } defaultUserDataDirectory) - { - return false; - } - - var comparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; - return comparer.Equals(NormalizePath(userDataDirectory), NormalizePath(defaultUserDataDirectory)); - - static string NormalizePath(string path) => Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); - } - /// /// Resolves a logical browser name or explicit executable path to a runnable browser executable. /// @@ -66,53 +46,6 @@ internal static bool IsGoogleChromeDefaultUserDataDirectory(string browser, stri return PathLookupHelper.FindFullPathFromPath(browser); } - /// - /// Resolves the browser's persistent user data root for shared browser-log sessions. - /// - internal static string? TryResolveUserDataDirectory(string browser, string browserExecutable) - { - var browserKind = GetBrowserKind(browser, browserExecutable); - if (browserKind == BrowserKind.Unknown) - { - return null; - } - - if (OperatingSystem.IsMacOS()) - { - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - return browserKind switch - { - BrowserKind.Edge => Path.Combine(home, "Library", "Application Support", "Microsoft Edge"), - BrowserKind.Chrome => Path.Combine(home, "Library", "Application Support", "Google", "Chrome"), - _ => null - }; - } - - if (OperatingSystem.IsWindows()) - { - var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - return browserKind switch - { - BrowserKind.Edge => Path.Combine(localAppData, "Microsoft", "Edge", "User Data"), - BrowserKind.Chrome => Path.Combine(localAppData, "Google", "Chrome", "User Data"), - _ => null - }; - } - - var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - return browserKind switch - { - BrowserKind.Edge => Path.Combine(homeDirectory, ".config", "microsoft-edge"), - // Linux Chromium packages use a different root than Google Chrome even when the requested logical browser is - // "chrome" but the resolved executable is chromium/chromium-browser. - BrowserKind.Chrome => Path.Combine( - homeDirectory, - ".config", - MatchesBrowser(browser, browserExecutable, "chromium", "chromium-browser") ? "chromium" : "google-chrome"), - _ => null - }; - } - /// /// Resolves a Chromium profile directory name from a directory name, profile display name, or shortcut name. /// @@ -290,42 +223,6 @@ private static IEnumerable GetBrowserCandidates(string browser) return null; } - /// - /// Classifies a browser from both the requested value and the resolved executable path. - /// - private static BrowserKind GetBrowserKind(string browser, string browserExecutable) - { - if (MatchesBrowser(browser, browserExecutable, "msedge", "edge", "microsoft-edge")) - { - return BrowserKind.Edge; - } - - if (MatchesBrowser(browser, browserExecutable, "chrome", "google-chrome", "chromium", "chromium-browser")) - { - return BrowserKind.Chrome; - } - - return BrowserKind.Unknown; - } - - private static bool MatchesBrowser(string browser, string browserExecutable, params string[] names) - { - var browserLower = browser.ToLowerInvariant(); - var executableLower = browserExecutable.ToLowerInvariant(); - - foreach (var name in names) - { - if (browserLower == name || - Path.GetFileNameWithoutExtension(browserLower) == name || - executableLower.Contains(name, StringComparison.Ordinal)) - { - return true; - } - } - - return false; - } - private static bool MatchesBrowserProfile(JsonProperty profileEntry, string profile) { return string.Equals(profileEntry.Name, profile, StringComparison.OrdinalIgnoreCase) || @@ -339,11 +236,4 @@ private static bool MatchesBrowserProfileProperty(JsonElement profileElement, st propertyElement.ValueKind == JsonValueKind.String && string.Equals(propertyElement.GetString(), profile, StringComparison.OrdinalIgnoreCase); } - - private enum BrowserKind - { - Unknown, - Edge, - Chrome - } } diff --git a/src/Aspire.Hosting/Resources/MessageStrings.Designer.cs b/src/Aspire.Hosting/Resources/MessageStrings.Designer.cs index 1fcd3e4966e..5522a8cee36 100644 --- a/src/Aspire.Hosting/Resources/MessageStrings.Designer.cs +++ b/src/Aspire.Hosting/Resources/MessageStrings.Designer.cs @@ -232,20 +232,11 @@ internal static string BrowserLogsUnableToLocateBrowser { } /// - /// Looks up a localized string similar to Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first.. + /// Looks up a localized string similar to Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost.. /// - internal static string BrowserLogsNonDebuggableBrowserRunning { + internal static string BrowserLogsAppHostPathShaNotAvailable { get { - return ResourceManager.GetString("BrowserLogsNonDebuggableBrowserRunning", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode.. - /// - internal static string BrowserLogsUnableToResolveUserDataDirectory { - get { - return ResourceManager.GetString("BrowserLogsUnableToResolveUserDataDirectory", resourceCulture); + return ResourceManager.GetString("BrowserLogsAppHostPathShaNotAvailable", resourceCulture); } } @@ -258,24 +249,6 @@ internal static string BrowserLogsUserDataDirectoryNotFound { } } - /// - /// Looks up a localized string similar to Browser user data directory '{0}' was not found for browser '{1}'.. - /// - internal static string BrowserLogsUserDataDirectoryNotFoundForBrowser { - get { - return ResourceManager.GetString("BrowserLogsUserDataDirectoryNotFoundForBrowser", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state.. - /// - internal static string BrowserLogsGoogleChromeDefaultUserDataDirectoryNotSupported { - get { - return ResourceManager.GetString("BrowserLogsGoogleChromeDefaultUserDataDirectoryNotSupported", resourceCulture); - } - } - /// /// Looks up a localized string similar to A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode.. /// diff --git a/src/Aspire.Hosting/Resources/MessageStrings.resx b/src/Aspire.Hosting/Resources/MessageStrings.resx index fc160c92693..5a6c227fc60 100644 --- a/src/Aspire.Hosting/Resources/MessageStrings.resx +++ b/src/Aspire.Hosting/Resources/MessageStrings.resx @@ -177,26 +177,14 @@ Unable to locate browser '{0}'. Specify an installed Chromium-based browser or an explicit executable path. {0} is the requested browser name or path. - - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - - - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - {0} is the requested browser name or path. + + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + {0} is the Isolated user data mode value. Browser user data directory '{0}' was not found. {0} is the user data directory. - - Browser user data directory '{0}' was not found for browser '{1}'. - {0} is the user data directory, {1} is the browser name. - - - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - A tracked browser is already running for user data directory '{0}' with profile '{1}'. The requested profile is '{2}'. Close the existing tracked browser session or use isolated user data mode. {0} is the user data directory, {1} is the existing profile, {2} is the requested profile. diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf index c8f5d24aa80..31cab53b700 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf @@ -7,6 +7,11 @@ Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. {0} is the requested profile, {1} is the user data directory. + + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + {0} is the Isolated user data mode value. + (default) (default) @@ -27,11 +32,6 @@ Endpoint '{0}' for resource '{1}' has not been allocated yet. {0} is the endpoint name, {1} is the resource name. - - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. @@ -42,11 +42,6 @@ Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. - - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. @@ -87,21 +82,11 @@ Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. {0} is the Local State file path, {1} is the requested profile. - - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - {0} is the requested browser name or path. - Browser user data directory '{0}' was not found. Browser user data directory '{0}' was not found. {0} is the user data directory. - - Browser user data directory '{0}' was not found for browser '{1}'. - Browser user data directory '{0}' was not found for browser '{1}'. - {0} is the user data directory, {1} is the browser name. - Anonymous volumes cannot be read-only. Anonymní svazky nemůžou být jen pro čtení. diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf index 39f09186a23..5f06d2085fd 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf @@ -7,6 +7,11 @@ Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. {0} is the requested profile, {1} is the user data directory. + + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + {0} is the Isolated user data mode value. + (default) (default) @@ -27,11 +32,6 @@ Endpoint '{0}' for resource '{1}' has not been allocated yet. {0} is the endpoint name, {1} is the resource name. - - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. @@ -42,11 +42,6 @@ Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. - - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. @@ -87,21 +82,11 @@ Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. {0} is the Local State file path, {1} is the requested profile. - - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - {0} is the requested browser name or path. - Browser user data directory '{0}' was not found. Browser user data directory '{0}' was not found. {0} is the user data directory. - - Browser user data directory '{0}' was not found for browser '{1}'. - Browser user data directory '{0}' was not found for browser '{1}'. - {0} is the user data directory, {1} is the browser name. - Anonymous volumes cannot be read-only. Anonyme Volumes können nicht schreibgeschützt sein. diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf index bd910a7b579..35b24a70c4f 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf @@ -7,6 +7,11 @@ Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. {0} is the requested profile, {1} is the user data directory. + + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + {0} is the Isolated user data mode value. + (default) (default) @@ -27,11 +32,6 @@ Endpoint '{0}' for resource '{1}' has not been allocated yet. {0} is the endpoint name, {1} is the resource name. - - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. @@ -42,11 +42,6 @@ Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. - - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. @@ -87,21 +82,11 @@ Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. {0} is the Local State file path, {1} is the requested profile. - - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - {0} is the requested browser name or path. - Browser user data directory '{0}' was not found. Browser user data directory '{0}' was not found. {0} is the user data directory. - - Browser user data directory '{0}' was not found for browser '{1}'. - Browser user data directory '{0}' was not found for browser '{1}'. - {0} is the user data directory, {1} is the browser name. - Anonymous volumes cannot be read-only. Los volúmenes anónimos no pueden ser de solo lectura. diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf index 3fc089f94a0..e55c1cfbadf 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf @@ -7,6 +7,11 @@ Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. {0} is the requested profile, {1} is the user data directory. + + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + {0} is the Isolated user data mode value. + (default) (default) @@ -27,11 +32,6 @@ Endpoint '{0}' for resource '{1}' has not been allocated yet. {0} is the endpoint name, {1} is the resource name. - - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. @@ -42,11 +42,6 @@ Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. - - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. @@ -87,21 +82,11 @@ Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. {0} is the Local State file path, {1} is the requested profile. - - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - {0} is the requested browser name or path. - Browser user data directory '{0}' was not found. Browser user data directory '{0}' was not found. {0} is the user data directory. - - Browser user data directory '{0}' was not found for browser '{1}'. - Browser user data directory '{0}' was not found for browser '{1}'. - {0} is the user data directory, {1} is the browser name. - Anonymous volumes cannot be read-only. Les volumes anonymes ne peuvent pas être en lecture seule. diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf index d12562d4ead..ff58500f9b3 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf @@ -7,6 +7,11 @@ Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. {0} is the requested profile, {1} is the user data directory. + + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + {0} is the Isolated user data mode value. + (default) (default) @@ -27,11 +32,6 @@ Endpoint '{0}' for resource '{1}' has not been allocated yet. {0} is the endpoint name, {1} is the resource name. - - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. @@ -42,11 +42,6 @@ Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. - - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. @@ -87,21 +82,11 @@ Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. {0} is the Local State file path, {1} is the requested profile. - - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - {0} is the requested browser name or path. - Browser user data directory '{0}' was not found. Browser user data directory '{0}' was not found. {0} is the user data directory. - - Browser user data directory '{0}' was not found for browser '{1}'. - Browser user data directory '{0}' was not found for browser '{1}'. - {0} is the user data directory, {1} is the browser name. - Anonymous volumes cannot be read-only. I volumi anonimi non possono essere di sola lettura. diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf index 4ec839bc953..0aa3bd991ba 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf @@ -7,6 +7,11 @@ Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. {0} is the requested profile, {1} is the user data directory. + + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + {0} is the Isolated user data mode value. + (default) (default) @@ -27,11 +32,6 @@ Endpoint '{0}' for resource '{1}' has not been allocated yet. {0} is the endpoint name, {1} is the resource name. - - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. @@ -42,11 +42,6 @@ Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. - - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. @@ -87,21 +82,11 @@ Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. {0} is the Local State file path, {1} is the requested profile. - - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - {0} is the requested browser name or path. - Browser user data directory '{0}' was not found. Browser user data directory '{0}' was not found. {0} is the user data directory. - - Browser user data directory '{0}' was not found for browser '{1}'. - Browser user data directory '{0}' was not found for browser '{1}'. - {0} is the user data directory, {1} is the browser name. - Anonymous volumes cannot be read-only. 匿名ボリュームを読み取り専用にすることはできません。 diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf index 44c60e47f2c..d0345716ec2 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf @@ -7,6 +7,11 @@ Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. {0} is the requested profile, {1} is the user data directory. + + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + {0} is the Isolated user data mode value. + (default) (default) @@ -27,11 +32,6 @@ Endpoint '{0}' for resource '{1}' has not been allocated yet. {0} is the endpoint name, {1} is the resource name. - - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. @@ -42,11 +42,6 @@ Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. - - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. @@ -87,21 +82,11 @@ Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. {0} is the Local State file path, {1} is the requested profile. - - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - {0} is the requested browser name or path. - Browser user data directory '{0}' was not found. Browser user data directory '{0}' was not found. {0} is the user data directory. - - Browser user data directory '{0}' was not found for browser '{1}'. - Browser user data directory '{0}' was not found for browser '{1}'. - {0} is the user data directory, {1} is the browser name. - Anonymous volumes cannot be read-only. 익명 볼륨은 읽기 전용일 수 없습니다. diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf index c6933ebfa7f..c3999377582 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf @@ -7,6 +7,11 @@ Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. {0} is the requested profile, {1} is the user data directory. + + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + {0} is the Isolated user data mode value. + (default) (default) @@ -27,11 +32,6 @@ Endpoint '{0}' for resource '{1}' has not been allocated yet. {0} is the endpoint name, {1} is the resource name. - - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. @@ -42,11 +42,6 @@ Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. - - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. @@ -87,21 +82,11 @@ Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. {0} is the Local State file path, {1} is the requested profile. - - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - {0} is the requested browser name or path. - Browser user data directory '{0}' was not found. Browser user data directory '{0}' was not found. {0} is the user data directory. - - Browser user data directory '{0}' was not found for browser '{1}'. - Browser user data directory '{0}' was not found for browser '{1}'. - {0} is the user data directory, {1} is the browser name. - Anonymous volumes cannot be read-only. Woluminy anonimowe nie mogą być tylko do odczytu. diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf index 816f38c4e3f..a7679897c1d 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf @@ -7,6 +7,11 @@ Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. {0} is the requested profile, {1} is the user data directory. + + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + {0} is the Isolated user data mode value. + (default) (default) @@ -27,11 +32,6 @@ Endpoint '{0}' for resource '{1}' has not been allocated yet. {0} is the endpoint name, {1} is the resource name. - - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. @@ -42,11 +42,6 @@ Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. - - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. @@ -87,21 +82,11 @@ Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. {0} is the Local State file path, {1} is the requested profile. - - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - {0} is the requested browser name or path. - Browser user data directory '{0}' was not found. Browser user data directory '{0}' was not found. {0} is the user data directory. - - Browser user data directory '{0}' was not found for browser '{1}'. - Browser user data directory '{0}' was not found for browser '{1}'. - {0} is the user data directory, {1} is the browser name. - Anonymous volumes cannot be read-only. Volumes anônimos não podem ser somente leitura. diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf index 911a06d342a..d6228548040 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf @@ -7,6 +7,11 @@ Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. {0} is the requested profile, {1} is the user data directory. + + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + {0} is the Isolated user data mode value. + (default) (default) @@ -27,11 +32,6 @@ Endpoint '{0}' for resource '{1}' has not been allocated yet. {0} is the endpoint name, {1} is the resource name. - - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. @@ -42,11 +42,6 @@ Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. - - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. @@ -87,21 +82,11 @@ Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. {0} is the Local State file path, {1} is the requested profile. - - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - {0} is the requested browser name or path. - Browser user data directory '{0}' was not found. Browser user data directory '{0}' was not found. {0} is the user data directory. - - Browser user data directory '{0}' was not found for browser '{1}'. - Browser user data directory '{0}' was not found for browser '{1}'. - {0} is the user data directory, {1} is the browser name. - Anonymous volumes cannot be read-only. Анонимные тома не могут быть доступны только для чтения. diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf index d2baac494c4..9e763e3fbe2 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf @@ -7,6 +7,11 @@ Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. {0} is the requested profile, {1} is the user data directory. + + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + {0} is the Isolated user data mode value. + (default) (default) @@ -27,11 +32,6 @@ Endpoint '{0}' for resource '{1}' has not been allocated yet. {0} is the endpoint name, {1} is the resource name. - - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. @@ -42,11 +42,6 @@ Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. - - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. @@ -87,21 +82,11 @@ Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. {0} is the Local State file path, {1} is the requested profile. - - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - {0} is the requested browser name or path. - Browser user data directory '{0}' was not found. Browser user data directory '{0}' was not found. {0} is the user data directory. - - Browser user data directory '{0}' was not found for browser '{1}'. - Browser user data directory '{0}' was not found for browser '{1}'. - {0} is the user data directory, {1} is the browser name. - Anonymous volumes cannot be read-only. Anonim birimler salt okunur olamaz. diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf index 442273ca182..60aa23ee792 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf @@ -7,6 +7,11 @@ Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. {0} is the requested profile, {1} is the user data directory. + + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + {0} is the Isolated user data mode value. + (default) (default) @@ -27,11 +32,6 @@ Endpoint '{0}' for resource '{1}' has not been allocated yet. {0} is the endpoint name, {1} is the resource name. - - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. @@ -42,11 +42,6 @@ Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. - - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. @@ -87,21 +82,11 @@ Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. {0} is the Local State file path, {1} is the requested profile. - - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - {0} is the requested browser name or path. - Browser user data directory '{0}' was not found. Browser user data directory '{0}' was not found. {0} is the user data directory. - - Browser user data directory '{0}' was not found for browser '{1}'. - Browser user data directory '{0}' was not found for browser '{1}'. - {0} is the user data directory, {1} is the browser name. - Anonymous volumes cannot be read-only. 匿名卷不能为只读。 diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf index 1cd681cc064..acaab71eda1 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf @@ -7,6 +7,11 @@ Browser profile '{0}' matched multiple Chromium profiles under '{1}'. Specify the profile directory name instead. {0} is the requested profile, {1} is the user data directory. + + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + Cannot resolve the isolated browser user data directory because the AppHost path identifier is not available. Use '{0}' user data mode or run from a configured AppHost. + {0} is the Isolated user data mode value. + (default) (default) @@ -27,11 +32,6 @@ Endpoint '{0}' for resource '{1}' has not been allocated yet. {0} is the endpoint name, {1} is the resource name. - - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - Google Chrome blocks remote debugging against its default user data directory '{0}'. Use '{1}'='{2}' or select Microsoft Edge for shared browser state. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. Chromium profile metadata in '{0}' is invalid while resolving browser profile '{1}'. @@ -42,11 +42,6 @@ Tracked browser configuration value '{0}' is not a valid '{1}'. Expected '{2}' or '{3}'. {0} is the configured value, {1} is the user data mode configuration key, {2} and {3} are valid enum values. - - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - Browser user data directory '{0}' is already in use by a non-debuggable browser. Close that browser, use '{1}'='{2}', or start the browser from Aspire first. - {0} is the user data directory, {1} is the user data mode configuration key, {2} is Isolated. - Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. Tracked browser process exited with code {0} before the debug endpoint metadata was written to '{1}'. @@ -87,21 +82,11 @@ Unable to read Chromium profile metadata from '{0}' while resolving browser profile '{1}'. {0} is the Local State file path, {1} is the requested profile. - - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - Unable to resolve the user data directory for browser '{0}'. Specify a known browser such as 'msedge' or 'chrome' when using shared user data mode, or use the isolated user data mode. - {0} is the requested browser name or path. - Browser user data directory '{0}' was not found. Browser user data directory '{0}' was not found. {0} is the user data directory. - - Browser user data directory '{0}' was not found for browser '{1}'. - Browser user data directory '{0}' was not found for browser '{1}'. - {0} is the user data directory, {1} is the browser name. - Anonymous volumes cannot be read-only. 匿名磁碟區不可為唯讀。 diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsRunningSessionTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsRunningSessionTests.cs index 8d8a0d156e7..da2a636fb64 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsRunningSessionTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsRunningSessionTests.cs @@ -23,7 +23,7 @@ public async Task RunningSessionRoutesPageEventsToResourceLogsAndReleasesHostOnC TestBrowserHost? host = null; await using var registry = new BrowserHostRegistry( - fileSystemService: null!, + NullLogger.Instance, TimeProvider.System, createUserDataDirectory: (configuration, _) => BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory.FullName, configuration.Profile), @@ -39,7 +39,7 @@ public async Task RunningSessionRoutesPageEventsToResourceLogsAndReleasesHostOnC var logs = await ConsoleLoggingTestHelpers.CaptureLogsAsync(resourceLoggerService, resourceName, targetLogCount: 5, () => { session = BrowserLogsRunningSession.StartAsync( - new BrowserConfiguration(browserExecutable, Profile: null, BrowserUserDataMode.Shared), + new BrowserConfiguration(browserExecutable, Profile: null, BrowserUserDataMode.Shared, AppHostKey: null), resourceName, "session-0001", new Uri("https://localhost:5001/"), diff --git a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs index 1388c8cde7b..7d61d7819a8 100644 --- a/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs +++ b/tests/Aspire.Hosting.Tests/BrowserLogsSessionManagerTests.cs @@ -63,7 +63,7 @@ public async Task BrowserHostRegistry_ReusesHostUntilFinalLeaseReleasesIt() File.WriteAllText(browserExecutable, string.Empty); var createdHosts = new List(); await using var registry = new BrowserHostRegistry( - fileSystemService: null!, + NullLogger.Instance, TimeProvider.System, createUserDataDirectory: (configuration, _) => BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory.FullName, configuration.Profile), @@ -73,7 +73,7 @@ public async Task BrowserHostRegistry_ReusesHostUntilFinalLeaseReleasesIt() createdHosts.Add(host); return Task.FromResult(host); }); - var configuration = new BrowserConfiguration(browserExecutable, Profile: null, BrowserUserDataMode.Shared); + var configuration = new BrowserConfiguration(browserExecutable, Profile: null, BrowserUserDataMode.Shared, AppHostKey: null); var firstLease = await registry.AcquireAsync(configuration, CancellationToken.None); var secondLease = await registry.AcquireAsync(configuration, CancellationToken.None); @@ -103,7 +103,7 @@ public async Task BrowserHostRegistry_LateLeaseReleaseAfterRegistryDisposeNoOps( File.WriteAllText(browserExecutable, string.Empty); var createdHosts = new List(); var registry = new BrowserHostRegistry( - fileSystemService: null!, + NullLogger.Instance, TimeProvider.System, createUserDataDirectory: (configuration, _) => BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory.FullName, configuration.Profile), @@ -113,7 +113,7 @@ public async Task BrowserHostRegistry_LateLeaseReleaseAfterRegistryDisposeNoOps( createdHosts.Add(host); return Task.FromResult(host); }); - var configuration = new BrowserConfiguration(browserExecutable, Profile: null, BrowserUserDataMode.Shared); + var configuration = new BrowserConfiguration(browserExecutable, Profile: null, BrowserUserDataMode.Shared, AppHostKey: null); var lease = await registry.AcquireAsync(configuration, CancellationToken.None); @@ -137,19 +137,19 @@ public async Task BrowserHostRegistry_RejectsDifferentProfileForSharedHost() var browserExecutable = Path.Combine(userDataDirectory.FullName, "browser"); File.WriteAllText(browserExecutable, string.Empty); await using var registry = new BrowserHostRegistry( - fileSystemService: null!, + NullLogger.Instance, TimeProvider.System, createUserDataDirectory: (configuration, _) => BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory.FullName, configuration.Profile), createHostAsync: (configuration, identity, _, _) => Task.FromResult(new TestBrowserHost(identity, configuration.Profile))); var firstLease = await registry.AcquireAsync( - new BrowserConfiguration(browserExecutable, Profile: "Profile 1", BrowserUserDataMode.Shared), + new BrowserConfiguration(browserExecutable, Profile: "Profile 1", BrowserUserDataMode.Shared, AppHostKey: null), CancellationToken.None); var exception = await Assert.ThrowsAsync(() => registry.AcquireAsync( - new BrowserConfiguration(browserExecutable, Profile: "Profile 2", BrowserUserDataMode.Shared), + new BrowserConfiguration(browserExecutable, Profile: "Profile 2", BrowserUserDataMode.Shared, AppHostKey: null), CancellationToken.None)); await firstLease.DisposeAsync(); @@ -181,14 +181,14 @@ await BrowserEndpointDiscovery.WriteAsync( CancellationToken.None); await using var registry = new BrowserHostRegistry( - fileSystemService: null!, + NullLogger.Instance, TimeProvider.System, createUserDataDirectory: (configuration, _) => BrowserLogsUserDataDirectory.CreatePersistent(userDataDirectory.FullName, configuration.Profile), createHostAsync: null); var lease = await registry.AcquireAsync( - new BrowserConfiguration(browserExecutable, Profile: null, BrowserUserDataMode.Shared), + new BrowserConfiguration(browserExecutable, Profile: null, BrowserUserDataMode.Shared, AppHostKey: null), CancellationToken.None); await serverTask.WaitAsync(TimeSpan.FromSeconds(5)); @@ -371,110 +371,6 @@ await BrowserEndpointDiscovery.WriteAsync( } } - [Fact] - public void BrowserEndpointDiscovery_DetectsWindowsLockfileAsNonDebuggableBrowser() - { - var userDataDirectory = Directory.CreateTempSubdirectory(); - try - { - File.WriteAllText(Path.Combine(userDataDirectory.FullName, "lockfile"), string.Empty); - - Assert.True(BrowserEndpointDiscovery.IsNonDebuggableBrowserRunning(userDataDirectory.FullName, isWindows: true)); - Assert.False(BrowserEndpointDiscovery.IsNonDebuggableBrowserRunning(userDataDirectory.FullName, isWindows: false)); - } - finally - { - userDataDirectory.Delete(recursive: true); - } - } - - [Fact] - public void BrowserEndpointDiscovery_IgnoresPosixSingletonLockWithoutPidTarget() - { - var userDataDirectory = Directory.CreateTempSubdirectory(); - try - { - File.WriteAllText(Path.Combine(userDataDirectory.FullName, "SingletonLock"), string.Empty); - - Assert.False(BrowserEndpointDiscovery.IsNonDebuggableBrowserRunning(userDataDirectory.FullName, isWindows: false)); - } - finally - { - userDataDirectory.Delete(recursive: true); - } - } - - [Fact] - public void TryResolveUserDataDirectory_ReturnsExpectedPathForKnownBrowser() - { - var expectedPath = OperatingSystem.IsWindows() - ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Google", "Chrome", "User Data") - : OperatingSystem.IsMacOS() - ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Google", "Chrome") - : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "google-chrome"); - - var browserExecutable = OperatingSystem.IsWindows() - ? "chrome.exe" - : OperatingSystem.IsMacOS() - ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" - : "google-chrome"; - - var userDataDirectory = ChromiumBrowserResolver.TryResolveUserDataDirectory("chrome", browserExecutable); - - Assert.Equal(expectedPath, userDataDirectory); - } - - [Fact] - public void IsGoogleChromeDefaultUserDataDirectory_ReturnsTrueForGoogleChromeDefaultPath() - { - var browserExecutable = OperatingSystem.IsWindows() - ? @"C:\Program Files\Google\Chrome\Application\chrome.exe" - : OperatingSystem.IsMacOS() - ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" - : "/usr/bin/google-chrome"; - var userDataDirectory = ChromiumBrowserResolver.TryResolveUserDataDirectory("chrome", browserExecutable); - - Assert.NotNull(userDataDirectory); - Assert.True(ChromiumBrowserResolver.IsGoogleChromeDefaultUserDataDirectory("chrome", browserExecutable, userDataDirectory)); - } - - [Fact] - public void IsGoogleChromeDefaultUserDataDirectory_ReturnsFalseForChromium() - { - var browserExecutable = OperatingSystem.IsWindows() - ? @"C:\Program Files\Chromium\Application\chrome.exe" - : OperatingSystem.IsMacOS() - ? "/Applications/Chromium.app/Contents/MacOS/Chromium" - : "/usr/bin/chromium"; - var userDataDirectory = ChromiumBrowserResolver.TryResolveUserDataDirectory("chromium", browserExecutable); - - Assert.NotNull(userDataDirectory); - Assert.False(ChromiumBrowserResolver.IsGoogleChromeDefaultUserDataDirectory("chromium", browserExecutable, userDataDirectory)); - } - - [Fact] - public void TryResolveUserDataDirectory_ReturnsNullForUnknownBrowser() - { - var userDataDirectory = ChromiumBrowserResolver.TryResolveUserDataDirectory("custom-browser", "/opt/custom-browser"); - - Assert.Null(userDataDirectory); - } - - [Fact] - public void TryResolveUserDataDirectory_UsesChromiumPathOnLinux() - { - if (!OperatingSystem.IsLinux()) - { - return; - } - - var expectedPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "chromium"); - - var userDataDirectory = ChromiumBrowserResolver.TryResolveUserDataDirectory("chrome", "/usr/bin/chromium"); - - Assert.Equal(expectedPath, userDataDirectory); - } - [Fact] public void ResolveProfileDirectory_MatchesDirectoryNameCaseInsensitively() { @@ -561,14 +457,14 @@ public async Task StartSessionAsync_ThrowsWhenManagerIsDisposing() var resource = new BrowserLogsResource( "web-browser-logs", new TestResourceWithEndpoints("web"), - new BrowserConfiguration("chrome", null, BrowserUserDataMode.Isolated), + new BrowserConfiguration("chrome", null, BrowserUserDataMode.Isolated, AppHostKey: "test-apphost"), new BrowserConfigurationOverrides()); await manager.DisposeAsync(); await Assert.ThrowsAsync(() => manager.StartSessionAsync( resource, - new BrowserConfiguration("chrome", null, BrowserUserDataMode.Isolated), + new BrowserConfiguration("chrome", null, BrowserUserDataMode.Isolated, AppHostKey: "test-apphost"), resource.Name, new Uri("https://localhost"), CancellationToken.None)); From fb3fd5c0b82a2355311911a1ecdf00914e9ea223 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 26 Apr 2026 21:21:39 -0700 Subject: [PATCH 36/36] Address PR review feedback - Wrap CleanupAsync lease release + _stopCts.Dispose in try/finally so the CTS is always disposed even when BrowserHostLease times out (JamesNK) - Wrap BrowserLogsSessionManager.DisposeAsync StopAsync loop in try/catch and move lock+factory disposal into finally so one failing session does not strand others (JamesNK) - Increase BrowserHostLease release timeout from 5s to 60s to exceed worst-case browser startup (30s) held under the registry lock, and swallow OperationCanceledException at the lease boundary so disposal never throws (JamesNK) - Comment timeout/poll values in BrowserHost.WaitForBrowserEndpointAsync and add an entry trace log for cold-start visibility (JamesNK) - Comment Local State magic string and case-insensitive profile match in BrowserHostRegistry (JamesNK) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting/BrowserLogs/BrowserHost.cs | 5 +++ .../BrowserLogs/BrowserHostRegistry.cs | 7 +++ .../BrowserLogs/BrowserLogsRunningSession.cs | 15 +++++-- .../BrowserLogs/BrowserLogsSessionManager.cs | 44 ++++++++++++++----- .../BrowserLogs/IBrowserHost.cs | 17 ++++++- 5 files changed, 72 insertions(+), 16 deletions(-) diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs b/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs index e3f33287842..aef5ba9cf4d 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserHost.cs @@ -77,6 +77,10 @@ internal sealed class OwnedBrowserHost : BrowserHost // Browser startup is a local process + file hand-off. Give Chromium enough time to initialize under CI/dev-machine // load, poll frequently enough for a responsive dashboard command, and cap shutdown so AppHost disposal cannot hang // forever on a stuck browser process. + // Browser launch races against itself: the OS spawns the process, the process starts up, picks a remote-debugging + // port, and writes DevToolsActivePort. 30 seconds covers cold-start cases (large profile, AV scan, slow disk) while + // still failing fast enough to surface a wedged launch. The 100 ms poll interval is short enough to feel instant + // for warm starts but long enough to avoid burning a core busy-spinning on the file system. private static readonly TimeSpan s_browserEndpointTimeout = TimeSpan.FromSeconds(30); private static readonly TimeSpan s_browserEndpointPollInterval = TimeSpan.FromMilliseconds(100); @@ -308,6 +312,7 @@ private static async Task WaitForBrowserEndpointAsync( CancellationToken cancellationToken) { var timeoutAt = timeProvider.GetUtcNow() + s_browserEndpointTimeout; + logger.LogTrace("Waiting up to {Timeout} for tracked browser to publish DevToolsActivePort at '{DevToolsActivePortFilePath}'.", s_browserEndpointTimeout, devToolsActivePortFilePath); while (timeProvider.GetUtcNow() < timeoutAt) { diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs b/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs index 4f45f2eb96c..4002b23289e 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserHostRegistry.cs @@ -337,6 +337,9 @@ private BrowserLogsUserDataDirectory CreateUserDataDirectory(BrowserConfiguratio private static string ResolveProfileDirectoryName(string userDataDirectory, string profile) { var localStatePath = Path.Combine(userDataDirectory, "Local State"); + // Chromium writes a "Local State" JSON file at the user data root containing profile metadata (info_cache). + // ChromiumBrowserResolver uses it to map display names like "Personal" or shortcut names back to their on-disk + // profile directory ("Profile 1", "Profile 2", ...). if (File.Exists(localStatePath)) { return ChromiumBrowserResolver.ResolveProfileDirectory(userDataDirectory, profile); @@ -352,6 +355,10 @@ private static void ValidateProfileCompatibility(BrowserHostIdentity identity, s // A request without an explicit profile can attach to any tracked browser for the same user data root. Once a // caller asks for a named profile, however, reusing a host launched for a different profile would put the session // in the wrong browser context, so fail instead of silently attaching to the wrong profile. + // Profile directory names are case-insensitive on Windows and macOS (default APFS) but case-sensitive on Linux. + // We compare with OrdinalIgnoreCase intentionally so a request for "default" attaches to a host that was + // launched with "Default": Chromium itself accepts either casing on Windows/macOS, and on Linux the user is + // expected to specify the literal directory name. We err on the side of attaching rather than rejecting. if (requestedProfileDirectoryName is null || string.Equals(existingProfileDirectoryName, requestedProfileDirectoryName, StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs index 1026b22a61b..82464b5bd8c 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsRunningSession.cs @@ -297,9 +297,18 @@ private async Task CleanupAsync() return; } - await DisposePageSessionAsync().ConfigureAwait(false); - await DisposeBrowserHostLeaseAsync().ConfigureAwait(false); - _stopCts.Dispose(); + try + { + await DisposePageSessionAsync().ConfigureAwait(false); + await DisposeBrowserHostLeaseAsync().ConfigureAwait(false); + } + finally + { + // Always dispose the stop CTS even if lease release threw. BrowserHostLease.DisposeAsync can propagate + // OperationCanceledException from its release timeout; without try/finally we leak the CTS and any + // registrations on it. + _stopCts.Dispose(); + } } private async Task DisposePageSessionAsync() diff --git a/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs b/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs index 1a136d72360..78782f462d9 100644 --- a/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs +++ b/src/Aspire.Hosting/BrowserLogs/BrowserLogsSessionManager.cs @@ -199,21 +199,43 @@ public async ValueTask DisposeAsync() } } - foreach (var session in sessionsToStop) + try { - await session.StopAsync(CancellationToken.None).ConfigureAwait(false); - } - - await Task.WhenAll(completionObservers).ConfigureAwait(false); + // StopAsync can throw (for example OperationCanceledException from BrowserHostLease's release timeout). + // Catch per-session so one failure doesn't strand other sessions, and use try/finally below so the locks + // and session factory are always disposed. + foreach (var session in sessionsToStop) + { + try + { + await session.StopAsync(CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to stop tracked browser session during disposal."); + } + } - foreach (var (_, resourceState) in _resourceStates) - { - resourceState.Lock.Dispose(); + try + { + await Task.WhenAll(completionObservers).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Tracked browser session completion observer failed during disposal."); + } } - - if (_sessionFactory is IAsyncDisposable asyncDisposableFactory) + finally { - await asyncDisposableFactory.DisposeAsync().ConfigureAwait(false); + foreach (var (_, resourceState) in _resourceStates) + { + resourceState.Lock.Dispose(); + } + + if (_sessionFactory is IAsyncDisposable asyncDisposableFactory) + { + await asyncDisposableFactory.DisposeAsync().ConfigureAwait(false); + } } } diff --git a/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs b/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs index f5a3b658209..5535fdf39a0 100644 --- a/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs +++ b/src/Aspire.Hosting/BrowserLogs/IBrowserHost.cs @@ -69,7 +69,11 @@ internal enum BrowserPageSessionCompletionKind // releases a shared host, which keeps owned/adopted browser lifetime centralized in BrowserHostRegistry. internal sealed class BrowserHostLease : IAsyncDisposable { - private static readonly TimeSpan s_releaseTimeout = TimeSpan.FromSeconds(5); + // Lease release acquires the BrowserHostRegistry lock, which is held across CreateHostCoreAsync. Owned-browser + // startup waits up to s_browserEndpointTimeout (30s) for DevToolsActivePort, so the release timeout must exceed + // that worst case 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 _releaseAsync; private int _disposed; @@ -90,7 +94,16 @@ public async ValueTask DisposeAsync() } using var releaseCts = new CancellationTokenSource(s_releaseTimeout); - await _releaseAsync(releaseCts.Token).ConfigureAwait(false); + 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. + } } }