[BUG] Desktop app (Windows): main process pinned at 100% of a core by git watches left on deleted worktrees (archive re-attaches the watch, the worktree pool then deletes the folder)

Status Fixed / completed
Reported on v2.1.275
Maintainer reply None cached
Activity 0 comments · opened Sep 21, 2026 · closed Sep 22, 2026

Preflight Checklist

  • [x] I searched existing issues. Related, not the same: #77983 (same libuv loop hit by the CLI through ~/.claude/tasks, closed as stale on 2026-09-04), #88913 (VS Code extension host, open), nodejs/node#61398 (open).
  • [x] This is a single bug report.
  • [ ] Desktop app 2.2553.1 is the build I could test. The bundled CLI is 2.1.275; the latest published CLI is 2.1.278. I could not confirm a newer desktop build exists.

What's Wrong?

The Claude desktop app gets slower through the day until every click lags. The app's main process sits at about 99% of one core with nothing running.

The cause is GitWatchService. It keeps fs.watch handles open on the git folders of sessions that are already archived. Once the worktree pool deletes those worktrees, every one of those handles points into \$Extend\$Deleted\. libuv on Windows then loops on each of them forever, on the Electron main thread.

On my machine there were 8 such handles and the main thread was at 99%. Unarchiving and re-archiving the affected sessions closed them: 8 handles went to 0 and the main thread went from 99% to 2%, with no restart.

How it happens

Read from the 2.2553.1 bundle, resources/app.asar → .vite/build/index.chunk-BQEs5Gzg.js. Names are the minified ones.

  1. Archiving a warm session detaches the watch. teardownSession("archive") → stopPageScopedWork → gitWatch.detachSession(sessionId). This part is correct.
  2. About 2 seconds later the watch is re-attached. The session's query iterator completes and teardownQuery() runs. It ends with:

``js
this.sessions.get(e.sessionId)===e&&this.syncGitWatch(e)
``

There is no archived check. syncGitWatch → gitWatch.syncSession(sessionId, [worktreePath, harnessCwd]) re-attaches watches on <repo>\.git, <repo>\.git\logs, <repo>\.git\worktrees\<name> and <repo>\.git\worktrees\<name>\logs.

  1. The worktree pool deletes the folder. WorktreePool releases the worktree and later reaps it ("Reaping idle worktree", then "Removed worktree"). That deletes .git\worktrees\<name> while the app still holds watch handles on it and on its logs subfolder.
  2. libuv loops on the deleted folder (src/win/fs-event.c):
  • ReadDirectoryChangesW returns ERROR_ACCESS_DENIED.
  • GetFileInformationByHandleEx(FileStandardInfo) reports Directory && DeletePending.
  • The callback fires with UV_RENAME and status 0, and the request is re-queued immediately.

No error ever reaches JavaScript, so the app's watcher.on("error", …) → teardownRepo handler never runs. Each event also resets the scheduleFire debounce, so the debounced work never runs either: the only visible effect is CPU.

Each reaped worktree adds 2 stuck handles, and they accumulate until the app restarts.

What Should Happen?

  • Archiving a session detaches its git watch for good.
  • Deleting a worktree never leaves a watch open on it.
  • A watch on a deleted folder ends, with an error or a close, instead of spinning.

The main process should stay near idle when nothing is running.

Error Messages/Logs

App log (%LOCALAPPDATA%\Claude\Logs\main.log, local time UTC+3) for one session. Worktree and session IDs are shortened.

09:46:15 [AutoArchiveEngine] Archiving local_2a8c… (PRs …; worktree epic-saha-75da92) (PR merged)
09:46:15 Archived session local_2a8c…
09:46:17 Session local_2a8c… query iterator completed            <- git watch re-attached here
09:46:29 [WorktreePool] Released worktree epic-saha-75da92 to pool
10:44:20 [WorktreePool] Reaping idle worktree epic-saha-75da92
10:44:24 Removed worktree "epic-saha-75da92"                      <- the spin starts here
21:11:43 Unarchived session local_2a8c…
21:11:48 Archived session local_2a8c…                             <- handles closed, spin gone

That one session spun for about 10.5 hours.

Handles, read with NtQueryInformationProcess(ProcessHandleInformation) on the Claude main process: 8 File handles with access 0x00100081 (FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | SYNCHRONIZE, the fs.watch signature). Each one's final path is \\?\F:\$Extend\$Deleted\<24 hex digits>. Their NTFS file IDs match the IDs of .git\worktrees\<name> and .git\worktrees\<name>\logs recorded before the reap. Nothing is logged while it spins.

Steps to Reproduce

A. The underlying loop, in plain Node with no Claude involved (Windows, NTFS):

// repro-delete-watched-dir.mjs
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

const dir = fs.mkdtempSync(path.join(os.tmpdir(), "watch-delete-"));
let events = 0;
let errors = 0;
const watcher = fs.watch(dir, () => { events++; });
watcher.on("error", (e) => { errors++; console.log("error event:", e.code); });

setTimeout(() => {
  fs.rmSync(dir, { recursive: true, force: true });
  const cpu0 = process.cpuUsage();
  const t0 = Date.now();
  setTimeout(() => {
    const cpu = process.cpuUsage(cpu0);
    const ms = Date.now() - t0;
    console.log(`node ${process.version}, libuv ${process.versions.uv}`);
    console.log(`folder still exists: ${fs.existsSync(dir)}`);
    console.log(`events after delete: ${events} in ${ms} ms, error events: ${errors}`);
    console.log(`CPU: ${Math.round(((cpu.user + cpu.system) / 1000 / ms) * 100)}% of one core`);
    process.exit(0);
  }, 5000);
}, 500);

Output on this machine:

node v24.15.0, libuv 1.51.0
folder still exists: false
events after delete: 326375 in 5000 ms, error events: 0
CPU: 97% of one core

B. In the desktop app:

  1. Start a Code session in worktree mode and let it finish one turn.
  2. Archive it while it is still warm (before the 900 s idle pause), or let AutoArchive archive it when its PR merges.
  3. Wait for the worktree pool to reap the worktree (1 to 9 hours here), or have it removed.
  4. The app's main process goes to 100% of one core and stays there. Its handle table shows watch handles into \$Extend\$Deleted\.

Archiving a session whose CLI has already paused does not leak. Only the warm-archive path does, because only that path runs teardownQuery after the detach.

Claude Model

Not model-related.

Is this a regression?

Not known.

Last Working Version

Not known.

Claude Code Version

  • Desktop app 2.2553.1 (MSIX Claude_2.2553.1.0_x64__pzs8sxrjxfjjc), Electron 44.2.0 (Node v24.20.0)
  • Bundled Claude Code CLI 2.1.275

Platform

Anthropic API

Operating System

Windows 11 Home 10.0.26200, x64, NTFS

Terminal/Shell

Claude desktop app, Code tab. Many concurrent worktree sessions, with AutoArchive on (archives when the PR merges).

Additional Information

Suggested fixes (any one of the first three stops the leak)

  1. teardownQuery: skip syncGitWatch when the session is archived. This is the root cause of the leak.
  2. WorktreePool: detach every git watch on a worktree before reaping or removing it.
  3. GitWatchService: end the watch when its own root disappears. The debounce never fires during the storm, so the check has to sit on the event path, throttled. For example, on a rename event, at most once a second, check that the watched folder still exists and call teardownRepo if it does not.
  4. Detach on hide or pause as well. setSessionVisibility attaches the watch when a session becomes visible but never detaches it when the session is hidden.
  5. Runtime: take libuv/libuv#5013 (commit 44125af6, "win: fix watch loop logic", merged 2026-03-31).
  • With that fix, a watch on a folder under \$Extend\$Deleted\ ends with a UV_ENOENT error. The app's existing on("error") → teardownRepo handler would then clean it up by itself.
  • The fix is in no libuv release yet: v1.52.1 predates it. Node main still vendors libuv 1.52.1 without it.
  • Electron 44.2.0 ships Node v24.20.0 and carries no patch for it, so a cherry-pick into the bundled Node is the only near-term way to get it.

Workaround for users

For each affected session, unarchive it and then archive it again. The handles close at once, with no restart. Restarting the app also clears them.

View original on GitHub ↗