Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10033,6 +10033,96 @@ def _run_pre_update_backup(args) -> None:
print()


def _pause_windows_gateway_for_update(
gateway_mode: bool = False,
) -> dict[str, bool] | None:
"""Stop the current-profile Windows gateway before mutating the venv.

When the gateway is running from the same venv that ``hermes update``
is about to rewrite, imported extension modules such as
``tornado/speedups.pyd`` stay file-locked on Windows and the dependency
update fails with ``Access is denied``. Return a small resume token when
we actually stopped a live gateway so the caller can bring it back after
the update, or on early process exit via ``atexit``.
"""
if gateway_mode or not _is_windows():
return None

try:
from hermes_cli import gateway_windows
except Exception as exc:
logger.debug("Windows gateway pause skipped (import failed): %s", exc)
return None

try:
if not gateway_windows.is_installed():
return None
except Exception as exc:
logger.debug("Windows gateway pause skipped (install probe failed): %s", exc)
return None

try:
running_pids = list(gateway_windows._gateway_pids())
except Exception as exc:
logger.debug("Windows gateway pause skipped (PID probe failed): %s", exc)
return None

if not running_pids:
return None

print("→ Stopping Windows gateway before updating Python dependencies...")
try:
gateway_windows.stop()
except Exception as exc:
logger.debug("Windows gateway stop before update failed: %s", exc)
print(
" ⚠ Failed to stop the Windows gateway cleanly; continuing update."
)
print(
" If dependency install still hits 'Access is denied', run "
"`hermes gateway stop` and retry."
)
return None

return {"resume_needed": True}


def _resume_windows_gateway_after_update(
resume_token: dict[str, bool] | None,
) -> None:
"""Restart a Windows gateway that we paused for the update.

Idempotent: callers may invoke this explicitly at the end of a successful
update and also register it with ``atexit`` as an early-exit safety net.
"""
if not resume_token or not resume_token.get("resume_needed"):
return

resume_token["resume_needed"] = False
if not _is_windows():
return

try:
from hermes_cli import gateway_windows
except Exception as exc:
logger.debug("Windows gateway resume skipped (import failed): %s", exc)
return

try:
if gateway_windows._gateway_pids():
return
except Exception as exc:
logger.debug("Windows gateway resume PID probe failed: %s", exc)

print()
print("→ Restarting Windows gateway stopped for update...")
try:
gateway_windows.start()
except Exception as exc:
logger.debug("Windows gateway restart after update failed: %s", exc)
print(f" ⚠ Failed to restart the Windows gateway automatically: {exc}")


