diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a7b0a97b41f..c97705da11e 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7027,10 +7027,10 @@ def _recover_from_interrupted_install() -> None: " Restart Hermes from a different terminal, " "then run the manual recovery command below:" ) - print(f" cd {PROJECT_ROOT}") + print(f' cd /d "{PROJECT_ROOT}"') print( - f" {sys.executable} -m pip install " - "-e '.[all]'" + f' "{sys.executable}" -m pip install ' + '-e ".[all]"' ) _clear_update_incomplete_marker() try: diff --git a/scripts/release.py b/scripts/release.py index 2ef65144e1b..200192d6dad 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -168,6 +168,7 @@ AUTHOR_MAP = { "290859878+synapsesx@users.noreply.github.com": "synapsesx", "157689911+itsflownium@users.noreply.github.com": "itsflownium", "dirtyren@users.noreply.github.com": "dirtyren", + "97326386+Icather@users.noreply.github.com": "Icather", # PR #45554 salvage (self-lock guard breaks Windows update-recovery infinite loop; #52378 / #45542) "--email": "andryypaez@gmail.com", "mucio@mucio.net": "francescomucio", "291572938+thestral123@users.noreply.github.com": "thestral123", diff --git a/tests/hermes_cli/test_update_interrupted_recovery.py b/tests/hermes_cli/test_update_interrupted_recovery.py index aed84f71c80..6b4aacc8bb4 100644 --- a/tests/hermes_cli/test_update_interrupted_recovery.py +++ b/tests/hermes_cli/test_update_interrupted_recovery.py @@ -139,6 +139,90 @@ def _stub_install_env(monkeypatch, m, seen): ) +def test_recovery_self_lock_guard_clears_marker_without_install(tmp_path, monkeypatch): + # Windows self-lock: hermes.exe is an ancestor of this Python process, so a + # pip-install would fail trying to replace the running launcher (WinError 32 + # / 拒绝访问). Recovery must short-circuit — clear the marker, skip install, + # break the loop (#45542 / #52378) — instead of retrying forever. + monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path) + (tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n") + m._write_update_incomplete_marker() + + scripts_dir = tmp_path / "venv" / "Scripts" + scripts_dir.mkdir(parents=True) + shim = scripts_dir / "hermes.exe" + shim.write_text("") + + monkeypatch.setattr(m, "_is_windows", lambda: True) + monkeypatch.setattr(m, "_venv_scripts_dir", lambda: scripts_dir) + monkeypatch.setattr(m, "_hermes_exe_shims", lambda d: [shim]) + + class FakeProc: + def __init__(self, exe_path): + self._exe = exe_path + + def exe(self): + return self._exe + + def parents(self): + return [FakeProc(str(shim))] + + monkeypatch.setattr("psutil.Process", lambda: FakeProc(sys_executable_path())) + + seen = {"install": False} + _stub_install_env(monkeypatch, m, seen) + + m._recover_from_interrupted_install() + + assert seen["install"] is False, "self-lock must skip the install" + assert not m._update_marker_path().exists(), "marker cleared to break the loop" + + +def sys_executable_path(): + import sys + + return sys.executable + + +def test_recovery_self_lock_guard_inactive_when_not_ancestor(tmp_path, monkeypatch): + # Windows, but hermes.exe is NOT in the ancestry (launched via `hermes + # dashboard` from a separate cmd, say). The guard must fall through to the + # normal install so a genuinely interrupted install still gets healed. + monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path) + (tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n") + m._write_update_incomplete_marker() + + scripts_dir = tmp_path / "venv" / "Scripts" + scripts_dir.mkdir(parents=True) + shim = scripts_dir / "hermes.exe" + shim.write_text("") + + monkeypatch.setattr(m, "_is_windows", lambda: True) + monkeypatch.setattr(m, "_venv_scripts_dir", lambda: scripts_dir) + monkeypatch.setattr(m, "_hermes_exe_shims", lambda d: [shim]) + + class FakeProc: + def __init__(self, exe_path): + self._exe = exe_path + + def exe(self): + return self._exe + + def parents(self): + # Ancestry is plain pythons / cmd — no hermes.exe shim. + return [FakeProc(str(tmp_path / "cmd.exe"))] + + monkeypatch.setattr("psutil.Process", lambda: FakeProc(sys_executable_path())) + + seen = {"install": False} + _stub_install_env(monkeypatch, m, seen) + + m._recover_from_interrupted_install() + + assert seen["install"] is True, "without self-lock, normal recovery runs" + assert not m._update_marker_path().exists(), "marker cleared on successful install" + + def test_recovery_skips_when_lock_held(tmp_path, monkeypatch): # Another process is mid-recovery (fresh lockfile) — this launch must skip # the install entirely and leave both marker and lock untouched.