From ebd1e98baf615da59542b66736bfaa3e58f83610 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 31 Oct 2025 00:34:22 -0400 Subject: [PATCH 1/9] show debug/run apphost for apphost.cs --- extension/package.json | 4 ++-- extension/src/editor/AspireEditorCommandProvider.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/extension/package.json b/extension/package.json index 0b4150cdfe4..80cf43f0cdb 100644 --- a/extension/package.json +++ b/extension/package.json @@ -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" } ], diff --git a/extension/src/editor/AspireEditorCommandProvider.ts b/extension/src/editor/AspireEditorCommandProvider.ts index 8f07c6b5884..8855001cf9c 100644 --- a/extension/src/editor/AspireEditorCommandProvider.ts +++ b/extension/src/editor/AspireEditorCommandProvider.ts @@ -55,7 +55,16 @@ export class AspireEditorCommandProvider implements vscode.Disposable { public async processDocument(document: vscode.TextDocument): Promise { const fileExtension = path.extname(document.uri.fsPath).toLowerCase(); const isSupportedFile = getResourceDebuggerExtensions().some(extension => extension.getSupportedFileTypes().includes(fileExtension)); + vscode.commands.executeCommand('setContext', 'aspire.editorSupportsRunDebug', isSupportedFile); + + const isApphostCsFile = path.basename(document.uri.fsPath).toLowerCase() === 'apphost.cs'; + if (isApphostCsFile) { + vscode.commands.executeCommand('setContext', 'aspire.fileIsAppHostCs', true); + } + else { + vscode.commands.executeCommand('setContext', 'aspire.fileIsAppHostCs', false); + } } private onChangeAppHostPath(newPath: string | null) { From 844569b854b3a00c0187dc777b99ca307ce44faa Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 31 Oct 2025 01:52:55 -0400 Subject: [PATCH 2/9] wip --- extension/src/dcp/AspireDcpServer.ts | 1 - extension/src/debugger/AspireDebugSession.ts | 2 +- extension/src/debugger/languages/dotnet.ts | 7 ++++--- .../src/editor/AspireEditorCommandProvider.ts | 20 ++++++++++++++----- playground/FileBasedApps/apphost.cs | 4 ++++ src/Aspire.Cli/Projects/ProjectLocator.cs | 1 + 6 files changed, 25 insertions(+), 10 deletions(-) diff --git a/extension/src/dcp/AspireDcpServer.ts b/extension/src/dcp/AspireDcpServer.ts index 32426e371ad..aa9673ca987 100644 --- a/extension/src/dcp/AspireDcpServer.ts +++ b/extension/src/dcp/AspireDcpServer.ts @@ -237,7 +237,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; } diff --git a/extension/src/debugger/AspireDebugSession.ts b/extension/src/debugger/AspireDebugSession.ts index 74321204092..6dc722a7621 100644 --- a/extension/src/debugger/AspireDebugSession.ts +++ b/extension/src/debugger/AspireDebugSession.ts @@ -225,6 +225,7 @@ export class AspireDebugSession implements vscode.DebugAdapter { async startAndGetDebugSession(debugConfig: AspireResourceExtendedDebugConfiguration): Promise { 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 => { @@ -252,7 +253,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(); diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 7bf64de53a0..7d37062fadd 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -157,8 +157,8 @@ class DotNetService implements IDotNetService { } } -function isSingleFileAppHost(projectPath: string): boolean { - return path.basename(projectPath).toLowerCase() === 'apphost.cs'; +function isSingleFileApp(projectPath: string): boolean { + return path.extname(projectPath).toLowerCase().endsWith('.cs'); } function applyRunApiOutputToDebugConfiguration(runApiOutput: string, debugConfiguration: AspireResourceExtendedDebugConfiguration) { @@ -223,7 +223,7 @@ export function createProjectDebuggerExtension(dotNetService: IDotNetService): R 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()) { await dotNetService.buildDotNetProject(projectPath); @@ -232,6 +232,7 @@ export function createProjectDebuggerExtension(dotNetService: IDotNetService): R debugConfiguration.program = outputPath; } else { + //await dotNetService.buildDotNetProject(projectPath); const runApiOutput = await dotNetService.getDotNetRunApiOutput(projectPath); applyRunApiOutputToDebugConfiguration(runApiOutput, debugConfiguration); } diff --git a/extension/src/editor/AspireEditorCommandProvider.ts b/extension/src/editor/AspireEditorCommandProvider.ts index 8855001cf9c..d1b0878ab05 100644 --- a/extension/src/editor/AspireEditorCommandProvider.ts +++ b/extension/src/editor/AspireEditorCommandProvider.ts @@ -58,8 +58,7 @@ export class AspireEditorCommandProvider implements vscode.Disposable { vscode.commands.executeCommand('setContext', 'aspire.editorSupportsRunDebug', isSupportedFile); - const isApphostCsFile = path.basename(document.uri.fsPath).toLowerCase() === 'apphost.cs'; - if (isApphostCsFile) { + if (this.isAppHostCsFile(document.uri.fsPath)) { vscode.commands.executeCommand('setContext', 'aspire.fileIsAppHostCs', true); } else { @@ -67,6 +66,10 @@ export class AspireEditorCommandProvider implements vscode.Disposable { } } + private isAppHostCsFile(filePath: string): boolean { + return path.basename(filePath).toLowerCase() === 'apphost.cs'; + } + private onChangeAppHostPath(newPath: string | null) { vscode.commands.executeCommand('setContext', 'aspire.workspaceHasAppHost', !!newPath); this._workspaceAppHostPath = newPath; @@ -108,16 +111,23 @@ export class AspireEditorCommandProvider implements vscode.Disposable { } public async tryExecuteRunAppHost(noDebug: boolean): Promise { - if (!this._workspaceAppHostPath) { + let appHostToRun: string; + if (vscode.window.activeTextEditor && this.isAppHostCsFile(vscode.window.activeTextEditor.document.uri.fsPath)) { + appHostToRun = vscode.window.activeTextEditor.document.uri.fsPath; + } + else if (this._workspaceAppHostPath) { + appHostToRun = this._workspaceAppHostPath; + } + else { vscode.window.showErrorMessage(noAppHostInWorkspace); return; } await vscode.debug.startDebugging(undefined, { type: 'aspire', - name: `Aspire: ${vscode.workspace.asRelativePath(this._workspaceAppHostPath)}`, + name: `Aspire: ${vscode.workspace.asRelativePath(appHostToRun)}`, request: 'launch', - program: this._workspaceAppHostPath, + program: appHostToRun, noDebug: noDebug }); } diff --git a/playground/FileBasedApps/apphost.cs b/playground/FileBasedApps/apphost.cs index c75b86e37ed..604047bbd33 100644 --- a/playground/FileBasedApps/apphost.cs +++ b/playground/FileBasedApps/apphost.cs @@ -1,3 +1,7 @@ +/* Required SDK directive for Aspire CLI. This is only required in the Aspire repo itself. +#:sdk Aspire.AppHost.Sdk +*/ + // To run this app in this repo use the following command line to ensure latest changes are always picked up: // $ dotnet apphost.cs --no-cache diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index cd6774b3d7c..792fcb2d181 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -32,6 +32,7 @@ public async Task> FindAppHostProjectFilesAsync(string searchDire private async Task<(List BuildableAppHost, List UnbuildableSuspectedAppHostProjects)> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, CancellationToken cancellationToken) { + Console.WriteLine("test"); using var activity = telemetry.ActivitySource.StartActivity(); return await interactionService.ShowStatusAsync(InteractionServiceStrings.SearchingProjects, async () => From e0e9b9c07e4a39e5da483f3c3eb84dedba65f5e6 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 31 Oct 2025 12:01:53 -0400 Subject: [PATCH 3/9] Match VS launch profile behavior, choosing first available project profile if none is specified. Also apply dotnet run-api environment variables before DCP environment --- extension/src/debugger/languages/dotnet.ts | 35 +++-- extension/src/debugger/launchProfiles.ts | 29 +++- extension/src/test/launchProfiles.test.ts | 158 +++++++++++++++++++++ 3 files changed, 207 insertions(+), 15 deletions(-) diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 7d37062fadd..f3901414971 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -17,6 +17,7 @@ import { determineWorkingDirectory, determineServerReadyAction } from '../launchProfiles'; +import { debug } from 'util'; interface IDotNetService { getAndActivateDevKit(): Promise @@ -157,11 +158,16 @@ class DotNetService implements IDotNetService { } } -function isSingleFileApp(projectPath: string): boolean { +export function isSingleFileApp(projectPath: string): boolean { return path.extname(projectPath).toLowerCase().endsWith('.cs'); } -function applyRunApiOutputToDebugConfiguration(runApiOutput: string, debugConfiguration: AspireResourceExtendedDebugConfiguration) { +interface RunApiOutput { + executablePath: string; + env?: { [key: string]: string }; +} + +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}`); @@ -170,13 +176,10 @@ 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 { @@ -217,7 +220,6 @@ 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); @@ -230,11 +232,20 @@ export function createProjectDebuggerExtension(dotNetService: IDotNetService): R } debugConfiguration.program = outputPath; + debugConfiguration.env = Object.fromEntries(mergeEnvironmentVariables(baseProfile?.environmentVariables, env)); } else { - //await dotNetService.buildDotNetProject(projectPath); + 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)); + } } } }; diff --git a/extension/src/debugger/launchProfiles.ts b/extension/src/debugger/launchProfiles.ts index 1c04cc1ca4d..a9b2d68db6b 100644 --- a/extension/src/debugger/launchProfiles.ts +++ b/extension/src/debugger/launchProfiles.ts @@ -2,6 +2,7 @@ import * as path from 'path'; import * as fs from 'fs'; import { ExecutableLaunchConfiguration, EnvVar, ProjectLaunchConfiguration } from '../dcp/types'; import { extensionLogOutputChannel } from '../utils/logging'; +import { isSingleFileApp } from './languages/dotnet'; /* * Represents a launchSettings.json profile. @@ -9,6 +10,7 @@ import { extensionLogOutputChannel } from '../utils/logging'; * *and* in the launchSettings.json is available here. */ export interface LaunchProfile { + commandName: string; executablePath?: string; workingDirectory?: string; // args in debug configuration @@ -37,8 +39,15 @@ export interface LaunchProfileResult { */ export async function readLaunchSettings(projectPath: string): Promise { try { - const projectDir = path.dirname(projectPath); - const launchSettingsPath = path.join(projectDir, 'Properties', 'launchSettings.json'); + let launchSettingsPath: string; + + if (isSingleFileApp(projectPath)) { + const fileNameWithoutExt = path.basename(projectPath, path.extname(projectPath)); + launchSettingsPath = path.join(path.dirname(projectPath), `${fileNameWithoutExt}.run.json`); + } else { + const projectDir = path.dirname(projectPath); + launchSettingsPath = path.join(projectDir, 'Properties', 'launchSettings.json'); + } if (!fs.existsSync(launchSettingsPath)) { extensionLogOutputChannel.debug(`Launch settings file not found at: ${launchSettingsPath}`); @@ -88,6 +97,14 @@ export function determineBaseLaunchProfile( } } + // If launch_profile is absent, choose the first one with commandName='Project' + for (const [name, profile] of Object.entries(launchSettings.profiles)) { + if (profile.commandName === 'Project') { + extensionLogOutputChannel.debug(`Using default launch profile: ${name}`); + return { profile, profileName: name }; + } + } + // TODO: If launch_profile is absent, check for a ServiceDefaults project in the workspace // and look for a launch profile with that ServiceDefaults project name in the current project's launch settings extensionLogOutputChannel.debug('No base launch profile determined'); @@ -100,7 +117,8 @@ export function determineBaseLaunchProfile( */ export function mergeEnvironmentVariables( baseProfileEnv: { [key: string]: string } | undefined, - runSessionEnv: EnvVar[] + runSessionEnv: EnvVar[], + runApiEnv?: { [key: string]: string } ): [string, string][] { const merged: { [key: string]: string } = {}; @@ -109,6 +127,11 @@ export function mergeEnvironmentVariables( Object.assign(merged, baseProfileEnv); } + // Override with run API environment variables + if (runApiEnv) { + Object.assign(merged, runApiEnv); + } + // Override with run session environment variables (these take precedence) for (const envVar of runSessionEnv) { merged[envVar.name] = envVar.value; diff --git a/extension/src/test/launchProfiles.test.ts b/extension/src/test/launchProfiles.test.ts index 0d79e8ca88f..0044f134476 100644 --- a/extension/src/test/launchProfiles.test.ts +++ b/extension/src/test/launchProfiles.test.ts @@ -19,14 +19,22 @@ suite('Launch Profile Tests', () => { const sampleLaunchSettings: LaunchSettings = { profiles: { 'Development': { + commandName: 'Project', environmentVariables: { ASPNETCORE_ENVIRONMENT: 'Development' } }, 'Production': { + commandName: 'Project', environmentVariables: { ASPNETCORE_ENVIRONMENT: 'Production' } + }, + 'IISExpress': { + commandName: 'IISExpress', + environmentVariables: { + ASPNETCORE_ENVIRONMENT: 'Development' + } } } }; @@ -81,6 +89,55 @@ suite('Launch Profile Tests', () => { assert.strictEqual(result.profile, null); assert.strictEqual(result.profileName, null); }); + + test('returns first profile with commandName=Project when no explicit profile specified', () => { + const launchConfig: ProjectLaunchConfiguration = { + type: 'project', + project_path: '/test/project.csproj' + }; + + const result = determineBaseLaunchProfile(launchConfig, sampleLaunchSettings); + + assert.strictEqual(result.profileName, 'Development'); + assert.strictEqual(result.profile?.commandName, 'Project'); + assert.strictEqual(result.profile?.environmentVariables?.ASPNETCORE_ENVIRONMENT, 'Development'); + }); + + test('returns null when no profile has commandName=Project', () => { + const settingsWithoutProject: LaunchSettings = { + profiles: { + 'IISExpress': { + commandName: 'IISExpress', + environmentVariables: { + ASPNETCORE_ENVIRONMENT: 'Development' + } + } + } + }; + + const launchConfig: ProjectLaunchConfiguration = { + type: 'project', + project_path: '/test/project.csproj' + }; + + const result = determineBaseLaunchProfile(launchConfig, settingsWithoutProject); + + assert.strictEqual(result.profile, null); + assert.strictEqual(result.profileName, null); + }); + + test('explicit profile takes precedence over default commandName=Project logic', () => { + const launchConfig: ProjectLaunchConfiguration = { + type: 'project', + project_path: '/test/project.csproj', + launch_profile: 'IISExpress' + }; + + const result = determineBaseLaunchProfile(launchConfig, sampleLaunchSettings); + + assert.strictEqual(result.profileName, 'IISExpress'); + assert.strictEqual(result.profile?.commandName, 'IISExpress'); + }); }); suite('mergeEnvironmentVariables', () => { @@ -107,6 +164,89 @@ suite('Launch Profile Tests', () => { assert.strictEqual(resultMap.get('VAR4'), 'session4'); }); + test('merges with run API environment variables taking precedence over base profile', () => { + const baseProfileEnv = { + 'VAR1': 'base1', + 'VAR2': 'base2', + 'VAR3': 'base3' + }; + + const runApiEnv = { + 'VAR2': 'api2', + 'VAR5': 'api5' + }; + + const runSessionEnv: EnvVar[] = []; + + const result = mergeEnvironmentVariables(baseProfileEnv, runSessionEnv, runApiEnv); + + assert.strictEqual(result.length, 4); + + const resultMap = new Map(result); + assert.strictEqual(resultMap.get('VAR1'), 'base1'); + assert.strictEqual(resultMap.get('VAR2'), 'api2'); // Run API takes precedence over base + assert.strictEqual(resultMap.get('VAR3'), 'base3'); + assert.strictEqual(resultMap.get('VAR5'), 'api5'); + }); + + test('run session environment takes precedence over run API environment', () => { + const baseProfileEnv = { + 'VAR1': 'base1', + 'VAR2': 'base2' + }; + + const runApiEnv = { + 'VAR2': 'api2', + 'VAR3': 'api3' + }; + + const runSessionEnv: EnvVar[] = [ + { name: 'VAR2', value: 'session2' }, + { name: 'VAR4', value: 'session4' } + ]; + + const result = mergeEnvironmentVariables(baseProfileEnv, runSessionEnv, runApiEnv); + + assert.strictEqual(result.length, 4); + + const resultMap = new Map(result); + assert.strictEqual(resultMap.get('VAR1'), 'base1'); + assert.strictEqual(resultMap.get('VAR2'), 'session2'); // Run session has highest precedence + assert.strictEqual(resultMap.get('VAR3'), 'api3'); + assert.strictEqual(resultMap.get('VAR4'), 'session4'); + }); + + test('handles all three sources with correct precedence: session > api > base', () => { + const baseProfileEnv = { + 'BASE_ONLY': 'base_value', + 'OVERRIDDEN_BY_API': 'base_value', + 'OVERRIDDEN_BY_SESSION': 'base_value', + 'OVERRIDDEN_BY_BOTH': 'base_value' + }; + + const runApiEnv = { + 'API_ONLY': 'api_value', + 'OVERRIDDEN_BY_API': 'api_value', + 'OVERRIDDEN_BY_BOTH': 'api_value' + }; + + const runSessionEnv: EnvVar[] = [ + { name: 'SESSION_ONLY', value: 'session_value' }, + { name: 'OVERRIDDEN_BY_SESSION', value: 'session_value' }, + { name: 'OVERRIDDEN_BY_BOTH', value: 'session_value' } + ]; + + const result = mergeEnvironmentVariables(baseProfileEnv, runSessionEnv, runApiEnv); + + const resultMap = new Map(result); + assert.strictEqual(resultMap.get('BASE_ONLY'), 'base_value'); + assert.strictEqual(resultMap.get('API_ONLY'), 'api_value'); + assert.strictEqual(resultMap.get('SESSION_ONLY'), 'session_value'); + assert.strictEqual(resultMap.get('OVERRIDDEN_BY_API'), 'api_value'); + assert.strictEqual(resultMap.get('OVERRIDDEN_BY_SESSION'), 'session_value'); + assert.strictEqual(resultMap.get('OVERRIDDEN_BY_BOTH'), 'session_value'); + }); + test('handles empty base profile environment', () => { const runSessionEnv: EnvVar[] = [ { name: 'VAR1', value: 'session1' } @@ -133,6 +273,21 @@ suite('Launch Profile Tests', () => { assert.strictEqual(resultMap.get('VAR1'), 'base1'); assert.strictEqual(resultMap.get('VAR2'), 'base2'); }); + + test('handles only run API environment without base or session', () => { + const runApiEnv = { + 'VAR1': 'api1', + 'VAR2': 'api2' + }; + + const result = mergeEnvironmentVariables(undefined, [], runApiEnv); + + assert.strictEqual(result.length, 2); + + const resultMap = new Map(result); + assert.strictEqual(resultMap.get('VAR1'), 'api1'); + assert.strictEqual(resultMap.get('VAR2'), 'api2'); + }); }); suite('determineArguments', () => { @@ -184,6 +339,7 @@ suite('Launch Profile Tests', () => { test('uses absolute working directory from launch profile', () => { const baseProfile: LaunchProfile = { + commandName: 'Project', workingDirectory: path.join('C:', 'custom', 'working', 'dir') }; @@ -194,6 +350,7 @@ suite('Launch Profile Tests', () => { test('resolves relative working directory from launch profile', () => { const baseProfile: LaunchProfile = { + commandName: 'Project', workingDirectory: 'custom' }; @@ -204,6 +361,7 @@ suite('Launch Profile Tests', () => { test('uses project directory when no working directory specified', () => { const baseProfile: LaunchProfile = { + commandName: 'Project' }; const result = determineWorkingDirectory(projectPath, baseProfile); From d8b82469c6c45f45c414a4d24d3fe916506554f5 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 31 Oct 2025 13:12:10 -0400 Subject: [PATCH 4/9] wip --- extension/loc/xlf/aspire-vscode.xlf | 3 ++ extension/package.nls.json | 3 +- extension/src/capabilities.ts | 2 +- extension/src/dcp/AspireDcpServer.ts | 10 +++- extension/src/dcp/types.ts | 2 + extension/src/debugger/AspireDebugSession.ts | 13 ++++- extension/src/debugger/languages/dotnet.ts | 50 ++++++++++++++++++-- extension/src/loc/strings.ts | 1 + extension/src/test/dotnetDebugger.test.ts | 19 ++++++-- src/Aspire.Cli/Commands/RunCommand.cs | 29 ++++-------- 10 files changed, 97 insertions(+), 35 deletions(-) diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index e3eb3765056..b4f11058fd9 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -37,6 +37,9 @@ Authorization header must start with 'Bearer '. + + Build failed for project {0} with error: {1}. + Build failed with exit code {0}. diff --git a/extension/package.nls.json b/extension/package.nls.json index 15f80da811b..0da3e7d8a70 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -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}." } diff --git a/extension/src/capabilities.ts b/extension/src/capabilities.ts index 878e847807b..e5b9ac26afa 100644 --- a/extension/src/capabilities.ts +++ b/extension/src/capabilities.ts @@ -7,7 +7,7 @@ function isExtensionInstalled(extensionId: string): boolean { return !!extension; } -function isCsDevKitInstalled() { +export function isCsDevKitInstalled() { return isExtensionInstalled("ms-dotnettools.csdevkit"); } diff --git a/extension/src/dcp/AspireDcpServer.ts b/extension/src/dcp/AspireDcpServer.ts index aa9673ca987..8e1762397e6 100644 --- a/extension/src/dcp/AspireDcpServer.ts +++ b/extension/src/dcp/AspireDcpServer.ts @@ -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) { diff --git a/extension/src/dcp/types.ts b/extension/src/dcp/types.ts index 060b6afe324..e76e7f3bc21 100644 --- a/extension/src/dcp/types.ts +++ b/extension/src/dcp/types.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import { AspireDebugSession } from '../debugger/AspireDebugSession'; export interface ErrorResponse { error: ErrorDetails; @@ -96,6 +97,7 @@ export interface LaunchOptions { runId: string; debugSessionId: string; isApphost: boolean; + debugSession: AspireDebugSession; }; export interface AspireResourceDebugSession { diff --git a/extension/src/debugger/AspireDebugSession.ts b/extension/src/debugger/AspireDebugSession.ts index 6dc722a7621..b1851dfef6e 100644 --- a/extension/src/debugger/AspireDebugSession.ts +++ b/extension/src/debugger/AspireDebugSession.ts @@ -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(); @@ -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)); @@ -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) { diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index f3901414971..9b2e9f0f025 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -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 } from '../../loc/strings'; import { ChildProcessWithoutNullStreams, execFile, spawn } from 'child_process'; import * as util from 'util'; import * as path from 'path'; @@ -18,6 +18,8 @@ import { determineServerReadyAction } from '../launchProfiles'; import { debug } from 'util'; +import { AspireDebugSession } from '../AspireDebugSession'; +import { isCsDevKitInstalled } from '../../capabilities'; interface IDotNetService { getAndActivateDevKit(): Promise @@ -27,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 { const csharpDevKit = vscode.extensions.getExtension('ms-dotnettools.csdevkit'); if (!csharpDevKit) { @@ -46,6 +58,32 @@ class DotNetService implements IDotNetService { } async buildDotNetProject(projectFile: string): Promise { + const isDevKitEnabled = await this.getAndActivateDevKit(); + + if (!isDevKitEnabled) { + this.writeToDebugConsole('C# Dev Kit not available, building project using dotnet CLI...', 'stdout'); + const args = ['build', projectFile]; + try { + const { stdout, stderr } = await this.execFileAsync('dotnet', args, { encoding: 'utf8' }); + this.writeToDebugConsole(stdout, 'stdout'); + this.writeToDebugConsole(stderr, 'stderr'); + + // if build succeeds, simply return. otherwise throw to trigger error handling + if (stderr) { + throw new Error(stderr); + } + return; + } catch (err) { + const stdout = (err as any).stdout; + const stderr = (err as any).stderr; + if (stdout) { + this.writeToDebugConsole(String(stdout), 'stderr'); + } + + throw new Error(buildFailedForProjectWithError(projectFile, String(stderr ?? stdout))); + } + } + // 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 () => { @@ -182,7 +220,7 @@ function getRunApiConfigFromOutput(runApiOutput: string, debugConfiguration: Asp }; } -export function createProjectDebuggerExtension(dotNetService: IDotNetService): ResourceDebuggerExtension { +export function createProjectDebuggerExtension(dotNetServiceProducer: (debugSession: AspireDebugSession) => IDotNetService): ResourceDebuggerExtension { return { resourceType: 'project', debugAdapter: 'coreclr', @@ -197,6 +235,8 @@ export function createProjectDebuggerExtension(dotNetService: IDotNetService): R throw new Error(invalidLaunchConfiguration(JSON.stringify(launchConfig))); }, createDebugSessionConfigurationCallback: async (launchConfig, args, env, launchOptions, debugConfiguration: AspireResourceExtendedDebugConfiguration): Promise => { + 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))); @@ -224,10 +264,9 @@ export function createProjectDebuggerExtension(dotNetService: IDotNetService): R debugConfiguration.checkForDevCert = baseProfile?.useSSL; debugConfiguration.serverReadyAction = determineServerReadyAction(baseProfile?.launchBrowser, baseProfile?.applicationUrl); - // Build project if needed 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); } @@ -235,6 +274,7 @@ export function createProjectDebuggerExtension(dotNetService: IDotNetService): R 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); const runApiConfig = getRunApiConfigFromOutput(runApiOutput, debugConfiguration); @@ -251,4 +291,4 @@ export function createProjectDebuggerExtension(dotNetService: IDotNetService): R }; } -export const projectDebuggerExtension: ResourceDebuggerExtension = createProjectDebuggerExtension(new DotNetService()); +export const projectDebuggerExtension: ResourceDebuggerExtension = createProjectDebuggerExtension(debugSession => new DotNetService(debugSession)); diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 556cc4152b0..0ed546f0e15 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -62,3 +62,4 @@ export const invalidOrMissingToken = vscode.l10n.t('Invalid or missing token in export const invalidTokenLength = vscode.l10n.t('Invalid token length in Authorization header.'); export const authorizationHeaderMustStartWithBearer = vscode.l10n.t('Authorization header must start with \'Bearer \'.'); export const authorizationAndDcpHeadersRequired = vscode.l10n.t('Authorization and Microsoft-Developer-DCP-Instance-ID headers are required.'); +export const buildFailedForProjectWithError = (project: string, error: string) => vscode.l10n.t('Build failed for project {0} with error: {1}.', project, error); diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index 7450eb8998d..2a5d56d53fc 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -5,6 +5,7 @@ import { createProjectDebuggerExtension, projectDebuggerExtension } from '../deb import { AspireResourceExtendedDebugConfiguration, ExecutableLaunchConfiguration, ProjectLaunchConfiguration } from '../dcp/types'; import * as io from '../utils/io'; import { ResourceDebuggerExtension } from '../debugger/debuggerExtensions'; +import { AspireDebugSession } from '../debugger/AspireDebugSession'; class TestDotNetService { private _getDotNetTargetPathStub: sinon.SinonStub; @@ -48,7 +49,7 @@ suite('Dotnet Debugger Extension Tests', () => { function createDebuggerExtension(outputPath: string, rejectBuild: Error | null, hasDevKit: boolean, doesOutputFileExist: boolean): { dotNetService: TestDotNetService, extension: ResourceDebuggerExtension, doesFileExistStub: sinon.SinonStub } { const fakeDotNetService = new TestDotNetService(outputPath, rejectBuild, hasDevKit); - return { dotNetService: fakeDotNetService, extension: createProjectDebuggerExtension(fakeDotNetService), doesFileExistStub: sinon.stub(io, 'doesFileExist').resolves(doesOutputFileExist) }; + return { dotNetService: fakeDotNetService, extension: createProjectDebuggerExtension(() => fakeDotNetService), doesFileExistStub: sinon.stub(io, 'doesFileExist').resolves(doesOutputFileExist) }; } test('project is built when C# dev kit is installed and executable not found', async () => { const outputPath = 'C:\\temp\\bin\\Debug\\net7.0\\TestProject.dll'; @@ -68,7 +69,9 @@ suite('Dotnet Debugger Extension Tests', () => { request: 'launch' }; - await extension.createDebugSessionConfigurationCallback!(launchConfig, [], [], { debug: true, runId: '1', debugSessionId: '1', isApphost: false }, debugConfig); + const fakeAspireDebugSession = sinon.createStubInstance(AspireDebugSession); + + await extension.createDebugSessionConfigurationCallback!(launchConfig, [], [], { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, debugConfig); assert.strictEqual(debugConfig.program, outputPath); assert.strictEqual(dotNetService.buildDotNetProjectStub.called, true); @@ -93,7 +96,9 @@ suite('Dotnet Debugger Extension Tests', () => { request: 'launch' }; - await extension.createDebugSessionConfigurationCallback!(launchConfig, [], [], { debug: true, runId: '1', debugSessionId: '1', isApphost: false }, debugConfig); + const fakeAspireDebugSession = sinon.createStubInstance(AspireDebugSession); + + await extension.createDebugSessionConfigurationCallback!(launchConfig, [], [], { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, debugConfig); assert.strictEqual(debugConfig.program, outputPath); assert.strictEqual(dotNetService.buildDotNetProjectStub.notCalled, true); @@ -117,7 +122,9 @@ suite('Dotnet Debugger Extension Tests', () => { request: 'launch' }; - await extension.createDebugSessionConfigurationCallback!(launchConfig, [], [], { debug: true, runId: '1', debugSessionId: '1', isApphost: false }, debugConfig); + const fakeAspireDebugSession = sinon.createStubInstance(AspireDebugSession); + + await extension.createDebugSessionConfigurationCallback!(launchConfig, [], [], { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, debugConfig); assert.strictEqual(debugConfig.program, outputPath); assert.strictEqual(dotNetService.buildDotNetProjectStub.notCalled, true); @@ -177,7 +184,9 @@ suite('Dotnet Debugger Extension Tests', () => { request: 'launch' }; - await extension.createDebugSessionConfigurationCallback!(launchConfig, undefined, runEnv, { debug: true, runId: '1', debugSessionId: '1', isApphost: false }, debugConfig); + const fakeAspireDebugSession = sinon.createStubInstance(AspireDebugSession); + + await extension.createDebugSessionConfigurationCallback!(launchConfig, undefined, runEnv, { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, debugConfig); // program should be set assert.strictEqual(debugConfig.program, outputPath); diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 0276ca8369c..da1902b1c26 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -138,13 +138,11 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell await _certificateService.EnsureCertificatesTrustedAsync(_runner, cancellationToken); - var shouldBuildAppHostInExtension = await ShouldBuildAppHostInExtensionAsync(InteractionService, isSingleFileAppHost, cancellationToken); - var watch = !isSingleFileAppHost && (_features.IsFeatureEnabled(KnownFeatures.DefaultWatchEnabled, defaultValue: false) || (isExtensionHost && !startDebugSession)); if (!watch) { - if (!isSingleFileAppHost || isExtensionHost) + if (!isSingleFileAppHost && !isExtensionHost) { var buildOptions = new DotNetCliRunnerInvocationOptions { @@ -152,17 +150,13 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell StandardErrorCallback = buildOutputCollector.AppendError, }; - // The extension host will build the app host project itself, so we don't need to do it here if host exists. - if (!shouldBuildAppHostInExtension) - { - var buildExitCode = await AppHostHelper.BuildAppHostAsync(_runner, InteractionService, effectiveAppHostFile, buildOptions, ExecutionContext.WorkingDirectory, cancellationToken); + var buildExitCode = await AppHostHelper.BuildAppHostAsync(_runner, InteractionService, effectiveAppHostFile, buildOptions, ExecutionContext.WorkingDirectory, cancellationToken); - if (buildExitCode != 0) - { - InteractionService.DisplayLines(buildOutputCollector.GetLines()); - InteractionService.DisplayError(InteractionServiceStrings.ProjectCouldNotBeBuilt); - return ExitCodeConstants.FailedToBuildArtifacts; - } + if (buildExitCode != 0) + { + InteractionService.DisplayLines(buildOutputCollector.GetLines()); + InteractionService.DisplayError(InteractionServiceStrings.ProjectCouldNotBeBuilt); + return ExitCodeConstants.FailedToBuildArtifacts; } } } @@ -224,7 +218,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell cancellationToken); // Wait for the backchannel to be established. - var backchannel = await InteractionService.ShowStatusAsync(shouldBuildAppHostInExtension ? InteractionServiceStrings.BuildingAppHost : RunCommandStrings.ConnectingToAppHost, async () => { return await backchannelCompletitionSource.Task.WaitAsync(cancellationToken); }); + var backchannel = await InteractionService.ShowStatusAsync(isExtensionHost ? InteractionServiceStrings.BuildingAppHost : RunCommandStrings.ConnectingToAppHost, async () => { return await backchannelCompletitionSource.Task.WaitAsync(cancellationToken); }); var logFile = GetAppHostLogFile(); @@ -491,11 +485,4 @@ public void ProcessResourceState(RpcResourceState resourceState, Action ShouldBuildAppHostInExtensionAsync(IInteractionService interactionService, bool isSingleFileAppHost, CancellationToken cancellationToken) - { - return ExtensionHelper.IsExtensionHost(interactionService, out _, out var extensionBackchannel) - && await extensionBackchannel.HasCapabilityAsync(KnownCapabilities.DevKit, cancellationToken) - && !isSingleFileAppHost; - } } From b034a62d7522f11c05326883600295aa767b85e1 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 31 Oct 2025 15:00:29 -0400 Subject: [PATCH 5/9] Stream console output to debug console --- extension/src/debugger/languages/dotnet.ts | 67 +++++++++++++++------- 1 file changed, 46 insertions(+), 21 deletions(-) diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 9b2e9f0f025..541fcf15d99 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import { extensionLogOutputChannel } from '../../utils/logging'; -import { noCsharpBuildTask, buildFailedWithExitCode, noOutputFromMsbuild, failedToGetTargetPath, invalidLaunchConfiguration, buildFailedForProjectWithError } 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'; @@ -63,25 +63,45 @@ class DotNetService implements IDotNetService { if (!isDevKitEnabled) { this.writeToDebugConsole('C# Dev Kit not available, building project using dotnet CLI...', 'stdout'); const args = ['build', projectFile]; - try { - const { stdout, stderr } = await this.execFileAsync('dotnet', args, { encoding: 'utf8' }); - this.writeToDebugConsole(stdout, 'stdout'); - this.writeToDebugConsole(stderr, 'stderr'); - // if build succeeds, simply return. otherwise throw to trigger error handling - if (stderr) { - throw new Error(stderr); - } - return; - } catch (err) { - const stdout = (err as any).stdout; - const stderr = (err as any).stderr; - if (stdout) { - this.writeToDebugConsole(String(stdout), 'stderr'); - } + return new Promise((resolve, reject) => { + const buildProcess = spawn('dotnet', args); - throw new Error(buildFailedForProjectWithError(projectFile, String(stderr ?? stdout))); - } + 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 @@ -156,9 +176,10 @@ class DotNetService implements IDotNetService { } async getDotNetRunApiOutput(projectPath: string): Promise { + let childProcess: ChildProcessWithoutNullStreams; + return new Promise(async (resolve, reject) => { try { - let childProcess: ChildProcessWithoutNullStreams; const timeout = setTimeout(() => { childProcess?.kill(); reject(new Error('Timeout while waiting for dotnet run-api response')); @@ -175,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); @@ -192,7 +215,7 @@ class DotNetService implements IDotNetService { } catch (e) { reject(e); } - }); + }).finally(() => childProcess.removeAllListeners()); } } @@ -244,6 +267,8 @@ export function createProjectDebuggerExtension(dotNetServiceProducer: (debugSess 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)) { From 9b08e9532252e62b69df131f3cb0d67e0e52cef6 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 31 Oct 2025 15:12:12 -0400 Subject: [PATCH 6/9] remove accidental console log --- src/Aspire.Cli/Projects/ProjectLocator.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index 792fcb2d181..cd6774b3d7c 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -32,7 +32,6 @@ public async Task> FindAppHostProjectFilesAsync(string searchDire private async Task<(List BuildableAppHost, List UnbuildableSuspectedAppHostProjects)> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, CancellationToken cancellationToken) { - Console.WriteLine("test"); using var activity = telemetry.ActivitySource.StartActivity(); return await interactionService.ShowStatusAsync(InteractionServiceStrings.SearchingProjects, async () => From 247a301d9f1b7210eaca1986424737b4dbbf7054 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 31 Oct 2025 15:19:42 -0400 Subject: [PATCH 7/9] Update extension/src/debugger/languages/dotnet.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- extension/src/debugger/languages/dotnet.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 541fcf15d99..2fecf2f50d1 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -17,7 +17,6 @@ import { determineWorkingDirectory, determineServerReadyAction } from '../launchProfiles'; -import { debug } from 'util'; import { AspireDebugSession } from '../AspireDebugSession'; import { isCsDevKitInstalled } from '../../capabilities'; From 0f32cf125988640f7069d42a8705cdd12c7537a4 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 31 Oct 2025 17:05:12 -0400 Subject: [PATCH 8/9] remove test that no longer applies --- extension/src/test/dotnetDebugger.test.ts | 27 ----------------------- 1 file changed, 27 deletions(-) diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index 2a5d56d53fc..cca8f256933 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -77,33 +77,6 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(dotNetService.buildDotNetProjectStub.called, true); }); - test('project is not built when C# dev kit is not installed and executable not found', async () => { - const outputPath = 'C:\\temp\\bin\\Debug\\net7.0\\TestProject.dll'; - - const { extension, dotNetService } = createDebuggerExtension(outputPath, null, false, false); - - const projectPath = 'C:\\temp\\TestProject.csproj'; - const launchConfig: ProjectLaunchConfiguration = { - type: 'project', - project_path: projectPath - }; - - const debugConfig: AspireResourceExtendedDebugConfiguration = { - runId: '1', - debugSessionId: '1', - type: 'coreclr', - name: 'Test Debug Config', - request: 'launch' - }; - - const fakeAspireDebugSession = sinon.createStubInstance(AspireDebugSession); - - await extension.createDebugSessionConfigurationCallback!(launchConfig, [], [], { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, debugConfig); - - assert.strictEqual(debugConfig.program, outputPath); - assert.strictEqual(dotNetService.buildDotNetProjectStub.notCalled, true); - }); - test('project is not built when C# dev kit is installed and executable found', async () => { const outputPath = 'C:\\temp\\bin\\Debug\\net7.0\\TestProject.dll'; const { extension, dotNetService } = createDebuggerExtension(outputPath, null, true, true); From 9760f31ee17235cd87fa87b41229307842ea6966 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 3 Nov 2025 15:15:22 -0500 Subject: [PATCH 9/9] pr suggestions --- extension/src/dcp/AspireDcpServer.ts | 1 + extension/src/debugger/languages/dotnet.ts | 7 +------ extension/src/editor/AspireEditorCommandProvider.ts | 11 +++++++---- playground/FileBasedApps/apphost.cs | 7 +++---- 4 files changed, 12 insertions(+), 14 deletions(-) diff --git a/extension/src/dcp/AspireDcpServer.ts b/extension/src/dcp/AspireDcpServer.ts index 8e1762397e6..234bfdd6594 100644 --- a/extension/src/dcp/AspireDcpServer.ts +++ b/extension/src/dcp/AspireDcpServer.ts @@ -245,6 +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.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; } diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 2fecf2f50d1..a14ff8ad308 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -304,12 +304,7 @@ export function createProjectDebuggerExtension(dotNetServiceProducer: (debugSess 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)); - } + debugConfiguration.env = Object.fromEntries(mergeEnvironmentVariables(baseProfile?.environmentVariables, env, runApiConfig.env)); } } }; diff --git a/extension/src/editor/AspireEditorCommandProvider.ts b/extension/src/editor/AspireEditorCommandProvider.ts index d1b0878ab05..0d72b0d92e7 100644 --- a/extension/src/editor/AspireEditorCommandProvider.ts +++ b/extension/src/editor/AspireEditorCommandProvider.ts @@ -58,7 +58,7 @@ export class AspireEditorCommandProvider implements vscode.Disposable { vscode.commands.executeCommand('setContext', 'aspire.editorSupportsRunDebug', isSupportedFile); - if (this.isAppHostCsFile(document.uri.fsPath)) { + if (await this.isAppHostCsFile(document.uri.fsPath)) { vscode.commands.executeCommand('setContext', 'aspire.fileIsAppHostCs', true); } else { @@ -66,8 +66,11 @@ export class AspireEditorCommandProvider implements vscode.Disposable { } } - private isAppHostCsFile(filePath: string): boolean { - return path.basename(filePath).toLowerCase() === 'apphost.cs'; + private async isAppHostCsFile(filePath: string): Promise { + const fileText = await vscode.workspace.fs.readFile(vscode.Uri.file(filePath)).then(buffer => buffer.toString()); + const lines = fileText.split(/\r?\n/); + + return lines.some(line => line.startsWith('#:sdk Aspire.AppHost.Sdk')); } private onChangeAppHostPath(newPath: string | null) { @@ -112,7 +115,7 @@ export class AspireEditorCommandProvider implements vscode.Disposable { public async tryExecuteRunAppHost(noDebug: boolean): Promise { let appHostToRun: string; - if (vscode.window.activeTextEditor && this.isAppHostCsFile(vscode.window.activeTextEditor.document.uri.fsPath)) { + if (vscode.window.activeTextEditor && await this.isAppHostCsFile(vscode.window.activeTextEditor.document.uri.fsPath)) { appHostToRun = vscode.window.activeTextEditor.document.uri.fsPath; } else if (this._workspaceAppHostPath) { diff --git a/playground/FileBasedApps/apphost.cs b/playground/FileBasedApps/apphost.cs index 604047bbd33..d6375a87a2b 100644 --- a/playground/FileBasedApps/apphost.cs +++ b/playground/FileBasedApps/apphost.cs @@ -1,11 +1,10 @@ -/* Required SDK directive for Aspire CLI. This is only required in the Aspire repo itself. -#:sdk Aspire.AppHost.Sdk -*/ - // To run this app in this repo use the following command line to ensure latest changes are always picked up: // $ dotnet apphost.cs --no-cache // These directives are not required in regular apps, only here in the aspire repo itself +/* +#:sdk Aspire.AppHost.Sdk +*/ #:property IsAspireHost=true #:property PublishAot=false