def _discard_lockfile_churn(git_cmd, repo_root):
"""Restore tracked ``package-lock.json`` files that npm dirtied locally.

Expand Down Expand Up @@ -10574,6 +10664,16 @@ def _cmd_update_impl(args, gateway_mode: bool):
# Reinstall Python dependencies. Prefer .[all], but if one optional extra
# breaks on this machine, keep base deps and reinstall the remaining extras
# individually so update does not silently strip working capabilities.
windows_gateway_resume = _pause_windows_gateway_for_update(
gateway_mode=gateway_mode
)
if windows_gateway_resume:
import atexit as _atexit

_atexit.register(
_resume_windows_gateway_after_update, windows_gateway_resume
)

print("→ Updating Python dependencies...")
from hermes_cli.managed_uv import ensure_uv, rebuild_venv, update_managed_uv

Expand Down Expand Up @@ -11489,6 +11589,8 @@ def _service_restart_sec(
except Exception as e:
logger.debug("Gateway restart during update failed: %s", e)

_resume_windows_gateway_after_update(windows_gateway_resume)

# Warn if legacy Hermes gateway unit files are still installed.
# When both hermes.service (from a pre-rename install) and the
# current hermes-gateway.service are enabled, they SIGTERM-fight
Expand Down
107 changes: 107 additions & 0 deletions tests/hermes_cli/test_update_concurrent_quarantine.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from __future__ import annotations

import os
import subprocess
import sys
import types
from pathlib import Path
Expand Down Expand Up @@ -118,6 +119,54 @@ def test_detect_concurrent_is_noop_off_windows(_winp, tmp_path):
assert cli_main._detect_concurrent_hermes_instances(tmp_path) == []


@patch.object(cli_main, "_is_windows", return_value=True)
def test_pause_windows_gateway_for_update_stops_running_gateway(_winp, monkeypatch, capsys):
"""Running Windows gateways must be stopped before venv package rewrites."""
import hermes_cli.gateway_windows as gateway_windows

calls = []
monkeypatch.setattr(gateway_windows, "is_installed", lambda: True)
monkeypatch.setattr(gateway_windows, "_gateway_pids", lambda: [4242])
monkeypatch.setattr(gateway_windows, "stop", lambda: calls.append("stop"))

token = cli_main._pause_windows_gateway_for_update(gateway_mode=False)

assert token == {"resume_needed": True}
assert calls == ["stop"]
assert "Stopping Windows gateway before updating Python dependencies" in capsys.readouterr().out


@patch.object(cli_main, "_is_windows", return_value=True)
def test_pause_windows_gateway_for_update_ignores_uninstalled_gateway(_winp, monkeypatch):
"""Do not stop/restart manual foreground runs outside the installed service path."""
import hermes_cli.gateway_windows as gateway_windows

monkeypatch.setattr(gateway_windows, "is_installed", lambda: False)
monkeypatch.setattr(gateway_windows, "_gateway_pids", lambda: [4242])
monkeypatch.setattr(gateway_windows, "stop", lambda: (_ for _ in ()).throw(AssertionError("stop should not run")))

assert cli_main._pause_windows_gateway_for_update(gateway_mode=False) is None


@patch.object(cli_main, "_is_windows", return_value=True)
def test_resume_windows_gateway_after_update_starts_only_when_still_stopped(_winp, monkeypatch, capsys):
"""Resume helper should restart once, then become a no-op."""
import hermes_cli.gateway_windows as gateway_windows

calls = []
pid_reads = iter([[], [777]])
monkeypatch.setattr(gateway_windows, "_gateway_pids", lambda: next(pid_reads))
monkeypatch.setattr(gateway_windows, "start", lambda: calls.append("start"))

token = {"resume_needed": True}
cli_main._resume_windows_gateway_after_update(token)
cli_main._resume_windows_gateway_after_update(token)

assert calls == ["start"]
assert token["resume_needed"] is False
assert "Restarting Windows gateway stopped for update" in capsys.readouterr().out


# ---------------------------------------------------------------------------
# Parent-chain exclusion (issue #30768 follow-up — the setuptools .exe
# launcher on Windows is a separate native process that spawns python.exe;
Expand Down Expand Up @@ -227,6 +276,64 @@ def test_detect_concurrent_still_finds_unrelated_other_hermes(_winp, tmp_path):
assert result == [(sibling_pid, "hermes.exe")]


@patch.object(cli_main, "_is_windows", return_value=True)
def test_cmd_update_pauses_windows_gateway_before_dependency_update(_winp, tmp_path):
"""The Windows gateway pause must happen before the venv/site-packages rewrite."""
project = tmp_path / "project"
(project / ".git").mkdir(parents=True)
args = SimpleNamespace(
check=False,
gateway=False,
yes=False,
force=False,
backup=False,
no_backup=True,
)

events = []

def fake_pause(gateway_mode=False):
events.append(("pause", gateway_mode))
return None

def fake_install(*args, **kwargs):
events.append(("deps", None))
raise RuntimeError("stop after dependency step")

with patch.object(cli_main, "PROJECT_ROOT", project), \
patch.object(cli_main, "_run_pre_update_backup"), \
patch.object(cli_main, "_discard_lockfile_churn"), \
patch.object(cli_main, "_get_origin_url", return_value="https://github.com/NousResearch/hermes-agent.git"), \
patch.object(cli_main, "_is_fork", return_value=False), \
patch.object(cli_main, "_capture_head_sha", return_value="abc123"), \
patch.object(cli_main, "_validate_critical_files_syntax", return_value=(True, None, None)), \
patch.object(cli_main, "_clear_bytecode_cache", return_value=0), \
patch.object(cli_main, "_sync_with_upstream_if_needed"), \
patch.object(cli_main, "_pause_windows_gateway_for_update", side_effect=fake_pause), \
patch.object(cli_main, "_install_python_dependencies_with_optional_fallback", side_effect=fake_install), \
patch("hermes_cli.backup.create_quick_snapshot", return_value="snap-1"), \
patch("hermes_cli.managed_uv.update_managed_uv"), \
patch("hermes_cli.managed_uv.ensure_uv", return_value=("/usr/bin/uv", False)), \
patch("hermes_cli.managed_uv.rebuild_venv", return_value=True), \
patch.object(cli_main.subprocess, "run") as mock_run:
def _fake_run(cmd, **kwargs):
joined = " ".join(str(c) for c in cmd)
if "rev-parse" in joined and "--abbrev-ref" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout="main\n", stderr="")
if "rev-parse" in joined and "--verify" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
if "rev-list" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout="1\n", stderr="")
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")

mock_run.side_effect = _fake_run

with pytest.raises(RuntimeError, match="stop after dependency step"):
cli_main._cmd_update_impl(args, gateway_mode=False)

assert events == [("pause", False), ("deps", None)]


@patch.object(cli_main, "_is_windows", return_value=True)
def test_detect_concurrent_parent_chain_walks_deep(_winp, tmp_path):
"""Multi-level ancestry (shell → launcher → python) is fully excluded."""
Expand Down
Loading