From b181df58b4f6e52206a22e4fecc6fd30c1b64eea Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Fri, 22 May 2026 23:31:41 +0200 Subject: [PATCH 01/12] Add IdeSession-based browser debugging for Blazor WASM Implement 'Debug in Browser' command for Blazor WebAssembly apps using the DCP IdeSession resource model (microsoft/dcp#169): - BrowserDebugAnnotation: marks resources as browser-debuggable - IdeSession DCP resource: models debug sessions with lifecycle states - BrowserDebugLaunchConfiguration: launch config for browser-debug type - IDcpExecutor/DcpExecutor: CreateIdeSessionAsync + StartIdeSessionAsync - ExecutableCreator: creates IdeSession at startup, handles 404 gracefully when DCP does not yet support IdeSession - ApplicationOrchestrator: LaunchBrowserDebugSessionAsync coordinator - BlazorGatewayExtensions: annotation + command for standalone WASM - BlazorHostedExtensions: auto-registers debug command in EnsureEnvironmentCallback (triggered by ProxyBlazorService/ProxyBlazorTelemetry) - Command visibility gated on IDE connection (DEBUG_SESSION_PORT) - Suppress CS0436 in Aspire.Hosting.Blazor (pre-existing StringComparers conflict) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Aspire.Hosting.Blazor.csproj | 2 +- .../BlazorGatewayExtensions.cs | 58 ++++++++ .../BlazorHostedExtensions.cs | 132 ++++++++++++++++++ .../BrowserDebugAnnotation.cs | 35 +++++ src/Aspire.Hosting/Aspire.Hosting.csproj | 2 + src/Aspire.Hosting/Dcp/DcpExecutor.cs | 12 ++ src/Aspire.Hosting/Dcp/ExecutableCreator.cs | 83 +++++++++++ .../Model/ExecutableLaunchConfiguration.cs | 21 +++ src/Aspire.Hosting/Dcp/Model/GroupVersion.cs | 2 + src/Aspire.Hosting/Dcp/Model/IdeSession.cs | 84 +++++++++++ .../Orchestrator/ApplicationOrchestrator.cs | 10 ++ .../Utils/TestDcpExecutor.cs | 2 +- 12 files changed, 441 insertions(+), 2 deletions(-) create mode 100644 src/Aspire.Hosting/ApplicationModel/BrowserDebugAnnotation.cs create mode 100644 src/Aspire.Hosting/Dcp/Model/IdeSession.cs diff --git a/src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj b/src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj index 757c106895d..ab6ee3ca65f 100644 --- a/src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj +++ b/src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj @@ -5,7 +5,7 @@ true aspire integration hosting blazor webassembly gateway Blazor WebAssembly hosting support for Aspire. - $(NoWarn);ASPIREBLAZOR001 + $(NoWarn);ASPIREBLAZOR001;CS0436 diff --git a/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs b/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs index 99934ebaa3b..d7d4d6171b0 100644 --- a/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs +++ b/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs @@ -5,6 +5,8 @@ using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.ApplicationModel.Docker; +using Aspire.Hosting.Dcp; +using Aspire.Hosting.Orchestrator; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -171,6 +173,54 @@ public static IResourceBuilder WithBlazorClientApp( gateway.WithBlazorApp(wasmApp, pathPrefix, services, apiPrefix, otlpPrefix, proxyTelemetry); + // Register browser debugging support: annotate the gateway with the WASM client's + // project path and register a "Debug in Browser" command on the WASM app resource. + // The IdeSession DCP resource is created at orchestration time; the command just + // transitions its desired state to "Running". + if (!gateway.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + var debugAnnotation = new BrowserDebugAnnotation( + wasmApp.Resource.ProjectPath, + relativePath: pathPrefix); + + gateway.WithAnnotation(debugAnnotation); + + wasmApp.WithCommand( + name: "debug-in-browser", + displayName: "Debug in Browser", + executeCommand: async context => + { + var sessionName = debugAnnotation.IdeSessionName; + if (sessionName is null) + { + return new ExecuteCommandResult { Success = false, Message = "Debug session has not been initialized yet." }; + } + + var orchestrator = context.ServiceProvider.GetRequiredService(); + await orchestrator.LaunchBrowserDebugSessionAsync(sessionName, context.CancellationToken).ConfigureAwait(false); + return CommandResults.Success(); + }, + commandOptions: new() + { + UpdateState = ctx => + { + // Only show the debug command when an IDE is connected (DEBUG_SESSION_PORT is set by DCP + // when an IDE protocol session is active). + var configuration = ctx.ServiceProvider.GetRequiredService(); + if (string.IsNullOrEmpty(configuration[DcpExecutor.DebugSessionPortVar])) + { + return ResourceCommandState.Hidden; + } + + return ctx.ResourceSnapshot.State?.Text == KnownResourceStates.Running + ? ResourceCommandState.Enabled + : ResourceCommandState.Disabled; + }, + IconName = "BugArrowCounterclockwise", + IsHighlighted = true + }); + } + return gateway; } @@ -445,6 +495,14 @@ dotnet run "{{scriptRelativePath}}" -- \ """; }); + // This container only produces build artifacts (no ENTRYPOINT/CMD), so mark it as + // build-only to exclude it from the compute resource pipeline and avoid duplicate + // DeploymentTargetAnnotation errors. + if (companion.Resource.TryGetLastAnnotation(out var dockerfileAnnotation)) + { + dockerfileAnnotation.HasEntrypoint = false; + } + gateway.WithAnnotation(new ContainerFilesDestinationAnnotation { Source = companion.Resource, diff --git a/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs b/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs index be2d14e7b63..c6e8441d804 100644 --- a/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs +++ b/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs @@ -3,6 +3,10 @@ using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Dcp; +using Aspire.Hosting.Orchestrator; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Aspire.Hosting; @@ -68,6 +72,89 @@ public static IResourceBuilder ProxyBlazorTelemetry( return host; } + /// + /// Enables WebAssembly debugging for a hosted Blazor client project. Adds a + /// so DCP creates an IdeSession resource + /// for the WASM client, and registers a "Debug in Browser" command on the host resource. + /// + /// The client project metadata type (from the .Client project). + /// The host resource builder. + [AspireExportIgnore(Reason = "Blazor hosted APIs are not yet stable for ATS export.")] + public static IResourceBuilder ProxyWasmDebugging( + this IResourceBuilder host) + where TClientProject : IProjectMetadata, new() + { + if (host.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + return host; + } + + var clientMetadata = new TClientProject(); + return host.ProxyWasmDebugging(clientMetadata.ProjectPath); + } + + /// + /// Enables WebAssembly debugging for a hosted Blazor client project. Adds a + /// so DCP creates an IdeSession resource + /// for the WASM client, and registers a "Debug in Browser" command on the host resource. + /// + /// The host resource builder. + /// Path to the WASM client .csproj file (absolute or relative to AppHost directory). + [AspireExportIgnore(Reason = "Blazor hosted APIs are not yet stable for ATS export.")] + public static IResourceBuilder ProxyWasmDebugging( + this IResourceBuilder host, + string clientProjectPath) + { + if (host.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + return host; + } + + var resolvedPath = Path.IsPathRooted(clientProjectPath) + ? clientProjectPath + : Path.GetFullPath(Path.Combine(host.ApplicationBuilder.AppHostDirectory, clientProjectPath)); + + // The app URL for a hosted scenario is the host's own endpoint (no path prefix needed). + var debugAnnotation = new BrowserDebugAnnotation(resolvedPath); + host.WithAnnotation(debugAnnotation); + + // Register "Debug in Browser" command on the host resource. + host.WithCommand( + name: "debug-in-browser", + displayName: "Debug in Browser (WASM)", + executeCommand: async context => + { + var sessionName = debugAnnotation.IdeSessionName; + if (sessionName is null) + { + return new ExecuteCommandResult { Success = false, Message = "Debug session has not been initialized yet." }; + } + + var orchestrator = context.ServiceProvider.GetRequiredService(); + await orchestrator.LaunchBrowserDebugSessionAsync(sessionName, context.CancellationToken).ConfigureAwait(false); + return CommandResults.Success(); + }, + commandOptions: new() + { + UpdateState = ctx => + { + var configuration = ctx.ServiceProvider.GetRequiredService(); + if (string.IsNullOrEmpty(configuration[DcpExecutor.DebugSessionPortVar])) + { + return ResourceCommandState.Hidden; + } + + return ctx.ResourceSnapshot.State?.Text == KnownResourceStates.Running + ? ResourceCommandState.Enabled + : ResourceCommandState.Disabled; + }, + IconName = "BugArrowCounterclockwise", + IsHighlighted = true + }); + + return host; + } + private static void EnsureEnvironmentCallback( IResourceBuilder host, HostedClientAnnotation annotation) @@ -79,6 +166,51 @@ private static void EnsureEnvironmentCallback( annotation.IsInitialized = true; + // Register "Debug in Browser" command for the hosted WASM client automatically, + // unless ProxyWasmDebugging was already called explicitly (which adds its own annotation). + // In the hosted model, the server project hosts the WASM client — use the server's + // project path so the IDE can resolve the client assembly from its references. + if (!host.ApplicationBuilder.ExecutionContext.IsPublishMode + && !host.Resource.TryGetAnnotationsOfType(out _)) + { + var projectMetadata = host.Resource.GetProjectMetadata(); + var debugAnnotation = new BrowserDebugAnnotation(projectMetadata.ProjectPath); + host.WithAnnotation(debugAnnotation); + + host.WithCommand( + name: "debug-in-browser", + displayName: "Debug in Browser (WASM)", + executeCommand: async context => + { + var sessionName = debugAnnotation.IdeSessionName; + if (sessionName is null) + { + return new ExecuteCommandResult { Success = false, Message = "Debug session has not been initialized yet." }; + } + + var orch = context.ServiceProvider.GetRequiredService(); + await orch.LaunchBrowserDebugSessionAsync(sessionName, context.CancellationToken).ConfigureAwait(false); + return CommandResults.Success(); + }, + commandOptions: new() + { + UpdateState = ctx => + { + var configuration = ctx.ServiceProvider.GetRequiredService(); + if (string.IsNullOrEmpty(configuration[DcpExecutor.DebugSessionPortVar])) + { + return ResourceCommandState.Hidden; + } + + return ctx.ResourceSnapshot.State?.Text == KnownResourceStates.Running + ? ResourceCommandState.Enabled + : ResourceCommandState.Disabled; + }, + IconName = "BugArrowCounterclockwise", + IsHighlighted = true + }); + } + host.WithEnvironment(context => { var httpsHostEndpoint = GetEndpointIfDefined(host.Resource, "https"); diff --git a/src/Aspire.Hosting/ApplicationModel/BrowserDebugAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/BrowserDebugAnnotation.cs new file mode 100644 index 00000000000..c71486b26ca --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/BrowserDebugAnnotation.cs @@ -0,0 +1,35 @@ +// 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.ApplicationModel; + +/// +/// Marks a resource (gateway or host) as serving a browser-debuggable WebAssembly client. +/// At orchestration time, an IdeSession DCP resource is created for each annotation, +/// initially in "Initial" state. When the user invokes "Debug in Browser", the orchestrator +/// transitions the IdeSession to "Starting" and DCP initiates the IDE debug session. +/// +/// Absolute path to the WASM client .csproj for IDE symbol resolution. +/// +/// Optional path segment appended to the resolved endpoint URL (e.g., the WASM app's path prefix on a gateway). +/// +internal sealed class BrowserDebugAnnotation(string clientProjectPath, string? relativePath = null) : IResourceAnnotation +{ + /// + /// Absolute path to the WASM client .csproj file. + /// The IDE uses this to locate assemblies, PDBs, and source files for symbol resolution. + /// + public string ClientProjectPath { get; } = clientProjectPath; + + /// + /// Optional path appended to the base endpoint URL to form the app URL. + /// For example, when a WASM app is served at "/{prefix}/" on a gateway. + /// + public string? RelativePath { get; } = relativePath; + + /// + /// The DCP IdeSession name assigned to this annotation during orchestration. + /// Set by the executor after creating the IdeSession resource. + /// + internal string? IdeSessionName { get; set; } +} diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index eaa415dd086..e00d0310cf3 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -126,6 +126,8 @@ + + diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index d56d7607b48..00950bf9c0f 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -369,6 +369,7 @@ internal static string GetResourceType(T resource, IResource appModelResource Container => KnownResourceTypes.Container, Executable => appModelResource is ProjectResource ? KnownResourceTypes.Project : KnownResourceTypes.Executable, ContainerExec => KnownResourceTypes.ContainerExec, + IdeSession => "IdeSession", _ => throw new InvalidOperationException($"Unknown resource type {resource.GetType().Name}") }; } @@ -1096,6 +1097,17 @@ public async Task StartResourceAsync(IResourceReference resourceReference, Cance await _executableCreator.CreateObjectAsync(er, EmptyCreationContext.s_instance, resourceLogger, this, cancellationToken).ConfigureAwait(false); break; + case RenderedModelResource ideSessionRef: + // IdeSession is started by patching desired_state to "Running". + // DCP then initiates the IDE debug session via the IDE protocol. + var existing = await _kubernetesService.GetAsync(resourceReference.DcpResourceName, cancellationToken: cancellationToken).ConfigureAwait(false); + var patch = CreatePatch(existing, s => + { + s.Spec.DesiredState = IdeSessionState.Running; + }); + await _kubernetesService.PatchAsync(existing, patch, cancellationToken).ConfigureAwait(false); + break; + default: throw new InvalidOperationException($"Unexpected resource type: {appResource.DcpResourceKind}"); } diff --git a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs index 68c1f910873..67baeef97e8 100644 --- a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs +++ b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs @@ -136,6 +136,89 @@ public async Task CreateObjectAsync(RenderedModelResource er, EmptyC } await factory.CreateDcpObjectsAsync([exe], cancellationToken).ConfigureAwait(false); + + // Create IdeSession resources for browser-debuggable WASM clients served by this resource. + // Each BrowserDebugAnnotation results in a separate IdeSession in "Initial" state; + // the session transitions to "Running" when the user clicks "Debug in Browser". + if (er.ModelResource.TryGetAnnotationsOfType(out var debugAnnotations)) + { + foreach (var annotation in debugAnnotations) + { + var appUrl = ResolveDebugAppUrl(er.ModelResource, annotation); + var sessionName = $"debug-{er.DcpResource.Metadata.Name}-{DcpNameGenerator.GetRandomNameSuffix()}"; + + var spec2 = new IdeSessionSpec + { + LaunchConfigurations = + [ + new BrowserDebugLaunchConfiguration + { + ProjectPath = annotation.ClientProjectPath, + AppUrl = appUrl ?? string.Empty, + } + ], + DesiredState = IdeSessionState.Initial + }; + + var ideSession = IdeSession.Create(sessionName, spec2); + + try + { + await factory.CreateDcpObjectsAsync([ideSession], cancellationToken).ConfigureAwait(false); + + // Register the IdeSession as a tracked resource so it can be started + // via StartResourceAsync when the user clicks "Debug in Browser". + var ideSessionResource = new RenderedModelResource(er.ModelResource, ideSession); + ideSessionResource.MarkInitialized(); + _appResources.Add(ideSessionResource); + + // Store the session name so the command handler can reference it later. + annotation.IdeSessionName = sessionName; + } + catch (k8s.Autorest.HttpOperationException ex) when (ex.Response?.StatusCode == System.Net.HttpStatusCode.NotFound) + { + // DCP doesn't support IdeSession yet — degrade gracefully. + _logger.LogDebug( + "DCP does not support IdeSession resources. Browser debugging for '{ClientProject}' on resource '{ResourceName}' is unavailable.", + annotation.ClientProjectPath, er.ModelResource.Name); + continue; + } + + if (appUrl is null) + { + _logger.LogWarning( + "Could not resolve app URL for WASM client '{ClientProject}' on resource '{ResourceName}'. " + + "The IdeSession was created but the IDE may not be able to navigate to the app.", + annotation.ClientProjectPath, er.ModelResource.Name); + } + } + } + } + + /// + /// Resolves the URL where the WASM application is served, using the annotation's custom resolver + /// or falling back to the resource's first HTTPS/HTTP endpoint. + /// + private static string? ResolveDebugAppUrl(IResource resource, BrowserDebugAnnotation annotation) + { + // Use the first allocated HTTPS or HTTP endpoint on the resource. + if (!resource.TryGetEndpoints(out var endpoints)) + { + return null; + } + + var endpoint = endpoints.FirstOrDefault(e => e.Name is "https" && e.AllocatedEndpoint is not null) + ?? endpoints.FirstOrDefault(e => e.Name is "http" && e.AllocatedEndpoint is not null); + + if (endpoint?.AllocatedEndpoint is null) + { + return null; + } + + var baseUrl = endpoint.AllocatedEndpoint.UriString; + return annotation.RelativePath is { } path + ? $"{baseUrl}/{path}/" + : baseUrl; } private void PrepareProjectExecutables() diff --git a/src/Aspire.Hosting/Dcp/Model/ExecutableLaunchConfiguration.cs b/src/Aspire.Hosting/Dcp/Model/ExecutableLaunchConfiguration.cs index 12014edf3e6..89f4bad7e9e 100644 --- a/src/Aspire.Hosting/Dcp/Model/ExecutableLaunchConfiguration.cs +++ b/src/Aspire.Hosting/Dcp/Model/ExecutableLaunchConfiguration.cs @@ -41,3 +41,24 @@ internal sealed class ProjectLaunchConfiguration() : ExecutableLaunchConfigurati [JsonPropertyName("project_path")] public string ProjectPath { get; set; } = string.Empty; } + +/// +/// Launch configuration for browser-based debugging (e.g., Blazor WebAssembly). +/// The IDE receives this and launches a debug-enabled browser navigated to the app URL, +/// then connects a debug proxy (e.g., BrowserDebugProxy for .NET WASM) via CDP. +/// +internal sealed class BrowserDebugLaunchConfiguration() : ExecutableLaunchConfiguration("browser-debug") +{ + /// + /// Absolute path to the WASM client .csproj file. + /// The IDE uses this to locate assemblies, PDBs, and source files for symbol resolution. + /// + [JsonPropertyName("project_path")] + public string ProjectPath { get; set; } = string.Empty; + + /// + /// URL where the WASM application is served. The IDE navigates the debug browser here. + /// + [JsonPropertyName("app_url")] + public string AppUrl { get; set; } = string.Empty; +} diff --git a/src/Aspire.Hosting/Dcp/Model/GroupVersion.cs b/src/Aspire.Hosting/Dcp/Model/GroupVersion.cs index e1f5d3f2515..177c8c61b00 100644 --- a/src/Aspire.Hosting/Dcp/Model/GroupVersion.cs +++ b/src/Aspire.Hosting/Dcp/Model/GroupVersion.cs @@ -30,6 +30,7 @@ internal static class Dcp public static string ExecutableReplicaSetKind { get; } = "ExecutableReplicaSet"; public static string ContainerVolumeKind { get; } = "ContainerVolume"; public static string ContainerNetworkTunnelProxyKind { get; } = "ContainerNetworkTunnelProxy"; + public static string IdeSessionKind { get; } = "IdeSession"; static Dcp() { @@ -42,5 +43,6 @@ static Dcp() Schema.Add(ContainerVolumeKind, "containervolumes"); Schema.Add(ContainerExecKind, "containerexecs"); Schema.Add(ContainerNetworkTunnelProxyKind, "containernetworktunnelproxies"); + Schema.Add(IdeSessionKind, "idesessions"); } } diff --git a/src/Aspire.Hosting/Dcp/Model/IdeSession.cs b/src/Aspire.Hosting/Dcp/Model/IdeSession.cs new file mode 100644 index 00000000000..1cf8afaec68 --- /dev/null +++ b/src/Aspire.Hosting/Dcp/Model/IdeSession.cs @@ -0,0 +1,84 @@ +// 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.Serialization; + +namespace Aspire.Hosting.Dcp.Model; + +/// +/// Represents the desired and observed state of an on-demand IDE debug session. +/// Created by Aspire at app startup in "Initial" state; transitions to "Starting" +/// when the user requests a debug session. DCP reconciles by calling the IDE endpoint +/// and updating the status to "Running" or "Failed". +/// +internal sealed class IdeSession : CustomResource, IKubernetesStaticMetadata +{ + [JsonConstructor] + public IdeSession(IdeSessionSpec spec) : base(spec) { } + + public static IdeSession Create(string name, IdeSessionSpec spec) + { + var session = new IdeSession(spec); + session.Kind = Dcp.IdeSessionKind; + session.ApiVersion = Dcp.GroupVersion.ToString(); + session.Metadata.Name = name; + session.Metadata.NamespaceProperty = string.Empty; + return session; + } + + public static string ObjectKind => Dcp.IdeSessionKind; +} + +internal sealed class IdeSessionSpec +{ + /// + /// Launch configurations for the debug session. Typically contains a single + /// browser-debug launch configuration with the client project path and app URL. + /// + [JsonPropertyName("launch_configurations")] + public List LaunchConfigurations { get; set; } = []; + + /// + /// Desired session state. Aspire sets this to + /// to request DCP to start the session via the IDE protocol. + /// + [JsonPropertyName("desired_state")] + public string DesiredState { get; set; } = IdeSessionState.Initial; +} + +internal sealed class IdeSessionStatus +{ + /// + /// The current observed state of the IDE session as reported by DCP. + /// + [JsonPropertyName("state")] + public string? State { get; set; } + + /// + /// Human-readable message providing additional context about the session state + /// (e.g., error details when state is "Failed"). + /// + [JsonPropertyName("message")] + public string? Message { get; set; } +} + +/// +/// Well-known states for IdeSession resources. +/// +internal static class IdeSessionState +{ + /// Session created but not yet requested to start. + public const string Initial = "Initial"; + + /// Start requested; DCP is contacting the IDE. + public const string Starting = "Starting"; + + /// IDE confirmed the debug session is active. + public const string Running = "Running"; + + /// Session was stopped (by user or IDE disconnect). + public const string Stopped = "Stopped"; + + /// Session failed to start (IDE rejected or timeout). + public const string Failed = "Failed"; +} diff --git a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs index 67458641902..aa497bdd584 100644 --- a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs +++ b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs @@ -605,6 +605,16 @@ public async Task StopResourceAsync(string resourceName, CancellationToken cance await _dcpExecutor.StopResourceAsync(resourceReference, cancellationToken).ConfigureAwait(false); } + /// + /// Signals DCP to start an IDE debug session by patching the IdeSession resource's + /// desired state to "Running". DCP then contacts the IDE via the IDE execution protocol. + /// + public async Task LaunchBrowserDebugSessionAsync(string ideSessionName, CancellationToken cancellationToken) + { + var resource = _dcpExecutor.GetResource(ideSessionName); + await _dcpExecutor.StartResourceAsync(resource, cancellationToken).ConfigureAwait(false); + } + private async Task SetChildResourceAsync(IResource resource, string? state, DateTime? startTimeStamp, DateTime? stopTimeStamp) { foreach (var child in _parentChildLookup[resource].Where(c => c is IResourceWithParent)) diff --git a/tests/Aspire.Hosting.Tests/Utils/TestDcpExecutor.cs b/tests/Aspire.Hosting.Tests/Utils/TestDcpExecutor.cs index b9ae68caa3c..374457b8cd3 100644 --- a/tests/Aspire.Hosting.Tests/Utils/TestDcpExecutor.cs +++ b/tests/Aspire.Hosting.Tests/Utils/TestDcpExecutor.cs @@ -16,4 +16,4 @@ internal sealed class TestDcpExecutor : IDcpExecutor public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StopResourceAsync(IResourceReference resource, CancellationToken cancellationToken) => Task.CompletedTask; -} +} \ No newline at end of file From 375922cfac624188c42dc3bbc3cbe0cf1fe8d1fb Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Thu, 28 May 2026 18:05:04 +0200 Subject: [PATCH 02/12] Replat browser debugging from IdeSession to BrowserDebuggerResource - Remove IdeSession DCP resource type (no longer needed) - Reuse existing BrowserDebuggerResource/ExecutableResource pattern with WithExplicitStart() for on-demand browser debug sessions - Create BrowserDebuggerHelper to consolidate shared logic between BlazorHostedExtensions and BlazorGatewayExtensions - Use BrowserLaunchConfiguration with browser='msedge' for IDE dispatch - Command handler calls StartResourceAsync directly (removed dead LaunchBrowserDebugSessionAsync wrapper) - Clean up unused code from DcpExecutor, ExecutableCreator, GroupVersion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BlazorGatewayExtensions.cs | 50 +------ .../BlazorHostedExtensions.cs | 107 ++++----------- .../BrowserDebuggerHelper.cs | 123 ++++++++++++++++++ .../BrowserDebugAnnotation.cs | 15 ++- src/Aspire.Hosting/Dcp/DcpExecutor.cs | 12 -- src/Aspire.Hosting/Dcp/ExecutableCreator.cs | 83 ------------ .../Model/ExecutableLaunchConfiguration.cs | 30 +++-- src/Aspire.Hosting/Dcp/Model/GroupVersion.cs | 2 - src/Aspire.Hosting/Dcp/Model/IdeSession.cs | 84 ------------ .../Orchestrator/ApplicationOrchestrator.cs | 10 -- 10 files changed, 178 insertions(+), 338 deletions(-) create mode 100644 src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs delete mode 100644 src/Aspire.Hosting/Dcp/Model/IdeSession.cs diff --git a/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs b/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs index d7d4d6171b0..82291274027 100644 --- a/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs +++ b/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs @@ -5,8 +5,6 @@ using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.ApplicationModel.Docker; -using Aspire.Hosting.Dcp; -using Aspire.Hosting.Orchestrator; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -173,52 +171,16 @@ public static IResourceBuilder WithBlazorClientApp( gateway.WithBlazorApp(wasmApp, pathPrefix, services, apiPrefix, otlpPrefix, proxyTelemetry); - // Register browser debugging support: annotate the gateway with the WASM client's - // project path and register a "Debug in Browser" command on the WASM app resource. - // The IdeSession DCP resource is created at orchestration time; the command just - // transitions its desired state to "Running". + // Register browser debugging support: create a hidden child debugger resource + // parented to the gateway, and a "Debug in Browser" command on the WASM app resource. if (!gateway.ApplicationBuilder.ExecutionContext.IsPublishMode) { - var debugAnnotation = new BrowserDebugAnnotation( + BrowserDebuggerHelper.AddBrowserDebuggerResource( + gateway.ApplicationBuilder, + gateway.Resource, + wasmApp, wasmApp.Resource.ProjectPath, relativePath: pathPrefix); - - gateway.WithAnnotation(debugAnnotation); - - wasmApp.WithCommand( - name: "debug-in-browser", - displayName: "Debug in Browser", - executeCommand: async context => - { - var sessionName = debugAnnotation.IdeSessionName; - if (sessionName is null) - { - return new ExecuteCommandResult { Success = false, Message = "Debug session has not been initialized yet." }; - } - - var orchestrator = context.ServiceProvider.GetRequiredService(); - await orchestrator.LaunchBrowserDebugSessionAsync(sessionName, context.CancellationToken).ConfigureAwait(false); - return CommandResults.Success(); - }, - commandOptions: new() - { - UpdateState = ctx => - { - // Only show the debug command when an IDE is connected (DEBUG_SESSION_PORT is set by DCP - // when an IDE protocol session is active). - var configuration = ctx.ServiceProvider.GetRequiredService(); - if (string.IsNullOrEmpty(configuration[DcpExecutor.DebugSessionPortVar])) - { - return ResourceCommandState.Hidden; - } - - return ctx.ResourceSnapshot.State?.Text == KnownResourceStates.Running - ? ResourceCommandState.Enabled - : ResourceCommandState.Disabled; - }, - IconName = "BugArrowCounterclockwise", - IsHighlighted = true - }); } return gateway; diff --git a/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs b/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs index c6e8441d804..395854de45c 100644 --- a/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs +++ b/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs @@ -3,10 +3,6 @@ using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.Dcp; -using Aspire.Hosting.Orchestrator; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Aspire.Hosting; @@ -73,9 +69,9 @@ public static IResourceBuilder ProxyBlazorTelemetry( } /// - /// Enables WebAssembly debugging for a hosted Blazor client project. Adds a - /// so DCP creates an IdeSession resource - /// for the WASM client, and registers a "Debug in Browser" command on the host resource. + /// Enables WebAssembly debugging for a hosted Blazor client project. Creates a hidden + /// child debugger resource that launches a debug browser via DCP/IDE when the user + /// clicks "Debug in Browser" on the host resource. /// /// The client project metadata type (from the .Client project). /// The host resource builder. @@ -94,9 +90,9 @@ public static IResourceBuilder ProxyWasmDebugging - /// Enables WebAssembly debugging for a hosted Blazor client project. Adds a - /// so DCP creates an IdeSession resource - /// for the WASM client, and registers a "Debug in Browser" command on the host resource. + /// Enables WebAssembly debugging for a hosted Blazor client project. Creates a hidden + /// child debugger resource that launches a debug browser via DCP/IDE when the user + /// clicks "Debug in Browser" on the host resource. /// /// The host resource builder. /// Path to the WASM client .csproj file (absolute or relative to AppHost directory). @@ -114,43 +110,7 @@ public static IResourceBuilder ProxyWasmDebugging( ? clientProjectPath : Path.GetFullPath(Path.Combine(host.ApplicationBuilder.AppHostDirectory, clientProjectPath)); - // The app URL for a hosted scenario is the host's own endpoint (no path prefix needed). - var debugAnnotation = new BrowserDebugAnnotation(resolvedPath); - host.WithAnnotation(debugAnnotation); - - // Register "Debug in Browser" command on the host resource. - host.WithCommand( - name: "debug-in-browser", - displayName: "Debug in Browser (WASM)", - executeCommand: async context => - { - var sessionName = debugAnnotation.IdeSessionName; - if (sessionName is null) - { - return new ExecuteCommandResult { Success = false, Message = "Debug session has not been initialized yet." }; - } - - var orchestrator = context.ServiceProvider.GetRequiredService(); - await orchestrator.LaunchBrowserDebugSessionAsync(sessionName, context.CancellationToken).ConfigureAwait(false); - return CommandResults.Success(); - }, - commandOptions: new() - { - UpdateState = ctx => - { - var configuration = ctx.ServiceProvider.GetRequiredService(); - if (string.IsNullOrEmpty(configuration[DcpExecutor.DebugSessionPortVar])) - { - return ResourceCommandState.Hidden; - } - - return ctx.ResourceSnapshot.State?.Text == KnownResourceStates.Running - ? ResourceCommandState.Enabled - : ResourceCommandState.Disabled; - }, - IconName = "BugArrowCounterclockwise", - IsHighlighted = true - }); + AddBrowserDebuggerResource(host, resolvedPath, relativePath: null); return host; } @@ -166,49 +126,13 @@ private static void EnsureEnvironmentCallback( annotation.IsInitialized = true; - // Register "Debug in Browser" command for the hosted WASM client automatically, + // Register "Debug in Browser" for the hosted WASM client automatically, // unless ProxyWasmDebugging was already called explicitly (which adds its own annotation). - // In the hosted model, the server project hosts the WASM client — use the server's - // project path so the IDE can resolve the client assembly from its references. if (!host.ApplicationBuilder.ExecutionContext.IsPublishMode && !host.Resource.TryGetAnnotationsOfType(out _)) { var projectMetadata = host.Resource.GetProjectMetadata(); - var debugAnnotation = new BrowserDebugAnnotation(projectMetadata.ProjectPath); - host.WithAnnotation(debugAnnotation); - - host.WithCommand( - name: "debug-in-browser", - displayName: "Debug in Browser (WASM)", - executeCommand: async context => - { - var sessionName = debugAnnotation.IdeSessionName; - if (sessionName is null) - { - return new ExecuteCommandResult { Success = false, Message = "Debug session has not been initialized yet." }; - } - - var orch = context.ServiceProvider.GetRequiredService(); - await orch.LaunchBrowserDebugSessionAsync(sessionName, context.CancellationToken).ConfigureAwait(false); - return CommandResults.Success(); - }, - commandOptions: new() - { - UpdateState = ctx => - { - var configuration = ctx.ServiceProvider.GetRequiredService(); - if (string.IsNullOrEmpty(configuration[DcpExecutor.DebugSessionPortVar])) - { - return ResourceCommandState.Hidden; - } - - return ctx.ResourceSnapshot.State?.Text == KnownResourceStates.Running - ? ResourceCommandState.Enabled - : ResourceCommandState.Disabled; - }, - IconName = "BugArrowCounterclockwise", - IsHighlighted = true - }); + AddBrowserDebuggerResource(host, projectMetadata.ProjectPath, relativePath: null); } host.WithEnvironment(context => @@ -266,6 +190,19 @@ private static HashSet GetReferencedResourceNames(IResource resource) var endpoint = resource.GetEndpoint(endpointName); return endpoint.Exists ? endpoint : null; } + + private static void AddBrowserDebuggerResource( + IResourceBuilder host, + string clientProjectPath, + string? relativePath) + { + BrowserDebuggerHelper.AddBrowserDebuggerResource( + host.ApplicationBuilder, + host.Resource, + host, + clientProjectPath, + relativePath); + } } /// diff --git a/src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs b/src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs new file mode 100644 index 00000000000..2ed2590da83 --- /dev/null +++ b/src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs @@ -0,0 +1,123 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Dcp; +using Aspire.Hosting.Orchestrator; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +#pragma warning disable ASPIREEXTENSION001 // WithDebugSupport is experimental + +namespace Aspire.Hosting; + +/// +/// Shared helper for creating browser debugger resources. +/// Both hosted Blazor (BlazorHostedExtensions) and gateway (BlazorGatewayExtensions) +/// use this to avoid duplicating the child-resource + command registration pattern. +/// +internal static class BrowserDebuggerHelper +{ + /// + /// Creates a hidden child ExecutableResource with WithExplicitStart that launches a debug browser + /// via DCP/IDE when started. Registers a "Debug in Browser" command on the specified command target. + /// + /// The distributed application builder. + /// The resource that owns the endpoint to debug (gateway or host). + /// The resource on which to register the "Debug in Browser" command. + /// Absolute path to the WASM client .csproj. + /// Optional path prefix appended to the endpoint URL. + internal static void AddBrowserDebuggerResource( + IDistributedApplicationBuilder builder, + IResourceWithEndpoints parentResource, + IResourceBuilder commandTarget, + string clientProjectPath, + string? relativePath) + { + var debuggerResourceName = relativePath is not null + ? $"{parentResource.Name}-{commandTarget.Resource.Name}-debugger" + : $"{parentResource.Name}-wasm-debugger"; + + var clientProjectDir = Path.GetDirectoryName(clientProjectPath) ?? clientProjectPath; + + var debugAnnotation = new BrowserDebugAnnotation(clientProjectPath, relativePath); + debugAnnotation.DebuggerResourceName = debuggerResourceName; + parentResource.Annotations.Add(debugAnnotation); + + var debuggerResource = new ExecutableResource(debuggerResourceName, "browser-debug", clientProjectDir); + + builder.AddResource(debuggerResource) + .WithParentRelationship(parentResource) + .ExcludeFromManifest() + .WithExplicitStart() + .WithInitialState(new() + { + ResourceType = "BrowserDebugger", + Properties = [], + IsHidden = true + }) + .WithDebugSupport( + mode => + { + // Resolve the parent's endpoint at runtime to get the actual allocated URL. + EndpointAnnotation? endpointAnnotation = null; + if (parentResource.TryGetAnnotationsOfType(out var endpoints)) + { + endpointAnnotation = endpoints.FirstOrDefault(e => e.UriScheme == "https") + ?? endpoints.FirstOrDefault(e => e.UriScheme == "http"); + } + + if (endpointAnnotation is null) + { + throw new InvalidOperationException( + $"Resource '{parentResource.Name}' does not have an HTTP or HTTPS endpoint. " + + "Browser debugging requires an endpoint to navigate to."); + } + + var endpointReference = parentResource.GetEndpoint(endpointAnnotation.Name); + var appUrl = relativePath is not null + ? $"{endpointReference.Url}/{relativePath}/" + : endpointReference.Url; + + return new Dcp.Model.BrowserLaunchConfiguration + { + Mode = mode, + Url = appUrl, + WebRoot = clientProjectPath, + Browser = "msedge" + }; + }, + "browser"); + + // Register "Debug in Browser" command on the command target resource. + commandTarget.WithCommand( + name: "debug-in-browser", + displayName: "Debug in Browser", + executeCommand: async context => + { + var orchestrator = context.ServiceProvider.GetRequiredService(); + await orchestrator.StartResourceAsync(debuggerResourceName, context.CancellationToken).ConfigureAwait(false); + return CommandResults.Success(); + }, + commandOptions: new() + { + UpdateState = ctx => + { + // Hide command when no IDE is connected (DEBUG_SESSION_PORT is set by DCP + // when an IDE protocol session is active). + var configuration = ctx.ServiceProvider.GetRequiredService(); + if (string.IsNullOrEmpty(configuration[DcpExecutor.DebugSessionPortVar])) + { + return ResourceCommandState.Hidden; + } + + // Disable when the parent isn't running yet. + return ctx.ResourceSnapshot.State?.Text == KnownResourceStates.Running + ? ResourceCommandState.Enabled + : ResourceCommandState.Disabled; + }, + IconName = "BugArrowCounterclockwise", + IsHighlighted = true + }); + } +} diff --git a/src/Aspire.Hosting/ApplicationModel/BrowserDebugAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/BrowserDebugAnnotation.cs index c71486b26ca..67331b25ccb 100644 --- a/src/Aspire.Hosting/ApplicationModel/BrowserDebugAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/BrowserDebugAnnotation.cs @@ -4,10 +4,11 @@ namespace Aspire.Hosting.ApplicationModel; /// -/// Marks a resource (gateway or host) as serving a browser-debuggable WebAssembly client. -/// At orchestration time, an IdeSession DCP resource is created for each annotation, -/// initially in "Initial" state. When the user invokes "Debug in Browser", the orchestrator -/// transitions the IdeSession to "Starting" and DCP initiates the IDE debug session. +/// Marks a resource as serving a browser-debuggable WebAssembly client. +/// A hidden child is created for the debugger; +/// when the user clicks "Debug in Browser", the child resource is started via DCP +/// with ExecutionType=IDE and a browser launch configuration, causing the IDE +/// to open a debug-enabled browser navigated to the app URL. /// /// Absolute path to the WASM client .csproj for IDE symbol resolution. /// @@ -28,8 +29,8 @@ internal sealed class BrowserDebugAnnotation(string clientProjectPath, string? r public string? RelativePath { get; } = relativePath; /// - /// The DCP IdeSession name assigned to this annotation during orchestration. - /// Set by the executor after creating the IdeSession resource. + /// The name of the child debugger resource created for this annotation. + /// Set during resource registration so the command handler can reference it. /// - internal string? IdeSessionName { get; set; } + internal string? DebuggerResourceName { get; set; } } diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index 00950bf9c0f..d56d7607b48 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -369,7 +369,6 @@ internal static string GetResourceType(T resource, IResource appModelResource Container => KnownResourceTypes.Container, Executable => appModelResource is ProjectResource ? KnownResourceTypes.Project : KnownResourceTypes.Executable, ContainerExec => KnownResourceTypes.ContainerExec, - IdeSession => "IdeSession", _ => throw new InvalidOperationException($"Unknown resource type {resource.GetType().Name}") }; } @@ -1097,17 +1096,6 @@ public async Task StartResourceAsync(IResourceReference resourceReference, Cance await _executableCreator.CreateObjectAsync(er, EmptyCreationContext.s_instance, resourceLogger, this, cancellationToken).ConfigureAwait(false); break; - case RenderedModelResource ideSessionRef: - // IdeSession is started by patching desired_state to "Running". - // DCP then initiates the IDE debug session via the IDE protocol. - var existing = await _kubernetesService.GetAsync(resourceReference.DcpResourceName, cancellationToken: cancellationToken).ConfigureAwait(false); - var patch = CreatePatch(existing, s => - { - s.Spec.DesiredState = IdeSessionState.Running; - }); - await _kubernetesService.PatchAsync(existing, patch, cancellationToken).ConfigureAwait(false); - break; - default: throw new InvalidOperationException($"Unexpected resource type: {appResource.DcpResourceKind}"); } diff --git a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs index 67baeef97e8..68c1f910873 100644 --- a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs +++ b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs @@ -136,89 +136,6 @@ public async Task CreateObjectAsync(RenderedModelResource er, EmptyC } await factory.CreateDcpObjectsAsync([exe], cancellationToken).ConfigureAwait(false); - - // Create IdeSession resources for browser-debuggable WASM clients served by this resource. - // Each BrowserDebugAnnotation results in a separate IdeSession in "Initial" state; - // the session transitions to "Running" when the user clicks "Debug in Browser". - if (er.ModelResource.TryGetAnnotationsOfType(out var debugAnnotations)) - { - foreach (var annotation in debugAnnotations) - { - var appUrl = ResolveDebugAppUrl(er.ModelResource, annotation); - var sessionName = $"debug-{er.DcpResource.Metadata.Name}-{DcpNameGenerator.GetRandomNameSuffix()}"; - - var spec2 = new IdeSessionSpec - { - LaunchConfigurations = - [ - new BrowserDebugLaunchConfiguration - { - ProjectPath = annotation.ClientProjectPath, - AppUrl = appUrl ?? string.Empty, - } - ], - DesiredState = IdeSessionState.Initial - }; - - var ideSession = IdeSession.Create(sessionName, spec2); - - try - { - await factory.CreateDcpObjectsAsync([ideSession], cancellationToken).ConfigureAwait(false); - - // Register the IdeSession as a tracked resource so it can be started - // via StartResourceAsync when the user clicks "Debug in Browser". - var ideSessionResource = new RenderedModelResource(er.ModelResource, ideSession); - ideSessionResource.MarkInitialized(); - _appResources.Add(ideSessionResource); - - // Store the session name so the command handler can reference it later. - annotation.IdeSessionName = sessionName; - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response?.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // DCP doesn't support IdeSession yet — degrade gracefully. - _logger.LogDebug( - "DCP does not support IdeSession resources. Browser debugging for '{ClientProject}' on resource '{ResourceName}' is unavailable.", - annotation.ClientProjectPath, er.ModelResource.Name); - continue; - } - - if (appUrl is null) - { - _logger.LogWarning( - "Could not resolve app URL for WASM client '{ClientProject}' on resource '{ResourceName}'. " + - "The IdeSession was created but the IDE may not be able to navigate to the app.", - annotation.ClientProjectPath, er.ModelResource.Name); - } - } - } - } - - /// - /// Resolves the URL where the WASM application is served, using the annotation's custom resolver - /// or falling back to the resource's first HTTPS/HTTP endpoint. - /// - private static string? ResolveDebugAppUrl(IResource resource, BrowserDebugAnnotation annotation) - { - // Use the first allocated HTTPS or HTTP endpoint on the resource. - if (!resource.TryGetEndpoints(out var endpoints)) - { - return null; - } - - var endpoint = endpoints.FirstOrDefault(e => e.Name is "https" && e.AllocatedEndpoint is not null) - ?? endpoints.FirstOrDefault(e => e.Name is "http" && e.AllocatedEndpoint is not null); - - if (endpoint?.AllocatedEndpoint is null) - { - return null; - } - - var baseUrl = endpoint.AllocatedEndpoint.UriString; - return annotation.RelativePath is { } path - ? $"{baseUrl}/{path}/" - : baseUrl; } private void PrepareProjectExecutables() diff --git a/src/Aspire.Hosting/Dcp/Model/ExecutableLaunchConfiguration.cs b/src/Aspire.Hosting/Dcp/Model/ExecutableLaunchConfiguration.cs index 89f4bad7e9e..400c61238a8 100644 --- a/src/Aspire.Hosting/Dcp/Model/ExecutableLaunchConfiguration.cs +++ b/src/Aspire.Hosting/Dcp/Model/ExecutableLaunchConfiguration.cs @@ -43,22 +43,30 @@ internal sealed class ProjectLaunchConfiguration() : ExecutableLaunchConfigurati } /// -/// Launch configuration for browser-based debugging (e.g., Blazor WebAssembly). -/// The IDE receives this and launches a debug-enabled browser navigated to the app URL, -/// then connects a debug proxy (e.g., BrowserDebugProxy for .NET WASM) via CDP. +/// Launch configuration for browser-based debugging. +/// The IDE receives this via PUT /run_session, launches a browser navigated to the URL, +/// and attaches a debug adapter (determined by the field). /// -internal sealed class BrowserDebugLaunchConfiguration() : ExecutableLaunchConfiguration("browser-debug") +internal sealed class BrowserLaunchConfiguration() : ExecutableLaunchConfiguration("browser") { /// - /// Absolute path to the WASM client .csproj file. - /// The IDE uses this to locate assemblies, PDBs, and source files for symbol resolution. + /// URL where the application is served. The IDE navigates the debug browser here. /// - [JsonPropertyName("project_path")] - public string ProjectPath { get; set; } = string.Empty; + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; + + /// + /// Root path for source resolution. + /// For JS apps this is the web root directory; for Blazor WASM it is the .csproj path. + /// + [JsonPropertyName("web_root")] + public string WebRoot { get; set; } = string.Empty; /// - /// URL where the WASM application is served. The IDE navigates the debug browser here. + /// Browser/debug adapter type. The IDE extension maps this to a VS Code debug adapter. + /// Standard values: "msedge", "chrome". For Blazor WASM debugging use "blazor-webassembly" + /// (requires extension support). Defaults to "msedge". /// - [JsonPropertyName("app_url")] - public string AppUrl { get; set; } = string.Empty; + [JsonPropertyName("browser")] + public string Browser { get; set; } = "msedge"; } diff --git a/src/Aspire.Hosting/Dcp/Model/GroupVersion.cs b/src/Aspire.Hosting/Dcp/Model/GroupVersion.cs index 177c8c61b00..e1f5d3f2515 100644 --- a/src/Aspire.Hosting/Dcp/Model/GroupVersion.cs +++ b/src/Aspire.Hosting/Dcp/Model/GroupVersion.cs @@ -30,7 +30,6 @@ internal static class Dcp public static string ExecutableReplicaSetKind { get; } = "ExecutableReplicaSet"; public static string ContainerVolumeKind { get; } = "ContainerVolume"; public static string ContainerNetworkTunnelProxyKind { get; } = "ContainerNetworkTunnelProxy"; - public static string IdeSessionKind { get; } = "IdeSession"; static Dcp() { @@ -43,6 +42,5 @@ static Dcp() Schema.Add(ContainerVolumeKind, "containervolumes"); Schema.Add(ContainerExecKind, "containerexecs"); Schema.Add(ContainerNetworkTunnelProxyKind, "containernetworktunnelproxies"); - Schema.Add(IdeSessionKind, "idesessions"); } } diff --git a/src/Aspire.Hosting/Dcp/Model/IdeSession.cs b/src/Aspire.Hosting/Dcp/Model/IdeSession.cs deleted file mode 100644 index 1cf8afaec68..00000000000 --- a/src/Aspire.Hosting/Dcp/Model/IdeSession.cs +++ /dev/null @@ -1,84 +0,0 @@ -// 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.Serialization; - -namespace Aspire.Hosting.Dcp.Model; - -/// -/// Represents the desired and observed state of an on-demand IDE debug session. -/// Created by Aspire at app startup in "Initial" state; transitions to "Starting" -/// when the user requests a debug session. DCP reconciles by calling the IDE endpoint -/// and updating the status to "Running" or "Failed". -/// -internal sealed class IdeSession : CustomResource, IKubernetesStaticMetadata -{ - [JsonConstructor] - public IdeSession(IdeSessionSpec spec) : base(spec) { } - - public static IdeSession Create(string name, IdeSessionSpec spec) - { - var session = new IdeSession(spec); - session.Kind = Dcp.IdeSessionKind; - session.ApiVersion = Dcp.GroupVersion.ToString(); - session.Metadata.Name = name; - session.Metadata.NamespaceProperty = string.Empty; - return session; - } - - public static string ObjectKind => Dcp.IdeSessionKind; -} - -internal sealed class IdeSessionSpec -{ - /// - /// Launch configurations for the debug session. Typically contains a single - /// browser-debug launch configuration with the client project path and app URL. - /// - [JsonPropertyName("launch_configurations")] - public List LaunchConfigurations { get; set; } = []; - - /// - /// Desired session state. Aspire sets this to - /// to request DCP to start the session via the IDE protocol. - /// - [JsonPropertyName("desired_state")] - public string DesiredState { get; set; } = IdeSessionState.Initial; -} - -internal sealed class IdeSessionStatus -{ - /// - /// The current observed state of the IDE session as reported by DCP. - /// - [JsonPropertyName("state")] - public string? State { get; set; } - - /// - /// Human-readable message providing additional context about the session state - /// (e.g., error details when state is "Failed"). - /// - [JsonPropertyName("message")] - public string? Message { get; set; } -} - -/// -/// Well-known states for IdeSession resources. -/// -internal static class IdeSessionState -{ - /// Session created but not yet requested to start. - public const string Initial = "Initial"; - - /// Start requested; DCP is contacting the IDE. - public const string Starting = "Starting"; - - /// IDE confirmed the debug session is active. - public const string Running = "Running"; - - /// Session was stopped (by user or IDE disconnect). - public const string Stopped = "Stopped"; - - /// Session failed to start (IDE rejected or timeout). - public const string Failed = "Failed"; -} diff --git a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs index aa497bdd584..67458641902 100644 --- a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs +++ b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs @@ -605,16 +605,6 @@ public async Task StopResourceAsync(string resourceName, CancellationToken cance await _dcpExecutor.StopResourceAsync(resourceReference, cancellationToken).ConfigureAwait(false); } - /// - /// Signals DCP to start an IDE debug session by patching the IdeSession resource's - /// desired state to "Running". DCP then contacts the IDE via the IDE execution protocol. - /// - public async Task LaunchBrowserDebugSessionAsync(string ideSessionName, CancellationToken cancellationToken) - { - var resource = _dcpExecutor.GetResource(ideSessionName); - await _dcpExecutor.StartResourceAsync(resource, cancellationToken).ConfigureAwait(false); - } - private async Task SetChildResourceAsync(IResource resource, string? state, DateTime? startTimeStamp, DateTime? stopTimeStamp) { foreach (var child in _parentChildLookup[resource].Where(c => c is IResourceWithParent)) From e6d50b8d8d4bc63c4e13efa1e608052fce602fc6 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Thu, 28 May 2026 18:24:25 +0200 Subject: [PATCH 03/12] Source-share browser debugger types from Aspire.Hosting.JavaScript - Remove duplicate BrowserLaunchConfiguration from Aspire.Hosting.Dcp.Model - Source-share BrowserDebuggerResource and BrowserLaunchConfiguration from Aspire.Hosting.JavaScript via Compile Include links - Use BrowserDebuggerResource instead of raw ExecutableResource in helper - Remove public ProxyWasmDebugging methods; debugging is now applied automatically and idempotently by EnsureEnvironmentCallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Aspire.Hosting.Blazor.csproj | 3 ++ .../BlazorHostedExtensions.cs | 47 ------------------- .../BrowserDebuggerHelper.cs | 5 +- .../Model/ExecutableLaunchConfiguration.cs | 29 ------------ 4 files changed, 6 insertions(+), 78 deletions(-) diff --git a/src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj b/src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj index ab6ee3ca65f..5606a3ae2e2 100644 --- a/src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj +++ b/src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj @@ -32,6 +32,9 @@ + + + diff --git a/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs b/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs index 395854de45c..6e3a6d9704c 100644 --- a/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs +++ b/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs @@ -68,53 +68,6 @@ public static IResourceBuilder ProxyBlazorTelemetry( return host; } - /// - /// Enables WebAssembly debugging for a hosted Blazor client project. Creates a hidden - /// child debugger resource that launches a debug browser via DCP/IDE when the user - /// clicks "Debug in Browser" on the host resource. - /// - /// The client project metadata type (from the .Client project). - /// The host resource builder. - [AspireExportIgnore(Reason = "Blazor hosted APIs are not yet stable for ATS export.")] - public static IResourceBuilder ProxyWasmDebugging( - this IResourceBuilder host) - where TClientProject : IProjectMetadata, new() - { - if (host.ApplicationBuilder.ExecutionContext.IsPublishMode) - { - return host; - } - - var clientMetadata = new TClientProject(); - return host.ProxyWasmDebugging(clientMetadata.ProjectPath); - } - - /// - /// Enables WebAssembly debugging for a hosted Blazor client project. Creates a hidden - /// child debugger resource that launches a debug browser via DCP/IDE when the user - /// clicks "Debug in Browser" on the host resource. - /// - /// The host resource builder. - /// Path to the WASM client .csproj file (absolute or relative to AppHost directory). - [AspireExportIgnore(Reason = "Blazor hosted APIs are not yet stable for ATS export.")] - public static IResourceBuilder ProxyWasmDebugging( - this IResourceBuilder host, - string clientProjectPath) - { - if (host.ApplicationBuilder.ExecutionContext.IsPublishMode) - { - return host; - } - - var resolvedPath = Path.IsPathRooted(clientProjectPath) - ? clientProjectPath - : Path.GetFullPath(Path.Combine(host.ApplicationBuilder.AppHostDirectory, clientProjectPath)); - - AddBrowserDebuggerResource(host, resolvedPath, relativePath: null); - - return host; - } - private static void EnsureEnvironmentCallback( IResourceBuilder host, HostedClientAnnotation annotation) diff --git a/src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs b/src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs index 2ed2590da83..9f8f97e7be9 100644 --- a/src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs +++ b/src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs @@ -3,6 +3,7 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dcp; +using Aspire.Hosting.JavaScript; using Aspire.Hosting.Orchestrator; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -44,7 +45,7 @@ internal static void AddBrowserDebuggerResource( debugAnnotation.DebuggerResourceName = debuggerResourceName; parentResource.Annotations.Add(debugAnnotation); - var debuggerResource = new ExecutableResource(debuggerResourceName, "browser-debug", clientProjectDir); + var debuggerResource = new BrowserDebuggerResource(debuggerResourceName, "msedge", clientProjectDir); builder.AddResource(debuggerResource) .WithParentRelationship(parentResource) @@ -79,7 +80,7 @@ internal static void AddBrowserDebuggerResource( ? $"{endpointReference.Url}/{relativePath}/" : endpointReference.Url; - return new Dcp.Model.BrowserLaunchConfiguration + return new BrowserLaunchConfiguration { Mode = mode, Url = appUrl, diff --git a/src/Aspire.Hosting/Dcp/Model/ExecutableLaunchConfiguration.cs b/src/Aspire.Hosting/Dcp/Model/ExecutableLaunchConfiguration.cs index 400c61238a8..12014edf3e6 100644 --- a/src/Aspire.Hosting/Dcp/Model/ExecutableLaunchConfiguration.cs +++ b/src/Aspire.Hosting/Dcp/Model/ExecutableLaunchConfiguration.cs @@ -41,32 +41,3 @@ internal sealed class ProjectLaunchConfiguration() : ExecutableLaunchConfigurati [JsonPropertyName("project_path")] public string ProjectPath { get; set; } = string.Empty; } - -/// -/// Launch configuration for browser-based debugging. -/// The IDE receives this via PUT /run_session, launches a browser navigated to the URL, -/// and attaches a debug adapter (determined by the field). -/// -internal sealed class BrowserLaunchConfiguration() : ExecutableLaunchConfiguration("browser") -{ - /// - /// URL where the application is served. The IDE navigates the debug browser here. - /// - [JsonPropertyName("url")] - public string Url { get; set; } = string.Empty; - - /// - /// Root path for source resolution. - /// For JS apps this is the web root directory; for Blazor WASM it is the .csproj path. - /// - [JsonPropertyName("web_root")] - public string WebRoot { get; set; } = string.Empty; - - /// - /// Browser/debug adapter type. The IDE extension maps this to a VS Code debug adapter. - /// Standard values: "msedge", "chrome". For Blazor WASM debugging use "blazor-webassembly" - /// (requires extension support). Defaults to "msedge". - /// - [JsonPropertyName("browser")] - public string Browser { get; set; } = "msedge"; -} From bd7a750cca5a3ba9bc60db97c1950d4837f895d2 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Thu, 28 May 2026 18:29:12 +0200 Subject: [PATCH 04/12] Revert unnecessary whitespace change to TestDcpExecutor.cs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Aspire.Hosting.Tests/Utils/TestDcpExecutor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aspire.Hosting.Tests/Utils/TestDcpExecutor.cs b/tests/Aspire.Hosting.Tests/Utils/TestDcpExecutor.cs index 374457b8cd3..b9ae68caa3c 100644 --- a/tests/Aspire.Hosting.Tests/Utils/TestDcpExecutor.cs +++ b/tests/Aspire.Hosting.Tests/Utils/TestDcpExecutor.cs @@ -16,4 +16,4 @@ internal sealed class TestDcpExecutor : IDcpExecutor public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StopResourceAsync(IResourceReference resource, CancellationToken cancellationToken) => Task.CompletedTask; -} \ No newline at end of file +} From c9d642e03c699158b3c3818a2928b142801013f0 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Thu, 28 May 2026 18:31:26 +0200 Subject: [PATCH 05/12] Move BrowserDebugAnnotation to Aspire.Hosting.Blazor The annotation is only used by Blazor code; it doesn't belong in the core Aspire.Hosting package. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserDebugAnnotation.cs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) rename src/{Aspire.Hosting/ApplicationModel => Aspire.Hosting.Blazor}/BrowserDebugAnnotation.cs (70%) diff --git a/src/Aspire.Hosting/ApplicationModel/BrowserDebugAnnotation.cs b/src/Aspire.Hosting.Blazor/BrowserDebugAnnotation.cs similarity index 70% rename from src/Aspire.Hosting/ApplicationModel/BrowserDebugAnnotation.cs rename to src/Aspire.Hosting.Blazor/BrowserDebugAnnotation.cs index 67331b25ccb..59193ee6f4b 100644 --- a/src/Aspire.Hosting/ApplicationModel/BrowserDebugAnnotation.cs +++ b/src/Aspire.Hosting.Blazor/BrowserDebugAnnotation.cs @@ -1,14 +1,13 @@ // 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.ApplicationModel; +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting; /// /// Marks a resource as serving a browser-debuggable WebAssembly client. -/// A hidden child is created for the debugger; -/// when the user clicks "Debug in Browser", the child resource is started via DCP -/// with ExecutionType=IDE and a browser launch configuration, causing the IDE -/// to open a debug-enabled browser navigated to the app URL. +/// Used as an idempotency marker to prevent duplicate debugger resource registration. /// /// Absolute path to the WASM client .csproj for IDE symbol resolution. /// @@ -18,13 +17,11 @@ internal sealed class BrowserDebugAnnotation(string clientProjectPath, string? r { /// /// Absolute path to the WASM client .csproj file. - /// The IDE uses this to locate assemblies, PDBs, and source files for symbol resolution. /// public string ClientProjectPath { get; } = clientProjectPath; /// /// Optional path appended to the base endpoint URL to form the app URL. - /// For example, when a WASM app is served at "/{prefix}/" on a gateway. /// public string? RelativePath { get; } = relativePath; From 7c86aa9d7175a5a4ffe0aa5e0e215759787642a6 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Thu, 28 May 2026 18:33:21 +0200 Subject: [PATCH 06/12] Remove BrowserDebugAnnotation; use resource name check for idempotency The annotation only served as an idempotency marker. Replace with a simple check for an existing resource with the expected debugger name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BlazorHostedExtensions.cs | 14 ++++---- .../BrowserDebugAnnotation.cs | 33 ------------------- .../BrowserDebuggerHelper.cs | 4 --- 3 files changed, 8 insertions(+), 43 deletions(-) delete mode 100644 src/Aspire.Hosting.Blazor/BrowserDebugAnnotation.cs diff --git a/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs b/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs index 6e3a6d9704c..a50ffe4e818 100644 --- a/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs +++ b/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs @@ -79,13 +79,15 @@ private static void EnsureEnvironmentCallback( annotation.IsInitialized = true; - // Register "Debug in Browser" for the hosted WASM client automatically, - // unless ProxyWasmDebugging was already called explicitly (which adds its own annotation). - if (!host.ApplicationBuilder.ExecutionContext.IsPublishMode - && !host.Resource.TryGetAnnotationsOfType(out _)) + // Register "Debug in Browser" for the hosted WASM client automatically (idempotent). + if (!host.ApplicationBuilder.ExecutionContext.IsPublishMode) { - var projectMetadata = host.Resource.GetProjectMetadata(); - AddBrowserDebuggerResource(host, projectMetadata.ProjectPath, relativePath: null); + var debuggerName = $"{host.Resource.Name}-wasm-debugger"; + if (!host.ApplicationBuilder.Resources.Any(r => r.Name == debuggerName)) + { + var projectMetadata = host.Resource.GetProjectMetadata(); + AddBrowserDebuggerResource(host, projectMetadata.ProjectPath, relativePath: null); + } } host.WithEnvironment(context => diff --git a/src/Aspire.Hosting.Blazor/BrowserDebugAnnotation.cs b/src/Aspire.Hosting.Blazor/BrowserDebugAnnotation.cs deleted file mode 100644 index 59193ee6f4b..00000000000 --- a/src/Aspire.Hosting.Blazor/BrowserDebugAnnotation.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.Hosting.ApplicationModel; - -namespace Aspire.Hosting; - -/// -/// Marks a resource as serving a browser-debuggable WebAssembly client. -/// Used as an idempotency marker to prevent duplicate debugger resource registration. -/// -/// Absolute path to the WASM client .csproj for IDE symbol resolution. -/// -/// Optional path segment appended to the resolved endpoint URL (e.g., the WASM app's path prefix on a gateway). -/// -internal sealed class BrowserDebugAnnotation(string clientProjectPath, string? relativePath = null) : IResourceAnnotation -{ - /// - /// Absolute path to the WASM client .csproj file. - /// - public string ClientProjectPath { get; } = clientProjectPath; - - /// - /// Optional path appended to the base endpoint URL to form the app URL. - /// - public string? RelativePath { get; } = relativePath; - - /// - /// The name of the child debugger resource created for this annotation. - /// Set during resource registration so the command handler can reference it. - /// - internal string? DebuggerResourceName { get; set; } -} diff --git a/src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs b/src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs index 9f8f97e7be9..367396690eb 100644 --- a/src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs +++ b/src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs @@ -41,10 +41,6 @@ internal static void AddBrowserDebuggerResource( var clientProjectDir = Path.GetDirectoryName(clientProjectPath) ?? clientProjectPath; - var debugAnnotation = new BrowserDebugAnnotation(clientProjectPath, relativePath); - debugAnnotation.DebuggerResourceName = debuggerResourceName; - parentResource.Annotations.Add(debugAnnotation); - var debuggerResource = new BrowserDebuggerResource(debuggerResourceName, "msedge", clientProjectDir); builder.AddResource(debuggerResource) From a87d8c28009d46607430692df15dd064f1ecb955 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Thu, 28 May 2026 18:59:36 +0200 Subject: [PATCH 07/12] Add debug/stop command toggle with DCP instance name resolution - Register two mutually exclusive commands: 'Debug in Browser' (bug icon) and 'Stop Browser Debug' (stop icon) that toggle based on session state - Resolve DCP instance name from DcpInstancesAnnotation at execution time since StartResourceAsync/StopResourceAsync expect the DCP metadata name - Publish no-op updates on command target to force dashboard re-evaluation - Watch for debugger resource stop (only after Running state observed) to reset session flag when user closes the browser Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BrowserDebuggerHelper.cs | 131 ++++++++++++++++-- 1 file changed, 120 insertions(+), 11 deletions(-) diff --git a/src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs b/src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs index 367396690eb..cadc17f18a7 100644 --- a/src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs +++ b/src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs @@ -2,10 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.Dcp; using Aspire.Hosting.JavaScript; using Aspire.Hosting.Orchestrator; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; #pragma warning disable ASPIREEXTENSION001 // WithDebugSupport is experimental @@ -21,11 +19,12 @@ internal static class BrowserDebuggerHelper { /// /// Creates a hidden child ExecutableResource with WithExplicitStart that launches a debug browser - /// via DCP/IDE when started. Registers a "Debug in Browser" command on the specified command target. + /// via DCP/IDE when started. Registers "Debug in Browser" and "Stop Browser Debug" commands + /// on the specified command target. /// /// The distributed application builder. /// The resource that owns the endpoint to debug (gateway or host). - /// The resource on which to register the "Debug in Browser" command. + /// The resource on which to register the debug commands. /// Absolute path to the WASM client .csproj. /// Optional path prefix appended to the endpoint URL. internal static void AddBrowserDebuggerResource( @@ -43,6 +42,11 @@ internal static void AddBrowserDebuggerResource( var debuggerResource = new BrowserDebuggerResource(debuggerResourceName, "msedge", clientProjectDir); + // Tracks whether a debug browser session is currently active. + // Toggled by the start/stop command handlers and reset when the resource stops + // (e.g., user closes the browser). + var debugSessionActive = false; + builder.AddResource(debuggerResource) .WithParentRelationship(parentResource) .ExcludeFromManifest() @@ -86,29 +90,40 @@ internal static void AddBrowserDebuggerResource( }, "browser"); - // Register "Debug in Browser" command on the command target resource. + // Register "Debug in Browser" command — shown when no debug session is active. commandTarget.WithCommand( name: "debug-in-browser", displayName: "Debug in Browser", executeCommand: async context => { + // Resolve the DCP instance name from the model resource's DcpInstancesAnnotation. + // StartResourceAsync expects the DCP metadata name (e.g., "gateway-app-debugger-abc123"), + // not the model resource name (e.g., "gateway-app-debugger"). + var dcpInstanceName = GetDcpInstanceName(debuggerResource); var orchestrator = context.ServiceProvider.GetRequiredService(); - await orchestrator.StartResourceAsync(debuggerResourceName, context.CancellationToken).ConfigureAwait(false); + await orchestrator.StartResourceAsync(dcpInstanceName, context.CancellationToken).ConfigureAwait(false); + debugSessionActive = true; + + // Publish a no-op update on the command target to force the dashboard to + // re-evaluate UpdateState callbacks and toggle command visibility. + var notificationService = context.ServiceProvider.GetRequiredService(); + await notificationService.PublishUpdateAsync(commandTarget.Resource, s => s).ConfigureAwait(false); + + // Watch for the debugger resource to stop (e.g., user closes the browser) + // so we can flip the flag and re-show the "Debug in Browser" command. + _ = WatchForDebuggerStopAsync(context.ServiceProvider, commandTarget.Resource, debuggerResource, () => debugSessionActive = false); + return CommandResults.Success(); }, commandOptions: new() { UpdateState = ctx => { - // Hide command when no IDE is connected (DEBUG_SESSION_PORT is set by DCP - // when an IDE protocol session is active). - var configuration = ctx.ServiceProvider.GetRequiredService(); - if (string.IsNullOrEmpty(configuration[DcpExecutor.DebugSessionPortVar])) + if (debugSessionActive) { return ResourceCommandState.Hidden; } - // Disable when the parent isn't running yet. return ctx.ResourceSnapshot.State?.Text == KnownResourceStates.Running ? ResourceCommandState.Enabled : ResourceCommandState.Disabled; @@ -116,5 +131,99 @@ internal static void AddBrowserDebuggerResource( IconName = "BugArrowCounterclockwise", IsHighlighted = true }); + + // Register "Stop Browser Debug" command — shown when a debug session is active. + commandTarget.WithCommand( + name: "stop-browser-debug", + displayName: "Stop Browser Debug", + executeCommand: async context => + { + var dcpInstanceName = GetDcpInstanceName(debuggerResource); + var orchestrator = context.ServiceProvider.GetRequiredService(); + await orchestrator.StopResourceAsync(dcpInstanceName, context.CancellationToken).ConfigureAwait(false); + debugSessionActive = false; + + // Force dashboard to re-evaluate command visibility. + var notificationService = context.ServiceProvider.GetRequiredService(); + await notificationService.PublishUpdateAsync(commandTarget.Resource, s => s).ConfigureAwait(false); + + return CommandResults.Success(); + }, + commandOptions: new() + { + UpdateState = ctx => + { + if (!debugSessionActive) + { + return ResourceCommandState.Hidden; + } + + return ResourceCommandState.Enabled; + }, + IconName = "Stop", + IconVariant = IconVariant.Filled, + IsHighlighted = true + }); + } + + /// + /// Watches the debugger resource for a transition to stopped state (e.g., browser closed) + /// and invokes the callback to reset the active session flag. + /// Only triggers after the resource has been observed in Running state first, + /// so that immediate startup failures don't reset the debug session flag. + /// + private static async Task WatchForDebuggerStopAsync( + IServiceProvider serviceProvider, + IResource commandTargetResource, + IResource debuggerResource, + Action onStopped) + { + var resourceNotificationService = serviceProvider.GetRequiredService(); + var wasRunning = false; + + await foreach (var evt in resourceNotificationService.WatchAsync().ConfigureAwait(false)) + { + if (evt.Resource != debuggerResource) + { + continue; + } + + var state = evt.Snapshot.State?.Text; + + if (state == KnownResourceStates.Running) + { + wasRunning = true; + continue; + } + + // Only reset once the resource has been running and then transitions to a terminal state. + if (wasRunning + && (state == KnownResourceStates.Exited + || state == KnownResourceStates.Finished + || state == KnownResourceStates.FailedToStart)) + { + onStopped(); + + // Force dashboard to re-evaluate command visibility on the command target. + await resourceNotificationService.PublishUpdateAsync(commandTargetResource, s => s).ConfigureAwait(false); + break; + } + } + } + + /// + /// Resolves the DCP instance name from a resource's . + /// The DCP metadata name (e.g., "gateway-app-debugger-abc123") differs from the model resource + /// name (e.g., "gateway-app-debugger") because DCP appends a suffix during name generation. + /// + private static string GetDcpInstanceName(IResource resource) + { + if (resource.TryGetInstances(out var instances) && instances.Length > 0) + { + return instances[0].Name; + } + + // Fallback to the model resource name if instances haven't been populated yet. + return resource.Name; } } From e26e444583337f8f6bbe1cc4a91b24b0328fa756 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Thu, 28 May 2026 19:04:45 +0200 Subject: [PATCH 08/12] Remove leftover DockerfileBuildAnnotation code Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs | 8 -------- src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs | 7 +++++-- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs b/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs index 82291274027..4d4d186b178 100644 --- a/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs +++ b/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs @@ -457,14 +457,6 @@ dotnet run "{{scriptRelativePath}}" -- \ """; }); - // This container only produces build artifacts (no ENTRYPOINT/CMD), so mark it as - // build-only to exclude it from the compute resource pipeline and avoid duplicate - // DeploymentTargetAnnotation errors. - if (companion.Resource.TryGetLastAnnotation(out var dockerfileAnnotation)) - { - dockerfileAnnotation.HasEntrypoint = false; - } - gateway.WithAnnotation(new ContainerFilesDestinationAnnotation { Source = companion.Resource, diff --git a/src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs b/src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs index cadc17f18a7..575cb42ba2d 100644 --- a/src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs +++ b/src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs @@ -17,6 +17,9 @@ namespace Aspire.Hosting; /// internal static class BrowserDebuggerHelper { + // TODO: Replace with the WebAssembly debugger executable once available. + private const string BrowserCommand = "msedge"; + /// /// Creates a hidden child ExecutableResource with WithExplicitStart that launches a debug browser /// via DCP/IDE when started. Registers "Debug in Browser" and "Stop Browser Debug" commands @@ -40,7 +43,7 @@ internal static void AddBrowserDebuggerResource( var clientProjectDir = Path.GetDirectoryName(clientProjectPath) ?? clientProjectPath; - var debuggerResource = new BrowserDebuggerResource(debuggerResourceName, "msedge", clientProjectDir); + var debuggerResource = new BrowserDebuggerResource(debuggerResourceName, BrowserCommand, clientProjectDir); // Tracks whether a debug browser session is currently active. // Toggled by the start/stop command handlers and reset when the resource stops @@ -85,7 +88,7 @@ internal static void AddBrowserDebuggerResource( Mode = mode, Url = appUrl, WebRoot = clientProjectPath, - Browser = "msedge" + Browser = BrowserCommand }; }, "browser"); From 78e5291860e55c37263d9d3596d31b77350dcdda Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Fri, 29 May 2026 18:02:49 +0200 Subject: [PATCH 09/12] Add WASM managed debugging via C# extension's tryToUseVSDbgForMono When web_root ends in .csproj, the browser debug handler calls the C# extension's tryToUseVSDbgForMono API to start the VSWebAssemblyBridge. This enables managed .NET debugging (breakpoints, stepping) for Blazor WASM alongside JS debugging. - New wasmDebug.ts: tryStartWasmDebugging helper that calls the C# extension API, starts monovsdbg_wasm session, and wires the browser through the bridge port - browser.ts: imports and calls tryStartWasmDebugging when web_root is a .csproj - Graceful fallback: if C# ext missing or bridge fails, plain browser launch continues Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extension/src/debugger/AspireDebugSession.ts | 3 + extension/src/debugger/languages/browser.ts | 9 +- extension/src/debugger/languages/wasmDebug.ts | 106 ++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 extension/src/debugger/languages/wasmDebug.ts diff --git a/extension/src/debugger/AspireDebugSession.ts b/extension/src/debugger/AspireDebugSession.ts index d3134c27d16..2ac7550999f 100644 --- a/extension/src/debugger/AspireDebugSession.ts +++ b/extension/src/debugger/AspireDebugSession.ts @@ -44,6 +44,9 @@ export class AspireDebugSession implements vscode.DebugAdapter { public readonly debugSessionId: string; public configuration: AspireExtendedDebugConfiguration; + /** The underlying VS Code debug session. Used as the parent when starting child debug sessions. */ + public get session(): vscode.DebugSession { return this._session; } + constructor(session: vscode.DebugSession, rpcServer: AspireRpcServer, dcpServer: AspireDcpServer, terminalProvider: AspireTerminalProvider, removeAspireDebugSession: (session: AspireDebugSession) => void) { this._session = session; this._rpcServer = rpcServer; diff --git a/extension/src/debugger/languages/browser.ts b/extension/src/debugger/languages/browser.ts index 5719a1cd521..587092b6176 100644 --- a/extension/src/debugger/languages/browser.ts +++ b/extension/src/debugger/languages/browser.ts @@ -2,6 +2,7 @@ import { AspireResourceExtendedDebugConfiguration, ExecutableLaunchConfiguration import { browserDisplayName, browserLabel, invalidLaunchConfiguration } from "../../loc/strings"; import { extensionLogOutputChannel } from "../../utils/logging"; import { ResourceDebuggerExtension } from "../debuggerExtensions"; +import { tryStartWasmDebugging } from "./wasmDebug"; export const browserDebuggerExtension: ResourceDebuggerExtension = { resourceType: 'browser', @@ -15,7 +16,7 @@ export const browserDebuggerExtension: ResourceDebuggerExtension = { }, getSupportedFileTypes: () => [], getProjectFile: () => '', - createDebugSessionConfigurationCallback: async (launchConfig, _args, _env, _launchOptions, debugConfiguration: AspireResourceExtendedDebugConfiguration): Promise => { + createDebugSessionConfigurationCallback: async (launchConfig, _args, _env, launchOptions, debugConfiguration: AspireResourceExtendedDebugConfiguration): Promise => { if (!isBrowserLaunchConfiguration(launchConfig)) { extensionLogOutputChannel.info(`The resource type was not browser for ${JSON.stringify(launchConfig)}`); throw new Error(invalidLaunchConfiguration(JSON.stringify(launchConfig))); @@ -37,5 +38,11 @@ export const browserDebuggerExtension: ResourceDebuggerExtension = { delete debugConfiguration.program; delete debugConfiguration.args; delete debugConfiguration.cwd; + + // If web_root points to a .csproj, this is a Blazor WASM project — + // wire up managed debugging via the C# extension's VSWebAssemblyBridge. + if (launchConfig.web_root?.endsWith('.csproj') && launchConfig.url) { + await tryStartWasmDebugging(launchConfig.url, launchConfig.web_root, debugConfiguration, launchOptions.debugSession); + } } }; diff --git a/extension/src/debugger/languages/wasmDebug.ts b/extension/src/debugger/languages/wasmDebug.ts new file mode 100644 index 00000000000..ab17a19bd92 --- /dev/null +++ b/extension/src/debugger/languages/wasmDebug.ts @@ -0,0 +1,106 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import { AspireResourceExtendedDebugConfiguration } from '../../dcp/types'; +import { extensionLogOutputChannel } from '../../utils/logging'; +import { AspireDebugSession } from '../AspireDebugSession'; + +const CSHARP_EXTENSION_ID = 'ms-dotnettools.csharp'; + +interface CSharpExtensionExports { + tryToUseVSDbgForMono?: (url: string, projectPath: string) => Promise<[string, number, number]>; +} + +/** + * Attempts to start a managed WebAssembly debug session via the C# extension's + * VSWebAssemblyBridge. If successful, modifies the browser debug configuration + * to connect through the bridge and starts a companion monovsdbg_wasm session + * for .NET IL debugging. + * + * The monovsdbg_wasm session is started as a child of the parent Aspire debug session + * so it is properly tracked and terminated when the Aspire session ends. + * + * @param url The application URL to debug + * @param projectPath Path to the Blazor WASM client .csproj + * @param debugConfiguration The browser debug configuration to modify + * @param parentDebugSession The parent Aspire debug session (used as parent for child sessions) + * @returns true if WASM debugging was successfully wired up, false if falling back to plain browser + */ +export async function tryStartWasmDebugging( + url: string, + projectPath: string, + debugConfiguration: AspireResourceExtendedDebugConfiguration, + parentDebugSession: AspireDebugSession +): Promise { + const csharpExt = vscode.extensions.getExtension(CSHARP_EXTENSION_ID); + if (!csharpExt) { + extensionLogOutputChannel.warn('C# extension not installed — skipping WASM debugging'); + return false; + } + + if (!csharpExt.isActive) { + await csharpExt.activate(); + } + + if (!csharpExt.exports?.tryToUseVSDbgForMono) { + extensionLogOutputChannel.warn('C# extension does not export tryToUseVSDbgForMono — skipping WASM debugging'); + return false; + } + + let inspectUri: string; + let portICorDebug: number; + let portBrowserDebug: number; + + try { + [inspectUri, portICorDebug, portBrowserDebug] = + await csharpExt.exports.tryToUseVSDbgForMono(url, projectPath); + } catch (e) { + extensionLogOutputChannel.warn(`tryToUseVSDbgForMono threw: ${e}`); + return false; + } + + if (inspectUri === '') { + extensionLogOutputChannel.info('tryToUseVSDbgForMono returned empty inspectUri — falling back to plain browser'); + return false; + } + + extensionLogOutputChannel.info( + `WASM debug bridge ready — inspectUri: ${inspectUri}, iCorDebug port: ${portICorDebug}, browser port: ${portBrowserDebug}` + ); + + // Start the managed WASM debug session (monovsdbg_wasm). + // This connects to the bridge's iCorDebug port and provides .NET IL debugging + // (breakpoints, stepping, locals, etc.) for code running in the browser. + const wasmManagedConfig: vscode.DebugConfiguration = { + name: `${debugConfiguration.name} Wasm Managed`, + type: 'monovsdbg_wasm', + request: 'launch', + monoDebuggerOptions: { + ip: '127.0.0.1', + port: portICorDebug, + platform: 'browser', + isServer: true, + }, + // Cascade termination: when the browser session ends, terminate the managed debugger too + cascadeTerminateToConfigurations: [debugConfiguration.name], + }; + + // Start the monovsdbg_wasm session as a child of the Aspire debug session. + // This ensures it's tracked and terminated when the Aspire session ends. + const debugSessionStarted = await vscode.debug.startDebugging( + undefined, wasmManagedConfig, parentDebugSession.session); + if (!debugSessionStarted) { + extensionLogOutputChannel.warn('Failed to start monovsdbg_wasm session — falling back to plain browser'); + return false; + } + + // Redirect the browser session through the bridge's port. + // The bridge acts as a DevTools protocol proxy, forwarding between the browser + // and the mono debugger so both JS and managed debugging work simultaneously. + debugConfiguration.inspectUri = inspectUri; + (debugConfiguration as any).port = portBrowserDebug; + + // Set webRoot to the project directory (not the .csproj file) for source map resolution + debugConfiguration.webRoot = path.dirname(projectPath); + + return true; +} From 9f612aedfc2a457a8af482d76b75a6268ae163b8 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Sat, 30 May 2026 00:09:36 +0200 Subject: [PATCH 10/12] WIP: WASM browser debugging - bridge connects, debugging activation pending - Browser launches with correct URL via msedge adapter - Stable userDataDir + runtimeArgs suppress profile prompts - port property restored for bridge<->browser connection - monovsdbg_wasm session starts successfully as child session - inspectUri routing through bridge configured - Added WasmDebugLevel=2 and DebuggerSupport to BlazorStandalone - Next: verify rebuild picks up debugLevel, test breakpoints Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extension/src/debugger/languages/browser.ts | 46 +++++++++++++++---- extension/src/debugger/languages/wasmDebug.ts | 19 ++++---- .../BlazorStandalone/BlazorStandalone.csproj | 3 ++ 3 files changed, 52 insertions(+), 16 deletions(-) diff --git a/extension/src/debugger/languages/browser.ts b/extension/src/debugger/languages/browser.ts index 587092b6176..dc7d2e17dcc 100644 --- a/extension/src/debugger/languages/browser.ts +++ b/extension/src/debugger/languages/browser.ts @@ -1,3 +1,5 @@ +import * as path from 'path'; +import * as os from 'os'; import { AspireResourceExtendedDebugConfiguration, ExecutableLaunchConfiguration, isBrowserLaunchConfiguration } from "../../dcp/types"; import { browserDisplayName, browserLabel, invalidLaunchConfiguration } from "../../loc/strings"; import { extensionLogOutputChannel } from "../../utils/logging"; @@ -6,7 +8,7 @@ import { tryStartWasmDebugging } from "./wasmDebug"; export const browserDebuggerExtension: ResourceDebuggerExtension = { resourceType: 'browser', - debugAdapter: 'pwa-msedge', + debugAdapter: 'msedge', extensionId: null, // built-in to VS Code via js-debug getDisplayName: (launchConfiguration: ExecutableLaunchConfiguration) => { if (isBrowserLaunchConfiguration(launchConfiguration) && launchConfiguration.url) { @@ -22,17 +24,36 @@ export const browserDebuggerExtension: ResourceDebuggerExtension = { throw new Error(invalidLaunchConfiguration(JSON.stringify(launchConfig))); } - // Map browser name to VS Code js-debug adapter type (pwa- prefix required) + // Map browser name to VS Code js-debug adapter type. + // js-debug registers both 'msedge'/'chrome' and 'pwa-msedge'/'pwa-chrome'. const browser = launchConfig.browser || 'msedge'; - debugConfiguration.type = `pwa-${browser}`; + debugConfiguration.type = browser; debugConfiguration.request = 'launch'; debugConfiguration.url = launchConfig.url; - debugConfiguration.webRoot = launchConfig.web_root; + + // webRoot must be a directory for source map resolution. + // When web_root is a .csproj path (Blazor WASM), use the containing directory. + const webRoot = launchConfig.web_root?.endsWith('.csproj') + ? path.dirname(launchConfig.web_root) + : launchConfig.web_root; + debugConfiguration.webRoot = webRoot; + debugConfiguration.sourceMaps = true; debugConfiguration.resolveSourceMapLocations = ['**', '!**/node_modules/**']; - // Use an auto-managed temp user data directory so multiple browser debuggers - // can run concurrently without conflicting - debugConfiguration.userDataDir = true; + // Use a stable user data directory dedicated to Aspire debugging. + // This avoids the managed-profile sign-in prompt that appears with a fresh + // temp dir (userDataDir: true) on corp machines, and avoids conflicts with + // the user's existing browser profile (userDataDir: false). + debugConfiguration.userDataDir = path.join(os.tmpdir(), 'aspire-browser-debug'); + + // Suppress Edge/Chrome first-run wizards and profile selection prompts + // that appear on managed machines with enterprise policies. + debugConfiguration.runtimeArgs = [ + '--no-first-run', + '--no-default-browser-check', + '--hide-crash-restore-bubble', + '--disable-features=EdgeProfileOnStartup,msEdgeFirstRunExperience', + ]; // Remove program/args/cwd since browser debugging doesn't use them delete debugConfiguration.program; @@ -42,7 +63,16 @@ export const browserDebuggerExtension: ResourceDebuggerExtension = { // If web_root points to a .csproj, this is a Blazor WASM project — // wire up managed debugging via the C# extension's VSWebAssemblyBridge. if (launchConfig.web_root?.endsWith('.csproj') && launchConfig.url) { - await tryStartWasmDebugging(launchConfig.url, launchConfig.web_root, debugConfiguration, launchOptions.debugSession); + extensionLogOutputChannel.info(`[WASM] Detected Blazor WASM project: ${launchConfig.web_root}`); + const wasmStarted = await tryStartWasmDebugging(launchConfig.url, launchConfig.web_root, debugConfiguration, launchOptions.debugSession); + extensionLogOutputChannel.info(`[WASM] tryStartWasmDebugging result: ${wasmStarted}`); + if (wasmStarted) { + // The browser session must have debugging enabled so js-debug connects + // to the DevTools protocol through the bridge's port/inspectUri. + // Without this, noDebug:true causes js-debug to skip attaching entirely. + debugConfiguration.noDebug = false; + } } + extensionLogOutputChannel.info(`[Browser] Final debug config: type=${debugConfiguration.type}, url=${debugConfiguration.url}, webRoot=${debugConfiguration.webRoot}, noDebug=${debugConfiguration.noDebug}`); } }; diff --git a/extension/src/debugger/languages/wasmDebug.ts b/extension/src/debugger/languages/wasmDebug.ts index ab17a19bd92..1725914a97c 100644 --- a/extension/src/debugger/languages/wasmDebug.ts +++ b/extension/src/debugger/languages/wasmDebug.ts @@ -1,5 +1,4 @@ import * as vscode from 'vscode'; -import * as path from 'path'; import { AspireResourceExtendedDebugConfiguration } from '../../dcp/types'; import { extensionLogOutputChannel } from '../../utils/logging'; import { AspireDebugSession } from '../AspireDebugSession'; @@ -84,23 +83,27 @@ export async function tryStartWasmDebugging( cascadeTerminateToConfigurations: [debugConfiguration.name], }; + extensionLogOutputChannel.info(`[WASM] Attempting to start monovsdbg_wasm session with config: ${JSON.stringify(wasmManagedConfig)}`); + // Start the monovsdbg_wasm session as a child of the Aspire debug session. // This ensures it's tracked and terminated when the Aspire session ends. const debugSessionStarted = await vscode.debug.startDebugging( - undefined, wasmManagedConfig, parentDebugSession.session); + vscode.workspace.workspaceFolders?.[0], wasmManagedConfig, { parentSession: parentDebugSession.session }); if (!debugSessionStarted) { extensionLogOutputChannel.warn('Failed to start monovsdbg_wasm session — falling back to plain browser'); return false; } - // Redirect the browser session through the bridge's port. - // The bridge acts as a DevTools protocol proxy, forwarding between the browser - // and the mono debugger so both JS and managed debugging work simultaneously. + // Use the inspectUri returned by the bridge directly. + // The bridge acts as the debug proxy (replaces /_framework/debug/ws-proxy). + // The inspectUri contains js-debug placeholders like {browserInspectUriPath} + // which js-debug resolves at runtime using the browser's DevTools WebSocket path. debugConfiguration.inspectUri = inspectUri; - (debugConfiguration as any).port = portBrowserDebug; - // Set webRoot to the project directory (not the .csproj file) for source map resolution - debugConfiguration.webRoot = path.dirname(projectPath); + // Tell js-debug to launch the browser with remote debugging on the port + // the bridge expects. The bridge connects to the browser on this port to + // proxy DevTools protocol messages. + (debugConfiguration as any).port = portBrowserDebug; return true; } diff --git a/playground/BlazorStandalone/BlazorStandalone/BlazorStandalone.csproj b/playground/BlazorStandalone/BlazorStandalone/BlazorStandalone.csproj index 3f55014904d..16ac297dfb8 100644 --- a/playground/BlazorStandalone/BlazorStandalone/BlazorStandalone.csproj +++ b/playground/BlazorStandalone/BlazorStandalone/BlazorStandalone.csproj @@ -4,6 +4,9 @@ net10.0 enable enable + + true + 2 true true From d512af7abd6cec18e00adb1f9b18b573170bd518 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Sat, 30 May 2026 14:54:43 +0200 Subject: [PATCH 11/12] Fix WASM breakpoint binding: portable PDBs + auto-reload on attach - Override Arcade's DebugType=embedded with DebugType=portable in BlazorStandalone.csproj so separate .pdb files are produced and included in the _framework/ static web assets boot config - Add automatic page reload after browser debug session starts: the WASM runtime initializes before the debugger attaches, so a reload is needed for mono_wasm_debugger_init() to see the debugger - With these two fixes, breakpoints bind and hit in Razor/C# code Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extension/src/debugger/languages/wasmDebug.ts | 24 +++++++++++++++++++ .../BlazorStandalone/BlazorStandalone.csproj | 5 ++++ 2 files changed, 29 insertions(+) diff --git a/extension/src/debugger/languages/wasmDebug.ts b/extension/src/debugger/languages/wasmDebug.ts index 1725914a97c..3eac3c31986 100644 --- a/extension/src/debugger/languages/wasmDebug.ts +++ b/extension/src/debugger/languages/wasmDebug.ts @@ -105,5 +105,29 @@ export async function tryStartWasmDebugging( // proxy DevTools protocol messages. (debugConfiguration as any).port = portBrowserDebug; + // The WASM runtime checks for a debugger connection at startup. Since the browser + // launches before the debugger attaches, the runtime initializes with debugging + // disabled. We must reload the page after the browser debug session connects so + // the runtime re-initializes and calls mono_wasm_enable_debugging(). + const browserSessionName = debugConfiguration.name; + const disposable = vscode.debug.onDidStartDebugSession(async (session) => { + if (session.name === browserSessionName && session.type === debugConfiguration.type) { + disposable.dispose(); + extensionLogOutputChannel.info(`[WASM] Browser session started — reloading page to activate WASM debugging`); + // Give the browser a moment to fully connect before reloading + await new Promise(resolve => setTimeout(resolve, 1000)); + try { + // Use the DAP custom request to reload the page via CDP + await session.customRequest('evaluate', { + expression: 'location.reload()', + context: 'repl', + }); + extensionLogOutputChannel.info(`[WASM] Page reload triggered`); + } catch (e) { + extensionLogOutputChannel.warn(`[WASM] Failed to reload page: ${e}`); + } + } + }); + return true; } diff --git a/playground/BlazorStandalone/BlazorStandalone/BlazorStandalone.csproj b/playground/BlazorStandalone/BlazorStandalone/BlazorStandalone.csproj index 16ac297dfb8..dff13e6b99e 100644 --- a/playground/BlazorStandalone/BlazorStandalone/BlazorStandalone.csproj +++ b/playground/BlazorStandalone/BlazorStandalone/BlazorStandalone.csproj @@ -6,7 +6,12 @@ enable true + 2 + + portable + true true true From 7b8e5504f4f71b31dce32324f0e5d0e18212f81e Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Sat, 30 May 2026 17:33:59 +0200 Subject: [PATCH 12/12] Browser debug lifecycle: notify DCP on browser close, state machine robustness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extension sends sessionTerminated to DCP when browser debug session ends (onDidTerminateDebugSession in browser.ts → AspireDebugSession.sendSessionTerminated) - BrowserDebuggerHelper state machine improvements: - Double-click guard on debug command - CancellationTokenSource for watcher lifecycle - Terminal state detection: Finished, Terminated, Exited, FailedToStart, NotStarted - Convert BlazorHosted playground to global interactivity (client-side routing) - Add WASM debug settings to BlazorHosted.Client.csproj Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extension/src/debugger/AspireDebugSession.ts | 17 +++- extension/src/debugger/languages/browser.ts | 27 ++++++- .../BlazorHosted.Client.csproj | 11 +++ .../Layout/MainLayout.razor | 0 .../Layout/MainLayout.razor.css | 0 .../Layout/NavMenu.razor | 0 .../Layout/NavMenu.razor.css | 0 .../BlazorHosted.Client/Pages/Counter.razor | 3 +- .../Pages/Home.razor | 0 .../BlazorHosted.Client/Pages/Time.razor | 1 - .../BlazorHosted.Client/Pages/Weather.razor | 3 +- .../Routes.razor | 2 +- .../BlazorHosted.Client/_Imports.razor | 1 + .../BlazorHosted.Server/Components/App.razor | 2 +- .../Components/_Imports.razor | 1 - .../BlazorHosted.Server/Program.cs | 6 +- .../BlazorHostedExtensions.cs | 56 ++++++++++++- .../BrowserDebuggerHelper.cs | 78 +++++++++++++------ 18 files changed, 167 insertions(+), 41 deletions(-) rename playground/BlazorHosted/{BlazorHosted.Server/Components => BlazorHosted.Client}/Layout/MainLayout.razor (100%) rename playground/BlazorHosted/{BlazorHosted.Server/Components => BlazorHosted.Client}/Layout/MainLayout.razor.css (100%) rename playground/BlazorHosted/{BlazorHosted.Server/Components => BlazorHosted.Client}/Layout/NavMenu.razor (100%) rename playground/BlazorHosted/{BlazorHosted.Server/Components => BlazorHosted.Client}/Layout/NavMenu.razor.css (100%) rename playground/BlazorHosted/{BlazorHosted.Server/Components => BlazorHosted.Client}/Pages/Home.razor (100%) rename playground/BlazorHosted/{BlazorHosted.Server/Components => BlazorHosted.Client}/Routes.razor (76%) diff --git a/extension/src/debugger/AspireDebugSession.ts b/extension/src/debugger/AspireDebugSession.ts index 2ac7550999f..aa4c369568e 100644 --- a/extension/src/debugger/AspireDebugSession.ts +++ b/extension/src/debugger/AspireDebugSession.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import { EventEmitter } from "vscode"; import * as fs from "fs"; import { createDebugAdapterTracker, AppHostOutputHandler, AppHostRestartHandler } from "./adapterTracker"; -import { AspireResourceExtendedDebugConfiguration, AspireResourceDebugSession, EnvVar, AspireExtendedDebugConfiguration, NodeLaunchConfiguration, ProjectLaunchConfiguration, StartAppHostOptions } from "../dcp/types"; +import { AspireResourceExtendedDebugConfiguration, AspireResourceDebugSession, EnvVar, AspireExtendedDebugConfiguration, NodeLaunchConfiguration, ProjectLaunchConfiguration, SessionTerminatedNotification, StartAppHostOptions } from "../dcp/types"; import { extensionLogOutputChannel } from "../utils/logging"; import AspireDcpServer, { generateDcpIdPrefix } from "../dcp/AspireDcpServer"; import { spawnCliProcess } from "./languages/cli"; @@ -266,6 +266,21 @@ export class AspireDebugSession implements vscode.DebugAdapter { this._disposables.push(createDebugAdapterTracker(this._dcpServer, debugAdapter, onAppHostRestartRequested, onAppHostOutput)); } + /** + * Sends a sessionTerminated notification to DCP for the given run session. + * Used by resource debugger extensions (e.g., browser) to notify DCP when + * a debug session ends outside of the normal adapter tracker flow. + */ + sendSessionTerminated(sessionId: string, dcpId: string, exitCode: number = 0): void { + const notification: SessionTerminatedNotification = { + notification_type: 'sessionTerminated', + session_id: sessionId, + dcp_id: dcpId, + exit_code: exitCode + }; + this._dcpServer.sendNotification(notification); + } + private static readonly _nodeAppHostExtensions = ['.js', '.ts', '.mjs', '.mts', '.cjs', '.cts']; private static readonly _csharpAppHostExtensions = ['.cs', '.csproj']; diff --git a/extension/src/debugger/languages/browser.ts b/extension/src/debugger/languages/browser.ts index dc7d2e17dcc..7733b6e7bdf 100644 --- a/extension/src/debugger/languages/browser.ts +++ b/extension/src/debugger/languages/browser.ts @@ -1,3 +1,4 @@ +import * as vscode from 'vscode'; import * as path from 'path'; import * as os from 'os'; import { AspireResourceExtendedDebugConfiguration, ExecutableLaunchConfiguration, isBrowserLaunchConfiguration } from "../../dcp/types"; @@ -52,7 +53,8 @@ export const browserDebuggerExtension: ResourceDebuggerExtension = { '--no-first-run', '--no-default-browser-check', '--hide-crash-restore-bubble', - '--disable-features=EdgeProfileOnStartup,msEdgeFirstRunExperience', + '--disable-features=EdgeProfileOnStartup,msEdgeFirstRunExperience,EdgeBackgroundMode', + '--disable-background-mode', ]; // Remove program/args/cwd since browser debugging doesn't use them @@ -74,5 +76,28 @@ export const browserDebuggerExtension: ResourceDebuggerExtension = { } } extensionLogOutputChannel.info(`[Browser] Final debug config: type=${debugConfiguration.type}, url=${debugConfiguration.url}, webRoot=${debugConfiguration.webRoot}, noDebug=${debugConfiguration.noDebug}`); + + // Listen for the browser debug session to terminate (e.g., user closes the browser window). + // When it does, notify DCP so the resource transitions to a terminal state and + // the dashboard UI can reset. + // We match by session name only because js-debug child sessions do not carry + // custom configuration properties (runId) from the parent launch config. + const runId = debugConfiguration.runId; + const debugSessionId = debugConfiguration.debugSessionId; + const aspireSession = launchOptions.debugSession; + const browserSessionName = debugConfiguration.name; + + extensionLogOutputChannel.info(`[Browser] Registering terminate listener for session name="${browserSessionName}", runId=${runId}, debugSessionId=${debugSessionId}`); + + if (runId && debugSessionId) { + const disposable = vscode.debug.onDidTerminateDebugSession((session) => { + extensionLogOutputChannel.info(`[Browser] onDidTerminateDebugSession fired: name="${session.name}", configRunId=${session.configuration?.runId}, expected="${browserSessionName}"`); + if (session.name === browserSessionName) { + disposable.dispose(); + extensionLogOutputChannel.info(`[Browser] Browser debug session terminated — notifying DCP (runId: ${runId}, debugSessionId: ${debugSessionId})`); + aspireSession.sendSessionTerminated(runId, debugSessionId, 0); + } + }); + } } }; diff --git a/playground/BlazorHosted/BlazorHosted.Client/BlazorHosted.Client.csproj b/playground/BlazorHosted/BlazorHosted.Client/BlazorHosted.Client.csproj index 95d8cf49e76..57f1df59f59 100644 --- a/playground/BlazorHosted/BlazorHosted.Client/BlazorHosted.Client.csproj +++ b/playground/BlazorHosted/BlazorHosted.Client/BlazorHosted.Client.csproj @@ -8,6 +8,17 @@ true Default true + + true + + 2 + + portable + + false true true diff --git a/playground/BlazorHosted/BlazorHosted.Server/Components/Layout/MainLayout.razor b/playground/BlazorHosted/BlazorHosted.Client/Layout/MainLayout.razor similarity index 100% rename from playground/BlazorHosted/BlazorHosted.Server/Components/Layout/MainLayout.razor rename to playground/BlazorHosted/BlazorHosted.Client/Layout/MainLayout.razor diff --git a/playground/BlazorHosted/BlazorHosted.Server/Components/Layout/MainLayout.razor.css b/playground/BlazorHosted/BlazorHosted.Client/Layout/MainLayout.razor.css similarity index 100% rename from playground/BlazorHosted/BlazorHosted.Server/Components/Layout/MainLayout.razor.css rename to playground/BlazorHosted/BlazorHosted.Client/Layout/MainLayout.razor.css diff --git a/playground/BlazorHosted/BlazorHosted.Server/Components/Layout/NavMenu.razor b/playground/BlazorHosted/BlazorHosted.Client/Layout/NavMenu.razor similarity index 100% rename from playground/BlazorHosted/BlazorHosted.Server/Components/Layout/NavMenu.razor rename to playground/BlazorHosted/BlazorHosted.Client/Layout/NavMenu.razor diff --git a/playground/BlazorHosted/BlazorHosted.Server/Components/Layout/NavMenu.razor.css b/playground/BlazorHosted/BlazorHosted.Client/Layout/NavMenu.razor.css similarity index 100% rename from playground/BlazorHosted/BlazorHosted.Server/Components/Layout/NavMenu.razor.css rename to playground/BlazorHosted/BlazorHosted.Client/Layout/NavMenu.razor.css diff --git a/playground/BlazorHosted/BlazorHosted.Client/Pages/Counter.razor b/playground/BlazorHosted/BlazorHosted.Client/Pages/Counter.razor index 6b9e8cb4511..78e1fdba4ad 100644 --- a/playground/BlazorHosted/BlazorHosted.Client/Pages/Counter.razor +++ b/playground/BlazorHosted/BlazorHosted.Client/Pages/Counter.razor @@ -1,5 +1,4 @@ -@page "/counter" -@rendermode InteractiveWebAssembly +@page "/counter" Counter diff --git a/playground/BlazorHosted/BlazorHosted.Server/Components/Pages/Home.razor b/playground/BlazorHosted/BlazorHosted.Client/Pages/Home.razor similarity index 100% rename from playground/BlazorHosted/BlazorHosted.Server/Components/Pages/Home.razor rename to playground/BlazorHosted/BlazorHosted.Client/Pages/Home.razor diff --git a/playground/BlazorHosted/BlazorHosted.Client/Pages/Time.razor b/playground/BlazorHosted/BlazorHosted.Client/Pages/Time.razor index a3149a44faf..86d9c9e7cbb 100644 --- a/playground/BlazorHosted/BlazorHosted.Client/Pages/Time.razor +++ b/playground/BlazorHosted/BlazorHosted.Client/Pages/Time.razor @@ -1,5 +1,4 @@ @page "/time" -@rendermode InteractiveWebAssembly @inject IHttpClientFactory HttpClientFactory Time diff --git a/playground/BlazorHosted/BlazorHosted.Client/Pages/Weather.razor b/playground/BlazorHosted/BlazorHosted.Client/Pages/Weather.razor index 57d8911e7b3..4d35dbb88cd 100644 --- a/playground/BlazorHosted/BlazorHosted.Client/Pages/Weather.razor +++ b/playground/BlazorHosted/BlazorHosted.Client/Pages/Weather.razor @@ -1,5 +1,4 @@ -@page "/weather" -@rendermode InteractiveWebAssembly +@page "/weather" @inject IHttpClientFactory HttpClientFactory Weather diff --git a/playground/BlazorHosted/BlazorHosted.Server/Components/Routes.razor b/playground/BlazorHosted/BlazorHosted.Client/Routes.razor similarity index 76% rename from playground/BlazorHosted/BlazorHosted.Server/Components/Routes.razor rename to playground/BlazorHosted/BlazorHosted.Client/Routes.razor index 12d411e8e1a..30c8b853229 100644 --- a/playground/BlazorHosted/BlazorHosted.Server/Components/Routes.razor +++ b/playground/BlazorHosted/BlazorHosted.Client/Routes.razor @@ -1,4 +1,4 @@ - + diff --git a/playground/BlazorHosted/BlazorHosted.Client/_Imports.razor b/playground/BlazorHosted/BlazorHosted.Client/_Imports.razor index ca3658853b2..84bea657094 100644 --- a/playground/BlazorHosted/BlazorHosted.Client/_Imports.razor +++ b/playground/BlazorHosted/BlazorHosted.Client/_Imports.razor @@ -7,3 +7,4 @@ @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop @using BlazorHosted.Client +@using BlazorHosted.Client.Layout diff --git a/playground/BlazorHosted/BlazorHosted.Server/Components/App.razor b/playground/BlazorHosted/BlazorHosted.Server/Components/App.razor index b2942971fdd..ec48054637c 100644 --- a/playground/BlazorHosted/BlazorHosted.Server/Components/App.razor +++ b/playground/BlazorHosted/BlazorHosted.Server/Components/App.razor @@ -13,7 +13,7 @@ - + diff --git a/playground/BlazorHosted/BlazorHosted.Server/Components/_Imports.razor b/playground/BlazorHosted/BlazorHosted.Server/Components/_Imports.razor index 8a41dca9994..c1fc3278f77 100644 --- a/playground/BlazorHosted/BlazorHosted.Server/Components/_Imports.razor +++ b/playground/BlazorHosted/BlazorHosted.Server/Components/_Imports.razor @@ -9,4 +9,3 @@ @using BlazorHosted @using BlazorHosted.Client @using BlazorHosted.Components -@using BlazorHosted.Components.Layout diff --git a/playground/BlazorHosted/BlazorHosted.Server/Program.cs b/playground/BlazorHosted/BlazorHosted.Server/Program.cs index 6acf13d0337..380b75a311c 100644 --- a/playground/BlazorHosted/BlazorHosted.Server/Program.cs +++ b/playground/BlazorHosted/BlazorHosted.Server/Program.cs @@ -47,11 +47,7 @@ var app = builder.Build(); // Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.UseWebAssemblyDebugging(); -} -else +if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error", createScopeForErrors: true); app.UseHsts(); diff --git a/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs b/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs index a50ffe4e818..acea0a6a8b8 100644 --- a/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs +++ b/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; +using System.Xml.Linq; using Aspire.Hosting.ApplicationModel; using Microsoft.Extensions.Logging; @@ -86,7 +87,12 @@ private static void EnsureEnvironmentCallback( if (!host.ApplicationBuilder.Resources.Any(r => r.Name == debuggerName)) { var projectMetadata = host.Resource.GetProjectMetadata(); - AddBrowserDebuggerResource(host, projectMetadata.ProjectPath, relativePath: null); + // The debug bridge (monovsdbg_wasm) needs the CLIENT project path to resolve + // WASM BCL assemblies on disk. Passing the server's path fails because the + // server's output doesn't contain browser-wasm BCL DLLs (e.g. mscorlib.dll). + var clientProjectPath = ResolveBlazorWasmClientProjectPath(projectMetadata.ProjectPath) + ?? projectMetadata.ProjectPath; + AddBrowserDebuggerResource(host, clientProjectPath, relativePath: null); } } @@ -146,6 +152,54 @@ private static HashSet GetReferencedResourceNames(IResource resource) return endpoint.Exists ? endpoint : null; } + /// + /// Resolves the Blazor WebAssembly client project path from the server project's references. + /// The server's .csproj contains a ProjectReference to the client which uses + /// Microsoft.NET.Sdk.BlazorWebAssembly. We find that reference so the debug bridge + /// can locate WASM BCL assemblies in the client's output directory. + /// + private static string? ResolveBlazorWasmClientProjectPath(string serverProjectPath) + { + try + { + var serverDir = Path.GetDirectoryName(serverProjectPath); + if (serverDir is null) + { + return null; + } + + var doc = XDocument.Load(serverProjectPath); + var ns = doc.Root?.Name.Namespace ?? XNamespace.None; + + var projectRefs = doc.Descendants(ns + "ProjectReference") + .Select(e => e.Attribute("Include")?.Value) + .Where(v => v is not null); + + foreach (var relPath in projectRefs) + { + var fullPath = Path.GetFullPath(Path.Combine(serverDir, relPath!)); + if (!File.Exists(fullPath)) + { + continue; + } + + // Check if this project uses the BlazorWebAssembly SDK + var refDoc = XDocument.Load(fullPath); + var sdk = refDoc.Root?.Attribute("Sdk")?.Value; + if (string.Equals(sdk, "Microsoft.NET.Sdk.BlazorWebAssembly", StringComparison.OrdinalIgnoreCase)) + { + return fullPath; + } + } + } + catch + { + // If we can't resolve the client project, fall back to the server path. + } + + return null; + } + private static void AddBrowserDebuggerResource( IResourceBuilder host, string clientProjectPath, diff --git a/src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs b/src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs index 575cb42ba2d..316e34e290f 100644 --- a/src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs +++ b/src/Aspire.Hosting.Blazor/BrowserDebuggerHelper.cs @@ -49,6 +49,7 @@ internal static void AddBrowserDebuggerResource( // Toggled by the start/stop command handlers and reset when the resource stops // (e.g., user closes the browser). var debugSessionActive = false; + var watcherCts = new CancellationTokenSource(); builder.AddResource(debuggerResource) .WithParentRelationship(parentResource) @@ -99,6 +100,11 @@ internal static void AddBrowserDebuggerResource( displayName: "Debug in Browser", executeCommand: async context => { + if (debugSessionActive) + { + return CommandResults.Success(); + } + // Resolve the DCP instance name from the model resource's DcpInstancesAnnotation. // StartResourceAsync expects the DCP metadata name (e.g., "gateway-app-debugger-abc123"), // not the model resource name (e.g., "gateway-app-debugger"). @@ -107,6 +113,10 @@ internal static void AddBrowserDebuggerResource( await orchestrator.StartResourceAsync(dcpInstanceName, context.CancellationToken).ConfigureAwait(false); debugSessionActive = true; + // Cancel any previous watcher before starting a new one. + await watcherCts.CancelAsync().ConfigureAwait(false); + watcherCts = new CancellationTokenSource(); + // Publish a no-op update on the command target to force the dashboard to // re-evaluate UpdateState callbacks and toggle command visibility. var notificationService = context.ServiceProvider.GetRequiredService(); @@ -114,7 +124,7 @@ internal static void AddBrowserDebuggerResource( // Watch for the debugger resource to stop (e.g., user closes the browser) // so we can flip the flag and re-show the "Debug in Browser" command. - _ = WatchForDebuggerStopAsync(context.ServiceProvider, commandTarget.Resource, debuggerResource, () => debugSessionActive = false); + _ = WatchForDebuggerStopAsync(context.ServiceProvider, commandTarget.Resource, debuggerResource, watcherCts.Token, () => debugSessionActive = false); return CommandResults.Success(); }, @@ -131,7 +141,8 @@ internal static void AddBrowserDebuggerResource( ? ResourceCommandState.Enabled : ResourceCommandState.Disabled; }, - IconName = "BugArrowCounterclockwise", + IconName = "Bug", + IconVariant = IconVariant.Filled, IsHighlighted = true }); @@ -141,6 +152,9 @@ internal static void AddBrowserDebuggerResource( displayName: "Stop Browser Debug", executeCommand: async context => { + // Cancel the watcher so it doesn't race with our state reset. + await watcherCts.CancelAsync().ConfigureAwait(false); + var dcpInstanceName = GetDcpInstanceName(debuggerResource); var orchestrator = context.ServiceProvider.GetRequiredService(); await orchestrator.StopResourceAsync(dcpInstanceName, context.CancellationToken).ConfigureAwait(false); @@ -163,7 +177,7 @@ internal static void AddBrowserDebuggerResource( return ResourceCommandState.Enabled; }, - IconName = "Stop", + IconName = "DismissCircle", IconVariant = IconVariant.Filled, IsHighlighted = true }); @@ -172,46 +186,60 @@ internal static void AddBrowserDebuggerResource( /// /// Watches the debugger resource for a transition to stopped state (e.g., browser closed) /// and invokes the callback to reset the active session flag. - /// Only triggers after the resource has been observed in Running state first, - /// so that immediate startup failures don't reset the debug session flag. + /// Handles both the normal case (Running → terminal) and the immediate failure case + /// (Starting → FailedToStart without ever reaching Running). /// private static async Task WatchForDebuggerStopAsync( IServiceProvider serviceProvider, IResource commandTargetResource, IResource debuggerResource, + CancellationToken cancellationToken, Action onStopped) { var resourceNotificationService = serviceProvider.GetRequiredService(); - var wasRunning = false; + var wasStarted = false; - await foreach (var evt in resourceNotificationService.WatchAsync().ConfigureAwait(false)) + try { - if (evt.Resource != debuggerResource) + await foreach (var evt in resourceNotificationService.WatchAsync(cancellationToken).ConfigureAwait(false)) { - continue; - } + if (evt.Resource != debuggerResource) + { + continue; + } - var state = evt.Snapshot.State?.Text; + var state = evt.Snapshot.State?.Text; - if (state == KnownResourceStates.Running) - { - wasRunning = true; - continue; - } + if (state == KnownResourceStates.Running || state == KnownResourceStates.Starting) + { + wasStarted = true; + continue; + } - // Only reset once the resource has been running and then transitions to a terminal state. - if (wasRunning - && (state == KnownResourceStates.Exited + // Reset when the resource reaches a terminal state — either after running + // (normal browser close / crash) or immediately (failed to start). + // DCP executables use "Terminated" (killed by controller) and "Finished" (ran to completion). + // Explicit-start resources may also transition back to "NotStarted" after stopping. + var isTerminal = state == KnownResourceStates.Exited || state == KnownResourceStates.Finished - || state == KnownResourceStates.FailedToStart)) - { - onStopped(); + || state == KnownResourceStates.FailedToStart + || state == "Terminated" + || (wasStarted && state == KnownResourceStates.NotStarted); - // Force dashboard to re-evaluate command visibility on the command target. - await resourceNotificationService.PublishUpdateAsync(commandTargetResource, s => s).ConfigureAwait(false); - break; + if (isTerminal) + { + onStopped(); + + // Force dashboard to re-evaluate command visibility on the command target. + await resourceNotificationService.PublishUpdateAsync(commandTargetResource, s => s).ConfigureAwait(false); + break; + } } } + catch (OperationCanceledException) + { + // Expected when the watcher is cancelled (e.g., stop command or new debug session). + } } ///