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) {