Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
764d440
Revert "Support debugging browser and JavaScript apps in 13.2 (#14686)"
adamint Mar 9, 2026
d81c69a
Remove withDebugging() call from Python ValidationAppHost
adamint Mar 9, 2026
852f3fb
Regenerate CompatibilitySuppressions.xml for API compat
adamint Mar 9, 2026
ab65402
removed apis
adamint Mar 9, 2026
fa2fd60
Minimal JavaScript debugging support modeled after Python implementation
adamint Mar 9, 2026
fb14e7a
Add browser debugging support and fix duplicate 'run' in node debugger
adamint Mar 9, 2026
e71c3a9
Deferred launch configuration pattern with tests
adamint Mar 9, 2026
ec4195a
Merge remote-tracking branch 'upstream/release/13.2' into dev/adamint…
adamint Mar 10, 2026
5c338b8
Fix post-merge build errors and remove DCP log artifacts
adamint Mar 10, 2026
8202992
Extract IGuestProcessLauncher strategy for guest AppHost process laun…
adamint Mar 10, 2026
c3abf51
Localize Browser display name strings
adamint Mar 10, 2026
7feccfe
Apply playground changes from PR adamint/aspire#10
adamint Mar 10, 2026
69cf96a
make sealed
adamint Mar 10, 2026
38b1fad
Fix CS0436: Remove linked source files duplicated via InternalsVisibleTo
adamint Mar 10, 2026
edb8423
Fix CI failures: InternalsVisibleTo, cross-platform path, deferred en…
adamint Mar 10, 2026
9b67592
Address code review: fix browser debug type, delete cwd, clarify Firs…
adamint Mar 10, 2026
a0d7f28
add language guard to extension guest apphost launcher
adamint Mar 10, 2026
c072113
Add property to RuntimeSpec to advertise necessary capability to laun…
adamint Mar 10, 2026
7072bc2
update xlf
adamint Mar 10, 2026
a897300
Add tests for ExtensionLaunchCapability on GuestRuntime
adamint Mar 10, 2026
ade4c09
Remove PR-specific versions from sample and add null guard for _guest…
adamint Mar 10, 2026
36b36e1
only ignore connection loss in extension mode in run command
adamint Mar 10, 2026
740850f
add comment
adamint Mar 10, 2026
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
8 changes: 7 additions & 1 deletion extension/loc/xlf/aspire-vscode.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 6 additions & 4 deletions extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
"workspaceContains:**/*.csproj",
"onView:workbench.view.debug",
"workspaceContains:**/apphost.cs",
"workspaceContains:**/apphost.ts",
"workspaceContains:**/apphost.js",
"onCommand:aspire-vscode.installCliStable",
"onCommand:aspire-vscode.installCliDaily",
"onCommand:aspire-vscode.verifyCliInstalled"
Expand Down Expand Up @@ -290,24 +292,24 @@
"explorer/context": [
{
"command": "aspire-vscode.runAppHost",
"when": "resourceExtname == .cs && resourceFilename =~ /apphost\\.cs$/i",
"when": "resourceFilename =~ /apphost\\.(cs|ts|js)$/i",
"group": "aspire_actions@1"
},
{
"command": "aspire-vscode.debugAppHost",
"when": "resourceExtname == .cs && resourceFilename =~ /apphost\\.cs$/i",
"when": "resourceFilename =~ /apphost\\.(cs|ts|js)$/i",
"group": "aspire_actions@2"
}
],
"editor/title/run": [
{
"command": "aspire-vscode.runAppHost",
"when": "(aspire.fileIsAppHostCs || aspire.workspaceHasAppHost) && aspire.editorSupportsRunDebug",
"when": "(aspire.fileIsAppHost || aspire.workspaceHasAppHost) && aspire.editorSupportsRunDebug",
"group": "navigation@-4"
},
{
"command": "aspire-vscode.debugAppHost",
"when": "(aspire.fileIsAppHostCs || aspire.workspaceHasAppHost) && aspire.editorSupportsRunDebug",
"when": "(aspire.fileIsAppHost || aspire.workspaceHasAppHost) && aspire.editorSupportsRunDebug",
"group": "navigation@-3"
}
],
Expand Down
16 changes: 14 additions & 2 deletions extension/src/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { RunSessionInfo } from './dcp/types';

