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 7 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>
| // 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 }); |
There was a problem hiding this comment.
Confirmed: with the leader already exited, terminateCliProcess takes the else branch and returns, and on Windows processGroupPid is always undefined, so the force path never runs and no taskkill /t /f is issued. Killing the tree before the exit RPC would defeat the cooperative stop, so this needs Windows descendant ownership (job object or captured PIDs). Leaving open for that work rather than patching it here.
| // `stopCli` is cooperative and cannot be the only stop mechanism: it resolves without | ||
| // effect when the transport is already closed, and never settles when the CLI has stopped | ||
| // servicing the connection. Escalate to signalling the process group once the CLI has had | ||
| // a chance to exit on its own, so a CLI that ignores the request cannot outlive the | ||
| // session and keep the AppHost and its resource processes alive. | ||
| this.scheduleCliProcessTermination(); |
There was a problem hiding this comment.
Verified: removeAspireDebugSession is the first disposable (line 131) and the escalation is scheduled by a later one (line 520) on an unref()d 10s timer, so dispose() untracks the session while a termination is still pending. Keeping it in _aspireDebugSessions would make id lookups return a dead session, so this needs the extension-level owner you describe. Leaving open.
|
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>
| return; | ||
| } | ||
|
|
||
| if (options?.force) { |
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?