Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ae46882
Stop the Aspire CLI and own RPC connections during deactivation
Aug 7, 2026
66aaa5d
Reject foreign absolute paths in getRelativePathToWorkspace
Aug 7, 2026
f7e616a
Reject foreign absolute paths before the workspace-membership check
Aug 7, 2026
938888d
Terminate the Aspire CLI process group when the cooperative stop does…
Aug 7, 2026
67519d5
Refuse debug sessions registered after teardown and never spawn into one
Aug 7, 2026
512c887
Collect the CLI process group on self-exit and force-kill on deactiva…
Aug 7, 2026
08ecfe2
Cover the Windows taskkill termination path
Aug 7, 2026
64bcad3
Fix Windows CLI shutdown ownership
Aug 8, 2026
eea96b2
Limit exited Windows sweep to force shutdown
Aug 8, 2026
05514e8
Stabilize Azure Functions tools install
Aug 8, 2026
380cdb3
Make CLI process tree termination idempotent
Aug 8, 2026
bdca042
Revert Azure Functions Core Tools workflow release-archive install
adamint Aug 8, 2026
1f67278
Complete CLI process tree termination idempotence
Aug 8, 2026
30bb845
Cover deactivation process-tree cleanup
adamint Aug 9, 2026
dec2a1e
Prevent CLI spawn during extension shutdown
adamint Aug 9, 2026
63711a9
Avoid stop requests after deactivation timeout
adamint Aug 9, 2026
fe64b61
Avoid stale Windows CLI PID sweeps
adamint Aug 9, 2026
73b6048
Retire the Windows CLI PID instead of only skipping the immediate sweep
Aug 10, 2026
9689a52
Pin the platform in the POSIX process-group test
Aug 10, 2026
072e412
Stop late shutdown sessions cooperatively
adamint Aug 10, 2026
40144fb
Avoid stale Windows taskkill after CLI exit
adamint Aug 10, 2026
488fc84
Wait for late CLI RPC stop during shutdown
adamint Aug 10, 2026
97e7cc8
Merge branch 'main' into adamint/extension-shutdown-lifecycle
adamint Aug 10, 2026
f744667
Keep extension deactivation change focused
Aug 10, 2026
c05edf6
Merge upstream main into extension shutdown lifecycle
Aug 12, 2026
8039a12
Test extension deactivation export wiring
Aug 12, 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
182 changes: 176 additions & 6 deletions extension/src/AspireExtensionContext.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as vscode from 'vscode';
import { ErrorCodes, ResponseError } from 'vscode-jsonrpc';
import { AspireDebugSession } from './debugger/AspireDebugSession';
import { AspireDebugConfigurationProvider } from './debugger/AspireDebugConfigurationProvider';
import { debugSessionAlreadyExists, extensionContextNotInitialized } from './loc/strings';
Expand All @@ -7,8 +8,11 @@ import AspireDcpServer from './dcp/AspireDcpServer';
import { AspireTerminalProvider } from './utils/AspireTerminalProvider';
import { AspireEditorCommandProvider } from './editor/AspireEditorCommandProvider';
import type { AspireDebugConsoleOutputEvent } from './types/extensionApi';
import { extensionLogOutputChannel } from './utils/logging';

export class AspireExtensionContext implements vscode.Disposable {
private static readonly _cliStopTimeoutMs = 5_000;

private _rpcServer?: AspireRpcServer;
private _dcpServer?: AspireDcpServer;
private _extensionContext?: vscode.ExtensionContext;
Expand All @@ -21,6 +25,9 @@ export class AspireExtensionContext implements vscode.Disposable {
private readonly _debugSessionOutputSubscriptions = new Map<string, vscode.Disposable>();
private readonly _onDidChangeDebugSessions = new vscode.EventEmitter<void>();
private readonly _onDidReceiveDebugConsoleOutput = new vscode.EventEmitter<AspireDebugConsoleOutputEvent>();
private _shutdownPromise?: Promise<void>;
private _isShuttingDown = false;
private _isDisposed = false;
readonly onDidChangeDebugSessions = this._onDidChangeDebugSessions.event;
readonly onDidReceiveDebugConsoleOutput = this._onDidReceiveDebugConsoleOutput.event;

Expand Down Expand Up @@ -59,14 +66,27 @@ export class AspireExtensionContext implements vscode.Disposable {
return null;
}

return this._aspireDebugSessions.find(session => session.debugSessionId === debugSessionId) || null;
return this._aspireDebugSessions.find(session => session.debugSessionId === debugSessionId && !session.isDisposed) || null;
}

get aspireDebugSessions(): readonly AspireDebugSession[] {
return [...this._aspireDebugSessions];
// Disposed sessions can remain tracked only as CLI process owners. They must still be
// visible to deactivation, but not to RPC lookups or extension-state snapshots.
return this._aspireDebugSessions.filter(session => !session.isDisposed);
}

addAspireDebugSession(debugSession: AspireDebugSession) {
if (this._isDisposed) {
// `_disposeCore` disposes exactly the sessions present when it takes its snapshot, and
// it never runs twice. Tracking a session that arrives afterwards would therefore mean
// never disposing it at all: its CLI would keep running with nothing left alive to stop
// it. Sessions arriving *before* teardown are still accepted — `_waitForCliStopRequests`
// re-scans for them, and `_disposeCore` then disposes them on the normal path.
extensionLogOutputChannel.warn(`Refusing Aspire debug session ${debugSession.debugSessionId} because the extension has already been torn down; disposing it immediately.`);
debugSession.dispose();
return;
}

if (this._aspireDebugSessions.find(session => session.debugSessionId === debugSession.debugSessionId)) {
throw new Error(debugSessionAlreadyExists(debugSession.debugSessionId));
}
Expand All @@ -75,6 +95,17 @@ export class AspireExtensionContext implements vscode.Disposable {
this._debugSessionStateSubscriptions.set(debugSession.debugSessionId, debugSession.onDidChangeState(() => this._onDidChangeDebugSessions.fire()));
this._debugSessionOutputSubscriptions.set(debugSession.debugSessionId, debugSession.onDidSendDebugConsoleOutput(event => this._onDidReceiveDebugConsoleOutput.fire(event)));
this._onDidChangeDebugSessions.fire();

if (this._isShuttingDown) {
// A session can be registered while deactivation is already awaiting an earlier stop
// request. Ask it to stop immediately rather than waiting for the next drain-loop scan:
// the shared deadline may expire first, in which case the loop goes straight to the
// force sweep and this otherwise healthy late session would never get cooperative
// cleanup for resources outside the CLI process tree, such as containers.
void debugSession.requestCliStopForExtensionShutdown().catch(error => {
extensionLogOutputChannel.warn(`Failed to stop Aspire CLI during extension deactivation: ${error}`);
});
}
}

removeAspireDebugSession(debugSession: AspireDebugSession) {
Expand All @@ -94,14 +125,153 @@ export class AspireExtensionContext implements vscode.Disposable {
return this._debugConfigProvider;
}

dispose() {
this._rpcServer?.dispose();
this._dcpServer?.dispose();
deactivate(): Promise<void> {
if (this._isDisposed) {
return Promise.resolve();
}

if (this._shutdownPromise) {
return this._shutdownPromise;
}

this._isShuttingDown = true;
// Schedule the async work after storing the shared promise so a reentrant dispose/deactivate
// call cannot begin synchronous teardown between the stop request and the first await.
this._shutdownPromise = Promise.resolve().then(() => this._deactivateCore());
Comment thread
adamint marked this conversation as resolved.
return this._shutdownPromise;
}

dispose(): void {
if (this._isDisposed || this._isShuttingDown) {
return;
}

this._disposeCore();
}

private async _deactivateCore(): Promise<void> {
try {
await this._waitForCliStopRequests();
}
finally {
// A timeout or failed RPC stop still has to run the established debug-session and
// terminal teardown path so extension deactivation cannot leave the CLI process alive.
this._disposeCore();
}
}

private async _waitForCliStopRequests(): Promise<void> {
const requested = new Map<string, Promise<void>>();
const deadline = Date.now() + AspireExtensionContext._cliStopTimeoutMs;

// Re-snapshot after every await. `_isShuttingDown` does not stop `addAspireDebugSession`
// from registering a session, so a debug-adapter descriptor or an RPC-triggered
// `startDebugSession` that lands mid-await would never be asked to stop if the array were
// captured only once. Requesting a stop is idempotent per session, so re-scanning is safe.
while (Date.now() < deadline && this._collectStopRequests(requested)) {
const timedOut = await this._settleStopRequests([...requested.values()], deadline);
if (timedOut) {
extensionLogOutputChannel.warn(`Timed out after ${AspireExtensionContext._cliStopTimeoutMs}ms waiting for Aspire CLI stop requests; continuing extension teardown.`);
break;
}
}

// A cooperative stop that resolved, rejected or timed out proves only what happened to the
// RPC request; the CLI process can still be running. Signal any that are, so deactivation
// cannot leave an AppHost and its resource processes orphaned.
for (const session of [...this._aspireDebugSessions]) {
try {
// Force rather than signal-and-schedule: `terminateCliProcess` escalates to a hard
// kill on an `unref`'d timer, and `_deactivateCore` resolves immediately after this
// sweep, so the extension host can exit before that timer fires. The cooperative
// deadline above was this CLI's grace period; there is no second one.
session.terminateCliProcessTree({ force: true });
Comment thread
adamint marked this conversation as resolved.
}
catch (error) {
extensionLogOutputChannel.warn(`Failed to terminate the Aspire CLI process during extension deactivation: ${error}`);
}
}
}

/**
* Requests a CLI stop for every registered session that has not been asked yet, returning
* whether any new session was found.
*/
private _collectStopRequests(requested: Map<string, Promise<void>>): boolean {
let addedRequest = false;
for (const session of this._aspireDebugSessions) {
if (session.isDisposed) {
continue;
}

if (requested.has(session.debugSessionId)) {
continue;
}

addedRequest = true;
try {
requested.set(session.debugSessionId, session.requestCliStopForExtensionShutdown());
}
catch (error) {
requested.set(session.debugSessionId, Promise.reject(error));
}
}

return addedRequest;
}

private async _settleStopRequests(stopRequests: Promise<void>[], deadline: number): Promise<boolean> {
const allStops = Promise.allSettled(stopRequests);
let timeout: ReturnType<typeof setTimeout> | undefined;
const outcome = await Promise.race([
allStops.then(results => ({ timedOut: false as const, results })),
new Promise<{ timedOut: true }>(resolve => {
timeout = setTimeout(() => {
timeout = undefined;
resolve({ timedOut: true });
}, Math.max(0, deadline - Date.now()));
}),
]);

if (timeout) {
clearTimeout(timeout);
}

if (outcome.timedOut) {
return true;
}

const failures = outcome.results
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
.map(result => result.reason);
for (const failure of failures) {
// Closing the RPC transport rejects its outstanding stop request even though the
// synchronous debug-session and terminal teardown below has completed successfully.
if (failure instanceof ResponseError && failure.code === ErrorCodes.PendingResponseRejected) {
extensionLogOutputChannel.info(`Aspire CLI stop request ended after the RPC transport closed: ${failure}`);
}
else {
extensionLogOutputChannel.warn(`Failed to stop Aspire CLI during extension deactivation: ${failure}`);
}
}

return false;
}

private _disposeCore(): void {
if (this._isDisposed) {
return;
}

this._isDisposed = true;
this._debugSessionStateSubscriptions.forEach(disposable => disposable.dispose());
this._debugSessionStateSubscriptions.clear();
this._debugSessionOutputSubscriptions.forEach(disposable => disposable.dispose());
this._debugSessionOutputSubscriptions.clear();
this._aspireDebugSessions.forEach(session => session.dispose());
const sessions = this._aspireDebugSessions.splice(0);
sessions.forEach(session => session.dispose());
this._rpcServer?.dispose();
this._dcpServer?.dispose();
this._terminalProvider?.dispose();
this._editorCommandProvider?.dispose();
this._onDidChangeDebugSessions.dispose();
Expand Down
Loading
Loading