Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
11 changes: 9 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,6 @@ 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})`);
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
119 changes: 98 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,9 @@ import {
determineWorkingDirectory,
determineServerReadyAction
} from '../launchProfiles';
import { debug } from 'util';
import { AspireDebugSession } from '../AspireDebugSession';
import { isCsDevKitInstalled } from '../../capabilities';

interface IDotNetService {
getAndActivateDevKit(): Promise<boolean>
Expand All @@ -26,8 +29,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 +58,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 +176,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 +196,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 +215,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 +237,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 +258,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 +285,35 @@ 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;

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

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