Skip to content

Windows: _pip_install's commit destroys existing deps packages while the MCP server is running (rmtree + os.replace vs locked .pyd) — armed on every session start when the ML stack is missing #97

Description

@mbe14

Environment: Windows 11 Pro (build 26200), Python 3.13.13, cortex plugin 4.13.0 installed through the marketplace. Same box as #91#95. Found while diagnosing the SessionStart charmap crash (#96).

Setup that arms the bug

The 4.13.0 plugin cache's active deps dir (~\.claude\plugins\cache\cortex-plugins\cortex\4.13.0\deps) came up with the base stack (~200 top-level entries) but without the ML stack (no sentence_transformers / torch / flashrank). Since session_start is the one entry point that calls _ensure_all_deps (scripts/launcher.py:303-304), every session start walks into _pip_install(deps_dir, ["sentence-transformers==5.4.1", "flashrank==0.2.10"]).

The bug

_pip_install's commit step (scripts/launcher.py:246-252) moves each top-level entry from the temp target into deps/:

for entry in os.listdir(tmp_dir):
    dest = os.path.join(deps_dir, entry)
    if os.path.isdir(dest):
        shutil.rmtree(dest, ignore_errors=True)
    elif os.path.exists(dest):
        os.remove(dest)
    os.replace(os.path.join(tmp_dir, entry), dest)

pip resolves numpy as a transitive dep of sentence-transformers, so tmp_dir contains a numpy entry whose dest already exists. Meanwhile the MCP server — spawned in parallel by Claude Code at the same session start — has numpy imported, so its .pyd files are mapped and locked. On Windows that means:

  1. shutil.rmtree(dest, ignore_errors=True) deletes everything in deps/numpy except the locked .pyd files (silent, ignore_errors=True),
  2. os.replace fails: PermissionError: [WinError 5] Access is denied: '...\deps.tmp-11640\numpy' -> '...\deps\numpy',
  3. the exception propagates and the finally: shutil.rmtree(tmp_dir, ignore_errors=True) (lines 253-254) deletes the freshly installed copy too.

Net result on my box: deps/numpy reduced to a husk — only _core/ and linalg/ (the dirs holding the locked .pyds) survived; numpy-2.5.1.dist-info and numpy.libs only survived because the loop crashed before reaching them alphabetically.

  File "...\cortex\4.13.0\scripts\launcher.py", line 304, in main
    _ensure_all_deps(deps_dir)
  File "...\cortex\4.13.0\scripts\launcher.py", line 262, in _ensure_all_deps
    _pip_install(
  File "...\cortex\4.13.0\scripts\launcher.py", line 252, in _pip_install
    os.replace(os.path.join(tmp_dir, entry), dest)
PermissionError: [WinError 5] Access is denied: '...\deps.tmp-11640\numpy' -> '...\deps\numpy'

Aftermath / blast radius

  • Every subsequent hook spawn finds numpy as a namespace husk; _importable's husk eviction can't fully delete it either (same locks), so each hook invocation re-attempts a multi-hundred-MB pip install and re-fails at the same os.replace — per prompt, for the rest of the session.
  • Entries alphabetically before numpy (e.g. flashrank, huggingface_hub, …) get silently replaced with whatever pip just resolved before the crash — partial, unversioned drift of shared deps.
  • Whether a given session start corrupts or succeeds is a race against the server importing numpy, so the failure is intermittent and easy to misattribute.

Suggested fixes (any subset)

  1. Idempotence guard: skip an entry when dest exists and its *.dist-info already satisfies the pin — shared transitive deps like numpy should never be touched at all.
  2. Non-destructive commit: copy-merge (no-clobber) instead of rmtree + os.replace, or rename dest aside and roll back on failure; and don't delete tmp_dir in finally when the commit failed — leaving it is exactly what makes the current failure unrecoverable.
  3. Take the ML-stack ensure out of the SessionStart hot path: a one-time bootstrap with a success stamp + file lock can't race the server boot.
  4. Cheaper presence probe: _importable("sentence_transformers", ...) imports torch inside the hook process just to answer "is it installed"; a dist-info check is enough.

Workaround used

Out-of-band pip install --target <staging> of numpy==2.5.1 sentence-transformers==5.4.1 flashrank==0.2.10, then a no-clobber copy into deps/ (locked files are same-version, so skipping them is safe). With the ML stack present, _ensure_all_deps is a no-op and the destructive path is unreachable.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions