[BUG] Windows MSIX: orphaned Claude.exe helper locks the executable across relaunch/quit (root cause + fix for #42776)

Status Open
Maintainer reply None cached
Activity 2 comments · opened Aug 25, 2026

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, AppID Claude_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 at
C:\Program Files\WindowsApps\Claude_<ver>_x64__...\app\Claude.exe. A stale
Claude.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 the uE

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 (NcrQM(!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 $Mt entirely 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 new
Claude.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 by
Zr() at 227162. So the executable lock is unambiguously a lingering
Claude.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 | 105947105996 (app.exit(0) at 105983, 105987) |
| jM() = app.relaunch | 105510 |
| qjt() hard-exit (skips cleanup) | 105515105517 |
| 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

  1. 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).

  1. 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).

  1. Make the watchdog reap first. A force app.exit(0) with no child

teardown orphans helpers by definition.

  1. 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."

  1. 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, and
qjt (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 including
app.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

  1. Open Claude Desktop (Windows, MSIX build).
  2. Trigger Relaunch (or close), so the main window goes away.
  3. Immediately try to launch again from Start / shortcut.
  4. "Another program is currently using this file" appears; a Claude.exe

process is still resident in Task Manager holding the WindowsApps image.

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