Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion extension/src/debugger/AspireDebugSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -263,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'];

Expand Down
78 changes: 70 additions & 8 deletions extension/src/debugger/languages/browser.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import * as vscode from 'vscode';
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";
import { ResourceDebuggerExtension } from "../debuggerExtensions";
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) {
Expand All @@ -15,27 +19,85 @@ export const browserDebuggerExtension: ResourceDebuggerExtension = {
},
getSupportedFileTypes: () => [],
getProjectFile: () => '',
createDebugSessionConfigurationCallback: async (launchConfig, _args, _env, _launchOptions, debugConfiguration: AspireResourceExtendedDebugConfiguration): Promise<void> => {
createDebugSessionConfigurationCallback: async (launchConfig, _args, _env, launchOptions, debugConfiguration: AspireResourceExtendedDebugConfiguration): Promise<void> => {
if (!isBrowserLaunchConfiguration(launchConfig)) {
extensionLogOutputChannel.info(`The resource type was not browser for ${JSON.stringify(launchConfig)}`);
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,EdgeBackgroundMode',
'--disable-background-mode',
];

// Remove program/args/cwd since browser debugging doesn't use them
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) {
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}`);

// 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);
}
});
}
}
};
133 changes: 133 additions & 0 deletions extension/src/debugger/languages/wasmDebug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import * as vscode from 'vscode';
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<boolean> {
const csharpExt = vscode.extensions.getExtension<CSharpExtensionExports>(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],
};

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(
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;
}

// 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;

// 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;

// 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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
<!-- Enable managed debugging for WebAssembly -->
<DebuggerSupport>true</DebuggerSupport>
<!-- Set debug level > 0 so the WASM runtime enables its debug agent at startup -->
<WasmDebugLevel>2</WasmDebugLevel>
<!-- Override Arcade's embedded PDBs: the WASM debugger needs separate portable PDB files
served alongside the .wasm assemblies in _framework/ to bind breakpoints. -->
<DebugType>portable</DebugType>
<!-- Workaround: monovsdbg_wasm WebCIL parser fails in hosted mode.
Serving plain .dll files bypasses the issue.
Tracking: https://github.com/microsoft/vscode-dotnettools/issues/3002 -->
<WasmEnableWebcil>false</WasmEnableWebcil>
<!-- Enable WebAssembly diagnostics for OpenTelemetry tracing -->
<MetricsSupport>true</MetricsSupport>
<EventSourceSupport>true</EventSourceSupport>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
@page "/counter"
@rendermode InteractiveWebAssembly
@page "/counter"

<PageTitle>Counter</PageTitle>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
@page "/time"
@rendermode InteractiveWebAssembly
@inject IHttpClientFactory HttpClientFactory

<PageTitle>Time</PageTitle>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
@page "/weather"
@rendermode InteractiveWebAssembly
@page "/weather"
@inject IHttpClientFactory HttpClientFactory

<PageTitle>Weather</PageTitle>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Router AppAssembly="typeof(Program).Assembly" AdditionalAssemblies="new[] { typeof(Client._Imports).Assembly }">
<Router AppAssembly="typeof(BlazorHosted.Client._Imports).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
Expand Down
1 change: 1 addition & 0 deletions playground/BlazorHosted/BlazorHosted.Client/_Imports.razor
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using BlazorHosted.Client
@using BlazorHosted.Client.Layout
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
</head>

<body>
<Routes />
<BlazorHosted.Client.Routes @rendermode="InteractiveWebAssembly" />
<BlazorClientConfiguration />
<script src="_framework/blazor.web.js"></script>
</body>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,3 @@
@using BlazorHosted
@using BlazorHosted.Client
@using BlazorHosted.Components
@using BlazorHosted.Components.Layout
6 changes: 1 addition & 5 deletions playground/BlazorHosted/BlazorHosted.Server/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Enable managed debugging for WebAssembly -->
<DebuggerSupport>true</DebuggerSupport>
<!-- Set debug level > 0 so the WASM runtime enables its debug agent at startup -->
<WasmDebugLevel>2</WasmDebugLevel>
<!-- Override Arcade's embedded PDBs: the WASM debugger needs separate portable PDB files
served alongside the .wasm assemblies in _framework/ to bind breakpoints. -->
<DebugType>portable</DebugType>
<WasmCopyOutputSymbolsToPublishDirectory>true</WasmCopyOutputSymbolsToPublishDirectory>
<!-- Enable WebAssembly diagnostics for OpenTelemetry tracing -->
<MetricsSupport>true</MetricsSupport>
<EventSourceSupport>true</EventSourceSupport>
Expand Down
5 changes: 4 additions & 1 deletion src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<IsPackable>true</IsPackable>
<PackageTags>aspire integration hosting blazor webassembly gateway</PackageTags>
<Description>Blazor WebAssembly hosting support for Aspire.</Description>
<NoWarn>$(NoWarn);ASPIREBLAZOR001</NoWarn>
<NoWarn>$(NoWarn);ASPIREBLAZOR001;CS0436</NoWarn>
</PropertyGroup>

<PropertyGroup>
Expand All @@ -32,6 +32,9 @@
<ItemGroup>
<Compile Include="$(SharedDir)StringComparers.cs" Link="Utils\StringComparers.cs" />
<Compile Include="$(SharedDir)Model\KnownRelationshipTypes.cs" Link="Utils\KnownRelationshipTypes.cs" />
<!-- Source-share browser debugger types with Aspire.Hosting.JavaScript -->
<Compile Include="..\Aspire.Hosting.JavaScript\BrowserDebuggerResource.cs" Link="BrowserDebuggerResource.cs" />
<Compile Include="..\Aspire.Hosting.JavaScript\BrowserLaunchConfiguration.cs" Link="BrowserLaunchConfiguration.cs" />
</ItemGroup>

<ItemGroup>
Expand Down
Loading
Loading