Azure / Azure/azure-dev

Add --force to azd extension upgrade to stop running extension processes

Open
#9,307 1 comment 0 reactions 1 assignee Assigned to @vhvb1989 View on GitHub
area/extensions enhancement
Dominant language
Go
Stars
569
Forks
364
Avg merge
2d 19h
Merged PRs (30d)
136

Description

## Summary

`azd extension upgrade` fails on Windows whenever the extension has a running process, because the upgrade path deletes the install directory before reinstalling and Windows won't delete a running executable. Add a `--force` flag that finds and stops processes running out of the extension's install directory, then proceeds with the upgrade.

## Description

Extensions that spawn long-lived processes (MCP servers launched by VS Code or Copilot CLI, service supervisors, background daemons) hold their own binary open. Any `azd extension upgrade` against such an extension fails until the user manually hunts down and kills every one of those processes.

This isn't an edge case. A single extension can easily have several instances alive at once, one per editor window or agent session, and there's no obvious signal to the user about which processes are blocking the upgrade. The current failure surfaces as a generic "Access is denied" with no mention of the processes responsible.

Observed locally with three concurrent instances of the same extension:

```
C:\Users\\.azd\extensions\.\--windows-amd64.exe (3 live PIDs)
```

## Technical Details

The upgrade path uninstalls before installing:

- `Manager.upgradeInternal` calls `m.Uninstall(...)` first, then `m.installInternal(...)`
`cli/azd/pkg/extensions/manager.go:890-909`
- `Manager.Uninstall` removes the whole extension directory via `osutil.RemoveAll(ctx, extensionDir)`
`cli/azd/pkg/extensions/manager.go:785-800`
- On Windows, `os.RemoveAll` against a running `.exe` returns `ERROR_ACCESS_DENIED`
- `retryFileSystemOperation` retries 10 times at a 1 second constant backoff
`cli/azd/pkg/osutil/rename_windows.go:36-46`

The retry loop is built for transient locks (virus scanners, indexers). A process that's genuinely running never releases the file inside that 10 second window, so the retries always exhaust and the command fails with:

```
failed to uninstall extension: failed to remove extension: ... Access is denied.
```

### Related data point: rename works where delete doesn't

Windows blocks deleting a running executable but permits renaming it, since the loader opens image files with `FILE_SHARE_DELETE`. Verified on Windows 11:

| Operation on a running .exe | Result |
| --- | --- |
| Delete | Failed, access denied |
| Rename | Succeeded |
| Write a new .exe at the original path after rename | Succeeded |
| Delete the renamed old .exe after the process exits | Succeeded |

That means a rename-then-replace install (move the locked binary to `.old-`, drop the new binary in place, sweep stale `.old-*` files on a later run) would let most upgrades succeed without stopping anything. It's the same approach Chrome and the Go toolchain use for self-update.

Worth considering alongside `--force`, since the two solve different halves of the problem. Rename-then-replace fixes the file swap, but the user is still left running the previous version until the old processes restart, so an explicit `--force` remains useful when the intent is "upgrade and restart everything now."

## Steps to Reproduce

1. On Windows, install an extension that runs a long-lived process (for example, one exposing an MCP server).
2. Start that process, such as by opening a client that launches the extension's MCP server.
3. Confirm it's live:
```powershell
Get-Process | Where-Object { $_.Path -like "*\.azd\extensions\*" } | Select-Object Id, Path
```
4. Run `azd extension upgrade `.

**Expected:** the upgrade completes, or fails immediately with a message naming the blocking processes and how to get past them.

**Actual:** the command hangs for roughly 10 seconds, then fails with `failed to uninstall extension: failed to remove extension: ... Access is denied.` No indication of which processes are at fault.

## Proposal

Add `--force` to `azd extension upgrade` (and `azd extension uninstall`, which hits the same `Uninstall` path):

- Enumerate processes whose executable path resolves inside the extension's install directory.
- Terminate them, then continue with the upgrade.
- Report what was stopped so the behavior isn't silent.

Without `--force`, keep today's behavior but improve the error: name the blocking processes with their PIDs and point at `--force`.

## Acceptance Criteria

- [ ] `azd extension upgrade --force` succeeds while the extension has running processes
- [ ] `azd extension uninstall --force` behaves the same way
- [ ] Process discovery is scoped to executables inside that extension's install directory, so unrelated processes are never touched
- [ ] The command reports which processes it stopped (name and PID)
- [ ] Without `--force`, the failure message names the blocking processes and suggests `--force` instead of surfacing a bare "Access is denied"
- [ ] Behavior is deterministic under `--no-prompt` for CI and scripted use
- [ ] Works on Windows, macOS, and Linux, including the case where a stale process keeps running the old binary on non-Windows platforms
- [ ] `--force` composes with `--all` for bulk upgrades
- [ ] Unit tests cover process discovery, path scoping, and the non-forced error message
- [ ] Help text and usage snapshots updated for the new flag

## Done Definition

All acceptance criteria pass, `mage preflight` is clean (lint, format, copyright, spelling, build, tests), and the new behavior is verified on Windows with a real running extension process rather than only in unit tests.

## Related

- `cli/azd/pkg/extensions/manager.go` (`upgradeInternal`, `Uninstall`, `installInternal`)
- `cli/azd/pkg/osutil/rename_windows.go` (`RemoveAll`, `Rename`, `retryFileSystemOperation`)
- `cli/azd/cmd/extension.go` (`extensionUpgradeFlags`, `extensionUninstallAction`)

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.