Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
2704831
Implement public API for Aspire extension and add fetchAppHostsOnce m…
LittleLittleCloud May 29, 2026
91dc92d
Merge remote-tracking branch 'upstream/main' into pr-17705-csdk
adamint Jun 1, 2026
a030a21
Add Aspire test run DCP leases
adamint Jun 1, 2026
33dcde3
Clean lease notifications after release
adamint Jun 1, 2026
0e68f5b
Address Aspire extension API review feedback
adamint Jun 1, 2026
8d8861b
Harden Aspire test run lease integration
adamint Jun 1, 2026
12f2768
Refactor Aspire API to use DCP server for test run session management
LittleLittleCloud Jun 1, 2026
45487b5
Merge branch 'main' into u/xiaoyun/aspireC#DK
LittleLittleCloud Jun 2, 2026
2129f65
Refactor AspireDcpServer constructor to remove unused app parameter a…
LittleLittleCloud Jun 2, 2026
fdbba30
Update API function signatures to use inline arrow functions for DCP …
LittleLittleCloud Jun 2, 2026
c8739a3
Merge branch 'main' into u/xiaoyun/aspireC#DK
LittleLittleCloud Jun 3, 2026
b918206
Fix extension test failures by returning full E2E API from activate
LittleLittleCloud Jun 3, 2026
5ca3d02
Refactor Aspire extension API: consolidate resource command functions…
LittleLittleCloud Jun 3, 2026
b9a3118
Fix stopRunSession cleanup and remove redundant closeLeaseNotificatio…
LittleLittleCloud Jun 3, 2026
d6666bc
Merge branch 'main' into u/xiaoyun/aspireC#DK
LittleLittleCloud Jun 10, 2026
1321ec5
Enhance resource command handling and error logging in DCP server and…
LittleLittleCloud Jun 10, 2026
9939510
Remove terminalProvider parameter from createExtensionApi function ca…
LittleLittleCloud Jun 10, 2026
9fb1d95
Refactor process termination logic in AppHostDataRepository for clarity
LittleLittleCloud Jun 10, 2026
79b9dbe
Refactor DCP server and test run session manager for improved session…
LittleLittleCloud Jun 15, 2026
bbdefca
Refactor AspireDcpServer and TestRunSessionManager for improved clari…
LittleLittleCloud Jun 15, 2026
4d1ce6e
Fix test run session DCP credentials
adamint Jun 17, 2026
03f2ff5
Merge remote-tracking branch 'upstream/main' into copilot/pr-17705-fix
adamint Jun 17, 2026
8dbae32
Fix VS Code extension API lifecycle issues
adamint Jun 19, 2026
0131c76
Handle selected test comment permission failures
adamint Jun 19, 2026
778aea1
Merge upstream/main into PR 17705 fixes
adamint Jun 19, 2026
08fabc5
Grant issue comment permission to CI tests workflow
adamint Jun 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
345 changes: 307 additions & 38 deletions extension/src/dcp/AspireDcpServer.ts

Large diffs are not rendered by default.

143 changes: 143 additions & 0 deletions extension/src/dcp/TestRunSessionManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { randomBytes, timingSafeEqual } from 'crypto';
import { getRunSessionInfo, getSupportedCapabilities } from '../capabilities';
import { generateToken } from '../utils/security';
import { DcpServerConnectionInfo, RunSessionInfo } from './types';

export interface TestRunSessionAcquireOptions {
debug: boolean;
}

export interface AcquiredTestRunSession {
id: string;
sessionId: string;
env: Record<string, string>;
}

export interface TestRunSessionLease {
id: string;
sessionId: string;
token: string;
expiresAt: number;
}

export interface TestRunSessionManagerOptions {
leaseLifetimeMs?: number;
now?: () => number;
}

export class TestRunSessionManager {
private readonly leases = new Map<string, TestRunSessionLease>();
private readonly leaseLifetimeMs?: number;
private readonly now: () => number;

constructor(
private readonly connectionInfo: Pick<DcpServerConnectionInfo, 'address' | 'certificate'>,
private readonly getSupportedLaunchConfigurations: () => string[] = getSupportedCapabilities,
options: TestRunSessionManagerOptions = {}) {
this.leaseLifetimeMs = options.leaseLifetimeMs;
this.now = options.now ?? Date.now;
}

acquire(options: TestRunSessionAcquireOptions): AcquiredTestRunSession {
this.removeExpiredLeases();

const id = generateToken();
const sessionId = `aspire-extension-test-run-${randomBytes(16).toString('hex')}`;
const token = generateToken();
const runSessionInfo: RunSessionInfo = {
...getRunSessionInfo(),
supported_launch_configurations: this.getSupportedLaunchConfigurations()
};

this.leases.set(id, { id, sessionId, token, expiresAt: this.getExpiresAt() });

return {
id,
sessionId,
env: {
DEBUG_SESSION_PORT: this.connectionInfo.address,
DEBUG_SESSION_TOKEN: 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)
}
};
}

release(id: string): TestRunSessionLease | undefined {
const lease = this.leases.get(id);
this.leases.delete(id);
return lease;
}

isActive(id: string): boolean {
const lease = this.leases.get(id);
if (!lease) {
return false;
}

if (this.isExpired(lease)) {
this.leases.delete(id);
return false;
}

return true;
}

tryAuthorizeDcpRequest(dcpId: string, token: string): TestRunSessionLease | undefined {
const lease = this.tryGetLeaseForDcpId(dcpId);
if (!lease) {
return undefined;
}

const bearerTokenBuffer = Buffer.from(token);
const expectedTokenBuffer = Buffer.from(lease.token);
if (bearerTokenBuffer.length !== expectedTokenBuffer.length) {
return undefined;
}

if (timingSafeEqual(bearerTokenBuffer, expectedTokenBuffer) === false) {
return undefined;
}

return lease;
}

tryGetLeaseForDcpId(dcpId: string): TestRunSessionLease | undefined {
for (const lease of this.leases.values()) {
if (dcpId.startsWith(`${lease.sessionId}-`)) {
if (this.isExpired(lease)) {
this.leases.delete(lease.id);
return undefined;
}

return lease;
}
}

return undefined;
}

private removeExpiredLeases(): void {
for (const lease of this.leases.values()) {
if (this.isExpired(lease)) {
this.leases.delete(lease.id);
}
}
}

private isExpired(lease: TestRunSessionLease): boolean {
if (this.leaseLifetimeMs === undefined) {
return false;
}

return this.now() >= lease.expiresAt;
}

private getExpiresAt(): number {
return this.leaseLifetimeMs === undefined
? Number.POSITIVE_INFINITY
: this.now() + this.leaseLifetimeMs;
}
}
Comment thread
adamint marked this conversation as resolved.
4 changes: 2 additions & 2 deletions extension/src/dcp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export interface LaunchOptions {
runId: string;
debugSessionId: string;
isApphost: boolean;
debugSession: AspireDebugSession;
debugSession?: AspireDebugSession;
Comment thread
LittleLittleCloud marked this conversation as resolved.
Outdated
};