export type Capability =
| 'prompting' // Support using VS Code to capture user input instead of CLI
| 'baseline.v1'
| 'baseline.v1'
| 'secret-prompts.v1'
| 'file-pickers.v1'
| 'build-dotnet-using-cli' // Support building .NET projects using the CLI
Expand All @@ -12,7 +12,9 @@ export type Capability =
| 'project' // Support for running C# projects
| 'ms-dotnettools.csharp' // Older AppHost versions used this extension identifier instead of project
| 'python' // Support for running Python projects
| 'ms-python.python'; // Older AppHost versions used this extension identifier instead of python
| 'ms-python.python' // Older AppHost versions used this extension identifier instead of python
| 'node' // Support for running Node.js projects
| 'browser'; // Support for browser debugging (built-in to VS Code via js-debug)

export type Capabilities = Capability[];

Expand All @@ -33,6 +35,11 @@ export function isPythonInstalled() {
return isExtensionInstalled("ms-python.python");
}

export function isNodeInstalled() {
// Node.js debugging uses VS Code's built-in js-debug, no extension needed
return true;
}
Comment thread
adamint marked this conversation as resolved.

export function getSupportedCapabilities(): Capabilities {
const capabilities: Capabilities = ['prompting', 'baseline.v1', 'secret-prompts.v1', 'file-pickers.v1', 'build-dotnet-using-cli'];

Expand All @@ -51,6 +58,11 @@ export function getSupportedCapabilities(): Capabilities {
capabilities.push("ms-python.python");
}

if (isNodeInstalled()) {
capabilities.push("node");
capabilities.push("browser");
}

return capabilities;
}

Expand Down
23 changes: 23 additions & 0 deletions extension/src/dcp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,28 @@ export function isPythonLaunchConfiguration(obj: any): obj is PythonLaunchConfig
return obj && obj.type === 'python';
}

export interface NodeLaunchConfiguration extends ExecutableLaunchConfiguration {
type: "node";
script_path?: string;
runtime_executable?: string;
working_directory?: string;
}

export function isNodeLaunchConfiguration(obj: any): obj is NodeLaunchConfiguration {
return obj && obj.type === 'node';
}

export interface BrowserLaunchConfiguration extends ExecutableLaunchConfiguration {
type: "browser";
url?: string;
web_root?: string;
browser?: string;
}

export function isBrowserLaunchConfiguration(obj: any): obj is BrowserLaunchConfiguration {
return obj && obj.type === 'browser';
}

export interface EnvVar {
name: string;
value: string;
Expand Down Expand Up @@ -121,6 +143,7 @@ export interface AspireResourceExtendedDebugConfiguration extends vscode.DebugCo
runId: string;
debugSessionId: string | null;
projectFile?: string;
isApphost?: boolean;
}

