Windows: Claude Desktop Node service leaks one headless ConPTY (conhost.exe) per PowerShell tool call and never releases it — unbounded, ~300 MB/hour

Status Open
Reported on v2.1.218
Maintainer reply None cached
Activity 1 comment · opened Jul 25, 2026

Description

On Windows, the Claude Desktop Electron Node service
(claude.exe --type=utility --utility-sub-type=node.mojom.NodeService) creates a headless
ConPTY (conhost.exe) per PowerShell tool call and never releases it — not when the
shell exits, not when the session ends. The powershell.exe children do exit normally; the
pseudoconsoles they were created for do not. The result is a population of ConPTYs with no
shell to host, which grows for as long as the app runs.

This is unbounded, not a fixed overhead. I measured it twice against the same process,
83 minutes apart, and it nearly doubled with no sign of a ceiling.

The accumulation is activity-gated, not time-gated, which makes it easy to misread as
bounded: when no PowerShell tool calls are happening the count is perfectly flat, so a short
observation window during an idle period looks exactly like a plateau. It is not one — the
count resumes climbing the moment work resumes.

Environment

  • Claude Code: 2.1.218
  • Claude Desktop: 1.24012.9.0 (Store-packaged, Claude_1.24012.9.0_x64)
  • OS: Windows 11 Pro 10.0.22631
  • Shell: Windows PowerShell 5.1 (powershell.exe)

Evidence

1. Same process, 83 minutes apart — the count nearly doubled.

Both measurements are of one continuous process lifetime (same PID, start time confirmed to
the second, no restart and no PID reuse):

| process age | conhosts held | conhost working set |
|---|---|---|
| 17.7 min | 75 | 497 MB |
| 100.7 min | 143 | 884 MB |

+91% conhosts, +78% RSS. Sustained growth ~49 conhosts/hour, rising to ~84/hour
during a busy period, at ~6.2 MB each — roughly 300 MB/hour of unreclaimable working
set. The Node service's own working set also grew 255 MB → 380 MB over 32 minutes, for
~1.26 GB total attributable at the end.

Sampled every ~5 minutes across a busy 31-minute window:

process_age_min  conhosts  conhost_rss_MB
        17.7        75          497
        21          78            -
        68.7        98          617
        74.7       107          675
        79.7       112          705
        84.8       126          796
        90.0       128          806
        95.0       137          852
       100.1       142          878
       100.7       143          884

2. The conhosts vastly outnumber the shells. At the 143-conhost sample the same Node
service had only 7 live powershell.exe children and 0 bash children — so ~136
pseudoconsoles with nothing to host. All are headless ConPTY.

Only this one process is affected. Other Node-service processes hold 0 conhosts; every
other claude.exe holds exactly 1.

3. Nothing is ever released — retention is 100%. Cohorts matched on conhost PID against
the live set:

cohort of 107 taken 26 min earlier : 107 alive (100%)
cohort of 112 taken 21 min earlier : 112 alive (100%)
cohort of 126 taken 16 min earlier : 126 alive (100%)

At every sample the oldest conhost's age equalled the Node service's own age — the first
console the process ever created was still alive at 100.7 minutes. Combined with zero
observed departures, this process has never released a single conhost in its life.

4. Controlled A/B — the PowerShell tool causes it, the Bash tool does not. Same subject
process, back-to-back windows, identical background load:

| arm | calls | new conhosts | released |
|---|---|---|---|
| 5 × PowerShell tool | 5 | +4 | 0 |
| 5 × Bash tool | 5 | 0 | 0 |

Arrivals are ~1:1 with PowerShell tool calls. The Bash tool's consoles are released normally
— in one interval, fleet-wide conhost.exe fell 286 → 282 while this one process rose
128 → 137.

5. Why it can look bounded. Because nothing is released, each conhost's creation
timestamp is a permanent arrival record, so one snapshot reconstructs the whole history.
Arrivals per 10 minutes of process life:

 0-10 min : 65   (post-boot ramp)
10-20 min : 12
20-30 min :  8
30-40 min :  5
40-50 min :  2
50-60 min :  0   <- idle period; looks exactly like a ceiling
60-69 min :  6   <- work resumes, so does growth

A genuinely bounded process would refuse new conhosts, or release old ones, *while calls
keep arriving*. Neither happens.

Impact

  • ~300 MB/hour of unreclaimable working set under normal PowerShell-tool usage, scaling with

how many calls the app makes rather than with elapsed time.

  • Extrapolating the sustained rate, an 8-hour session reaches roughly 390 conhosts / ~2.4 GB;

at the busier rate, ~600-670 conhosts / ~3.7-4.1 GB.

  • Also consumes handles and PIDs at the same rate.
  • The only reclaim is restarting Claude Desktop. There is no user-side mitigation: the pty is

created and owned by the app, so a PreToolUse hook cannot close a handle the app holds.

Expected behavior

The ConPTY created for a PowerShell tool call should be closed when that call's shell exits,
the same way the Bash tool's consoles already are.

Relationship to #80510

Related but, I believe, a distinct defect. #80510 reports powershell.exe + conhost.exe
accumulating as 1:1 pairs (16 shells, 16 consoles) because the AST-parser shells
themselves never terminate. Here the ratio is 143 conhosts : 7 live shells — the shells
do exit and the consoles survive them. Different signature, different app version
(Desktop 1.24012.9.0 / Code 2.1.218 vs 2.1.200), and possibly a different code path, though
the two may share a root cause in the Node service's pty handling. Happy to have this folded
into #80510 if maintainers judge them the same underlying bug.

Reproduction

  1. On Windows, note the Electron Node service PID:

Get-CimInstance Win32_Process | Where-Object CommandLine -match 'node\.mojom\.NodeService'

  1. Count its conhost.exe children and its live powershell.exe children (join

Win32_Process on ParentProcessIdGet-Process alone gives no parentage).

  1. Make N PowerShell tool calls. The conhost count rises by ~N; the shell count returns to

baseline.

  1. Re-count after any interval. The conhost count never decreases.

Useful discriminator: compare the oldest child conhost's age against the parent's own
age. If they match, that parent has never released one — a much sharper test than cohort
survival over a short window.

Measurements were taken via the Bash tool (powershell.exe -NoProfile -File ... under
bash.exe, whose consoles are released) so the probe did not perturb its own subject. The
only PowerShell tool calls in the window were the 5 deliberate A/B calls, subtracted from all
quoted rates. Nothing was killed or restarted.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