[BUG] Windows MSIX: orphaned Claude.exe helper locks the executable across relaunch/quit (root cause + fix for #42776)
Follow-up on #42776 with a root-cause trace and a proposed fix. The original
report was auto-labeled invalid, I think only because the version field read
"Claude Desktop 1.1.9669.0" (not a parseable Claude Code version) — the bug
itself is real and reproducible. Reproduced and analyzed on:
- Claude Desktop 1.37937.0 (MSIX), Electron 42.10.0
- Windows 11 Enterprise 26220, x64
- Package family
Claude_pzs8sxrjxfjjc, AppIDClaude_pzs8sxrjxfjjc!Claude
All line references below are against the beautified main-process bundle
(app.asar → .vite/build/index.chunk-DWsVOCkp.js, run through js-beautify).
Module aliases in that bundle: t = node:child_process, n = node:path,o = electron, i = node:process, P = the logger, Zr() = the MSIX
install-kind detector.
Symptom
Close or Relaunch the app, then launch again a moment later. Windows throws
"Another program is currently using this file" pointing atC:\Program Files\WindowsApps\Claude_<ver>_x64__...\app\Claude.exe. A staleClaude.exe process is still alive and holding the image mapped; killing it via
Resource Monitor (or rebooting) clears it.
Root cause
Every Electron helper the app forks runs from the same Claude.exe image
inside WindowsApps, so each helper holds a section (image-mapping) handle on
that executable. The forked helpers include:
- MCP node hosts —
o.utilityProcess.fork(...)at 60816, typed
Utility / node.mojom.NodeService, named Claude Desktop MCP Node Host
(60830, confirmed again at 129236).
- A heavy-work worker at 99132 and a file-index worker (both
utilityProcess.fork) that are persistent and have no quit-time shutdown
handler registered at all.
The quit/relaunch orchestration:
$Mt()— onQuitCleanup, 105947–105996. Hides windows, runs theuE
cleanup array under Promise.allSettled with a 75 s abort, sets
XM = !0 (readyForQuit), then arms a watchdog: at +15 s (packaged) it logs
"Quit watchdog fired … forcing exit" and calls o.app.exit(0) (105983,
105987).
- Graceful relaunch (
Ncr→QM(!0)at 105933, and
window-all-closed → QM() at 228200) registers the relaunch via
jM() = o.app.relaunch(...) (105510) and then o.app.quit(), which
does run $Mt.
- Several paths skip
$Mtentirely by hard-exiting:
qjt() = jM(e), o.app.exit(0) (105515–105517),
MM() = await fg(); qjt() (105518), the preview-URL reload at
70958–70960, the dev-menu relaunch at 150583–150585, the oversized
deep-link relaunch at 227706–227708, and the auto-update
o.autoUpdater.quitAndInstall() at 105859.
The lock is a relaunch race. app.relaunch() schedules Electron's
relauncher, which waits only for the main PID before activating a newClaude.exe. Nothing waits for the child helpers. Chromium's job object does
reap the helpers when the main process dies, but that termination is
asynchronous — so the new MSIX instance can start (worst case, quitAndInstall
can begin overwriting the package) while old Claude.exe utility children still
hold the image mapped → ERROR_SHARING_VIOLATION → the dialog.
On the hard-exit paths and when the watchdog's app.exit(0) fires mid-teardown,
there's no child cleanup at all, so a helper can linger as a genuine persistent
orphan rather than just a transient racer. There's a second contributor: the_close() deferral around 61035 (killOrDeferToSpawn returns false) can
resolve an MCP node host's close promise without the process actually dead.
Ruled out
cowork-svc.exe, chrome-native-host.exe, and the cowork VM
(smol-bin.x64.vhdx) are separate binaries — they explain "extra processes
in Task Manager" but can't lock Claude.exe. chrome-native-host is
browser-launched (109632); cowork-vm-shutdown is a registered handler
(135385) but only runs on the graceful path. The only detached: true
spawns in the entire bundle are the Squirrel installer's Update.exe
(227170, 227174), and that whole function is gated off for MSIX byZr() at 227162. So the executable lock is unambiguously a lingeringClaude.exe Electron helper, not a detached sidecar.
Evidence
| What | Where |
|---|---|
| MCP node host fork | 60816 |
| MCP node host name string | 60830, 129236 |
| heavy-work worker fork (no shutdown handler) | 99132 |
| _close() deferral that can resolve before death | 61035 |
| onQuitCleanup + watchdog force-exit | 105947–105996 (app.exit(0) at 105983, 105987) |
| jM() = app.relaunch | 105510 |
| qjt() hard-exit (skips cleanup) | 105515–105517 |
| MM() hard-exit | 105518 |
| graceful relaunch QM(!0) | 105933 |
| window-all-closed → QM() | 228200 |
| other hard-exit relaunch paths | 70958, 150583, 227706 |
| auto-update quitAndInstall | 105859 |
| detached spawns are Squirrel-only, gated off for MSIX | 227162, 227170, 227174 |
The fix
- Reap helper children before any
relaunch/exit. Enumerate every
non-Browser entry from app.getAppMetrics() (each has .pid / .type)
and terminate it synchronously (taskkill /pid <pid> /T /F on Windows) so no
Claude.exe helper survives into the relaunch. /T also sweeps each
utility's own child tree (external stdio MCP servers).
- Route the hard-exit shortcuts (
qjt/MM, preview reload, dev-menu
relaunch, deep-link relaunch) through the graceful teardown, or at minimum
through the reap in (1).
- Make the watchdog reap first. A force
app.exit(0)with no child
teardown orphans helpers by definition.
- Give the heavy-work and file-index workers a shutdown handler, and fix
the killOrDeferToSpawn path (61035) so _close() doesn't resolve while
the process is only "deferred."
- Defense in depth for the mapping race: have the relauncher wait on child
PIDs (not just the main PID), and add a bounded retry/backoff on
ERROR_SHARING_VIOLATION at launch, so even a slow OS section release can't
surface the dialog.
Items 1–4 are pure JS-layer changes in the main bundle. Item 5 is the ideal
belt-and-suspenders but touches the native relauncher, which is yours to own.
Demonstration patch (JS layer, items 1–3)
Adds one module-scope helper and wires it into the four choke points: after
cleanup in $Mt (post-fg() flush, so no data loss), both watchdog exits, andqjt (which also covers MM, preview, and the dev relaunch). taskkill /F is a
synchronous kill, so by the time exit(0)/relaunch proceeds the helpers are
already gone — which also shrinks the mapping-release window that feeds item 5.
The patched bundle passes node --check.
@@ function definitions near line 105498 @@
return Yc(e) || nEe(e) || e.startsWith(`--os-entry=`) || n.default.isAbsolute(DM(e)) || e === `--relaunched-after-gpu-crash-loop` || e.startsWith(`${AM}=`)
}
+function nkt_reapHelperChildren() {
+ if (process.platform !== `win32`) return;
+ let self = process.pid,
+ pids = [];
+ try {
+ for (let m of o.app.getAppMetrics())
+ if (m.pid && m.pid !== self && m.type !== `Browser`) pids.push(m.pid)
+ } catch (e) {
+ P.warn(`reapHelperChildren: getAppMetrics failed: %o`, { error: e });
+ return
+ }
+ for (let pid of pids) try {
+ t.spawnSync(`taskkill`, [`/pid`, String(pid), `/T`, `/F`], { windowsHide: !0, timeout: 3e3 })
+ } catch (e) {
+ P.warn(`reapHelperChildren: taskkill %d failed: %o`, pid, { error: e })
+ }
+ pids.length && P.info(`reapHelperChildren: swept %d helper child(ren) before relaunch/exit`, pids.length)
+}
+
function jM(e = []) {
let t = process.defaultApp ? 2 : 1,
n = [...process.argv.slice(1, t), ...process.argv.slice(t).filter((e => !Kjt(e))), ...e];
@@ hard-exit relaunch, near line 105515 @@
function qjt(e = []) {
- jM(e), o.app.exit(0)
+ jM(e), nkt_reapHelperChildren(), o.app.exit(0)
}
async function MM(e = []) {
await fg(), qjt(e)
@@ onQuitCleanup + watchdog, near line 105976 @@
n.add(e.name), P.info(`Successfully run onQuitCleanup: %s`, e.name)
}
- })))]), P.info(`Successully ran all onQuitCleanup handlers, marking readyForQuit`), XM = !0;
+ })))]), P.info(`Successully ran all onQuitCleanup handlers, marking readyForQuit`), XM = !0, nkt_reapHelperChildren();
let e = o.app.isPackaged ? 15e3 : 5e3;
setTimeout((() => {
P.warn(`Quit watchdog fired after %dms \u2014 event loop still alive, forcing exit`, e);
- let t = setTimeout((() => o.app.exit(0)), 2e3);
+ let t = setTimeout((() => (nkt_reapHelperChildren(), o.app.exit(0))), 2e3);
t.unref(), Y(`desktop_quit_watchdog_fired`, {
timeout_ms: e
}).finally((() => {
- clearTimeout(t), o.app.exit(0)
+ clearTimeout(t), nkt_reapHelperChildren(), o.app.exit(0)
}))
}), e).unref()
} catch (e) {
(The diff is anchored on the beautified bundle; in your source tree these map to
the quit-orchestration module and the relaunch helpers — the point is where
the reap goes, not the minified identifiers.)
Note for anyone hitting this before it's fixed
You can't patch the installed app — it's a signed MSIX, every file includingapp.asar is hashed in AppxBlockMap.xml and covered by AppxSignature.p7x,
so editing the asar just makes the package refuse to launch. Until a fix ships,
the reliable workaround is to kill the stale process and relaunch by AppID
instead of rebooting:
Get-Process claude -EA SilentlyContinue |
Where-Object { $_.Path -like 'C:\Program Files\WindowsApps\Claude_*' } |
Stop-Process -Force
Start-Sleep -Milliseconds 500
explorer.exe "shell:AppsFolder\Claude_pzs8sxrjxfjjc!Claude"
Repro
- Open Claude Desktop (Windows, MSIX build).
- Trigger Relaunch (or close), so the main window goes away.
- Immediately try to launch again from Start / shortcut.
- "Another program is currently using this file" appears; a
Claude.exe
process is still resident in Task Manager holding the WindowsApps image.
3 Comments
Confirming this on Windows 11 (26200), MSIX build, and adding ~4 months of evidence that rules out the usual local suspects. The orphaned-helper explanation in this issue is the first one that survives all of it.
Recurrences: 2026-04-17, 2026-06-02, 2026-08-20, 2026-08-25. Every one immediately after a package swap; reboot always clears it. Dialog text is the generic shell one,
Another program is currently using this file, pointing atC:\Program Files\WindowsApps\Claude_<version>_x64__pzs8sxrjxfjjc\app\Claude.exe.Three local root causes tested and falsified:
Add-MpPreference -ExclusionPath "C:\Program Files\WindowsApps\Claude_*"on 2026-06-07. Verified still active in an elevated shell on 2026-08-21, i.e. it was in place during the 2026-08-20 lock. Made no difference. Exclusion has since been removed.HKLM\SOFTWARE\Policies\Microsoft\WindowsStore\AutoDownload = 2, verified surviving a reboot. The app still updated itself from1.34493.0.0to1.37937.0.0on 2026-08-25. Store policy does not gate this updater, which matches the "stealth update" reports in #89599 and #89680.fltmc filters(elevated) shows no third-party AV or EDR minifilter present at all: bindflt, UCPD, WdFilter, storqosflt, wcifs, gameflt, CldFlt, bfs, FileCrypt, luafv, UnionFS, npsvctrig, Wof, FileInfo.Why
handle.exemisleads people here: it reports no holders because it is being pointed at the new package path, while the orphans hold the old one. That false negative is what sent me chasing filter drivers for four months.One caveat on the workaround in this issue.
Get-Process claude | Stop-Process -Forceunfiltered is dangerous on any machine that also uses the CLI. Mine currently has 21 processes namedclaudeacross four distinct paths: the MSIX app, the npm-installed CLI, the VS Code extension's bundled binary, andAppData\Roaming\Claude\claude-code\<ver>\claude.exe. Killing all of them takes out live terminal sessions. The path filter in this issue's version is the load-bearing part and is worth keeping in any copy of the command:Operational impact worth weighting the fix by: the app hosts scheduled local tasks. When the lock hits, the already-running pre-update process keeps going and keeps logging, but every newly spawned task launch fails silently. On 2026-06-02 that killed a seven-task chain between 06:15 and 08:45 with no error surfaced anywhere. Nothing indicated the app was wedged until the missing output was noticed by hand hours later. A relaunch that fails on
ERROR_SHARING_VIOLATIONcurrently has no user-visible signal other than that shell dialog, which nobody sees on a headless or unattended run.Something I just realized might be relevant - I also have the Claude plugin installed in VSCode. Today I triggered the error in the desktop app by starting a new chat in VSCode. Now the desktop app won't start but when I restart VSCode I'm still able to use the Claude plugin for that.
So it's possible that either the VSCode plugin is interfering with the desktop app in some way or the bug in the desktop app also exists in the plugin.
I typically leave VSCode open in the background and it's probably been open every time I've encountered this issue. Closing VSCode doesn't allow the desktop app to start.
EDIT: Uninstalling the VSCode Claude plugin did not resolve the issue.
Automated workaround: window-close detection + process tree kill
I built a PowerShell watchdog that solves the relaunch-blocking problem described here. The key insight is that closing the Claude Desktop window does not exit the main Electron process -- it continues running headless with no tray icon -- so process-exit detection (
Wait-Process, Job Objects) cannot work from the outside. Instead, the watchdog polls every 500ms using Win32EnumWindows+IsWindowVisibleto detect when the window is destroyed, then force-terminates the full process tree (allclaude.exechildren plusconhost.exe,bash.exe,powershell.exedescendants). Minimized windows retainWS_VISIBLEand do not trigger cleanup.Runs as a hidden scheduled task at logon. Detection-to-kill latency is under 1 second.
Repo with install instructions: https://github.com/richardberman/KlodDeZombifier
Not a fix for the underlying Silo/Job Object issue, but eliminates the
ERROR_SHARING_VIOLATIONon restart entirely.