export interface StartAppHostOptions {
Expand All @@ -173,7 +173,7 @@ export interface StartAppHostOptions {
export interface AspireResourceDebugSession {
id: string;
session: vscode.DebugSession;
stopSession(): void;
stopSession(): Thenable<void> | void;
}

export interface AspireResourceExtendedDebugConfiguration extends vscode.DebugConfiguration {
Expand Down
13 changes: 9 additions & 4 deletions extension/src/debugger/languages/dotnet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,21 @@ interface IDotNetService {
}

class DotNetService implements IDotNetService {
private _debugSession: AspireDebugSession;
private _debugSession?: AspireDebugSession;
Comment thread
LittleLittleCloud marked this conversation as resolved.
Outdated

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

execFileAsync = util.promisify(execFile);

writeToDebugConsole(message: string, category: 'stdout' | 'stderr', addNewLine: boolean = false): void {
this._debugSession.sendMessage(message, addNewLine, category);
if (this._debugSession) {
this._debugSession.sendMessage(message, addNewLine, category);
}
else {
extensionLogOutputChannel.appendLine(message);
}
Comment thread
LittleLittleCloud marked this conversation as resolved.
Outdated
}
Comment thread
LittleLittleCloud marked this conversation as resolved.

async getAndActivateDevKit(): Promise<boolean> {
Expand Down Expand Up @@ -253,7 +258,7 @@ function createDotNetRunArguments(projectPath: string, baseProfileArgs: string |
return dotnetRunArgs;
}

export function createProjectDebuggerExtension(dotNetServiceProducer: (debugSession: AspireDebugSession) => IDotNetService): ResourceDebuggerExtension {
export function createProjectDebuggerExtension(dotNetServiceProducer: (debugSession?: AspireDebugSession) => IDotNetService): ResourceDebuggerExtension {
return {
resourceType: 'project',
debugAdapter: 'coreclr',
Expand Down
64 changes: 61 additions & 3 deletions extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { getSupportedLanguageIds } from './editor/parsers/AppHostResourceParser'
import { readGitCommitSha } from './utils/versionInfo';
import { collectResourceCommandArguments } from './views/ResourceCommandArguments';
import { createResourceCommandArgumentLoader } from './views/ResourceCommandArgumentsLoader';
import { spawnCliProcess } from './debugger/languages/cli';
import { AppHostDisplayInfo, ResourceCommandJson, ResourceJson, isMatchingAppHostPath } from './views/AppHostDataRepository';
import { AppHostDiscoveryService } from './utils/appHostDiscovery';
import { AppHostLaunchRequestedEvent, AppHostLaunchService } from './services/AppHostLaunchService';
Expand Down Expand Up @@ -366,8 +367,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, dataRepository, terminalProvider, appHostLaunchService, appHostTreeProvider, onDidChangeStateEmitter.event);

return Object.freeze(api);
}
Expand Down Expand Up @@ -417,6 +419,7 @@ function createExtensionApi(
rpcServer: AspireRpcServer,
dcpServer: AspireDcpServer,
dataRepository: AppHostDataRepository,
terminalProvider: AspireTerminalProvider,
appHostLaunchService: AppHostLaunchService,
appHostTreeProvider: AspireAppHostTreeProvider,
onDidChangeState: vscode.Event<AspireExtensionStateSnapshot>,
Expand Down Expand Up @@ -459,6 +462,18 @@ function createExtensionApi(
waitForState,
waitForRepositoryIdle: options => waitForState(state => !state.isRepositoryLoading && state.isWorkspaceAppHostDiscoveryComplete, options),
getDashboardUrl: appHostPath => getDashboardUrl(dataRepository, appHostPath),
async getRunningAppHosts(): Promise<readonly AspireAppHostState[]> {
const appHosts = await dataRepository.fetchAppHostsOnce();
return appHosts.map(appHost => cloneAppHostState(appHost, false));
},
async stopResource(resourceName: string, appHostPath: string): Promise<void> {
return executePublicResourceCommand(terminalProvider, resourceName, appHostPath, 'stop');
},
async startResource(resourceName: string, appHostPath: string): Promise<void> {
return executePublicResourceCommand(terminalProvider, resourceName, appHostPath, 'start');
},
acquireTestRunSession: (options) => dcpServer.acquireTestRunSession(options),
releaseTestRunSession: (id) => dcpServer.releaseTestRunSession(id),
};
if (context.extensionMode === vscode.ExtensionMode.Test) {
api.__testOnlyRpcServerInfo = rpcServer.connectionInfo;
Expand Down Expand Up @@ -1161,6 +1176,48 @@ function getDashboardUrl(dataRepository: AppHostDataRepository, appHostPath?: st
return sanitizeDashboardUrl(getSensitiveDashboardUrl(dataRepository, appHostPath));
}

async function executePublicResourceCommand(terminalProvider: AspireTerminalProvider, resourceName: string, appHostPath: string, commandName: 'start' | 'stop'): Promise<void> {
const trimmedAppHostPath = appHostPath.trim();
if (!trimmedAppHostPath || !path.isAbsolute(trimmedAppHostPath)) {
throw new Error('appHostPath must be a non-empty absolute path');
}

const cliPath = await terminalProvider.getAspireCliExecutablePath();
const args = ['resource', resourceName, commandName, '--apphost', trimmedAppHostPath];
return new Promise<void>((resolve, reject) => {
let stdout = '';
let stderr = '';
let settled = false;

spawnCliProcess(terminalProvider, cliPath, args, {
noExtensionVariables: true,
stdoutCallback: (data) => { stdout += data; },
stderrCallback: (data) => { stderr += data; },
exitCallback: (code) => {
Comment thread
LittleLittleCloud marked this conversation as resolved.
Outdated
if (settled) {
return;
}

settled = true;
if (code === 0) {
resolve();
} else {
const output = (stderr || stdout).trim();
reject(new Error(`aspire resource ${commandName} exited with code ${code}${output ? `: ${output}` : ''}`));
}
},
errorCallback: (error) => {
if (settled) {
return;
}

settled = true;
reject(error);
},
});
});
}

function getSensitiveDashboardUrl(dataRepository: AppHostDataRepository, appHostPath?: string): string | undefined {
if (appHostPath) {
const matchingAppHost = dataRepository.appHosts.find(appHost => isMatchingAppHostPath(appHost.appHostPath, appHostPath));
Expand All @@ -1186,7 +1243,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,
};
}

Expand All @@ -1196,6 +1253,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,
Expand Down
Loading
Loading