terminal-suggest: PATH is resolved once at activation, so a replaced PATH directory stops updating completions until reload
- Dominant language
- TypeScript
- Stars
- 193k
- Forks
- 42.4k
- PR merge metrics
- PR metrics pending
Description
Does this issue occur when all extensions are disabled?: Yes, other than the built-in `vscode.terminal-suggest`, which is the extension involved.
- VS Code Version: 1.133.0 (user setup)
- OS Version: Windows 11 Pro 25H2, build 26200.9168
### What happens
`watchPathDirectories()` in `extensions/terminal-suggest/src/terminalSuggestMain.ts` reads `env.PATH` once, at activation, `stat`s each entry once, and registers one watcher per entry:
```ts
const envPath = env.PATH;
if (envPath) { envPath.split(delimiter).forEach(p => pathDirectories.add(p)); }
...
for (const dir of pathDirectories) {
try {
const stat = await fs.promises.stat(dir);
if (!stat.isDirectory()) { continue; }
} catch { continue; }
const watcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(vscode.Uri.file(dir), '*'));
context.subscriptions.push(
watcher,
watcher.onDidCreate(() => handleChange()),
watcher.onDidChange(() => handleChange()),
watcher.onDidDelete(() => handleChange())
);
activeWatchers.add(dir);
}
```
There is no re-`stat`, no re-read of `PATH`, and no handling of the watched root going away. Whatever directory object each watcher binds to at activation is what it keeps.
That breaks when an installer **replaces** a `PATH` directory in place rather than writing into it. Git for Windows and Docker Desktop both do this on update, and so do plenty of others. The watcher stays bound to the old directory object, the live one at that path is never watched, and `handleChange()` never fires for it. Since `handleChange()` is what calls `pathExecutableCache.refresh()`, completions for everything in that directory are frozen at whatever was cached before the update, until the window is reloaded.
The same gap means `PATH` entries added after activation are never picked up either. Only entries that existed when the extension started are ever watched.
On Windows there is a second consequence. NTFS cannot unlink a directory while a handle is open on it, so it relocates the directory to `C:\$Extend\$Deleted\` and the handle stays valid. libuv keeps re-arming `ReadDirectoryChangesW` against the orphan forever and the watcher utility process burns a core. That half is microsoft/vscode#287800, upstream nodejs/node#61398, fixed by libuv/libuv#5013 but not present in any libuv release or in `nodejs/node@main` as of today.
I hit both halves at once. Git for Windows 2.55.0.3 and Docker Desktop 4.87.0 updated a day apart, replacing `C:\Program Files\Git\cmd` and `C:\Program Files\Docker\Docker\resources\bin`. Every `PATH` watcher is registered per window and `PATH` is the same in every window, so all seven open windows ended up stuck on the same two orphaned directory objects: 93,829 to 95,424 CPU seconds each, about 95% of a core apiece sustained for 27 hours. A window opened after both installers had run was fine, and it was the only watcher process still holding `C:\Program Files\Git\cmd` and `C:\Program Files\Docker\Docker\resources\bin` open. Full measurements are in my comment on #287800.
The stale-completions half is not fixed by the libuv fix. Once libuv reports `UV_ENOENT` the CPU spin stops, but nothing re-points terminal-suggest at the replacement directory, so that `PATH` entry stays dead until reload.
### Steps to Reproduce
1. `mkdir C:\pathtest`, put `foo.cmd` in it, add `C:\pathtest` to the user `PATH`.
2. Restart VS Code so terminal-suggest resolves the new `PATH`, then open an integrated terminal. That activates `vscode.terminal-suggest` via `onTerminalShellIntegration:*`.
3. Check that `foo` is offered by terminal suggest.
4. `rmdir /s /q C:\pathtest && mkdir C:\pathtest`, then put `bar.cmd` in the new directory. This is what an in-place installer upgrade does. It has to be a permanent delete, not the Recycle Bin.
5. `bar` is never offered, and `foo` may still be offered from cache. On Windows the watcher utility process also climbs to 100% of a core and stays there, holding a handle under `C:\$Extend\$Deleted\`.
6. `Developer: Reload Window` fixes both.
**Expected:** terminal-suggest notices the `PATH` entry was replaced, and re-establishes the watch and the executable cache against the new directory.
**Actual:** it stays bound to the directory object it captured at activation, for the life of the window.
### Possible fixes
Listing options rather than prescribing one, since the right layer is a call for whoever owns this area.
1. Capture the watched root's file ID at watch time and re-check it periodically, or on terminal activation, or when the executable cache refreshes. Rebuild any watcher whose root no longer matches.
2. Re-read `PATH` on a timer or on shell-integration activation and diff it against `activeWatchers`. This also fixes the added-after-activation case, which nothing covers today.
3. Treat `onDidDelete` on a watch root as a signal to re-resolve that entry instead of leaving the watcher in place.
4. Deduplicate the `PATH` watch set across windows. It is identical in every window, so N windows currently register N copies of the same watches, which is what turned one replaced directory into seven stuck processes here.
Contributor guide
Assessment
This issue has not been assessed yet.