Skip to content
Merged
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
3 changes: 3 additions & 0 deletions extension/loc/xlf/aspire-vscode.xlf

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

4 changes: 2 additions & 2 deletions extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -159,12 +159,12 @@
"editor/title/run": [
{
"command": "aspire-vscode.runAppHost",
"when": "aspire.workspaceHasAppHost && aspire.editorSupportsRunDebug",
"when": "(aspire.fileIsAppHostCs || aspire.workspaceHasAppHost) && aspire.editorSupportsRunDebug",
"group": "navigation@-4"
},
{
"command": "aspire-vscode.debugAppHost",
"when": "aspire.workspaceHasAppHost && aspire.editorSupportsRunDebug",
"when": "(aspire.fileIsAppHostCs || aspire.workspaceHasAppHost) && aspire.editorSupportsRunDebug",
"group": "navigation@-3"
}
],
Expand Down
3 changes: 2 additions & 1 deletion extension/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,6 @@
"aspire-vscode.strings.invalidOrMissingToken": "Invalid or missing token in Authorization header.",
"aspire-vscode.strings.invalidTokenLength": "Invalid token length in Authorization header.",
"aspire-vscode.strings.authorizationHeaderMustStartWithBearer": "Authorization header must start with 'Bearer '.",
"aspire-vscode.strings.authorizationAndDcpHeadersRequired": "Authorization and Microsoft-Developer-DCP-Instance-ID headers are required."
"aspire-vscode.strings.authorizationAndDcpHeadersRequired": "Authorization and Microsoft-Developer-DCP-Instance-ID headers are required.",
"aspire-vscode.strings.buildFailedForProjectWithError": "Build failed for project {0} with error: {1}."
}
2 changes: 1 addition & 1 deletion extension/src/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ function isExtensionInstalled(extensionId: string): boolean {
return !!extension;
}

function isCsDevKitInstalled() {
export function isCsDevKitInstalled() {
return isExtensionInstalled("ms-dotnettools.csdevkit");
}

Expand Down
12 changes: 10 additions & 2 deletions extension/src/dcp/AspireDcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,15 @@ export default class AspireDcpServer {
return;
}

const config = await createDebugSessionConfiguration(aspireDebugSession.configuration, launchConfig, payload.args ?? [], payload.env ?? [], { debug: launchConfig.mode === "Debug", runId, debugSessionId: dcpId, isApphost: false }, foundDebuggerExtension);
const config = await createDebugSessionConfiguration(
aspireDebugSession.configuration,
launchConfig,
payload.args ?? [],
payload.env ?? [],
{ debug: launchConfig.mode === "Debug", runId, debugSessionId: dcpId, isApphost: false, debugSession: aspireDebugSession },
foundDebuggerExtension
);

const resourceDebugSession = await aspireDebugSession.startAndGetDebugSession(config);

if (!resourceDebugSession) {
Expand Down Expand Up @@ -237,7 +245,7 @@ export default class AspireDcpServer {
// If no WebSocket is available for the session, log a warning
const ws = this.wsBySession.get(notification.dcp_id);
if (!ws || ws.readyState !== WebSocket.OPEN) {
extensionLogOutputChannel.warn(`No WebSocket found for DCP ID: ${notification.dcp_id} or WebSocket is not open (state: ${ws?.readyState})`);
extensionLogOutputChannel.trace(`No WebSocket found for DCP ID: ${notification.dcp_id} or WebSocket is not open (state: ${ws?.readyState})`);
this.pendingNotificationQueueByDcpId.set(notification.dcp_id, [...(this.pendingNotificationQueueByDcpId.get(notification.dcp_id) || []), notification]);
return;
}
Expand Down
2 changes: 2 additions & 0 deletions extension/src/dcp/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as vscode from 'vscode';
import { AspireDebugSession } from '../debugger/AspireDebugSession';

export interface ErrorResponse {
error: ErrorDetails;
Expand Down Expand Up @@ -96,6 +97,7 @@ export interface LaunchOptions {
runId: string;
debugSessionId: string;
isApphost: boolean;
debugSession: AspireDebugSession;
};

export interface AspireResourceDebugSession {
Expand Down
15 changes: 13 additions & 2 deletions extension/src/debugger/AspireDebugSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { AspireTerminalProvider } from "../utils/AspireTerminalProvider";
import { ICliRpcClient } from "../server/rpcClient";
import path from "path";
import { EnvironmentVariables } from "../utils/environment";
import { isCsDevKitInstalled } from "../capabilities";

export class AspireDebugSession implements vscode.DebugAdapter {
private readonly _onDidSendMessage = new EventEmitter<any>();
Expand Down Expand Up @@ -86,6 +87,10 @@ export class AspireDebugSession implements vscode.DebugAdapter {
args.push('--wait-for-debugger');
}

if (this._terminalProvider.isCliDebugLoggingEnabled()) {
args.push('--debug');
}

if (isDirectory(appHostPath)) {
this.sendMessageWithEmoji("📁", launchingWithDirectory(appHostPath));

Expand Down Expand Up @@ -197,7 +202,13 @@ export class AspireDebugSession implements vscode.DebugAdapter {
this.createDebugAdapterTrackerCore(projectDebuggerExtension.debugAdapter);

extensionLogOutputChannel.info(`Starting AppHost for project: ${projectFile} with args: ${args.join(' ')}`);
const appHostDebugSessionConfiguration = await createDebugSessionConfiguration(this.configuration, { project_path: projectFile, type: 'project' } as ProjectLaunchConfiguration, args, environment, { debug, forceBuild: debug, runId: '', debugSessionId: this.debugSessionId, isApphost: true }, projectDebuggerExtension);
const appHostDebugSessionConfiguration = await createDebugSessionConfiguration(
this.configuration,
{ project_path: projectFile, type: 'project' } as ProjectLaunchConfiguration,
args,
environment,
{ debug, forceBuild: isCsDevKitInstalled(), runId: '', debugSessionId: this.debugSessionId, isApphost: true, debugSession: this },
projectDebuggerExtension);
const appHostDebugSession = await this.startAndGetDebugSession(appHostDebugSessionConfiguration);

if (!appHostDebugSession) {
Expand Down Expand Up @@ -225,6 +236,7 @@ export class AspireDebugSession implements vscode.DebugAdapter {

async startAndGetDebugSession(debugConfig: AspireResourceExtendedDebugConfiguration): Promise<AspireResourceDebugSession | undefined> {
return new Promise(async (resolve) => {
extensionLogOutputChannel.info(`Starting debug session with configuration: ${JSON.stringify(debugConfig)}`);
this.createDebugAdapterTrackerCore(debugConfig.type);

const disposable = vscode.debug.onDidStartDebugSession(session => {
Expand Down Expand Up @@ -252,7 +264,6 @@ export class AspireDebugSession implements vscode.DebugAdapter {
}
});

extensionLogOutputChannel.info(`Starting debug session with configuration: ${JSON.stringify(debugConfig)}`);
const started = await vscode.debug.startDebugging(undefined, debugConfig, this._session);
if (!started) {
disposable.dispose();
Expand Down
113 changes: 92 additions & 21 deletions extension/src/debugger/languages/dotnet.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as vscode from 'vscode';
import { extensionLogOutputChannel } from '../../utils/logging';
import { noCsharpBuildTask, buildFailedWithExitCode, noOutputFromMsbuild, failedToGetTargetPath, invalidLaunchConfiguration } from '../../loc/strings';
import { noCsharpBuildTask, buildFailedWithExitCode, noOutputFromMsbuild, failedToGetTargetPath, invalidLaunchConfiguration, buildFailedForProjectWithError, processExitedWithCode } from '../../loc/strings';
import { ChildProcessWithoutNullStreams, execFile, spawn } from 'child_process';
import * as util from 'util';
import * as path from 'path';
Expand All @@ -17,6 +17,8 @@ import {
determineWorkingDirectory,
determineServerReadyAction
} from '../launchProfiles';
import { AspireDebugSession } from '../AspireDebugSession';
import { isCsDevKitInstalled } from '../../capabilities';

interface IDotNetService {
getAndActivateDevKit(): Promise<boolean>
Expand All @@ -26,8 +28,18 @@ interface IDotNetService {
}

class DotNetService implements IDotNetService {
private _debugSession: AspireDebugSession;

constructor(debugSession: AspireDebugSession) {
this._debugSession = debugSession;
}

execFileAsync = util.promisify(execFile);

writeToDebugConsole(message: string, category: 'stdout' | 'stderr') {
this._debugSession.sendMessage(message, false, category);
}

async getAndActivateDevKit(): Promise<boolean> {
const csharpDevKit = vscode.extensions.getExtension('ms-dotnettools.csdevkit');
if (!csharpDevKit) {
Expand All @@ -45,6 +57,52 @@ class DotNetService implements IDotNetService {
}

async buildDotNetProject(projectFile: string): Promise<void> {
const isDevKitEnabled = await this.getAndActivateDevKit();

if (!isDevKitEnabled) {
this.writeToDebugConsole('C# Dev Kit not available, building project using dotnet CLI...', 'stdout');
const args = ['build', projectFile];

return new Promise<void>((resolve, reject) => {
const buildProcess = spawn('dotnet', args);

let stdoutOutput = '';
let stderrOutput = '';

// Stream stdout in real-time
buildProcess.stdout?.on('data', (data: Buffer) => {
const output = data.toString();
stdoutOutput += output;
this.writeToDebugConsole(output, 'stdout');
});

// Stream stderr in real-time
buildProcess.stderr?.on('data', (data: Buffer) => {
const output = data.toString();
stderrOutput += output;
this.writeToDebugConsole(output, 'stderr');
});

buildProcess.on('error', (err) => {
extensionLogOutputChannel.error(`dotnet build process error: ${err}`);
reject(new Error(buildFailedForProjectWithError(projectFile, err.message)));
});

buildProcess.on('close', (code) => {
if (code === 0) {
// if build succeeds, simply return. otherwise throw to trigger error handling
if (stderrOutput) {
reject(new Error(stderrOutput));
} else {
resolve();
}
} else {
reject(new Error(buildFailedForProjectWithError(projectFile, stdoutOutput || stderrOutput || `Exit code ${code}`)));
}
});
});
}

// C# Dev Kit may not register the build task immediately, so we need to retry until it is available
const pRetry = (await import('p-retry')).default;
const buildTask = await pRetry(async () => {
Expand Down Expand Up @@ -117,9 +175,10 @@ class DotNetService implements IDotNetService {
}

async getDotNetRunApiOutput(projectPath: string): Promise<string> {
let childProcess: ChildProcessWithoutNullStreams;

return new Promise<string>(async (resolve, reject) => {
try {
let childProcess: ChildProcessWithoutNullStreams;
const timeout = setTimeout(() => {
childProcess?.kill();
reject(new Error('Timeout while waiting for dotnet run-api response'));
Expand All @@ -136,7 +195,9 @@ class DotNetService implements IDotNetService {
childProcess.on('error', reject);
childProcess.on('exit', (code, signal) => {
clearTimeout(timeout);
reject(new Error(`dotnet run-api exited with ${code ?? signal}`));
if (code !== 0) {
reject(new Error(processExitedWithCode(code?.toString() ?? "unknown")));
}
});

const rl = readline.createInterface(childProcess.stdout);
Expand All @@ -153,15 +214,20 @@ class DotNetService implements IDotNetService {
} catch (e) {
reject(e);
}
});
}).finally(() => childProcess.removeAllListeners());
}
}

function isSingleFileAppHost(projectPath: string): boolean {
return path.basename(projectPath).toLowerCase() === 'apphost.cs';
export function isSingleFileApp(projectPath: string): boolean {
return path.extname(projectPath).toLowerCase().endsWith('.cs');
}

interface RunApiOutput {
executablePath: string;
env?: { [key: string]: string };
}

function applyRunApiOutputToDebugConfiguration(runApiOutput: string, debugConfiguration: AspireResourceExtendedDebugConfiguration) {
function getRunApiConfigFromOutput(runApiOutput: string, debugConfiguration: AspireResourceExtendedDebugConfiguration): RunApiOutput {
const parsed = JSON.parse(runApiOutput);
if (parsed.$type === 'Error') {
throw new Error(`dotnet run-api failed: ${parsed.Message}`);
Expand All @@ -170,16 +236,13 @@ function applyRunApiOutputToDebugConfiguration(runApiOutput: string, debugConfig
throw new Error(`dotnet run-api failed: Unexpected response type '${parsed.$type}'`);
}

debugConfiguration.program = parsed.ExecutablePath;
if (parsed.EnvironmentVariables) {
debugConfiguration.env = {
...debugConfiguration.env,
...parsed.EnvironmentVariables
};
}
return {
executablePath: parsed.ExecutablePath,
env: parsed.EnvironmentVariables
};
}

export function createProjectDebuggerExtension(dotNetService: IDotNetService): ResourceDebuggerExtension {
export function createProjectDebuggerExtension(dotNetServiceProducer: (debugSession: AspireDebugSession) => IDotNetService): ResourceDebuggerExtension {
return {
resourceType: 'project',
debugAdapter: 'coreclr',
Expand All @@ -194,13 +257,17 @@ export function createProjectDebuggerExtension(dotNetService: IDotNetService): R
throw new Error(invalidLaunchConfiguration(JSON.stringify(launchConfig)));
},
createDebugSessionConfigurationCallback: async (launchConfig, args, env, launchOptions, debugConfiguration: AspireResourceExtendedDebugConfiguration): Promise<void> => {
const dotNetService: IDotNetService = dotNetServiceProducer(launchOptions.debugSession);

if (!isProjectLaunchConfiguration(launchConfig)) {
extensionLogOutputChannel.info(`The resource type was not project for ${JSON.stringify(launchConfig)}`);
throw new Error(invalidLaunchConfiguration(JSON.stringify(launchConfig)));
}

const projectPath = launchConfig.project_path;

extensionLogOutputChannel.info(`Reading launch settings for: ${projectPath}`);

// Apply launch profile settings if available
const launchSettings = await readLaunchSettings(projectPath);
if (!isProjectLaunchConfiguration(launchConfig)) {
Expand All @@ -217,26 +284,30 @@ export function createProjectDebuggerExtension(dotNetService: IDotNetService): R
// Configure debug session with launch profile settings
debugConfiguration.cwd = determineWorkingDirectory(projectPath, baseProfile);
debugConfiguration.args = determineArguments(baseProfile?.commandLineArgs, args);
debugConfiguration.env = Object.fromEntries(mergeEnvironmentVariables(baseProfile?.environmentVariables, env));
debugConfiguration.executablePath = baseProfile?.executablePath;
debugConfiguration.checkForDevCert = baseProfile?.useSSL;
debugConfiguration.serverReadyAction = determineServerReadyAction(baseProfile?.launchBrowser, baseProfile?.applicationUrl);

// Build project if needed
if (!isSingleFileAppHost(projectPath)) {
if (!isSingleFileApp(projectPath)) {
const outputPath = await dotNetService.getDotNetTargetPath(projectPath);
if ((!(await doesFileExist(outputPath)) || launchOptions.forceBuild) && await dotNetService.getAndActivateDevKit()) {
if ((!(await doesFileExist(outputPath)) || launchOptions.forceBuild)) {
await dotNetService.buildDotNetProject(projectPath);
}

debugConfiguration.program = outputPath;
debugConfiguration.env = Object.fromEntries(mergeEnvironmentVariables(baseProfile?.environmentVariables, env));
}
else {
// Single file apps should always be built
await dotNetService.buildDotNetProject(projectPath);
const runApiOutput = await dotNetService.getDotNetRunApiOutput(projectPath);
applyRunApiOutputToDebugConfiguration(runApiOutput, debugConfiguration);
const runApiConfig = getRunApiConfigFromOutput(runApiOutput, debugConfiguration);
debugConfiguration.program = runApiConfig.executablePath;

debugConfiguration.env = Object.fromEntries(mergeEnvironmentVariables(baseProfile?.environmentVariables, env, runApiConfig.env));
}
}
};
}

export const projectDebuggerExtension: ResourceDebuggerExtension = createProjectDebuggerExtension(new DotNetService());
export const projectDebuggerExtension: ResourceDebuggerExtension = createProjectDebuggerExtension(debugSession => new DotNetService(debugSession));
Loading