export type AspireCommandType = 'run' | 'deploy' | 'publish' | 'do';
Expand Down
75 changes: 56 additions & 19 deletions extension/src/debugger/AspireDebugSession.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import * as vscode from "vscode";
import { EventEmitter } from "vscode";
import * as fs from "fs";
import { createDebugAdapterTracker } from "./adapterTracker";
import { AspireResourceExtendedDebugConfiguration, AspireResourceDebugSession, EnvVar, AspireExtendedDebugConfiguration, ProjectLaunchConfiguration, StartAppHostOptions } from "../dcp/types";
import { createDebugAdapterTracker, AppHostRestartHandler } from "./adapterTracker";
import { AspireResourceExtendedDebugConfiguration, AspireResourceDebugSession, EnvVar, AspireExtendedDebugConfiguration, NodeLaunchConfiguration, ProjectLaunchConfiguration, StartAppHostOptions } from "../dcp/types";
import { extensionLogOutputChannel } from "../utils/logging";
import AspireDcpServer, { generateDcpIdPrefix } from "../dcp/AspireDcpServer";
import { spawnCliProcess } from "./languages/cli";
import { disconnectingFromSession, launchingWithAppHost, launchingWithDirectory, processExceptionOccurred, processExitedWithCode, aspireDashboard } from "../loc/strings";
import { projectDebuggerExtension } from "./languages/dotnet";
import { nodeDebuggerExtension } from "./languages/node";
import AspireRpcServer from "../server/AspireRpcServer";
import { createDebugSessionConfiguration } from "./debuggerExtensions";
import { AspireTerminalProvider } from "../utils/AspireTerminalProvider";
Expand All @@ -34,7 +35,6 @@ export class AspireDebugSession implements vscode.DebugAdapter {
private _dashboardDebugSession: vscode.DebugSession | null = null;
private readonly _disposables: vscode.Disposable[] = [];
private _disposed = false;
private _userInitiatedStop = false;

public readonly onDidSendMessage = this._onDidSendMessage.event;
public readonly debugSessionId: string;
Expand Down Expand Up @@ -129,7 +129,6 @@ export class AspireDebugSession implements vscode.DebugAdapter {
}
else if (message.command === 'disconnect' || message.command === 'terminate') {
this.sendMessageWithEmoji("🔌", disconnectingFromSession);
this._userInitiatedStop = true;
this.dispose();

this.sendEvent({
Expand Down Expand Up @@ -215,33 +214,71 @@ export class AspireDebugSession implements vscode.DebugAdapter {
}
}

createDebugAdapterTrackerCore(debugAdapter: string) {
createDebugAdapterTrackerCore(debugAdapter: string, onAppHostRestartRequested?: AppHostRestartHandler) {
if (this._trackedDebugAdapters.includes(debugAdapter)) {
return;
}

this._trackedDebugAdapters.push(debugAdapter);
this._disposables.push(createDebugAdapterTracker(this._dcpServer, debugAdapter));
this._disposables.push(createDebugAdapterTracker(this._dcpServer, debugAdapter, onAppHostRestartRequested));
}

private static readonly _nodeAppHostExtensions = ['.js', '.ts', '.mjs', '.mts', '.cjs', '.cts'];

private _appHostRestartRequested = false;

async startAppHost(projectFile: string, args: string[], environment: EnvVar[], debug: boolean, options: StartAppHostOptions): Promise<void> {
try {
this.createDebugAdapterTrackerCore(projectDebuggerExtension.debugAdapter);
const fileExtension = path.extname(projectFile).toLowerCase();
const isNodeAppHost = AspireDebugSession._nodeAppHostExtensions.includes(fileExtension);

const debuggerExtension = isNodeAppHost ? nodeDebuggerExtension : projectDebuggerExtension;

// Register the adapter tracker with an app host restart handler.
// When the user clicks "restart" on the app host child session,
// we suppress VS Code's automatic child restart and restart the
// entire Aspire debug session instead.
this.createDebugAdapterTrackerCore(debuggerExtension.debugAdapter, (debugSessionId) => {
if (debugSessionId === this.debugSessionId) {
this._appHostRestartRequested = true;
return true; // suppress VS Code's child restart
}
return false;
});

// The CLI sends the full dotnet CLI args (e.g., ["run", "--no-build", "--project", "...", "--", ...appHostArgs]).
// Since we launch the apphost directly via the debugger (not via dotnet run), extract only the args after "--".
const separatorIndex = args.indexOf('--');
const appHostArgs = separatorIndex >= 0 ? args.slice(separatorIndex + 1) : args;
let appHostArgs: string[];
let launchConfig;

if (isNodeAppHost) {
// The CLI prepends the runtime command (e.g., "npx") as args[0].
// Extract it as the runtimeExecutable and use the rest as the actual args.
const runtimeExecutable = args.length > 0 ? args[0] : undefined;
appHostArgs = args.slice(1);
launchConfig = {
script_path: projectFile,
working_directory: path.dirname(projectFile),
type: 'node',
...(runtimeExecutable ? { runtime_executable: runtimeExecutable } : {})
} as NodeLaunchConfiguration;
}
else {
// The CLI sends the full dotnet CLI args (e.g., ["run", "--no-build", "--project", "...", "--", ...appHostArgs]).
// Since we launch the apphost directly via the debugger (not via dotnet run), extract only the args after "--".
const separatorIndex = args.indexOf('--');
appHostArgs = separatorIndex >= 0 ? args.slice(separatorIndex + 1) : args;
launchConfig = { project_path: projectFile, type: 'project' } as ProjectLaunchConfiguration;
}

extensionLogOutputChannel.info(`Starting AppHost for project: ${projectFile} with args: ${appHostArgs.join(' ')}`);

const appHostDebugSessionConfiguration = await createDebugSessionConfiguration(
this.configuration,
{ project_path: projectFile, type: 'project' } as ProjectLaunchConfiguration,
launchConfig,
appHostArgs,
environment,
{ debug, forceBuild: options.forceBuild, runId: '', debugSessionId: this.debugSessionId, isApphost: true, debugSession: this },
projectDebuggerExtension);
{ debug, forceBuild: isNodeAppHost ? false : options.forceBuild, runId: '', debugSessionId: this.debugSessionId, isApphost: true, debugSession: this },
debuggerExtension);

const appHostDebugSession = await this.startAndGetDebugSession(appHostDebugSessionConfiguration);

if (!appHostDebugSession) {
Expand All @@ -252,15 +289,15 @@ export class AspireDebugSession implements vscode.DebugAdapter {

const disposable = vscode.debug.onDidTerminateDebugSession(async session => {
if (this._appHostDebugSession && session.id === this._appHostDebugSession.id) {
const command = this.configuration.command ?? 'run';
// Only restart for 'run' — pipeline commands (do/deploy/publish) exit normally after completing.
const shouldRestart = !this._userInitiatedStop && command === 'run';
// Only restart the Aspire session when the user explicitly clicked
// "restart" on the app host debug toolbar (detected via DAP tracker).
// All other cases (user stop, process crash/exit) just dispose.
const shouldRestart = this._appHostRestartRequested;
const config = this.configuration;
// Always dispose the current Aspire debug session when the AppHost stops.
this.dispose();

if (shouldRestart) {
extensionLogOutputChannel.info('AppHost terminated unexpectedly, restarting Aspire debug session');
extensionLogOutputChannel.info('AppHost restart requested, restarting Aspire debug session');
await vscode.debug.startDebugging(undefined, config);
}
}
Expand Down
28 changes: 27 additions & 1 deletion extension/src/debugger/adapterTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,36 @@ import AspireDcpServer from '../dcp/AspireDcpServer';
import { removeTrailingNewline } from '../utils/strings';
import { dcpServerNotInitialized } from '../loc/strings';

export function createDebugAdapterTracker(dcpServer: AspireDcpServer, debugAdapter: string): vscode.Disposable {
/**
* Callback invoked when a restart is requested on an app host debug session.
* Return `true` to suppress VS Code's automatic child session restart.
*/
export type AppHostRestartHandler = (debugSessionId: string) => boolean;

export function createDebugAdapterTracker(dcpServer: AspireDcpServer, debugAdapter: string, onAppHostRestartRequested?: AppHostRestartHandler): vscode.Disposable {
return vscode.debug.registerDebugAdapterTrackerFactory(debugAdapter, {
createDebugAdapterTracker(session: vscode.DebugSession) {
return {
onWillReceiveMessage: message => {
if (!isDebugConfigurationWithId(session.configuration)) {
return;
}

// Detect restart requests on app host debug sessions.
// When the user clicks "restart" on the app host child session,
// suppress VS Code's automatic child restart so the Aspire debug
// session can restart entirely instead.
if (session.configuration.isApphost
&& (message.command === 'disconnect' || message.command === 'terminate')
&& message.arguments?.restart
&& onAppHostRestartRequested
&& session.configuration.debugSessionId) {
const shouldSuppress = onAppHostRestartRequested(session.configuration.debugSessionId);
if (shouldSuppress) {
message.arguments.restart = false;
}
}
},
onDidSendMessage: message => {
if (message.type === 'event' && message.event === 'output') {
if (!isDebugConfigurationWithId(session.configuration) || session.configuration.debugSessionId === null) {
Expand Down
8 changes: 7 additions & 1 deletion extension/src/debugger/debuggerExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { extensionLogOutputChannel } from "../utils/logging";
import { projectDebuggerExtension } from "./languages/dotnet";
import { isCsharpInstalled, isPythonInstalled } from "../capabilities";
import { pythonDebuggerExtension } from "./languages/python";
import { nodeDebuggerExtension } from "./languages/node";
import { browserDebuggerExtension } from "./languages/browser";
import { isDirectory } from "../utils/io";

// Represents a resource-specific debugger extension for when the default session configuration is not sufficient to launch the resource.
Expand Down Expand Up @@ -39,7 +41,8 @@ export async function createDebugSessionConfiguration(debugSessionConfig: Aspire
noDebug: !launchOptions.debug,
runId: launchOptions.runId,
debugSessionId: launchOptions.debugSessionId,
console: 'internalConsole'
console: 'internalConsole',
isApphost: launchOptions.isApphost
};

if (debugSessionConfig.debuggers) {
Expand Down Expand Up @@ -72,6 +75,9 @@ export function getResourceDebuggerExtensions(): ResourceDebuggerExtension[] {
extensions.push(pythonDebuggerExtension);
}

extensions.push(nodeDebuggerExtension);
extensions.push(browserDebuggerExtension);

return extensions;
}

38 changes: 38 additions & 0 deletions extension/src/debugger/languages/browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { AspireResourceExtendedDebugConfiguration, ExecutableLaunchConfiguration, isBrowserLaunchConfiguration } from "../../dcp/types";
import { browserDisplayName, browserLabel, invalidLaunchConfiguration } from "../../loc/strings";
import { extensionLogOutputChannel } from "../../utils/logging";
import { ResourceDebuggerExtension } from "../debuggerExtensions";

export const browserDebuggerExtension: ResourceDebuggerExtension = {
resourceType: 'browser',
debugAdapter: 'pwa-msedge',
extensionId: null, // built-in to VS Code via js-debug
getDisplayName: (launchConfiguration: ExecutableLaunchConfiguration) => {
if (isBrowserLaunchConfiguration(launchConfiguration) && launchConfiguration.url) {
return browserDisplayName(launchConfiguration.url);
}
return browserLabel;
},
getSupportedFileTypes: () => [],
getProjectFile: () => '',
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)));
}

debugConfiguration.type = launchConfig.browser || 'msedge';
debugConfiguration.request = 'launch';
debugConfiguration.url = launchConfig.url;
debugConfiguration.webRoot = launchConfig.web_root;
debugConfiguration.sourceMaps = true;
Comment thread
adamint marked this conversation as resolved.
Outdated
debugConfiguration.resolveSourceMapLocations = ['**', '!**/node_modules/**'];
// Use an auto-managed temp user data directory so multiple browser debuggers
// can run concurrently without conflicting
debugConfiguration.userDataDir = true;

// Remove program/args/cwd since browser debugging doesn't use them
delete debugConfiguration.program;
delete debugConfiguration.args;
}
Comment thread
adamint marked this conversation as resolved.
};
Loading
Loading