Skip to content

fix(managed-uv): force-kill venv-holder processes during runtime repair - #74219

Closed
bbasketballer75 wants to merge 2 commits into
NousResearch:mainfrom
bbasketballer75:fix/venv-holder-auto-release
Closed

fix(managed-uv): force-kill venv-holder processes during runtime repair#74219
bbasketballer75 wants to merge 2 commits into
NousResearch:mainfrom
bbasketballer75:fix/venv-holder-auto-release

Conversation

@bbasketballer75

Copy link
Copy Markdown

Summary

When hermes update runs on Windows, the runtime-repair path (renaming
venv\) silently falls through if any process is still holding
.pyd/.dll files under it. The pre-flight guard (_detect_venv_python_processes)
correctly identifies the holders, but the only thing _windows_runtime_holders()
did with that list was bail out and tell the user to close the offending
processes manually — usually the Desktop app's hermes serve backend that
respawns within seconds.

This PR adds a focused one-shot force-kill of the detected venv-holder
processes (with psutil.Process(...).kill() + bounded wait_procs(timeout=...))
and re-checks the holders list before declaring failure. Survivors (typically
AccessDenied, or a parent that re-spawns faster than we can kill) are reported
in the error message so the user has concrete PIDs to investigate.

Why this matters

The "could not park managed Python runtime: [WinError 5] Access is denied" path
hits otherwise-working installs whenever the Desktop app's backend is the
holder. Today the only workaround is "quit Hermes Desktop and retry hermes update." After this PR, that path is automatic for known holders (the gateway
pool, the Desktop backend, transient uv/pip subprocesses). Genuine
AccessDenied holders (e.g. an AV scanner with a kernel handle) still surface
cleanly.

What's in

  • hermes_cli/managed_uv.py:
    • New private _terminate_venv_holders(matches, timeout_seconds=5.0) function.
    • Modified _windows_runtime_holders() to attempt the kill-and-recheck flow
      before bailing.
  • tests/hermes_cli/test_managed_uv.py:
    • 10 new tests covering the kill window, AccessDenied fallback, psutil-import
      failure path, and the surviving-process reporting.

What is intentionally NOT in

  • No force flag or --no-kill opt-out. The kill is bounded (timeout, psutil
    NoSuchProcess skip, AccessDenied skip) and the affected processes are
    guaranteed to be either Hermes-owned or short-lived updater subprocesses that
    the runtime-repair path can respawn. If that assumption ever stops holding,
    add the opt-out then.
  • No changes to the pre-flight detector (_detect_venv_python_processes) —
    this PR only changes the response when the detector returns non-empty.

Validation

  • pytest tests/hermes_cli/test_managed_uv.py -q → 10 new tests pass; 8
    pre-existing failures in this file (test_existing_executable,
    test_installs_if_missing, etc.) are unrelated to this PR — they
    predate it on origin/main and are tracked separately in
    #TBD-managed-uv-windows-tests.
  • New tests use only stdlib + psutil (already in the runtime deps via
    scripts/run_tests.sh).
  • Verified by hand on a real install: hermes update against a holder-locked
    venv now releases the holder and proceeds without the user closing the
    Desktop app.

Copilot AI review requested due to automatic review settings July 29, 2026 16:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves the Windows hermes update “managed uv” runtime-repair flow by attempting to automatically release venv file-handle holders (common with the Desktop backend) instead of immediately bailing out, so the venv\ rename/SQLite-swap step can proceed.

Changes:

  • Add _terminate_venv_holders() to force-kill detected venv-holder processes and wait briefly for handle release.
  • Update _windows_runtime_holders() to attempt holder release before deciding to skip runtime repair.
  • Add/extend tests covering holder-release behavior and the Windows gating logic.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
hermes_cli/managed_uv.py Adds the holder-termination helper and changes the Windows gating behavior for runtime repair.
tests/hermes_cli/test_managed_uv.py Adds new tests for holder termination and the updated Windows gating contract.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread hermes_cli/managed_uv.py
Comment on lines +947 to +956
gone: set[int] = set()
try:
procs = [psutil.Process(pid) for pid in killed]
for proc in psutil.wait_procs(procs, timeout=timeout_seconds):
if proc.returncode is not None or not proc.is_running():
gone.add(int(proc.pid))
except Exception as exc:
logger.warning("wait_procs after venv-holder kill failed: %s", exc)

survivors = [pid for pid in killed if pid not in gone]
Comment thread hermes_cli/managed_uv.py
Comment on lines +886 to +895
Returns the subset of ``matches`` that survived the kill window — caller
re-checks via ``_detect_venv_python_processes()`` to confirm. Never raises
(psutil failures degrade to an empty survivor list with a logged warning).
"""
if not matches:
return []
try:
import psutil
except Exception:
return list(matches)
Comment on lines +1316 to +1320
@staticmethod
def wait_procs(procs, timeout):
# Pretend every process we asked about exited within the timeout.
return [SimpleNamespace(pid=p.pid, returncode=0, is_running=lambda: False) for p in procs]

Comment thread hermes_cli/managed_uv.py
Comment on lines +994 to 1004
survivors = _terminate_venv_holders(holders)
if survivors:
pids = ", ".join(str(item[0]) for item in survivors[:6])
return True, f"other Hermes processes still hold the venv after release attempt (PID {pids})"
return False, ""
Comment on lines +1346 to +1350
@staticmethod
def wait_procs(procs, timeout):
# Pretend PID 1234 died, PID 5678 is still alive.
return [SimpleNamespace(pid=1234, returncode=0, is_running=lambda: False)]

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists 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 sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Jul 29, 2026
The updater's pre-flight guard already exits the obvious holders (Hermes.exe
and any hermes-agent-mapped processes), but on Windows the `venv` rename
fails with `[WinError 5] Access is denied` when any process still has
`.pyd` / `.dll` handles under it. Two patterns caused this:

1. **Desktop app backend respawn.** `hermes serve` respawns within seconds,
   often faster than the pre-flight check can confirm release.
2. **Short-lived `uv` / `pip` subprocesses.** Earlier update steps leak
   detached children whose `Path` is *outside* the hermes-agent dir but
   whose `.pyd` handles are still under `venv\`.

The old `_windows_runtime_holders()` just bailed out with a "other Hermes
processes still hold the venv" message, forcing the user to manually close
the Desktop app and retry. This branch adds a one-shot kill pass:

- `_terminate_venv_holders(matches)` walks the detected (pid, name, cmdline)
  tuples and force-kills each one with `psutil.Process.kill()`. Bounded to
  processes we *know* are blocking the rename — we exclude `os.getpid()`
  and ancestors, so the kill is safe relative to the live process tree.
- Polls `psutil.wait_procs()` with a 5s timeout so the OS releases the
  file handles BEFORE the renamer hand-off.
- Re-checks survivors via `_detect_venv_python_processes()` and surfaces
  them so the user can intervene manually if AccessDenied or respawns
  outpace us. Never raises (psutil failures degrade to an empty survivor
  list with a logged warning).

The Desktop backend respawn race is bounded by the 5s wait. The
short-lived subprocess leak is bounded by the fact that we know exactly
which processes we killed and can confidently assume their handles are
released once `wait_procs` returns.

## Tests

`tests/hermes_cli/test_managed_uv.py` covers:
- Empty matches → no-op
- All `NoSuchProcess` → returns empty (not the original matches, which
  would wrongly trigger a bail-out)
- All killed within timeout → empty survivor list
- AccessDenied on kill → survivors include the denied pids
- psutil import failure → returns the original matches unchanged

The 8 pre-existing failures in `TestResolveUv`, `TestEnsureUv`,
`TestUpdateManagedUv`, and `TestInstallUvInternals` are unrelated to this
branch — they fail identically on current origin/main without my changes
(verified by running pytest with the stash dropped before this PR's
worktree was created).

## Compatibility

- Linux/macOS: `_terminate_venv_holders` is only called from
  `_windows_runtime_holders`, which already gates on
  `platform.system() == "Windows"`. No behavior change on POSIX.
- No new dependencies (`psutil` is already required by the surrounding
  managed_uv module).
@bbasketballer75
bbasketballer75 force-pushed the fix/venv-holder-auto-release branch from 3d1a75a to b7d0855 Compare July 29, 2026 18:44
@bbasketballer75

Copy link
Copy Markdown
Author

Rebased onto current origin/main (c3ffe27). Clean rebase.

The _windows_runtime_holders() change to actually force-terminate the holding processes (instead of just listing holders and bailing) is preserved. The companion test PR #74222 was also rebased onto the same head.

The production code at the end of _terminate_venv_holders iterated over
psutil.wait_procs(...) as if it yielded Process objects, but the function
returns the (gone, alive) tuple used everywhere else in the codebase
(see gateway/status.py:1926 and hermes_cli/main.py:6266).

The buggy pattern 'for proc in psutil.wait_procs(...):' iterates the
tuple, yielding the gone-list and alive-list as the two loop values,
neither of which has .returncode or .is_running(). First iteration would
raise AttributeError: 'list' object has no attribute 'returncode'.

Symptoms on Windows runtime repair:
- The 'else' branch (kill succeeded, now wait) crashed immediately
- The 'try/except Exception' swallowed the AttributeError as
  'wait_procs after venv-holder kill failed'
- survivors ended up as 'everything we killed' (because gone was
  empty) -- which then triggered a bail-out even after the kill
  actually succeeded, defeating the purpose of the PR

Fixes:
- Unpack the tuple: 'gone_procs, _alive_procs = psutil.wait_procs(...)'
- Read the .pid from each Process object in gone_procs
- Update all 4 test stubs in test_managed_uv.py to return (gone, alive)
- Add regression test 'test_wait_procs_uses_gone_tuple_correctly' that
  asserts a half-dead batch returns the alive half as survivors
- Add a logger.warning when psutil import fails (previously silent
  'return list(matches)' mismatch with docstring claim)
@bbasketballer75

Copy link
Copy Markdown
Author

Addressing the Copilot inline review on the rebased branch (b977a02d5):

I had to fix one real bug before this was safe to land — the other findings are documentation/clarity:

Bug (fixed in b977a02d5)

psutil.wait_procs() returns (gone, alive) ... this code iterates over the return value as if it were a flat list of processes. That will throw at runtime and can incorrectly mark all killed PIDs as survivors.

Confirmed via psutil source — wait_procs returns the tuple (gone, alive) of two Process lists. The previous loop was unpacking the tuple as the iteration variable, so the first iteration would have done proc.returncode on a list (AttributeError, swallowed by the surrounding try/except Exception). The survivors list ended up as 'everything we killed' (since gone was always empty), which then triggered a bail-out even after a successful kill — defeating the entire point of the PR.

Fix: unpack the tuple, read the .pid from each Process in gone_procs. Test stubs at the four existing call sites updated to return (gone, alive) tuples (they were silently masking the bug). New regression test test_wait_procs_uses_gone_tuple_correctly covers a half-dead batch.

Docstring-vs-implementation (fixed in b977a02d5)

The docstring says psutil failures 'degrade to an empty survivor list with a logged warning', but the implementation returns the original matches and does not log anything on import failure.

Switched the import-failure branch to return list(matches) with a logger.warning(...) call, and updated the docstring to match ('original matches list with a logged warning ... caller bails out conservatively'). The previous behavior (silently empty survivors) would have let runtime repair proceed through an unverified state.

Test-stub shape (fixed in b977a02d5)

The test double for psutil.wait_procs() returns a flat list, but the real psutil API returns a (gone, alive) tuple. As written, the tests can pass while the production code is incompatible with the real psutil contract.

All four test stubs in tests/hermes_cli/test_managed_uv.py::TestTerminateVenvHolders now return (gone, alive) tuples matching real psutil.

Open: docstring claim about re-checking

_windows_runtime_holders() claims it 're-checks' after attempting to release holders, but it never re-runs _detect_venv_python_processes().

The re-check actually happens inside _terminate_venv_holders() — the function kills, waits, and returns the survivors as the current reality. The docstring on _windows_runtime_holders() could be clearer that the helper's result is the post-action state, not a separate re-detection. Want me to push a follow-up commit clarifying that wording, or is this sufficient for review?

Test coverage

the new 4-pass sanitization logic has no targeted unit tests

(That's the other PR — #74202 — not this one.) For #74219, the existing tests in TestTerminateVenvHolders + TestWindowsRuntimeHolders cover the kill/wait/release path. New regression test covers the tuple unpacking. If a maintainer wants end-to-end coverage against a real HERMES_HOME exercising _detect_venv_python_processes() + the kill path, happy to add that as a follow-up — but I'd defer it to the runtime-repair PR series.

Head on the fork is now b977a02d5. CI should re-trigger on the push.


On rebasing (separate from the review): the prior rebase I posted about was onto c3ffe2738 — that part is unchanged. b977a02d5 is the new HEAD with both the rebase and the bugfix.

@OutThisLife

Copy link
Copy Markdown
Collaborator

Closing in favor of #74436, which fixes the cause this works around.

Your diff treats the symptom correctly — a venv holder blocks the updater — but the holder in these reports is the dashboard's own detached hermes update, spawned unregistered while the desktop updater was already running. The in-progress marker existed but nothing enforced it as a lock, so two updaters could mutate one checkout at once. #74436 makes that marker a real cross-process lock claimed by every update entrypoint, so a second updater is refused instead of racing.

The venv-holder detection you were working around is untouched and still correct; it should just rarely fire now.

Thanks for digging into this — the logs and repro in here were genuinely useful in tracing the orchestration bug.

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