diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b805307d913..ce4f5ff6946 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,12 +70,13 @@ jobs: uses: ./.github/workflows/tests.yml name: Tests needs: [prepare_for_ci] - # pull-requests: write is used only by tests.yml's isolated comment_selection job (which runs no - # PR code) to post the "selected tests" comment; every job that runs PR code stays at - # contents:read. Reusable-workflow tokens are capped by the caller, so this is where the elevated - # scope must be granted. + # issues:write + pull-requests:write are used only by tests.yml's isolated comment_selection job + # (which runs no PR code) to post the "selected tests" issue comment; every job that runs PR code + # stays at contents:read. Reusable-workflow tokens are capped by the caller, so this is where the + # elevated scope must be granted. permissions: contents: read + issues: write pull-requests: write if: ${{ github.repository_owner == 'microsoft' && needs.prepare_for_ci.outputs.skip_workflow != 'true' }} with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a4605db077b..836f4091d00 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -155,6 +155,7 @@ jobs: continue-on-error: true permissions: contents: read + issues: write pull-requests: write steps: - name: Download selection summary @@ -194,14 +195,19 @@ jobs: const mine = comments.filter(c => c.body && c.body.includes(marker)); const current = mine.filter(c => c.body.includes(sha)).at(-1); let keepId; - if (current) { - await github.rest.issues.updateComment({ owner, repo, comment_id: current.id, body }); - keepId = current.id; - core.info(`Updated selection comment ${current.id} for ${shortSha}.`); - } else { - const created = await github.rest.issues.createComment({ owner, repo, issue_number, body }); - keepId = created.data.id; - core.info(`Created selection comment ${created.data.id} for ${shortSha}.`); + try { + if (current) { + await github.rest.issues.updateComment({ owner, repo, comment_id: current.id, body }); + keepId = current.id; + core.info(`Updated selection comment ${current.id} for ${shortSha}.`); + } else { + const created = await github.rest.issues.createComment({ owner, repo, issue_number, body }); + keepId = created.data.id; + core.info(`Created selection comment ${created.data.id} for ${shortSha}.`); + } + } catch (error) { + core.info(`Could not create or update selection comment for ${shortSha}: ${error.message}`); + return; } // Collapse every other prior selection comment (superseded commits, or the legacy single diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index 0d01dd96660..cf9d8910024 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -280,6 +280,9 @@ Failed to parse Aspire config info: {0}. Try updating the Aspire CLI with: aspire update + + Failed to parse {0} output: {1} + Failed to show prompt, text was empty. @@ -562,6 +565,9 @@ Stop + + Test run session manager has not been initialized with DCP server connection information. + The AppHost is not compatible. Consider upgrading the AppHost or Aspire CLI. @@ -679,9 +685,18 @@ You're all set! Add integrations for databases, messaging, and cloud services, or deploy your app to production. [Add an integration](command:aspire-vscode.add) [Open Aspire docs](https://aspire.dev/docs/) + + appHostPath must be a non-empty absolute path + aspire restore failed for {0}: {1} + + timed out after {0}ms + + + {0} exited with code {1}{2} + {0} · {1} diff --git a/extension/package.nls.json b/extension/package.nls.json index 964a1593f3f..c005d3c6ad4 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -97,6 +97,10 @@ "aspire-vscode.strings.debugSessionAlreadyExists": "A debug session is already active for id {0}.", "aspire-vscode.strings.processExceptionOccurred": "Encountered an exception ({0}) while running the following command: {1}.", "aspire-vscode.strings.failedToStartDebugSession": "Failed to start debug session.", + "aspire-vscode.strings.aspireCliCommandFailed": "{0} exited with code {1}{2}", + "aspire-vscode.strings.aspireCliCommandTimedOut": "timed out after {0}ms", + "aspire-vscode.strings.aspireCliOutputParseFailed": "Failed to parse {0} output: {1}", + "aspire-vscode.strings.appHostPathMustBeNonEmptyAbsolute": "appHostPath must be a non-empty absolute path", "aspire-vscode.strings.aspireCliDescribeNotSupported": "Workspace resources require Aspire CLI {0} or newer. Update the Aspire CLI and refresh the Aspire panel.", "aspire-vscode.strings.appHostDescribeMayNotBeSupported": "No workspace resources were returned. Workspace resources require the AppHost to reference Aspire.Hosting {0} or newer.", "aspire-vscode.strings.failedToGetConfigInfo": "Failed to get Aspire config info (exit code: {0}). Try updating the Aspire CLI with: aspire update", @@ -118,6 +122,7 @@ "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.authorizationHeaderRequired": "Authorization header is required.", + "aspire-vscode.strings.testRunSessionManagerNotInitialized": "Test run session manager has not been initialized with DCP server connection information.", "aspire-vscode.strings.buildFailedForProjectWithError": "Build failed for project {0} with error: {1}.", "aspire-vscode.strings.failedToInspectRuntimeConfig": "Failed to inspect runtimeconfig for {0}: {1}", "aspire-vscode.strings.dotNetRunFallbackDisablesDebugger": "Project output {0} is not directly runnable; launching {1} with dotnet run without debugger attach. Breakpoints will not be hit for this resource.", diff --git a/extension/src/dcp/TestRunSessionManager.ts b/extension/src/dcp/TestRunSessionManager.ts new file mode 100644 index 00000000000..2cbb3b59dd7 --- /dev/null +++ b/extension/src/dcp/TestRunSessionManager.ts @@ -0,0 +1,149 @@ +import * as vscode from 'vscode'; +import { getRunSessionInfo, getSupportedCapabilities } from '../capabilities'; +import { AspireDebugSession } from '../debugger/AspireDebugSession'; +import type { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; +import { extensionLogOutputChannel } from '../utils/logging'; +import { testRunSessionManagerNotInitialized } from '../loc/strings'; +import type AspireRpcServer from '../server/AspireRpcServer'; +import { generateToken } from '../utils/security'; +import { DcpServerConnectionInfo, RunSessionInfo } from './types'; +import { generateDcpIdPrefix } from './AspireDcpServer'; +import type AspireDcpServer from './AspireDcpServer'; + +export interface TestRunSessionAcquireOptions { + debug: boolean; +} + +export interface AcquiredTestRunSession { + id: string; + sessionId: string; + env: Record; +} + +interface TestRunSessionLease { + id: string; + sessionId: string; +} + +export interface TestRunSessionDebugSessionOptions { + rpcServer: AspireRpcServer; + dcpServer: AspireDcpServer; + terminalProvider: AspireTerminalProvider; + addAspireDebugSession: (session: AspireDebugSession) => void; + removeAspireDebugSession: (session: AspireDebugSession) => void; + getAspireDebugSession: (debugSessionId: string | null) => AspireDebugSession | null; +} + +export class TestRunSessionManager { + private readonly leases = new Map(); + private connectionInfo?: DcpServerConnectionInfo; + private debugSessionSubscription?: vscode.Disposable; + private readonly leasedDebugSessionDisposers = new Map void>(); + + constructor( + connectionInfo?: DcpServerConnectionInfo, + private readonly getSupportedLaunchConfigurations: () => string[] = getSupportedCapabilities) { + this.connectionInfo = connectionInfo; + } + + initializeConnectionInfo(connectionInfo: DcpServerConnectionInfo): void { + this.connectionInfo = connectionInfo; + } + + listenForLeasedDebugSessions(options: TestRunSessionDebugSessionOptions): vscode.Disposable { + this.debugSessionSubscription?.dispose(); + const startSubscription = vscode.debug.onDidStartDebugSession(session => { + const lease = this.tryGetLeaseForDebugSession(session); + if (!lease || options.getAspireDebugSession(lease.sessionId)) { + return; + } + + const aspireDebugSession = new AspireDebugSession( + session, + options.rpcServer, + options.dcpServer, + options.terminalProvider, + options.removeAspireDebugSession, + lease.sessionId); + + options.addAspireDebugSession(aspireDebugSession); + this.leasedDebugSessionDisposers.set(lease.id, () => aspireDebugSession.dispose()); + extensionLogOutputChannel.info(`Registered leased Aspire debug session ${lease.sessionId} for VS Code debug session ${session.id}.`); + }); + const terminateSubscription = vscode.debug.onDidTerminateDebugSession(session => { + const lease = this.tryGetLeaseForDebugSession(session); + if (lease) { + this.releaseLease(lease.id); + } + }); + this.debugSessionSubscription = vscode.Disposable.from(startSubscription, terminateSubscription); + + return this.debugSessionSubscription; + } + + private tryGetLeaseForDebugSession(session: vscode.DebugSession): TestRunSessionLease | undefined { + const dcpInstanceIdPrefix = session.configuration.env?.DCP_INSTANCE_ID_PREFIX; + if (typeof dcpInstanceIdPrefix !== 'string') { + return undefined; + } + + return this.tryGetLeaseForSessionId(dcpInstanceIdPrefix.replace(/-$/, '')); + } + + acquireTestRunSession(options: TestRunSessionAcquireOptions): AcquiredTestRunSession { + if (!this.connectionInfo) { + throw new Error(testRunSessionManagerNotInitialized); + } + + const id = generateToken(); + const sessionId = generateDcpIdPrefix(); + const runSessionInfo: RunSessionInfo = { + ...getRunSessionInfo(), + supported_launch_configurations: this.getSupportedLaunchConfigurations() + }; + + this.leases.set(id, { id, sessionId }); + + return { + id, + sessionId, + env: { + DEBUG_SESSION_PORT: this.connectionInfo.address, + DEBUG_SESSION_TOKEN: this.connectionInfo.token, + DEBUG_SESSION_SERVER_CERTIFICATE: this.connectionInfo.certificate, + DCP_INSTANCE_ID_PREFIX: `${sessionId}-`, + DEBUG_SESSION_RUN_MODE: options.debug ? 'Debug' : 'NoDebug', + DEBUG_SESSION_INFO: JSON.stringify(runSessionInfo) + } + }; + } + + releaseTestRunSession(id: string): Promise { + this.releaseLease(id); + + return Promise.resolve(); + } + + private releaseLease(id: string): TestRunSessionLease | undefined { + const lease = this.leases.get(id); + this.leases.delete(id); + this.removeLeasedDebugSession(id); + return lease; + } + + private removeLeasedDebugSession(id: string): void { + const dispose = this.leasedDebugSessionDisposers.get(id); + this.leasedDebugSessionDisposers.delete(id); + dispose?.(); + } + + private tryGetLeaseForSessionId(sessionId: string): TestRunSessionLease | undefined { + for (const lease of this.leases.values()) { + if (lease.sessionId === sessionId) { + return lease; + } + } + + return undefined; + } +} diff --git a/extension/src/debugger/AspireDebugSession.ts b/extension/src/debugger/AspireDebugSession.ts index dcb30090270..34c9105246d 100644 --- a/extension/src/debugger/AspireDebugSession.ts +++ b/extension/src/debugger/AspireDebugSession.ts @@ -75,14 +75,14 @@ export class AspireDebugSession implements vscode.DebugAdapter { return this._startupCompleted; } - constructor(session: vscode.DebugSession, rpcServer: AspireRpcServer, dcpServer: AspireDcpServer, terminalProvider: AspireTerminalProvider, removeAspireDebugSession: (session: AspireDebugSession) => void) { + constructor(session: vscode.DebugSession, rpcServer: AspireRpcServer, dcpServer: AspireDcpServer, terminalProvider: AspireTerminalProvider, removeAspireDebugSession: (session: AspireDebugSession) => void, debugSessionId: string = generateDcpIdPrefix()) { this._session = session; this._rpcServer = rpcServer; this._dcpServer = dcpServer; this._terminalProvider = terminalProvider; this.configuration = session.configuration as AspireExtendedDebugConfiguration; - this.debugSessionId = generateDcpIdPrefix(); + this.debugSessionId = debugSessionId; this._disposables.push({ dispose: () => removeAspireDebugSession(this) diff --git a/extension/src/debugger/adapterTracker.ts b/extension/src/debugger/adapterTracker.ts index dfc067c3c45..fa42a5125a0 100644 --- a/extension/src/debugger/adapterTracker.ts +++ b/extension/src/debugger/adapterTracker.ts @@ -23,101 +23,88 @@ export type AppHostOutputHandler = (output: string, category: DapOutputCategory) export function createDebugAdapterTracker(dcpServer: AspireDcpServer, debugAdapter: string, onAppHostRestartRequested?: AppHostRestartHandler, onAppHostOutput?: AppHostOutputHandler): 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) { - extensionLogOutputChannel.warn(`Debug session ${session.id} does not have an attached run id.`); - return; - } - - const { category, output } = message.body; - if (typeof output === 'string' && category !== 'telemetry') { - if (session.configuration.isApphost) { - onAppHostOutput?.(output, category); - return; - } - - const notification: ServiceLogsNotification = { - notification_type: 'serviceLogs', - session_id: session.configuration.runId, - dcp_id: session.configuration.debugSessionId, - is_std_err: category === 'stderr', - log_message: removeTrailingNewline(output) - }; + const configuration = session.configuration; + if (!isDebugConfigurationWithId(configuration) || configuration.debugSessionId === null) { + return undefined; + } + const debugSessionId = configuration.debugSessionId; - dcpServer.sendNotification(notification); - } + return { + onWillReceiveMessage: message => { + // 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 (configuration.isApphost + && (message.command === 'disconnect' || message.command === 'terminate') + && message.arguments?.restart + && onAppHostRestartRequested + && debugSessionId) { + const shouldSuppress = onAppHostRestartRequested(debugSessionId); + if (shouldSuppress) { + message.arguments.restart = false; } - - // Listen for process event with isRestart (if supported by adapter) - if (message.type === 'event' && message.event === 'process') { - if (typeof message.body?.systemProcessId !== 'number') { - extensionLogOutputChannel.warn(`Debug session ${session.id} does not have a valid system process ID.`); - return; - } - - if (!isDebugConfigurationWithId(session.configuration) || session.configuration.debugSessionId === null) { - extensionLogOutputChannel.warn(`Debug session ${session.id} does not have an attached run id.`); + } + }, + onDidSendMessage: message => { + if (message.type === 'event' && message.event === 'output') { + const { category, output } = message.body; + if (typeof output === 'string' && category !== 'telemetry') { + if (configuration.isApphost) { + onAppHostOutput?.(output, category); return; } - if (!dcpServer) { - extensionLogOutputChannel.warn(dcpServerNotInitialized); - return; - } - const processNotification: ProcessRestartedNotification = { - notification_type: 'processRestarted', - session_id: session.configuration.runId, - dcp_id: session.configuration.debugSessionId, - pid: message.body.systemProcessId + const notification: ServiceLogsNotification = { + notification_type: 'serviceLogs', + session_id: configuration.runId, + dcp_id: debugSessionId, + is_std_err: category === 'stderr', + log_message: removeTrailingNewline(output) }; - dcpServer.sendNotification(processNotification); - } - }, - onExit(code: number | undefined) { - if (!isDebugConfigurationWithId(session.configuration) || session.configuration.debugSessionId === null) { - extensionLogOutputChannel.warn(`Debug session ${session.id} does not have an attached run id.`); - return; + dcpServer.sendNotification(notification); } + } - // Exit code 143 should be treated as normal exit (SIGTERM) on macOS and Linux - if ((process.platform === 'darwin' || process.platform === 'linux') && code === 143) { - code = 0; + // Listen for process event with isRestart (if supported by adapter) + if (message.type === 'event' && message.event === 'process') { + if (typeof message.body?.systemProcessId !== 'number') { + extensionLogOutputChannel.warn(`Debug session ${session.id} does not have a valid system process ID.`); + return; } - const notification: SessionTerminatedNotification = { - notification_type: 'sessionTerminated', - session_id: session.configuration.runId, - dcp_id: session.configuration.debugSessionId, - exit_code: code ?? 0 + if (!dcpServer) { + extensionLogOutputChannel.warn(dcpServerNotInitialized); + return; + } + const processNotification: ProcessRestartedNotification = { + notification_type: 'processRestarted', + session_id: configuration.runId, + dcp_id: debugSessionId, + pid: message.body.systemProcessId }; - dcpServer.sendNotification(notification); + dcpServer.sendNotification(processNotification); } - }; - } + }, + onExit(code: number | undefined) { + // Exit code 143 should be treated as normal exit (SIGTERM) on macOS and Linux + if ((process.platform === 'darwin' || process.platform === 'linux') && code === 143) { + code = 0; + } + + const notification: SessionTerminatedNotification = { + notification_type: 'sessionTerminated', + session_id: configuration.runId, + dcp_id: debugSessionId, + exit_code: code ?? 0 + }; + + dcpServer.sendNotification(notification); + } + }; + } }); } diff --git a/extension/src/extension.ts b/extension/src/extension.ts index b0cd7b8ebeb..9f3d8a34967 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -19,6 +19,7 @@ import { AspireDebugConfigurationProvider } from './debugger/AspireDebugConfigur import { AspireExtensionContext } from './AspireExtensionContext'; import AspireRpcServer, { RpcServerConnectionInfo } from './server/AspireRpcServer'; import AspireDcpServer from './dcp/AspireDcpServer'; +import { TestRunSessionManager } from './dcp/TestRunSessionManager'; import { configureLaunchJsonCommand } from './commands/configureLaunchJson'; import { AspireTerminalProvider, AspireTerminalCommandEvent, shellArg } from './utils/AspireTerminalProvider'; import { MessageConnection } from 'vscode-jsonrpc'; @@ -55,6 +56,7 @@ export async function activate(context: vscode.ExtensionContext) { initializeTelemetry(context); const terminalProvider = new AspireTerminalProvider(context.subscriptions); + const testRunSessionManager = new TestRunSessionManager(); const rpcServer = await AspireRpcServer.create( (rpcServerConnectionInfo: RpcServerConnectionInfo, connection: MessageConnection, token: string, debugSessionId: string | null) => { @@ -74,6 +76,8 @@ export async function activate(context: vscode.ExtensionContext) { }, ); + testRunSessionManager.initializeConnectionInfo(dcpServer.connectionInfo); + terminalProvider.rpcServerConnectionInfo = rpcServer.connectionInfo; terminalProvider.dcpServerConnectionInfo = dcpServer.connectionInfo; terminalProvider.closeAllOpenAspireTerminals(); @@ -312,6 +316,14 @@ export async function activate(context: vscode.ExtensionContext) { ); context.subscriptions.push(vscode.debug.registerDebugAdapterDescriptorFactory('aspire', new AspireDebugAdapterDescriptorFactory(rpcServer, dcpServer, terminalProvider, aspireExtensionContext.addAspireDebugSession.bind(aspireExtensionContext), aspireExtensionContext.removeAspireDebugSession.bind(aspireExtensionContext)))); + context.subscriptions.push(testRunSessionManager.listenForLeasedDebugSessions({ + rpcServer, + dcpServer, + terminalProvider, + addAspireDebugSession: aspireExtensionContext.addAspireDebugSession.bind(aspireExtensionContext), + removeAspireDebugSession: aspireExtensionContext.removeAspireDebugSession.bind(aspireExtensionContext), + getAspireDebugSession: aspireExtensionContext.getAspireDebugSession.bind(aspireExtensionContext), + })); aspireExtensionContext.initialize(rpcServer, context, debugConfigProvider, dcpServer, terminalProvider, editorCommandProvider); @@ -366,8 +378,9 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push(aspireExtensionContext.onDidChangeDebugSessions(fireStateChanged)); const e2eStateFileBridge = createE2eStateFileBridge(context, dataRepository, appHostLaunchService, appHostTreeProvider, terminalProvider, onDidChangeStateEmitter.event); context.subscriptions.push(e2eStateFileBridge); - - const api = createExtensionApi(context, rpcServer, dcpServer, dataRepository, appHostLaunchService, appHostTreeProvider, onDidChangeStateEmitter.event); + + // Return exported API for tests or other extensions + const api = createExtensionApi(context, rpcServer, dcpServer, testRunSessionManager, dataRepository, appHostLaunchService, appHostTreeProvider, onDidChangeStateEmitter.event); return Object.freeze(api); } @@ -416,6 +429,7 @@ function createExtensionApi( context: vscode.ExtensionContext, rpcServer: AspireRpcServer, dcpServer: AspireDcpServer, + testRunSessionManager: TestRunSessionManager, dataRepository: AppHostDataRepository, appHostLaunchService: AppHostLaunchService, appHostTreeProvider: AspireAppHostTreeProvider, @@ -448,7 +462,7 @@ function createExtensionApi( }; const api: AspireExtensionApi & { __testOnlyRpcServerInfo?: RpcServerConnectionInfo } = { - apiVersion: 1, + apiVersion: 2, rpcServerInfo: { address: rpcServer.connectionInfo.address }, dcpServerInfo: { address: dcpServer.connectionInfo.address }, logDirectory: context.logUri.fsPath, @@ -459,6 +473,18 @@ function createExtensionApi( waitForState, waitForRepositoryIdle: options => waitForState(state => !state.isRepositoryLoading && state.isWorkspaceAppHostDiscoveryComplete, options), getDashboardUrl: appHostPath => getDashboardUrl(dataRepository, appHostPath), + async getRunningAppHosts(): Promise { + const appHosts = await dataRepository.fetchAppHostsOnce(); + return appHosts.map(appHost => cloneAppHostState(appHost, false)); + }, + async stopResource(resourceName: string, appHostPath: string): Promise { + return dataRepository.runResourceCommand(resourceName, appHostPath, 'stop'); + }, + async startResource(resourceName: string, appHostPath: string): Promise { + return dataRepository.runResourceCommand(resourceName, appHostPath, 'start'); + }, + acquireTestRunSession: (options) => testRunSessionManager.acquireTestRunSession(options), + releaseTestRunSession: (id) => testRunSessionManager.releaseTestRunSession(id), }; if (context.extensionMode === vscode.ExtensionMode.Test) { api.__testOnlyRpcServerInfo = rpcServer.connectionInfo; @@ -1196,7 +1222,7 @@ function cloneAppHostState(appHost: AppHostDisplayInfo, includeSensitiveDashboar appHostPath: appHost.appHostPath, appHostPid: appHost.appHostPid, dashboardUrl: appHost.dashboardUrl && includeSensitiveDashboardUrls ? stripResourceSuffix(appHost.dashboardUrl) : (sanitizeDashboardUrl(appHost.dashboardUrl) ?? null), - resources: appHost.resources?.map(resource => cloneResourceState(resource, includeSensitiveDashboardUrls)) ?? appHost.resources, + resources: appHost.resources ? appHost.resources.map(resource => cloneResourceState(resource, includeSensitiveDashboardUrls)) : appHost.resources, }; } @@ -1206,6 +1232,7 @@ function cloneResourceState(resource: ResourceJson, includeSensitiveDashboardUrl displayName: resource.displayName, resourceType: resource.resourceType, state: resource.state, + projectPath: resource.properties?.['project.path'] ?? null, dashboardUrl: resource.dashboardUrl && includeSensitiveDashboardUrls ? stripResourceSuffix(resource.dashboardUrl) : (sanitizeDashboardUrl(resource.dashboardUrl) ?? null), urls: resource.urls?.map(cloneResourceUrlState) ?? null, commands: resource.commands ? cloneResourceCommands(resource.commands) : null, diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 6aa0172fc91..a62be0d727d 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -58,6 +58,10 @@ export const processExceptionOccurred = (error: string, command: string) => vsco export const pidDescription = (pid: number) => vscode.l10n.t('PID: {0}', pid); export const dashboardLabel = vscode.l10n.t('Dashboard'); export const errorFetchingAppHosts = (error: string) => vscode.l10n.t('Error fetching running AppHosts: {0}', error); +export const aspireCliCommandFailed = (command: string, exitCode: string, output: string) => vscode.l10n.t('{0} exited with code {1}{2}', command, exitCode, output); +export const aspireCliCommandTimedOut = (timeoutMs: number) => vscode.l10n.t('timed out after {0}ms', timeoutMs); +export const aspireCliOutputParseFailed = (command: string, error: string) => vscode.l10n.t('Failed to parse {0} output: {1}', command, error); +export const appHostPathMustBeNonEmptyAbsolute = vscode.l10n.t('appHostPath must be a non-empty absolute path'); export const aspireDescribeMinimumVersion = '13.2.0'; export const aspireCliDescribeNotSupported = (version: string) => vscode.l10n.t('Workspace resources require Aspire CLI {0} or newer. Update the Aspire CLI and refresh the Aspire panel.', version); export const appHostDescribeMayNotBeSupported = (version: string) => vscode.l10n.t('No workspace resources were returned. Workspace resources require the AppHost to reference Aspire.Hosting {0} or newer.', version); @@ -131,6 +135,7 @@ export const invalidTokenLength = vscode.l10n.t('Invalid token length in Authori 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 authorizationHeaderRequired = vscode.l10n.t('Authorization header is required.'); +export const testRunSessionManagerNotInitialized = vscode.l10n.t('Test run session manager has not been initialized with DCP server connection information.'); export const buildFailedForProjectWithError = (project: string, error: string) => vscode.l10n.t('Build failed for project {0} with error: {1}.', project, error); export const failedToInspectRuntimeConfig = (outputPath: string, error: string) => vscode.l10n.t('Failed to inspect runtimeconfig for {0}: {1}', outputPath, error); export const dotNetRunFallbackDisablesDebugger = (outputPath: string, projectPath: string) => vscode.l10n.t('Project output {0} is not directly runnable; launching {1} with dotnet run without debugger attach. Breakpoints will not be hit for this resource.', outputPath, projectPath); diff --git a/extension/src/test/adapterTracker.test.ts b/extension/src/test/adapterTracker.test.ts index 3e5787bec38..7d9a502fc84 100644 --- a/extension/src/test/adapterTracker.test.ts +++ b/extension/src/test/adapterTracker.test.ts @@ -308,12 +308,34 @@ suite('Debug Adapter Tracker Tests', () => { const factory = registerFactoryStub.lastCall.args[1]; const tracker = factory.createDebugAdapterTracker(invalidSession); - // Call onExit - tracker.onExit(0); + assert.strictEqual(tracker, undefined); // Verify notification was NOT sent assert.strictEqual(dcpServer.sendNotification.called, false); disposable.dispose(); }); + + test('does not track leased parent debug session without runId', async () => { + const leasedParentSession = { + ...debugSession, + configuration: { + type: 'coreclr', + name: 'Aspire test run', + request: 'launch', + env: { + DCP_INSTANCE_ID_PREFIX: 'aspire-extension-test-run-123-' + } + } + }; + + const disposable = createDebugAdapterTracker(dcpServer as any, 'coreclr'); + const factory = registerFactoryStub.lastCall.args[1]; + const tracker = factory.createDebugAdapterTracker(leasedParentSession); + + assert.strictEqual(tracker, undefined); + assert.strictEqual(dcpServer.sendNotification.called, false); + + disposable.dispose(); + }); }); diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index 3aa77742201..804f150faf7 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -6,7 +6,7 @@ import * as sinon from 'sinon'; import * as vscode from 'vscode'; import { EventEmitter } from 'events'; import { PassThrough } from 'stream'; -import { AppHostDataRepository } from '../views/AppHostDataRepository'; +import { AppHostDataRepository, AspireCliFailedError } from '../views/AppHostDataRepository'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import type { AppHostDiscoveryService, CandidateAppHostDisplayInfo } from '../utils/appHostDiscovery'; import * as cliModule from '../debugger/languages/cli'; @@ -132,6 +132,277 @@ suite('AppHostDataRepository', () => { repository.dispose(); }); + test('fetchAppHostsOnce uses ps without resources and describes each AppHost', async () => { + const psProcess = new TestChildProcess(); + const describeProcess = new TestChildProcess(); + spawnStub.onFirstCall().returns(psProcess); + spawnStub.onSecondCall().returns(describeProcess); + const repository = new AppHostDataRepository(terminalProvider); + + try { + const fetchPromise = repository.fetchAppHostsOnce(); + await waitForMicrotasks(); + + assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--format', 'json']); + assert.strictEqual(spawnStub.firstCall.args[3].noExtensionVariables, true); + + spawnStub.firstCall.args[3].stdoutCallback(JSON.stringify([{ + appHostPath: '/workspace/AppHost.csproj', + appHostPid: 1234, + dashboardUrl: 'https://localhost:1234', + cliPid: 5678, + resources: null, + }])); + spawnStub.firstCall.args[3].exitCallback(0); + await waitForMicrotasks(); + await waitForMicrotasks(); + + assert.deepStrictEqual(spawnStub.secondCall.args[2], ['describe', '--format', 'json', '--apphost', '/workspace/AppHost.csproj']); + assert.strictEqual(spawnStub.secondCall.args[3].noExtensionVariables, true); + + spawnStub.secondCall.args[3].stdoutCallback(JSON.stringify({ + resources: [{ + name: 'api', + displayName: 'api', + resourceType: 'Project', + state: 'Running', + stateStyle: null, + healthStatus: null, + healthReports: null, + exitCode: null, + dashboardUrl: null, + urls: [], + commands: null, + properties: null, + }] + })); + spawnStub.secondCall.args[3].exitCallback(0); + + const appHosts = await fetchPromise; + + assert.strictEqual(describeProcess.killed, true); + assert.strictEqual(appHosts.length, 1); + assert.strictEqual(appHosts[0].resources?.[0].name, 'api'); + } finally { + repository.dispose(); + } + }); + + test('fetchAppHostsOnce rejects ps failures with CLI diagnostics', async () => { + const psProcess = new TestChildProcess(); + spawnStub.returns(psProcess); + const repository = new AppHostDataRepository(terminalProvider); + + try { + const fetchPromise = repository.fetchAppHostsOnce(); + await waitForMicrotasks(); + + spawnStub.firstCall.args[3].stderrCallback('Unrecognized command or argument --resources'); + spawnStub.firstCall.args[3].exitCallback(2); + + await assert.rejects(fetchPromise, (error: unknown) => { + assert.ok(error instanceof AspireCliFailedError); + assert.match(error.message, /Unrecognized command or argument --resources/); + return true; + }); + } finally { + repository.dispose(); + } + }); + + test('fetchAppHostsOnce times out hung ps process', async () => { + const clock = sinon.useFakeTimers(); + const psProcess = new TestChildProcess(); + spawnStub.returns(psProcess); + const repository = new AppHostDataRepository(terminalProvider); + + try { + const fetchPromise = repository.fetchAppHostsOnce(); + await waitForMicrotasks(); + + await clock.tickAsync(30000); + + await assert.rejects(fetchPromise, (error: unknown) => { + assert.ok(error instanceof AspireCliFailedError); + assert.match(error.message, /timed out after 30000ms/); + return true; + }); + assert.strictEqual(psProcess.killed, true); + } finally { + repository.dispose(); + clock.restore(); + } + }); + + test('runResourceCommand uses one-shot CLI runner', async () => { + const resourceProcess = new TestChildProcess(); + spawnStub.returns(resourceProcess); + const repository = new AppHostDataRepository(terminalProvider); + + try { + const runPromise = repository.runResourceCommand('api', ' /workspace/AppHost.csproj ', 'stop'); + await waitForMicrotasks(); + + assert.deepStrictEqual(spawnStub.firstCall.args[2], ['resource', 'api', 'stop', '--apphost', '/workspace/AppHost.csproj']); + assert.strictEqual(spawnStub.firstCall.args[3].noExtensionVariables, true); + + resourceProcess.markExited(0); + spawnStub.firstCall.args[3].exitCallback(0); + + await runPromise; + } finally { + repository.dispose(); + } + }); + + test('runResourceCommand rejects failures with CLI diagnostics', async () => { + const resourceProcess = new TestChildProcess(); + spawnStub.returns(resourceProcess); + const repository = new AppHostDataRepository(terminalProvider); + + try { + const runPromise = repository.runResourceCommand('api', '/workspace/AppHost.csproj', 'start'); + await waitForMicrotasks(); + + spawnStub.firstCall.args[3].stderrCallback('resource is disabled'); + resourceProcess.markExited(1); + spawnStub.firstCall.args[3].exitCallback(1); + + await assert.rejects(runPromise, (error: unknown) => { + assert.ok(error instanceof AspireCliFailedError); + assert.match(error.message, /resource is disabled/); + return true; + }); + } finally { + repository.dispose(); + } + }); + + test('fetchAppHostsOnce returns healthy AppHosts when one describe fails', async () => { + const psProcess = new TestChildProcess(); + const healthyDescribeProcess = new TestChildProcess(); + const failedDescribeProcess = new TestChildProcess(); + spawnStub.onFirstCall().returns(psProcess); + spawnStub.onSecondCall().returns(healthyDescribeProcess); + spawnStub.onThirdCall().returns(failedDescribeProcess); + const repository = new AppHostDataRepository(terminalProvider); + + try { + const fetchPromise = repository.fetchAppHostsOnce(); + await waitForMicrotasks(); + + spawnStub.firstCall.args[3].stdoutCallback(JSON.stringify([{ + appHostPath: '/workspace/AppHost.csproj', + appHostPid: 1234, + dashboardUrl: 'https://localhost:1234', + cliPid: 5678, + resources: null, + }, { + appHostPath: '/workspace/DeadAppHost.csproj', + appHostPid: 4321, + dashboardUrl: 'https://localhost:4321', + cliPid: 8765, + resources: null, + }])); + spawnStub.firstCall.args[3].exitCallback(0); + await waitForMicrotasks(); + await waitForMicrotasks(); + + spawnStub.secondCall.args[3].stdoutCallback(JSON.stringify({ + resources: [{ + name: 'api', + displayName: 'api', + resourceType: 'Project', + state: 'Running', + stateStyle: null, + healthStatus: null, + healthReports: null, + exitCode: null, + dashboardUrl: null, + urls: [], + commands: null, + properties: null, + }] + })); + spawnStub.secondCall.args[3].exitCallback(0); + spawnStub.thirdCall.args[3].stderrCallback('describe failed'); + spawnStub.thirdCall.args[3].exitCallback(1); + + const appHosts = await fetchPromise; + + assert.strictEqual(appHosts.length, 2); + assert.strictEqual(appHosts[0].resources?.[0].name, 'api'); + assert.deepStrictEqual(appHosts[1].resources, []); + assert.strictEqual(healthyDescribeProcess.killed, true); + assert.strictEqual(failedDescribeProcess.killed, true); + } finally { + repository.dispose(); + } + }); + + test('fetchAppHostsOnce ignores non-JSON describe output before resource data', async () => { + const psProcess = new TestChildProcess(); + const describeProcess = new TestChildProcess(); + spawnStub.onFirstCall().returns(psProcess); + spawnStub.onSecondCall().returns(describeProcess); + const repository = new AppHostDataRepository(terminalProvider); + + try { + const fetchPromise = repository.fetchAppHostsOnce(); + await waitForMicrotasks(); + + spawnStub.firstCall.args[3].stdoutCallback(JSON.stringify([{ + appHostPath: '/workspace/AppHost.csproj', + appHostPid: 1234, + dashboardUrl: 'https://localhost:1234', + cliPid: 5678, + resources: null, + }])); + spawnStub.firstCall.args[3].exitCallback(0); + await waitForMicrotasks(); + await waitForMicrotasks(); + + spawnStub.secondCall.args[3].stdoutCallback(`Starting AppHost...\n${JSON.stringify({ + resources: [{ + name: 'api', + displayName: 'api', + resourceType: 'Project', + state: 'Running', + stateStyle: null, + healthStatus: null, + healthReports: null, + exitCode: null, + dashboardUrl: null, + urls: [], + commands: null, + properties: null, + }] + })}`); + spawnStub.secondCall.args[3].exitCallback(0); + + const appHosts = await fetchPromise; + + assert.strictEqual(appHosts.length, 1); + assert.strictEqual(appHosts[0].resources?.[0].name, 'api'); + assert.strictEqual(describeProcess.killed, true); + } finally { + repository.dispose(); + } + }); + + test('runResourceCommand rejects invalid AppHost paths before spawning CLI', async () => { + const repository = new AppHostDataRepository(terminalProvider); + + try { + await assert.rejects( + () => repository.runResourceCommand('api', ' ', 'start'), + /appHostPath must be a non-empty absolute path/); + assert.strictEqual(spawnStub.called, false); + } finally { + repository.dispose(); + } + }); + test('describe watch optimistically sends disabled command flag when capabilities cannot be read', async () => { // If `config info` can't be read (e.g. a CLI too old to support it) we keep the optimistic // default so newer-but-unprobeable CLIs still get the flag; the no-data fallback protects us. diff --git a/extension/src/test/extensionApi.test.ts b/extension/src/test/extensionApi.test.ts index 6a7e048ec07..6c39f239d1c 100644 --- a/extension/src/test/extensionApi.test.ts +++ b/extension/src/test/extensionApi.test.ts @@ -7,7 +7,7 @@ suite('extension exports', () => { const extension = await getAndActivateExtension(); const api = extension.exports as AspireExtensionApi; - assert.strictEqual(api.apiVersion, 1); + assert.strictEqual(api.apiVersion, 2); assert.ok(api.rpcServerInfo.address); assert.ok(api.dcpServerInfo.address); assert.strictEqual('token' in api.rpcServerInfo, false); diff --git a/extension/src/test/rpc/e2eServerTests.test.ts b/extension/src/test/rpc/e2eServerTests.test.ts index 74757781c3a..760b014e4e6 100644 --- a/extension/src/test/rpc/e2eServerTests.test.ts +++ b/extension/src/test/rpc/e2eServerTests.test.ts @@ -2,11 +2,14 @@ import * as assert from 'assert'; import waitForExpect from 'wait-for-expect'; import * as vscode from 'vscode'; import * as tls from 'tls'; +import * as https from 'https'; import { createMessageConnection } from 'vscode-jsonrpc'; import { StreamMessageReader, StreamMessageWriter } from 'vscode-jsonrpc/node'; import { getAndActivateExtension } from '../common'; import { RpcServerConnectionInfo } from '../../server/AspireRpcServer'; +import { AcquiredTestRunSession } from '../../dcp/TestRunSessionManager'; +import { AspireExtensionApi } from '../../types/extensionApi'; interface TestOnlyExtensionExports { __testOnlyRpcServerInfo?: RpcServerConnectionInfo; @@ -40,6 +43,29 @@ suite('End-to-end RPC server auth tests', () => { } }); + test('exports test run session API for C# Dev Kit', async () => { + const extension = await getAndActivateExtension(); + + await waitForExpect(() => { + assert.ok(extension.exports.acquireTestRunSession); + assert.ok(extension.exports.releaseTestRunSession); + // dcpServerInfo exposes only the address — sensitive fields (token, certificate) must not leak + assert.strictEqual('token' in (extension.exports.dcpServerInfo ?? {}), false); + assert.strictEqual('certificate' in (extension.exports.dcpServerInfo ?? {}), false); + }, 2000, 50); + + const api = extension.exports as AspireExtensionApi; + const lease = api.acquireTestRunSession({ debug: true }); + + assert.ok(lease.id); + assert.ok(lease.env.DEBUG_SESSION_TOKEN); + assert.ok(lease.env.DEBUG_SESSION_PORT); + assert.strictEqual(lease.env.DCP_INSTANCE_ID_PREFIX, `${lease.sessionId}-`); + assert.strictEqual(await getDcpInfoStatusCode(lease), 200); + + await api.releaseTestRunSession(lease.id); + }); + async function getRealRpcServer() { const extension = await getAndActivateExtension(); const exports = extension.exports as TestOnlyExtensionExports; @@ -67,4 +93,27 @@ suite('End-to-end RPC server auth tests', () => { connection.listen(); return { connection, rpcServerInfo, client }; } + + async function getDcpInfoStatusCode(lease: AcquiredTestRunSession): Promise { + const [host, port] = lease.env.DEBUG_SESSION_PORT.split(':'); + + return await new Promise((resolve, reject) => { + const request = https.request({ + host, + port: Number(port), + path: '/info', + method: 'GET', + rejectUnauthorized: false, + headers: { + Authorization: `Bearer ${lease.env.DEBUG_SESSION_TOKEN}`, + 'Microsoft-Developer-DCP-Instance-ID': `${lease.sessionId}-test` + } + }, response => { + response.resume(); + response.on('end', () => resolve(response.statusCode)); + }); + request.on('error', reject); + request.end(); + }); + } }); diff --git a/extension/src/test/testRunSessionManager.test.ts b/extension/src/test/testRunSessionManager.test.ts new file mode 100644 index 00000000000..a23e73ffc4b --- /dev/null +++ b/extension/src/test/testRunSessionManager.test.ts @@ -0,0 +1,131 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; + +import { TestRunSessionManager } from '../dcp/TestRunSessionManager'; +import { DcpServerConnectionInfo } from '../dcp/types'; +import { AspireDebugSession } from '../debugger/AspireDebugSession'; + +suite('TestRunSessionManager', () => { + const connectionInfo: DcpServerConnectionInfo = { + address: 'localhost:1234', + token: 'test-token', + certificate: 'test-cert', + }; + + teardown(() => { + sinon.restore(); + }); + + test('acquireTestRunSession returns DCP environment for a leased session', () => { + const manager = new TestRunSessionManager(connectionInfo, () => ['project']); + + const lease = manager.acquireTestRunSession({ debug: true }); + + assert.ok(lease.id); + assert.ok(lease.sessionId.startsWith('aspire-extension-run-')); + assert.strictEqual(lease.env.DEBUG_SESSION_PORT, connectionInfo.address); + assert.strictEqual(lease.env.DEBUG_SESSION_TOKEN, connectionInfo.token); + assert.strictEqual(lease.env.DEBUG_SESSION_SERVER_CERTIFICATE, connectionInfo.certificate); + assert.strictEqual(lease.env.DCP_INSTANCE_ID_PREFIX, `${lease.sessionId}-`); + assert.strictEqual(lease.env.DEBUG_SESSION_RUN_MODE, 'Debug'); + assert.deepStrictEqual(JSON.parse(lease.env.DEBUG_SESSION_INFO).supported_launch_configurations, ['project']); + }); + + test('releaseTestRunSession removes leased Aspire debug session', async () => { + const debugSessionEvents = stubDebugSessionEvents(); + const manager = new TestRunSessionManager(connectionInfo); + const addedSessions: AspireDebugSession[] = []; + const removedSessions: AspireDebugSession[] = []; + const stopDebuggingStub = sinon.stub(vscode.debug, 'stopDebugging').resolves(); + const lease = manager.acquireTestRunSession({ debug: false }); + + manager.listenForLeasedDebugSessions({ + rpcServer: {} as any, + dcpServer: {} as any, + terminalProvider: {} as any, + addAspireDebugSession: session => addedSessions.push(session), + removeAspireDebugSession: session => removedSessions.push(session), + getAspireDebugSession: () => null, + }); + debugSessionEvents.start(createDebugSession(lease.sessionId)); + + await manager.releaseTestRunSession(lease.id); + await manager.releaseTestRunSession(lease.id); + + assert.deepStrictEqual(removedSessions, addedSessions); + assert.strictEqual(stopDebuggingStub.calledOnce, true); + }); + + test('parent debug session termination releases the matching lease', async () => { + const debugSessionEvents = stubDebugSessionEvents(); + const manager = new TestRunSessionManager(connectionInfo); + const addedSessions: AspireDebugSession[] = []; + const removedSessions: AspireDebugSession[] = []; + const stopDebuggingStub = sinon.stub(vscode.debug, 'stopDebugging').resolves(); + const lease = manager.acquireTestRunSession({ debug: false }); + const debugSession = createDebugSession(lease.sessionId); + + manager.listenForLeasedDebugSessions({ + rpcServer: {} as any, + dcpServer: {} as any, + terminalProvider: {} as any, + addAspireDebugSession: session => addedSessions.push(session), + removeAspireDebugSession: session => removedSessions.push(session), + getAspireDebugSession: () => null, + }); + debugSessionEvents.start(debugSession); + + debugSessionEvents.terminate(debugSession); + await manager.releaseTestRunSession(lease.id); + + assert.deepStrictEqual(removedSessions, addedSessions); + assert.strictEqual(stopDebuggingStub.calledOnce, true); + }); +}); + +function stubDebugSessionEvents(): { + start: (session: vscode.DebugSession) => void; + terminate: (session: vscode.DebugSession) => void; +} { + let startDebugSession: ((session: vscode.DebugSession) => void) | undefined; + let terminateDebugSession: ((session: vscode.DebugSession) => void) | undefined; + sinon.stub(vscode.debug, 'onDidStartDebugSession').callsFake(listener => { + startDebugSession = listener; + return { dispose: () => { } }; + }); + sinon.stub(vscode.debug, 'onDidTerminateDebugSession').callsFake(listener => { + terminateDebugSession = listener; + return { dispose: () => { } }; + }); + + return { + start: session => { + assert.ok(startDebugSession); + startDebugSession(session); + }, + terminate: session => { + assert.ok(terminateDebugSession); + terminateDebugSession(session); + }, + }; +} + +function createDebugSession(sessionId: string): vscode.DebugSession { + return { + id: `vscode-${sessionId}`, + type: 'coreclr', + name: 'Aspire test run', + workspaceFolder: undefined, + configuration: { + type: 'coreclr', + name: 'Aspire test run', + request: 'launch', + env: { + DCP_INSTANCE_ID_PREFIX: `${sessionId}-`, + }, + }, + customRequest: sinon.stub(), + getDebugProtocolBreakpoint: sinon.stub(), + }; +} diff --git a/extension/src/types/extensionApi.ts b/extension/src/types/extensionApi.ts index e607e9097f2..6d7b9b854a9 100644 --- a/extension/src/types/extensionApi.ts +++ b/extension/src/types/extensionApi.ts @@ -3,6 +3,7 @@ import type { ViewMode } from '../views/AppHostDataRepository'; import type { CommandInvocationEvent } from '../utils/telemetry'; import type { AspireTerminalCommandEvent } from '../utils/AspireTerminalProvider'; import type { AppHostLaunchRequestedEvent } from '../services/AppHostLaunchService'; +import type { AcquiredTestRunSession, TestRunSessionAcquireOptions } from '../dcp/TestRunSessionManager'; export interface AspireExtensionStateSnapshot { viewMode: ViewMode; @@ -34,6 +35,7 @@ export interface AspireResourceState { displayName: string | null; resourceType: string; state: string | null; + projectPath: string | null; dashboardUrl: string | null; urls: readonly AspireResourceUrlState[] | null; commands: Record | null; @@ -67,8 +69,7 @@ export interface WaitForStateOptions { timeoutMs?: number; } -export interface AspireExtensionApi { - readonly apiVersion: 1; +export interface AspireExtensionApiBase { readonly rpcServerInfo: AspireServerInfo; readonly dcpServerInfo: AspireServerInfo; readonly logDirectory: string; @@ -79,6 +80,21 @@ export interface AspireExtensionApi { getDashboardUrl(appHostPath?: string): string | undefined; } +export interface AspireExtensionApiV1 extends AspireExtensionApiBase { + readonly apiVersion: 1; +} + +export interface AspireExtensionApiV2 extends AspireExtensionApiBase { + readonly apiVersion: 2; + getRunningAppHosts(): Promise; + stopResource(resourceName: string, appHostPath: string): Promise; + startResource(resourceName: string, appHostPath: string): Promise; + acquireTestRunSession(options: TestRunSessionAcquireOptions): AcquiredTestRunSession; + releaseTestRunSession(id: string): Promise; +} + +export type AspireExtensionApi = AspireExtensionApiV2; + export interface AspireExtensionE2EStateFile { updatedAt: string; state: AspireExtensionStateSnapshot; diff --git a/extension/src/views/AppHostDataRepository.ts b/extension/src/views/AppHostDataRepository.ts index 01d59983bb2..8919483cc3e 100644 --- a/extension/src/views/AppHostDataRepository.ts +++ b/extension/src/views/AppHostDataRepository.ts @@ -4,7 +4,7 @@ import { ChildProcessWithoutNullStreams } from 'child_process'; import { spawnCliProcess } from '../debugger/languages/cli'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { extensionLogOutputChannel } from '../utils/logging'; -import { appHostDescribeMayNotBeSupported, aspireCliDescribeNotSupported, aspireDescribeMinimumVersion, errorFetchingAppHosts, workspaceViewSelectedMultipleAppHosts, workspaceViewSelectedSingleAppHost } from '../loc/strings'; +import { appHostDescribeMayNotBeSupported, appHostPathMustBeNonEmptyAbsolute, aspireCliCommandFailed, aspireCliCommandTimedOut, aspireCliDescribeNotSupported, aspireCliOutputParseFailed, aspireDescribeMinimumVersion, errorFetchingAppHosts, workspaceViewSelectedMultipleAppHosts, workspaceViewSelectedSingleAppHost } from '../loc/strings'; import { AppHostCandidate, AppHostDiscoveryService, formatAppHostLanguage, getWorkspaceAppHostProjectSearchResult, isBuildableAppHostCandidate } from '../utils/appHostDiscovery'; import { ConfigInfoProvider } from '../utils/configInfoProvider'; import { describeIncludeDisabledCommandsCapability } from '../types/configInfo'; @@ -92,6 +92,38 @@ export interface AppHostDisplayInfo { resources: ResourceJson[] | null | undefined; } +interface DescribeSnapshotJson { + resources?: ResourceJson[]; +} + +export class AspireCliNotInstalledError extends Error { + constructor(message: string) { + super(message); + this.name = 'AspireCliNotInstalledError'; + } +} + +export class AspireCliFailedError extends Error { + constructor( + public readonly command: string, + public readonly exitCode: number | null, + public readonly stdout: string, + public readonly stderr: string) { + super(aspireCliCommandFailed(command, String(exitCode), getCommandOutputSuffix(stdout, stderr))); + this.name = 'AspireCliFailedError'; + } +} + +export class AspireCliParseError extends Error { + constructor( + public readonly command: string, + public readonly output: string, + innerError: unknown) { + super(aspireCliOutputParseFailed(command, String(innerError))); + this.name = 'AspireCliParseError'; + } +} + export type ViewMode = 'workspace' | 'global'; interface GlobalDescribeStream { @@ -120,8 +152,12 @@ interface GlobalDescribeStream { * mode this backs the full tree; in workspace mode it confirms whether the * selected workspace AppHost is running when the resource stream is empty. */ +const oneShotOutputBufferLimit = 4000; + export class AppHostDataRepository { private static readonly _processShutdownGracePeriodMs = 5000; + private static readonly _oneShotCommandTimeoutMs = 30000; + private static readonly _oneShotOutputBufferLimit = oneShotOutputBufferLimit; private readonly _onDidChangeData = new vscode.EventEmitter(); readonly onDidChangeData = this._onDidChangeData.event; @@ -157,6 +193,7 @@ export class AppHostDataRepository { private _workspaceAppHost: AppHostDisplayInfo | undefined; private _pollingInterval: ReturnType | undefined; private _psProcesses = new Set(); + private _oneShotProcesses = new Set(); private _psFetchVersion = 0; private _supportsPsFollow = true; private _fetchInProgress = false; @@ -347,11 +384,42 @@ export class AppHostDataRepository { this._syncPolling(); } + async fetchAppHostsOnce(): Promise { + const appHosts = await this._runCliJson('aspire ps', ['ps', '--format', 'json']); + const appHostList = Array.isArray(appHosts) ? appHosts : [appHosts]; + const appHostsWithResources = await Promise.allSettled(appHostList.map(async appHost => ({ + ...appHost, + resources: await this._fetchAppHostResourcesOnce(appHost.appHostPath), + }))); + + return appHostsWithResources.map((result, index) => { + if (result.status === 'fulfilled') { + return result.value; + } + + extensionLogOutputChannel.warn(`Failed to describe AppHost ${appHostList[index].appHostPath}: ${result.reason}`); + return { + ...appHostList[index], + resources: [], + }; + }); + } + + async runResourceCommand(resourceName: string, appHostPath: string, commandName: 'start' | 'stop'): Promise { + const trimmedAppHostPath = appHostPath.trim(); + if (!trimmedAppHostPath || !path.isAbsolute(trimmedAppHostPath)) { + throw new Error(appHostPathMustBeNonEmptyAbsolute); + } + + await this._runCliCommand(`aspire resource ${commandName}`, ['resource', resourceName, commandName, '--apphost', trimmedAppHostPath]); + } + dispose(): void { this._disposed = true; this._stopPolling(); this._stopDescribeWatch(); this._stopAllGlobalDescribes(); + this._stopOneShotProcesses(); this._cancelWorkspaceAppHostDiscovery(); this._configChangeDisposable.dispose(); this._appHostDiscoveryChangeDisposable.dispose(); @@ -979,6 +1047,7 @@ export class AppHostDataRepository { if (!trimmed) { return true; } + try { const resource: ResourceJson = JSON.parse(trimmed); if (resource.name) { @@ -995,6 +1064,83 @@ export class AppHostDataRepository { return false; } + private async _runCliJson(command: string, args: string[]): Promise { + const { stdout } = await this._runCliCommand(command, args); + + try { + return parseCliJsonOutput(stdout); + } catch (error) { + throw new AspireCliParseError(command, stdout, error); + } + } + + private async _runCliCommand(command: string, args: string[]): Promise<{ stdout: string; stderr: string }> { + const cliPath = await this._terminalProvider.getAspireCliExecutablePath().catch(error => { + throw new AspireCliNotInstalledError(String(error)); + }); + + return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { + let stdout = ''; + let stderr = ''; + let settled = false; + let timeoutTimer: ReturnType | undefined; + let cliProcess: ChildProcessWithoutNullStreams | undefined; + + const settle = (callback: () => void) => { + if (settled) { + return; + } + + settled = true; + if (timeoutTimer) { + clearTimeout(timeoutTimer); + timeoutTimer = undefined; + } + if (cliProcess) { + this._oneShotProcesses.delete(cliProcess); + if (cliProcess.exitCode === null && !cliProcess.killed) { + this._terminateProcess(cliProcess, command); + } + } + callback(); + }; + + timeoutTimer = setTimeout(() => { + settle(() => reject(new AspireCliFailedError(command, null, stdout, stderr || aspireCliCommandTimedOut(AppHostDataRepository._oneShotCommandTimeoutMs)))); + }, AppHostDataRepository._oneShotCommandTimeoutMs); + + cliProcess = spawnCliProcess(this._terminalProvider, cliPath, args, { + noExtensionVariables: true, + stdoutCallback: (data) => { stdout += data; }, + stderrCallback: (data) => { stderr = appendLimitedOutput(stderr, data, AppHostDataRepository._oneShotOutputBufferLimit); }, + exitCallback: (code) => { + if (code !== 0) { + settle(() => reject(new AspireCliFailedError(command, code, stdout, stderr))); + return; + } + + settle(() => resolve({ stdout, stderr })); + }, + errorCallback: (error) => { + settle(() => reject(new AspireCliNotInstalledError(error.message))); + }, + }); + this._oneShotProcesses.add(cliProcess); + }); + } + + private async _fetchAppHostResourcesOnce(appHostPath: string): Promise { + const snapshot = await this._runCliJson('aspire describe', ['describe', '--format', 'json', '--apphost', appHostPath]); + return snapshot.resources ?? []; + } + + private _stopOneShotProcesses(): void { + for (const process of this._oneShotProcesses) { + this._terminateProcess(process, 'one-shot aspire command'); + } + this._oneShotProcesses.clear(); + } + private _stopGlobalDescribe(appHostPath: string): void { const stream = this._globalDescribeStreams.get(appHostPath); if (!stream) { @@ -1620,6 +1766,45 @@ function getComparisonKey(value: string): string { return process.platform === 'win32' ? value.toLowerCase() : value; } +function getCommandOutputSuffix(stdout: string, stderr: string): string { + const output = (stderr || stdout).trim(); + const limitedOutput = output.length <= oneShotOutputBufferLimit + ? output + : output.slice(output.length - oneShotOutputBufferLimit); + + return limitedOutput ? `: ${limitedOutput}` : ''; +} + +function appendLimitedOutput(existing: string, data: string, limit: number): string { + const combined = existing + data; + + return combined.length <= limit ? combined : combined.slice(combined.length - limit); +} + +function parseCliJsonOutput(stdout: string): T { + try { + return JSON.parse(stdout); + } catch (error) { + // Some CLI invocations can emit startup diagnostics before the final JSON payload: + // Starting AppHost... + // {"resources":[{"name":"api", ...}]} + // Parse the whole output first for the normal deterministic path, then fall back to + // the last JSON-looking line so older or chatty CLIs do not poison the snapshot. + for (const line of stdout.split(/\r?\n/).reverse()) { + const trimmed = line.trim(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + try { + return JSON.parse(trimmed); + } catch { + // Keep scanning in case the CLI wrote a JSON-looking diagnostic after the payload. + } + } + } + + throw error; + } +} + function isPathInWorkspace(filePath: string): boolean { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; if (!workspaceFolder) {