Skip to content

⛔ 100% CPU on Windows when a watched file is deleted #287800

Description

Does this issue occur when all extensions are disabled?: Yes

Version: 1.108.0 (user setup)
Commit: 94e8ae2
Date: 2026-01-08T13:53:10.781Z
Electron: 39.2.7
ElectronBuildId: 12953945
Chromium: 142.0.7444.235
Node.js: 22.21.1
V8: 14.2.231.21-electron.0
OS: Windows_NT x64 10.0.26200

I wrote about it in #263718, but couldn't get the team to reopen the issue. tl;dr if a watched file is deleted, then ReadDirectoryChangesW is called in an infinite loop, causing 100% CPU. Happened several times for me.

Steps to Reproduce:

  • Open a project with .vscode\settings.json on windows
  • Open that file in notepad
  • Every time it's changed and saved in notepad, ReadDirectoryChangesW is called for the path 2 times by the utility service
  • Now remove .vscode folder
  • Change and save in notepad again, vscode utility process start calling ReadDirectoryChangesW in an infinite loop.

Activity

  1. bpasero commented on Jan 14, 2026

    @bpasero
    Contributor

    Can you try to reproduce with our nightly insider builds? You can give our preview releases a try from: https://code.visualstudio.com/insiders/

  2. justanotheranonymoususer commented on Jan 14, 2026

    @justanotheranonymoususer
    ContributorAuthor

    Yes, but only with installed ver, not portable.
    Steps:

    • Install
    • Create folder, open with vscode, trust it
    • Open same folder in cmd
    • Run:
    C:\temp>mkdir .vscode && echo {"editor.fontSize": 33}>.vscode\settings.json
    
    C:\temp>rmdir /s /q .vscode\ && mkdir .vscode && echo {"editor.fontSize": 33}>.vscode\settings.json
    
    • 100% CPU for one core
  3. added
    help wantedIssues identified as good community contribution opportunities
    team-low-hangingLow effort issues team members can help fix. No external contributions will be accepted.
    and removed
    info-neededIssue requires more information from poster
    on Jan 14, 2026
  4. bpasero commented on Jan 14, 2026

    @bpasero
    Contributor

    Needs investigation if confirmed. Are you using any special file system or driver?

  5. justanotheranonymoususer commented on Jan 14, 2026

    @justanotheranonymoususer
    ContributorAuthor
  6. added
    bugIssue identified by VS Code Team member as probable bug
    confirmedIssue has been confirmed by VS Code Team member
    and removed on Jan 15, 2026
  7. 31 remaining items

  8. KamilDev commented on Jul 16, 2026

    @KamilDev

    Hit this in the wild today and captured forensics while the process was spinning — posting because it (a) confirms the repro class outside synthetic steps, and (b) shows why this recurs "randomly" for anyone using CLI coding agents, plus a question about shipping the fix.

    Environment: VS Code 1.127.0 (user setup), Windows 11 10.0.26200, NTFS, no unusual filesystem drivers.

    Symptoms (captured live, process had been spinning for ~1h):

    • The fileWatcher utility process (Code.exe --utility-sub-type=node.mojom.NodeService, watcher.node loaded) had one thread pegged at ~100% of a core, ~55 CPU-minutes accumulated.
    • ~301,000 "Other" I/O operations/second (consistent with a ReadDirectoryChangesW retry loop), zero read/write operations, while no file in any watched tree had changed for minutes — pure spin, no event storm.
    • Sysinternals handle showed the process holding an open handle to C:\$Extend\$Deleted\<FRN> — i.e. a watched directory that was POSIX-deleted while the watcher held its handle. The five fileWatcher processes of my other open windows held zero such handles and were idle.

    Trigger identified via NTFS USN journal: the $Deleted entry corresponded to a directory under C:\Users\<user>\.claude\ (agents/skills/rules — config dirs that VS Code extensions register watchers on). CLI coding-agent tooling (Claude Code and similar) scaffolds and permanently deletes these dirs as part of its session lifecycle — matching the finding in the linked Node issue that only permanent deletion (not Recycle Bin) triggers the loop. That explains the "happens eventually, seemingly at random" reports: any user running CLI agent tools alongside VS Code re-rolls this dice constantly. Killing the fileWatcher process recovers (VS Code respawns it); the spin otherwise persists indefinitely.

    On the fix: the upstream fix landed in libuv master on 2026-03-31 — libuv/libuv#5013 (44125af6, "win: fix watch loop logic") — but as of today it is in no libuv release (latest v1.52.1 predates it) and has not been cherry-picked into Node's deps/uv, so no VS Code build including Insiders has it, and the release path libuv → Node 22.x → Electron → VS Code looks like several more months.

    Robo (@deepak1556) could libuv/libuv@44125af6 be cherry-picked into VS Code's Electron/Node runtime directly rather than waiting for the full release chain? Given how cheap the trigger is (delete a watched folder permanently), this seems worth shipping ahead of upstream.

  9. SixFive7 commented on Aug 18, 2026

    @SixFive7
    Contributor

    Another field report, with a trigger that does not involve anyone deleting anything, plus the current shipping status of the libuv fix. The cherry-pick question Kamil (@KamilDev) raised in July is still open and there is a new data point on it at the bottom.

    VS Code 1.133.0 (user setup), Windows 11 Pro 25H2 build 26200.9168, NTFS, 32 logical cores, no filesystem filter drivers beyond Defender.

    An in-place installer is enough

    I deleted nothing. Two ordinary product updates did it:

    • Git for Windows 2.55.0.3 replaced C:\Program Files\Git\cmd (replacement directory created 2026-08-16 20:19:50)
    • Docker Desktop 4.87.0 replaced C:\Program Files\Docker\Docker\resources\bin (created 2026-08-17 20:40:42)

    Both are on PATH, VS Code held watch handles on both, so NTFS could not unlink them and relocated them to C:\$Extend\$Deleted\ instead. That is the same zombie-handle state Stefan Stojanovic (@StefanStojanovic) described in nodejs/node#61398, reached with no user action that looks anything like a delete.

    Which makes the trigger routine rather than exotic. Any installer that replaces a PATH directory in place re-rolls this for anyone with VS Code open, and on Windows that is a lot of installers.

    Why every window is hit at once

    vscode.terminal-suggest is built in, on by default, and activates on onTerminalShellIntegration:*, so it comes up as soon as any terminal with shell integration starts. watchPathDirectories() in extensions/terminal-suggest/src/terminalSuggestMain.ts then registers one non-recursive watcher per PATH entry, per window:

    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), '*'));
        ...
    }

    The pattern is '*', so these are non-recursive requests and land on the node.js watcher you identified (nodejsWatcherLib.ts), not on the parcel recursive watcher. And every window builds the same set, because PATH is the same everywhere. One replaced PATH directory therefore produces one spinning watcher process in every open window.

    The shape is the problem: stat once, watch, done. No re-stat, no recovery path, nothing that reacts to the root going away. (#276477, merged 2025-11-10 as 5e5ba2f, is the change that moved these watches onto the shared watcher service.)

    Measurements

    Seven Code.exe --type=utility --utility-sub-type=node.mojom.NodeService processes, all children of the same VS Code main process, sampled 2026-08-17 between 23:29 and 23:44 local:

    PID 45196  CPU 95,424 s  threads 27  handles 381  WS 181 MB
    PID 45316  CPU 94,193 s  threads 27  handles 381  WS 152 MB
    PID 45044  CPU 94,165 s  threads 27  handles 381  WS 148 MB
    PID 45092  CPU 94,165 s  threads 27  handles 387  WS 149 MB
    PID 45412  CPU 93,852 s  threads 26  handles 379  WS 148 MB
    PID 30880  CPU 93,839 s  threads 27  handles 381  WS 148 MB
    PID 45008  CPU 93,829 s  threads 27  handles 381  WS 148 MB
    

    Seven other NodeService processes under the same parent at the same moment: 2.1 to 2.4 CPU seconds each.

    The Git directory was replaced 27.3 hours before that snapshot. 94,000 CPU seconds over 27.3 hours is 95% to 97% of one core, sustained, per process. Times seven that is 6.7 cores of a 32-thread machine, standing, for over a day. No meaningful read or write I/O, just a continuous stream of metadata operations.

    Handle evidence

    All seven held exactly two directory handles under $Extend\$Deleted, and it is the same two NTFS file references in every one of them:

    PID 45044: watchHandles=64  DELETED=2  \\?\C:\$Extend\$Deleted\001200000007826C14D88775 ; \\?\C:\$Extend\$Deleted\00F100000001B3A4480AB3C6
    PID 45092: watchHandles=70  DELETED=2  (same two)
    PID 45412: watchHandles=65  DELETED=2  (same two)
    PID 45008: watchHandles=64  DELETED=2  (same two)
    PID 30880: watchHandles=64  DELETED=2  (same two)
    PID 45316: watchHandles=64  DELETED=2  (same two)
    PID 45196: watchHandles=64  DELETED=2  (same two)
    PID 74480: watchHandles=63  DELETED=0
    

    PID 74480 is the control. That window was opened after both installers had run, so it re-resolved PATH at activation. It is idle, and it is the only watcher process still holding

    \\?\C:\Program Files\Git\cmd
    \\?\C:\Program Files\Docker\Docker\resources\bin
    

    open. Diffing the full directory-handle set of a sick watcher against that control, and ignoring the two workspace folders that differ because the windows have different folders open, the entire difference is:

    only in healthy:  C:\Program Files\Git\cmd
                      C:\Program Files\Docker\Docker\resources\bin
    only in sick:     C:\$Extend\$Deleted\001200000007826C14D88775
                      C:\$Extend\$Deleted\00F100000001B3A4480AB3C6
    

    A one-for-one substitution. So the sick watchers are not only spinning. They have also silently stopped watching the live Git and Docker directories, and nothing ever re-points them.

    Loaded modules: 43 in the sick process, 43 in the control, and the two lists are identical. The only native addon in there is @parcel/watcher, which is loaded in the same utility process for recursive workspace watching and is a red herring here. The spin is libuv's uv_fs_event, which matches the symbolised stack already posted in #263718.

    A reboot cleared it. Developer: Reload Window clears it. Killing the utility process clears it too, VS Code just respawns it.

    Repro

    1. Open three or more VS Code windows on Windows with defaults (terminal.integrated.suggest.enabled is on).
    2. Open an integrated terminal in each. That activates vscode.terminal-suggest and registers the PATH watchers.
    3. Run an installer that replaces a directory already on PATH in place. Git for Windows and Docker Desktop both qualify.

    Without an installer: mkdir C:\pathtest, add it to the user PATH, restart VS Code so terminal-suggest picks it up, open a terminal in each window, then rmdir /s /q C:\pathtest && mkdir C:\pathtest. Permanent delete only, the Recycle Bin does not reproduce it, per Stefan Stojanovic (@StefanStojanovic).

    One NodeService utility process per window then climbs to roughly 100% of a core and stays there indefinitely with no file activity, and a handle enumerator shows the directory handles under C:\$Extend\$Deleted\.

    Expected: the watcher notices its root is gone, fails, and baseWatcher poll-and-resume re-establishes the watch on the replacement directory.

    Actual: it spins on the orphaned directory object forever and never re-resolves.

    Why the existing guard misses this

    The suspend and resume machinery in src/vs/platform/files/node/watcher/baseWatcher.ts (suspendWatchRequest(), monitorSuspendedWatchRequest(), polling at suspendedWatchRequestPollingInterval = 5007) hangs entirely off onDidWatchFail, which is emitted by notifyWatchFailed() in nodejsWatcherLib.ts.

    In the zombie-handle case nothing fails. ReadDirectoryChangesW keeps returning success, libuv keeps delivering rename events, fs.watch never emits error, so notifyWatchFailed() is never called, the request is never suspended, and the resume poll never starts.

    Same reason #288003 does not help. As merged it added a re-entrancy guard inside notifyWatchFailed(), which de-duplicates repeated failure notifications. There are no failure notifications here to de-duplicate.

    Shipping status of the fix, checked today

    • win: fix watch loop logic libuv/libuv#5013 (44125af6, "win: fix watch loop logic") merged 2026-03-31. In libuv main, src/win/fs-event.c now calls GetFinalPathNameByHandleW, tests the result for \$Extend\$Deleted\, and fires handle->cb(handle, NULL, 0, UV_ENOENT) rather than re-arming. That is precisely the onDidWatchFail signal baseWatcher is sitting there waiting for.
    • Newest libuv release is still v1.52.1, published 2026-03-06, so it predates the merge. No libuv release contains the fix.
    • nodejs/node@main has deps/uv pinned at 1.52.1, and deps/uv/src/win/fs-event.c has no $Extend\$Deleted check and no dir_event_detected as of today.
    • But Node does cherry-pick individual libuv commits straight into deps/uv, and did exactly that to this very file four days ago: 38e39556, "deps: cherry-pick libuv/libuv@e640dc9", deps: cherry-pick libuv/libuv@e640dc9 nodejs/node#65118, landed 2026-08-14.

    So the route Kamil (@KamilDev) asked about in July is available and in current use, and 44125af6 simply has not been picked up. The upstream Node issue was also stale-botted on 2026-07-20 and only stayed open because someone commented, so sitting on the libuv release chain has its own risk.

    Given that the trigger now includes any installer that replaces a PATH directory, and that the cost multiplies by the number of open windows, this looks worth pulling forward rather than waiting for libuv release, then Node, then Electron, then VS Code.

    Options

    Offered as options, not a prescription.

    1. Cherry-pick libuv/libuv@44125af6 into the Electron/Node runtime VS Code ships, the same way deps: cherry-pick libuv/libuv@e640dc9 nodejs/node#65118 picked e640dc9 on 2026-08-14. Smallest change, fixes the root cause for every watcher.
    2. Mirror the libuv check in nodejsWatcherLib.ts as an interim guard. Capture the watched root's file ID at watch time, and on a burst of root-level rename events resolve the handle's final path (or stat the root) and call notifyWatchFailed() if it no longer matches. That hands baseWatcher the signal it already waits for, and recovery then happens by itself once the replacement directory appears.
    3. Make terminal-suggest re-resolve PATH. This one is independent of the CPU bug and survives the libuv fix: once libuv starts reporting UV_ENOENT the spin stops, but nothing re-points the watcher at the new directory, so that PATH entry's completions stay stale until reload. Filed separately as terminal-suggest: PATH is resolved once at activation, so a replaced PATH directory stops updating completions until reload #331457.
    4. Deduplicate the PATH watch set across windows. It is identical in every window, so N windows register N copies of the same watches. Deduplicating them, or moving them to the shared process, cuts both the steady-state cost and the blast radius of any future watcher bug by the window count.
  10. rjgotten commented on Sep 2, 2026

    @rjgotten

    Being hit by this as well, and it makes things like collecting reliable benchmark figures via a run inside the VSCode terminal an exercise in futility. The numbers go all over the place due to one core being pegged at 100% utility by the spinning file watcher and reaching max thermal, followed by the entire CPU package downclocking.

    Microsoft - how about putting some pressure on Node.js to get the libuv fix through the pipelines and to end-users and integrators?
    Or god forbid- put your expertise to use on your own temporary fork of whatever dependencies require this fix to be transitively incorporated?

    "But it's not our bug to fix; it's upstream" is not something anyone should settle for, with an issue of this nature.
    This is putting real, potentially dangerously real wear on actual silicon! Remember 13th-14th gen Intel chips and their lovely failure rates under constant high pressure? What do you think a runaway core kept at 100% utility for hours on end, without ever letting up save for thermal protection kicking in, is going to do to those chips? Nothing good, I'd say...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugIssue identified by VS Code Team member as probable bugconfirmedIssue has been confirmed by VS Code Team memberfile-watcherFile watcherfreeze-slow-crash-leakVS Code crashing, performance, freeze and memory leak issuesnodejsNodeJS support issuesupstreamIssue identified as 'upstream' component related (exists outside of VS Code)

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions