anthropics / anthropics/claude-code

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

Abierto
#89,648 2 comentarios 0 reacciones 0 asignados Ver en GitHub
area:desktop bug has repro platform:windows
Lenguaje dominante
Python
Estrellas
145k
Forks
23.1k
Métricas de merge de PR
Métricas de PR pendientes

Descripción

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__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 (`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 `$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 | `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

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 /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).
2. **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).
3. **Make the watchdog reap first.** A force `app.exit(0)` with no child
teardown orphans helpers by definition.
4. **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."
5. **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`.

```diff
@@ 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:

```powershell
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.

Guía de contribución

No hay ninguna guía de contribución indexada para este repositorio

Línea de trabajo

Start with the beautified app.asar bundle at .vite/build/index.chunk-DWsVOCkp.js and trace the relaunch and quit entry points named in the issue: jM, qjt, MM, $Mt, QM, and the watchdog. Reproduce the MSIX relaunch race on Windows 11, then verify that helpers are gone before exit or relaunch and that a rapid relaunch no longer reports an executable-sharing violation.

Escrito por el modelo de indexación a partir del texto del issue.

Evaluación

Stack tecnológico
electron, javascript
Área
desktop, operating-systems
Tipo de issue
Error
Dificultad
5/5
Tiempo estimado
Más de una semana
Estado de actividad
Activo
Claridad
Bastante claro
Aptitud para principiantes
35/100

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.