Skip to content

Stop the Aspire CLI and own its RPC connections during extension deactivation - #19152

Draft
Adam Ratzman (adamint) wants to merge 7 commits into
microsoft:mainfrom
adamint:adamint/extension-shutdown-lifecycle
Draft

Stop the Aspire CLI and own its RPC connections during extension deactivation#19152
Adam Ratzman (adamint) wants to merge 7 commits into
microsoft:mainfrom
adamint:adamint/extension-shutdown-lifecycle

Conversation

@adamint

Copy link
Copy Markdown
Member

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 deactivation

Extension deactivation was fire-and-forget. deactivate() returned void, so VS Code did not wait for the CLI stop requests it triggered, and closing a window could leave aspire run processes alive. Connections whose debug-session handshake was still pending were not tracked by the RPC server, so they were never disposed. InteractionService had 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.tsdeactivate() returns a promise and awaits AspireExtensionContext.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) — implements vscode.Disposable and latches disposal, so a late showStatus after the connection closed is ignored.

a0cb46e7 — Reject foreign absolute paths in getRelativePathToWorkspace

vscode.workspace.asRelativePath returns 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 as C:\Users\... or \\server\share\... therefore satisfies the host's path.isAbsolute on 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 both path.posix.isAbsolute and path.win32.isAbsolute before 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 e79efb125c rather than current main, because main does not currently compile: #19084 and #18976 merged 27 seconds apart and combine into error CS0117: 'AzureBicepResourceScope' does not contain a definition for 'ForSubscription'. #19148 fixes that. Any Hosting.Azure or Hosting.Azure.Kubernetes failure on this PR originates there, not from this branch, which changes only extension/src/**.

Fixes # (no linked issue; split out of #19124)

Checklist

  • Is this feature complete?
    • Yes. Ready to ship.
    • No. Follow-up changes expected.
  • Are you including unit tests for the changes and scenario tests if relevant?
    • Yes
    • No
  • Did you add public API?
    • Yes
      • If yes, did you have an API Review for it?
        • Yes
        • No
      • Did you add <remarks /> and <code /> elements on your triple slash comments?
        • Yes
        • No
    • No
  • Does the change make any security assumptions or guarantees?
    • Yes
      • If yes, have you done a threat model and had a security review?
        • Yes
        • No
    • No

Copilot AI balanced review requested due to automatic review settings August 7, 2026 19:26
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 19152

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 19152"

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread extension/src/utils/workspace.ts
Adam Ratzman and others added 3 commits August 7, 2026 15:44
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>
Copilot AI review requested due to automatic review settings August 7, 2026 19:49
@adamint
Adam Ratzman (adamint) force-pushed the adamint/extension-shutdown-lifecycle branch from a0cb46e to f7e616a Compare August 7, 2026 19:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 12/12 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread extension/src/AspireExtensionContext.ts
Comment thread extension/src/AspireExtensionContext.ts Outdated
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

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>
Copilot AI review requested due to automatic review settings August 7, 2026 20:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 12/12 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread extension/src/debugger/AspireDebugSession.ts Outdated
Comment thread extension/src/AspireExtensionContext.ts Outdated
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>
Copilot AI review requested due to automatic review settings August 7, 2026 20:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Suppressed comments (1)

extension/src/AspireExtensionContext.ts:171

  • A session can dispose/remove itself while _settleStopRequests is 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>
Copilot AI review requested due to automatic review settings August 7, 2026 21:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 14/14 changed files
  • Comments generated: 2
  • Review effort level: Balanced

// 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 });

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +526 to +531
// `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();

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

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>
Copilot AI review requested due to automatic review settings August 7, 2026 21:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 14/14 changed files
  • Comments generated: 1
  • Review effort level: Balanced

return;
}

if (options?.force) {
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants