Skip to content

fix(windows): trampoline hermes update out of its own launcher - #86101

Closed
adamcap926 wants to merge 1 commit into
NousResearch:mainfrom
adamcap926:win-self-update-fix
Closed

fix(windows): trampoline hermes update out of its own launcher#86101
adamcap926 wants to merge 1 commit into
NousResearch:mainfrom
adamcap926:win-self-update-fix

Conversation

@adamcap926

Copy link
Copy Markdown
Contributor

The bug

On Windows, hermes update can never complete. It fails with the same error on the git path, the ZIP fallback, and every retry:

error: failed to remove file
       `...\venv\Lib\site-packages\../../Scripts/hermes.exe`:
       The process cannot access the file because it is being used by
       another process. (os error 32)

hermes is launched through a distlib console-script launcher. That launcher is not a thin redirect -- it spawns venv\Scripts\python.exe as a child, hands it the launcher's own path as the script, and blocks. Every invocation is two processes:

hermes.exe (pid A, holds Scripts\hermes.exe mapped as its running image)
  \_ python.exe (pid B, runs hermes_cli.main)

hermes update ends in uv pip install -e ., which rewrites the console-script shims -- including the one pid A has mapped. Windows refuses. Nothing pid B can do releases the lock, because the holder is its parent.

Why the existing mitigation could not work

_quarantine_running_hermes_exe renames the shim aside first, which is the right idea. But when the rename lost, it fell back to MoveFileExW(MOVEFILE_DELAY_UNTIL_REBOOT) and then continued as though the path were free.

It is not. That API moves nothing at call time -- it only appends the rename to PendingFileRenameOperations for next boot. The shim stays exactly where it was, so the install fails identically, the git path "fails", the ZIP fallback runs and fails the same way, and each attempt queues another registry entry.

The user-facing message The new shim was written at the same path is not true; nothing was written.

The part that bites later

Because no replacement shim is ever written, applying those queued entries at boot renames hermes.exe away and leaves nothing behind. A failed update silently removes hermes from PATH on the user's next restart, with nothing on screen connecting it to an update that failed days earlier.

On the machine this was found on, one failed update had queued four such entries.

The fix

1. Trampoline out of the launcher (new hermes_cli/win_self_update.py)

Before the update touches anything, re-launch it as a detached python -m hermes_cli.main grandchild and let pids B and A exit. The grandchild waits for the launcher to disappear, then updates against an ordinary unlocked file.

hermes.exe (A) --> python.exe (B) --> python.exe (C, detached)
     exit             exit              waits for A, then updates

Hooked into cmd_update before the update lock is acquired -- a trampolining parent that had already written the marker would release it moments later while the grandchild ran unlocked. Placed after --check, which installs nothing.

stdin is DEVNULL so the detached updater cannot race the reclaimed shell for keystrokes; that also selects the existing non-interactive path, so local changes follow updates.non_interactive_local_changes (default stash) instead of asking a question nobody can answer.

2. Stop treating a reboot-deferred rename as success

_quarantine_running_hermes_exe now raises ShimQuarantineError. _run_quarantined_install rolls back shims it already moved, and the user gets a remedy instead of a traceback ending in an opaque uv error.

3. Detect the landmine already queued on affected machines

pending_shim_renames() reads PendingFileRenameOperations and warns before each update. tools/clear-pending-shim-renames.ps1 clears the entries pairwise, preserving unrelated pending operations -- the usual advice of clearing the whole key breaks Windows Update, Edge, and AV products.

Verification

Reproduced and fixed on Windows 11, Python 3.11.15, uv, non-elevated.

Before: four consecutive failures, four queued registry entries, hermes.exe due to be orphaned at next boot.

After, running hermes update through the shim:

-> Handing off to a detached updater (PID 15012) so hermes.exe can be replaced.
...
-> Fetching updates...
   Currently on branch '...' -- switching to main for update...
-> Local changes detected -- stashing before update...
-> Restoring local changes...
Already up to date!
   Refreshed Windows gateway launcher scripts
   Starting Windows gateway after update

Launcher exits immediately, the grandchild completes the update, zero os error 32, and no new PendingFileRenameOperations entries queued.

Non-Windows is unaffected: maybe_trampoline returns False immediately off Windows, and POSIX replaces a running executable's inode atomically.

Note for maintainers

One adjacent issue surfaced during testing and is not addressed here: the gateway's supervisor respawns it inside the pause-to-guard window, so _detect_venv_python_processes trips on a gateway the updater itself just paused, and the update refuses until the user runs hermes gateway stop manually. It fails safe, so I left it alone rather than widen this PR.


Generated with Claude Code

https://claude.ai/code/session_01FGLv7LyNrpaPm4ciGqhyxD

@alt-glitch alt-glitch added type/bug Something isn't working comp/cli CLI entry point, hermes_cli/, setup wizard platform/windows Native Windows-specific behavior or breakage area/install-update Installer, updater, packaging, wheels, doctor P2 Medium — degraded but workaround exists sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows labels Aug 14, 2026
On Windows `hermes update` can never complete. `hermes` is launched
through a distlib console-script launcher, which is not a thin redirect:
it spawns `venv\Scripts\python.exe` as a child, hands it the launcher's
own path as the script, and blocks. Every invocation is two processes:

    hermes.exe (pid A, holds Scripts\hermes.exe as its running image)
      \_ python.exe (pid B, runs hermes_cli.main)

The update ends in `uv pip install -e .`, which rewrites the console-
script shims -- including the one pid A has mapped. Windows refuses to
replace a running image, so uv fails with "The process cannot access the
file because it is being used by another process. (os error 32)". The
git path fails, the ZIP fallback fails identically, and no retry can
succeed: nothing pid B does releases the lock, because the holder is its
parent.

Remove the hazard rather than race it. Before the update touches
anything, re-launch it as a detached `python -m hermes_cli.main`
grandchild and let pids B and A exit. The grandchild waits for the
launcher to disappear, then updates against an ordinary unlocked file:

    hermes.exe (A) --> python.exe (B) --> python.exe (C, detached)
         exit             exit              waits for A, then updates

Hooked into cmd_update after --check (which installs nothing) and before
the update lock is acquired: a trampolining parent that had already
written the lock marker would release it moments later while the
grandchild ran unlocked, since the grandchild's acquire() sees its
still-live parent as an ancestor and would run under a claim about to
vanish.

stdin is DEVNULL so the detached updater cannot race the reclaimed shell
for keystrokes. That also selects the existing non-interactive path, so
local changes follow updates.non_interactive_local_changes (default
stash) rather than prompting where nobody can answer.

Off Windows maybe_trampoline() returns False immediately and behavior is
unchanged; POSIX replaces a running executable's inode atomically.

Verified on Windows 11 / Python 3.11.15 / uv, non-elevated: before, four
consecutive failed updates; after, the launcher exits, the grandchild
completes the update, and no os error 32 occurs on either path.

Scoped deliberately to the trampoline. The residual case where another
process holds a shim, and cleanup of the reboot-deferred rename entries
that case queues, are addressed by NousResearch#68821 and NousResearch#85942 respectively and
are not duplicated here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGLv7LyNrpaPm4ciGqhyxD
@adamcap926
adamcap926 force-pushed the win-self-update-fix branch from 3a6fa7a to c7b26a8 Compare August 14, 2026 14:36
@adamcap926

Copy link
Copy Markdown
Contributor Author

Narrowed this PR after searching for overlapping work. It was three fixes in one; two of them duplicated open PRs, so I've dropped those and left only the part nothing else covers.

Now: 2 files, +357/-0 -- hermes_cli/win_self_update.py plus a hook in cmd_update. No deletions, no edits to existing logic, so it should compose cleanly with the PRs below rather than conflict in main.py.

What I dropped and why

What's left, and why it isn't covered elsewhere

The CLI self-lock itself. hermes update runs as a child of the hermes.exe console-script launcher, so the launcher holds the very shim uv must rewrite -- and no retry, quarantine or cleanup can succeed while that process is alive, because the lock holder is the parent of the Python process doing the work.

#85679 applies the same insight ("drive the hand-off through the venv python, not the hermes.exe shim") but only in scripts/desktop-update/windows.ps1, so the CLI path is untouched by it. I searched for CLI-side re-exec machinery across open PRs and found none -- happy to be pointed at one I missed.

Worth noting the three are complementary rather than competing: this removes the cause, #68821 handles the residual case where a genuinely foreign process holds a shim, and #85942 cleans up what earlier versions already queued on affected machines. All three are needed for a machine that has already hit this.

The previous, wider version of this branch is preserved locally if any of the dropped material is wanted here after all.

@OutThisLife

Copy link
Copy Markdown
Collaborator

Superseded by #86326, which takes the same off-the-shim approach and adds a source-level regression guard. Credited you as co-author there. Thanks for the fix and the thorough write-up.

@Halldrix

Copy link
Copy Markdown
Contributor

Validated on a real Windows 11 host. The trampoline works end-to-end under a
real shim rewrite, and the PendingFileRenameOperations analysis in this
PR's body checks out against a live system.

