Stop the Aspire CLI and own its RPC connections during extension deactivation - #19152
Stop the Aspire CLI and own its RPC connections during extension deactivation#19152Adam Ratzman (adamint) wants to merge 13 commits into
Conversation
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 19152Or
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 19152" |
There was a problem hiding this comment.
Pull request overview
Hardens VS Code extension shutdown and prevents absolute paths from appearing in debug configuration names.
Changes:
- Awaits CLI stop requests during deactivation with bounded teardown.
- Owns and disposes RPC connections and status notifications.
- Adds cross-platform workspace-path handling and regression tests.
Show a summary per file
| File | Description |
|---|---|
extension/src/extension.ts |
Awaits extension deactivation. |
extension/src/AspireExtensionContext.ts |
Coordinates bounded CLI shutdown and disposal. |
extension/src/debugger/AspireDebugSession.ts |
Deduplicates CLI stop requests. |
extension/src/server/AspireRpcServer.ts |
Tracks and disposes RPC clients. |
extension/src/server/rpcClient.ts |
Adds idempotent transport disposal. |
extension/src/server/interactionService.ts |
Clears and suppresses disposed status updates. |
extension/src/utils/workspace.ts |
Adds cross-platform path filtering. |
extension/src/test/AspireExtensionContext.test.ts |
Tests deactivation sequencing and failures. |
extension/src/test/aspireDebugSession.test.ts |
Tests stop-request deduplication. |
extension/src/test/rpc/aspireRpcServer.test.ts |
Tests RPC ownership during races. |
extension/src/test/rpc/interactionServiceTests.test.ts |
Tests transport and status cleanup. |
extension/src/test/workspace.test.ts |
Tests path fallback behavior. |
Review details
- Files reviewed: 12/12 changed files
- Comments generated: 1
- Review effort level: Balanced
Extension deactivation was fire-and-forget: `deactivate()` returned void, so VS Code never waited for the CLI stop requests it triggered, and connections whose debug-session handshake was still pending were never owned by the RPC server. A window close could therefore leave `aspire run` processes alive and leave progress indicators on screen with nothing left to clear them. - `deactivate()` now returns a promise and awaits `AspireExtensionContext.deactivate()`, which asks every live debug session to stop its CLI (deduplicating in-flight requests) with a bounded 5s timeout before disposing the rest of the extension. - `AspireRpcServer` tracks the connections it creates, including ones still inside the handshake, and disposes them on server disposal. - `RpcClient.dispose()` is idempotent and closes the transport. - `InteractionService` is disposable and latches disposal so a status message still in flight when the transport closed cannot resurrect progress. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`asRelativePath` returns its input unchanged when the path cannot be made relative, and that value was returned verbatim. It resolves against the workspace, which may not share the extension host's path semantics, so a Windows absolute path (`C:\Users\...` or `\\server\share\...`) passes the host's `path.isAbsolute` on POSIX — the case for remote SSH, WSL and Codespaces — and the full path leaked into the debug configuration name. Reject both POSIX and Win32 absolute forms and fall back to the workspace folder name, and use the file name rather than the full path when the target is outside every workspace folder. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The cross-platform absolute-path rejection sat after the getWorkspaceFolder early return, so it was unreachable for the input it existed to catch. A Windows path is inside no workspace folder on a POSIX host, so getWorkspaceFolder returns undefined and control reached path.basename, which on POSIX does not split on '\' and returned C:\Users\me\secret\AppHost.csproj whole as the debug configuration name. Move the rejection ahead of that early return and reduce the path with path.win32.basename, which splits on both separators. Only Win32 forms can be foreign, because path.win32.isAbsolute also accepts a leading '/'. The existing regression test stubbed getWorkspaceFolder to return a folder for the Windows paths, which cannot happen on a POSIX host, so it was green against a code path that never ran. It now covers the asRelativePath guard with a host-native path, and a new test drives the foreign paths with getWorkspaceFolder returning undefined, asserting both the file name and that no separator survives on either host platform. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
a0cb46e to
f7e616a
Compare
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
… not `stopCli` is an RPC request, not a kill. It resolves without effect when the transport is already closed and never settles when the CLI has stopped servicing the connection, so neither outcome proves the process exited. `spawnAspireCommand` discarded the ChildProcess that `spawnCliProcess` returns and did not request a process group, so there was nothing to signal as a fallback. Every other CLI spawn site in the extension already retains its child and calls `terminateCliProcess`; the longest-lived one did not. Retain the child, spawn `aspire run` as a process-group leader, and add `terminateCliProcessTree()`. Session disposal escalates to it after a 10s grace period so a cooperative stop still gets the first chance to shut resources down cleanly, and deactivation calls it directly once the stop requests settle or time out. Also re-snapshot the session array between awaits during deactivation. `_isShuttingDown` does not gate `addAspireDebugSession`, so a debug-adapter descriptor or an RPC-triggered `startDebugSession` landing mid-await was never asked to stop. Requesting a stop is idempotent per session, so re-scanning until no new session appears is safe. Verified red-green: with the escalation, the process group and the re-snapshot loop reverted, 4 of the 5 new tests fail and the existing 5 deactivation tests still pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The re-snapshot loop asks every session that appears *before* teardown to stop, but `_disposeCore` disposes exactly the sessions present when it takes its snapshot and never runs again. A session registered after that point was still tracked forever and never disposed, so its CLI kept running with nothing left alive to stop it. `addAspireDebugSession` now refuses and disposes once `_isDisposed` is set; the pre-teardown window is unchanged and still handled by the drain. `spawnAspireCommand` awaits the CLI path before spawning, so deactivation can complete inside that await. Spawning afterwards produced an `aspire run` that no teardown path could reach — and now that it is spawned detached as a process-group leader, one that would not even die with the extension host. Two fixes to the tests added alongside the process-group change: - `terminateCliProcessTree signals a running CLI process` ran the real `terminateCliProcess`, which on Windows shells out to `taskkill /pid <pid> /t` rather than calling `child.kill`. The assertion would have failed on the Windows CI agents, and the run would have signalled whatever process owned PID 4322 there. It now stubs the module function. - Restore the newline that was lost from the `reuses an in-flight CLI stop request` test declaration. Also drops the `if (deactivate)` fallback in the test helper. `deactivate` is a declared method, so the fallback could never run, and had it ever run it would have silently retargeted the suite at `dispose()`. Verified red-green: reverting the two guards fails exactly the two new tests and nothing else. 1476 passing, 0 failing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (1)
extension/src/AspireExtensionContext.ts:171
- A session can dispose/remove itself while
_settleStopRequestsis awaiting (for example when its AppHost session terminates). This final loop then loses that session's process handle, so a hung stop receives no immediate termination; only the session's unref'd 10-second timer remains, even though deactivation resolves after 5 seconds and the extension host may exit first. Retain the session objects associated with every collected stop request and terminate the union of those sessions and the currently registered sessions. A regression test should remove a session while its stop request is pending.
for (const session of this._aspireDebugSessions) {
try {
session.terminateCliProcessTree();
- Files reviewed: 12/12 changed files
- Comments generated: 0 new
- Review effort level: Balanced
…tion An Aspire CLI spawned with createProcessGroup leads a detached group that the AppHost and every resource process joins. Two paths let that group outlive the extension: - When the CLI exited on its own, the exit callback only cancelled the escalation timer and terminateCliProcessTree early-returned on an exited leader, so nothing ever signalled the surviving descendants. terminateCliProcess already reaps a managed group whose leader has exited; it just was not being invoked. Collect synchronously from the exit callback, because once the leader's PID is released the OS may recycle it as another group's id. - The deactivation sweep sent SIGTERM and scheduled the hard kill on an unref'd timer, but _deactivateCore resolves as soon as the sweep returns, so the host could exit first and leave a CLI that ignored SIGTERM alive. Deactivation has already spent its 5s cooperative window, so it now forces immediately. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
`terminateCliProcess` never calls `child.kill` on Windows: it spawns `taskkill.exe /pid <pid> /t` so the descendants come down with the leader, and only falls back to `child.kill` from taskkill's error handler. That branch had no coverage anywhere, which is how a test asserting `child.kill` reached CI — it passed on macOS and Linux and could only fail on the Windows unit-test job, the one leg with no counterpart on another platform. Assert the taskkill invocation and that the child is not signalled directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (1)
extension/src/AspireExtensionContext.ts:164
- The re-scan only runs after
_settleStopRequestsreturns. If an existing stop request hangs, a session registered during that five-second wait is never passed torequestCliStopForExtensionShutdown()because this branch breaks immediately at the deadline; it is then force-killed without any cooperative cleanup attempt. Wake the wait whenaddAspireDebugSessionadds a session (or otherwise re-scan while waiting), and cover a hung first session plus a responsive late session.
while (this._collectStopRequests(requested) && Date.now() < deadline) {
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;
- Files reviewed: 15/15 changed files
- Comments generated: 1
- Review effort level: Balanced
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (1)
extension/src/debugger/languages/cli.ts:176
taskkill /pid <pid> /ttargets the specified live process and its children; once the CLI leader has exited, that PID no longer identifies its former tree (and may be reused). Therefore this branch cannot reliably terminate the orphaned AppHost/resource processes and can potentially target an unrelated process after PID reuse. The new exited-leader unit test only verifies thattaskkillis spawned, not that it can find the old tree. Windows needs ownership retained while the leader is alive (for example, a Job Object) or descendant PIDs captured before exit rather than post-exit cleanup by the stale PID.
// Windows does not tie child lifetimes to the parent process. An exited CLI leader can
// still have an AppHost/resource tree underneath its recorded PID, so sweep it with
// taskkill during forceful shutdown instead of treating the leader's exit as proof that
// teardown completed. Non-force callers keep the historical no-op behavior because a short
// helper CLI can legitimately exit before its close handler observes it.
- Files reviewed: 15/15 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (3)
extension/src/AspireExtensionContext.ts:79
- A session can still arrive after
_waitForCliStopRequests()observes no new sessions: theawaitin_deactivateCore()yields once before_disposeCore(). Such a session is accepted here, missed by the force sweep, then spliced out beforesession.dispose()schedules its delayed termination, so window close can again leave its CLI alive. Reject/dispose sessions once_isShuttingDownis set, or make the final drain and teardown atomic with respect to registration; add coverage for registration after an empty drain.
addAspireDebugSession(debugSession: AspireDebugSession) {
if (this._isDisposed) {
extension/src/debugger/AspireDebugSession.ts:512
- When the CLI exits on its own on Windows, this call is non-forced.
terminateCliProcess()sees the already-exited leader and returns atcli.ts:177without runningtaskkill; this method nevertheless marks_cliProcessTreeTerminationAttempted, sodispose()will not schedule another attempt and AppHost/resource descendants can remain alive. Use the forced tree sweep for this exited-leader path (and assert the option in the existing exit-callback regression test).
this.terminateCliProcessTree();
.github/workflows/run-tests.yml:269
- The PR description explicitly says this split contains only the extension lifecycle/path changes and “changes only
extension/src/**,” but this also replaces the Azure Functions Core Tools installation for the Playground/Azure test workflow. That is an unrelated CI behavior and supply-chain change; remove it from this PR or split it into a separately described and validated change.
core_tools_version='4.12.1'
core_tools_directory="$RUNNER_TEMP/azure-functions-core-tools"
core_tools_archive="$RUNNER_TEMP/Azure.Functions.Cli.linux-x64.${core_tools_version}.zip"
curl --fail --location --retry 3 --retry-all-errors \
--output "$core_tools_archive" \
"https://github.com/Azure/azure-functions-core-tools/releases/download/${core_tools_version}/Azure.Functions.Cli.linux-x64.${core_tools_version}.zip"
echo 'faf8fb8d50b5293df338bec70594b12f45730e9fe251805298859b2238cf627e '"$core_tools_archive" | sha256sum --check -
- Files reviewed: 16/16 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (3)
extension/src/debugger/AspireDebugSession.ts:516
- When the CLI leader exits on Windows, this non-force call does not collect its descendants:
terminateCliProcess()returns from the exited-Windows branch unlessforceis set. This method has already latched_cliProcessTreeTerminationAttempted, sodispose()will not schedule the force timer and then releases the session from extension-context ownership; the AppHost/resource tree can therefore survive a normal CLI exit and cannot be found during later deactivation. Force the exited-leader cleanup here.
this.terminateCliProcessTree();
extension/src/AspireExtensionContext.ts:160
- The condition creates a stop promise before checking whether the deadline has elapsed. If a late session is first discovered just as the previous wait reaches the deadline, the body is skipped and no
allSettledhandler is ever attached; a rejectedstopCli()then becomes an unhandled rejection during deactivation. Check the deadline before collecting the next batch; the force-termination sweep below still handles sessions discovered after the cooperative window.
while (this._collectStopRequests(requested) && Date.now() < deadline) {
.github/workflows/run-tests.yml:260
- The PR description says this split contains only the extension lifecycle/path changes and “changes only
extension/src/**,” but this modifies shared Playground/Azure CI provisioning. This unrelated workflow change should be removed or split/documented so the PR matches its stated scope and validation.
shell: bash
- Files reviewed: 16/16 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Restores .github/workflows/run-tests.yml to main for this PR, reverting the out-of-scope 05514e8 workflow change. The Azure Functions Core Tools fix now lives on adamint/fix-azfunc-core-tools-ci. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (2)
extension/src/AspireExtensionContext.ts:143
- The force sweep runs inside an awaited helper, leaving a microtask gap before
_disposeCore(). IfgetAspireCliExecutablePath()resolves in that gap, the sweep has already seen no_cliProcess,spawnAspireCommand()still sees_disposed === falseand spawns, then_disposeCore()splices the session before its unref'd 10-second termination timer is scheduled. Deactivation can therefore resolve while that CLI survives. Run the force sweep immediately before_disposeCore()in the same synchronous continuation so a path resolution either occurs before the sweep or after the session is disposed.
await this._waitForCliStopRequests();
extension/src/AspireExtensionContext.ts:160
- Evaluate the deadline before collecting requests. In the current order, when the deadline expires just as a late session appears,
_collectStopRequests()creates and stores itsstopCli()promise, but the loop body is skipped, so noallSettledhandler ever observes it. Transport disposal can then reject that promise as an unhandled rejection during shutdown.
while (this._collectStopRequests(requested) && Date.now() < deadline) {
- Files reviewed: 15/15 changed files
- Comments generated: 1
- Review effort level: Balanced
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (2)
extension/src/extension.ts:427
- The core regression is only exercised with stubbed
process.kill,taskkill, and RPC transports. No extension E2E test deactivates/reloads the extension while an AppHost is running, so the suite cannot detect VS Code failing to await this promise or real CLI/AppHost descendants surviving on Windows or POSIX. Add a lifecycle scenario that launches an AppHost, triggers extension-host shutdown/reload, and verifies the owned process IDs exit.
export function deactivate(): Promise<void> {
return aspireExtensionContext.deactivate();
extension/src/debugger/AspireDebugSession.ts:541
- This claim is inaccurate: several extension CLI spawn sites do not request a process group, including
AspirePackageRestoreProvider.ts:189,e2eStateFileBridge.ts:1041, andResourceCommandArgumentsLoader.ts:65. Keep the comment scoped to why this long-livedaspire runinvocation needs process-group ownership.
// PID when the cooperative `stopCli` RPC does not finish the job. Every other CLI spawn
// site in the extension already does this; this one is the longest-lived of them.
- Files reviewed: 15/15 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Description
Split out of #19124, which combined extension shutdown/lifecycle hardening with an unrelated change to how activity progress is displayed. Separating them reduced #19124 from 24 files / +2288/-120 to 11 files / +280/-37. There is no dependency between the two branches; they touch disjoint hunks and can merge in either order.
Two independent changes, one per commit.
4f34b592— Stop the Aspire CLI and own RPC connections during deactivationExtension deactivation was fire-and-forget.
deactivate()returnedvoid, so VS Code did not wait for the CLI stop requests it triggered, and closing a window could leaveaspire runprocesses alive. Connections whose debug-session handshake was still pending were not tracked by the RPC server, so they were never disposed.InteractionServicehad no disposal at all, so a status message still in flight when the transport closed could paint progress that nothing remained alive to clear.extension.ts—deactivate()returns a promise and awaitsAspireExtensionContext.deactivate().AspireExtensionContext.ts(+102/-2) — asks every live debug session to stop its CLI, then disposes the rest of the extension. The wait is bounded at 5s so a hung CLI cannot block window close.AspireDebugSession.ts(+12) —requestCliStopForExtensionShutdown()deduplicates concurrent requests by reusing the in-flight promise.AspireRpcServer.ts(+82/-8) — tracks connections it creates, including those still inside the handshake, and disposes them on server disposal. Connections added after disposal are rejected and disposed rather than published.rpcClient.ts(+26/-3) —dispose()is idempotent and closes the transport.interactionService.ts(+18/-2) — implementsvscode.Disposableand latches disposal, so a lateshowStatusafter the connection closed is ignored.a0cb46e7— Reject foreign absolute paths ingetRelativePathToWorkspacevscode.workspace.asRelativePathreturns its input unchanged when the path cannot be made relative, and that value was returned verbatim. It resolves against the workspace, which does not necessarily share the extension host's path semantics. A Windows absolute path such asC:\Users\...or\\server\share\...therefore satisfies the host'spath.isAbsoluteon POSIX, which is the case for remote SSH, WSL and Codespaces, and the full path was used as the debug configuration name.utils/workspace.ts(+23/-9) — rejects bothpath.posix.isAbsoluteandpath.win32.isAbsolutebefore accepting the relative path, falling back to the workspace folder name. When the target is outside every workspace folder, the file name is used instead of the full path.The single caller is the debug configuration name built in
interactionService.startDebugSession.Tests
AspireExtensionContext.test.ts(+216, new) — deactivation stops the CLI for every session, tolerates rejection and timeout, and disposes in order.rpc/aspireRpcServer.test.ts(+199, new) — server disposal while a handshake is pending, rejected handshake on an open transport, transport disposal mid-handshake, and connections added after disposal.rpc/interactionServiceTests.test.ts(+43/-1) — client disposal closes the transport once and prevents late status resurrection; server disposal clears status when the connection never closes.aspireDebugSession.test.ts(+23) — extension shutdown reuses an in-flight CLI stop request.workspace.test.ts(+59/-1) —C:\Users\...,\\server\share\...and/home/...all fall back to the workspace name regardless of host platform; relative paths in either separator style are preserved; a path outside the workspace resolves to its file name.Extension suite on this branch: 1468 passing, 4 pending, exit 0 (
yarn run compile-tests,yarn run lint,yarn run unit-test).Base
Based on
e79efb125crather than currentmain, becausemaindoes not currently compile: #19084 and #18976 merged 27 seconds apart and combine intoerror CS0117: 'AzureBicepResourceScope' does not contain a definition for 'ForSubscription'. #19148 fixes that. AnyHosting.AzureorHosting.Azure.Kubernetesfailure on this PR originates there, not from this branch, which changes onlyextension/src/**.Fixes # (no linked issue; split out of #19124)
Checklist
<remarks />and<code />elements on your triple slash comments?