Validation performed:

  1. Rebased this branch onto current origin/mainclean, no conflicts.

  2. Built a venv and ran a real update from the console-script shim, with a
    local commit forcing uv pip install -e . to actually rewrite the shim:

    venv\Scripts\hermes.exe update --yes --branch main
    

    The trampoline fired exactly as designed:

    ✓ Handing off to a detached updater (PID 15832) so hermes.exe can be replaced.
      This shell returns to a prompt immediately; update output continues below...
    

    The process tree matched the PR's model precisely:
    hermes.exe (launcher, exited) → venv\Scripts\python.exe -m hermes_cli.main update (trampolined grandchild) → uv-base python.exe -m hermes_cli.main update (the detached updater that did the work).

    The update ran to completion (✓ Code updated! / ✓ Update complete!, 82
    bundled skills synced) and uv pip install -e . really rewrote
    venv\Scripts\hermes.exe
    — the shim's SHA-256 changed and it came through
    intact. Zero self-lock markers throughout:

    Marker (present pre-fix) Under this PR
    Could not quarantine hermes.exe absent
    os error 32 (being used by another process) absent
    os error 5 (Access is denied) absent

    This is exactly the rewrite step that fails with os error 32 on main
    today (bug(desktop): Desktop client broken after last 2 updates on Windows — backend exits (1), cannot restart itself, WinError 32 lock chain #86223, Windows: hermes update self-locks cryptography._rust.pyd — the updater process itself holds the .pyd mapped; any cryptography bump fails with os error 5 (no gateway/desktop required) #83569, @AsLSX99's three reproductions).

On the PendingFileRenameOperations landmine: this host's key currently
holds 44 queued entries — every one of them a legitimate third-party
operation (EdgeUpdate, Microsoft Office TxF, GamingServices). That is exactly
why "clear the whole key" advice is dangerous: it would break Windows Update /
Edge / Office here. Your pending_shim_renames() reads and warns
selectively, and tools/clear-pending-shim-renames.ps1 clears pairwise while
preserving unrelated entries — confirmed correct against the live data, not
just in principle.

Complement, not duplicate, of #85679: this fixes the CLI path
(cmd_update → trampoline). #85679 fixes the Desktop hand-off
(windows.ps1python.exe entrypoint). Verified the two rebase cleanly
onto each other (git merge-tree, no conflicts); both doors into the same
self-lock, both needed.

One edge note from testing (not a bug in this PR): maybe_trampoline
correctly returns False when the venv lives at .venv\ rather than venv\
(_venv_scripts_dir() returns None → the "no venv Scripts dir resolved"
guard at line 271). Real installs use venv\ so this is fine; just noting the
guard's dependence on the venv dir name in case a plain uv venv-created
layout ever needs covering.

Happy to run further scenarios on the Windows host if useful.

@adamcap926

Copy link
Copy Markdown
Contributor Author

Thanks for the credit on #86326, and @Halldrix thank you for the validation run \u2014 that was a real Windows host doing a real shim rewrite, which is not a cheap thing to set up.

One scope note before this closes for good, and I may be misreading it, so please correct me if so.

#86326 changes scripts/desktop-update/windows.ps1 and its test. #86101 changed hermes_cli/main.py and added hermes_cli/win_self_update.py. Those are two different update paths: the Desktop app's PowerShell updater, and hermes update typed into a terminal. The off-the-shim insight is the same, but as merged it is applied only to the former.

Checking current origin/main (7d96537):

hermes_cli/main.py:
  win_self_update            0 occurrences
  maybe_trampoline           0 occurrences
  CREATE_NEW_PROCESS_GROUP   0 occurrences
  _schedule_replace_on_reboot  still called at L8414
  MOVEFILE_DELAY_UNTIL_REBOOT  6 occurrences

So on current main the CLI path still has both halves of the original problem:

  1. hermes update still runs as a child of venv\Scripts\hermes.exe, which holds the shim uv must rewrite. The two-process launcher chain is unchanged, so uv pip install -e . still fails with os error 32 \u2014 on the git path and the ZIP fallback alike.
  2. _quarantine_running_hermes_exe still falls back to MoveFileExW(MOVEFILE_DELAY_UNTIL_REBOOT) and continues as though the path were free. It is not: that call moves nothing at call time, it only queues the rename for next boot. The install fails identically, and because no replacement shim is ever written, applying those queued entries at boot renames hermes.exe away and leaves nothing behind \u2014 hermes drops off PATH on a later restart with nothing connecting it to the failed update.

Repro on Windows, unelevated, no desktop app involved:

venv\Scripts\hermes.exe update

with anything that forces uv to actually rewrite the shim. On the machine this came from, one failed run queued four PendingFileRenameOperations entries.

I am not asking to reverse the merge \u2014 #86326 is a good fix for the path it covers. The question is just whether the CLI path is considered covered by it. If it isn't, I'm happy to either have this reopened or to file a fresh PR against current main (the branch rebases cleanly, as @Halldrix found). Whichever you prefer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/install-update Installer, updater, packaging, wheels, doctor comp/cli CLI entry point, hermes_cli/, setup wizard P2 Medium — degraded but workaround exists platform/windows Native Windows-specific behavior or breakage sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants