From def007a5d461dbc649b5d8aabeea427c25c5be64 Mon Sep 17 00:00:00 2001 From: Finn763 <165816600+Finn763@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:23:28 +0800 Subject: [PATCH 1/2] fix(update): defer Windows runtime repair when the updater holds the venv On Windows, hermes update launched from the install's own venv can never complete the managed-runtime repair: Windows keeps the image of the updater's venv\Scripts\python.exe (and the waiting hermes.exe launcher ancestor) mapped until exit, so the park rename in _cut_over_candidate always fails with ERROR_ACCESS_DENIED. The pre-flight holder scan deliberately excludes the calling process and its ancestors, so the guard passes and the repair burns its retries on a structurally unwinnable rename - forever, since the failure is non-fatal and the 'next update will retry' message is misleading for this case. Detect the self-lock (sys.executable or a launcher ancestor inside the live venv) before provisioning and defer with actionable guidance instead of walking into the doomed cutover. The deferral happens pre-provisioning, so the incomplete generation-* leftovers the reporter observed are no longer produced. No-op off Windows: POSIX renames work while the updater maps the tree. Mirrors the existing _defer_update_for_self_lock pattern. Regression tests prove the fix bites: neutralized guard -> repair proceeds to provisioning (red); restored guard -> deferred before provisioning (green). Verified on Windows 11 against a real venv. Closes #93032 --- hermes_cli/managed_uv.py | 98 +++++++++++++++++++ tests/hermes_cli/test_managed_uv.py | 145 ++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+) diff --git a/hermes_cli/managed_uv.py b/hermes_cli/managed_uv.py index 8317b96ad8e2..704e74435c28 100644 --- a/hermes_cli/managed_uv.py +++ b/hermes_cli/managed_uv.py @@ -1108,6 +1108,76 @@ def _windows_runtime_holders() -> tuple[bool, str]: return False, "" +def _windows_runtime_self_lock(live: Path) -> tuple[bool, str]: + """Detect the one holder the generic scan above is blind to: THIS process. + + ``_detect_venv_python_processes`` excludes the calling process and its + ancestors on purpose — a CLI ``hermes update`` itself runs from the + venv python — which is correct for the dependency-sync path, where only + a *loaded* ``.pyd`` image blocks the rewrite and a fresh child process + dodges it. For the whole-venv park rename that exemption is fatal: + Windows keeps the image of any executable a running process was started + from mapped until that process exits, so a directory containing the + updater's own ``python.exe`` (or a waiting ``hermes.exe`` launcher + ancestor) can never be renamed from inside the updater. The retry loop + in ``_cut_over_candidate`` cannot help against that — the lock is + structural, not transient (#93032). + + No-op off Windows: POSIX renames work fine while this process maps + files from the renamed tree (open FDs and mmaps keep inodes alive). + """ + if platform.system() != "Windows": + return False, "" + try: + live_res = str(live.resolve()).lower().rstrip(os.sep) + os.sep + except OSError: + live_res = str(live).lower().rstrip(os.sep) + os.sep + + def _under_live(path_value: str | None) -> bool: + if not path_value: + return False + try: + resolved = str(Path(path_value).resolve()).lower() + except (OSError, ValueError): + resolved = str(path_value).lower() + return resolved.startswith(live_res) + + try: + exe = sys.executable + except Exception: + exe = None + if _under_live(exe): + return True, ( + f"the updater itself runs from the live venv it must replace " + f"({exe}); Windows cannot rename a directory while a process " + "executes from inside it" + ) + # Belt-and-braces: the venv\Scripts\hermes.exe launcher stays mapped + # while it waits for this child, so an ancestor started from the venv + # blocks the rename too. + try: + import psutil + + try: + parents = psutil.Process().parents() + except Exception: + parents = [] + for anc in parents: + try: + anc_exe = anc.exe() + except Exception: + continue + if _under_live(anc_exe): + return True, ( + f"ancestor process PID {anc.pid} runs from the live venv " + f"({anc_exe}); Windows cannot rename a directory while a " + "process executes from inside it" + ) + except Exception: + pass + return False, "" + + def _uv_version_string(uv_bin: str) -> str: """Return ``uv --version`` output, or ``""`` when it cannot be read.""" try: @@ -1276,6 +1346,34 @@ def repair_vulnerable_runtime( sqlite_before=current.sqlite_version_string, ) + self_locked, self_detail = _windows_runtime_self_lock(live) + if self_locked: + # Structural, not transient: this process maps the live venv's own + # executable, so the park rename fails the same way on every run and + # no number of retries converges. Defer BEFORE provisioning — a + # candidate staged for a cutover that can never run only leaks an + # incomplete generation (#93032). + print(f" ⚠ SQLite runtime repair deferred: {self_detail}.") + print( + " Retrying `hermes update` from inside this venv cannot help: " + "the mapped executable is released only when this process exits." + ) + print( + " To complete the repair, run the updater from an interpreter " + "that lives outside this venv, e.g.:" + ) + print(f" cd {root}") + print(" -m hermes_cli.main update") + print( + " Sessions stay protected meanwhile: Hermes keeps databases " + "out of WAL mode on this SQLite build." + ) + return RuntimeRepairResult( + "skipped", + self_detail, + sqlite_before=current.sqlite_version_string, + ) + runtime_root = root / _RUNTIME_DIR_NAME lock = _acquire_repair_lock(runtime_root) if lock is None: diff --git a/tests/hermes_cli/test_managed_uv.py b/tests/hermes_cli/test_managed_uv.py index 67b137ff50e4..1a5d337ca9a6 100644 --- a/tests/hermes_cli/test_managed_uv.py +++ b/tests/hermes_cli/test_managed_uv.py @@ -1354,3 +1354,148 @@ def _no_reload(module): # pragma: no cover - must not run expected = Path("/opt/hermes/venv/Scripts/python.exe") \ if sys.platform == "win32" else Path("/opt/hermes/venv/bin/python") assert _venv_python(Path("/opt/hermes/venv")) == expected + + + +class TestWindowsRuntimeSelfLock: + """The repair pre-flight must see the ONE holder the generic scan hides: + the updater itself (#93032). + + A CLI ``hermes update`` runs from the venv's own python, and + ``_detect_venv_python_processes`` excludes the calling process and its + ancestors on purpose (correct for the dependency-sync path). For the + whole-venv park rename that exemption is fatal on Windows: a directory + containing an executable mapped by a running process cannot be renamed, + so the cutover retries burn out against a lock that cannot be released + while the updater lives. The repair must detect the self-lock and defer + with honest guidance instead of provisioning a candidate for a doomed + rename. + """ + + def _checkout(self, tmp_path): + root, live, sentinel = _make_runtime_install(tmp_path) + # Windows-layout interpreter so sys.executable can point inside the + # live venv on any host (the detector only string-compares paths). + scripts_python = live / "Scripts" / "python.exe" + scripts_python.parent.mkdir(parents=True, exist_ok=True) + scripts_python.write_text("live interpreter", encoding="utf-8") + return root, live, sentinel, scripts_python + + def test_self_lock_defers_repair_before_provisioning( + self, tmp_path, monkeypatch, capsys + ): + """Regression for #93032: pre-fix, the repair walks straight into the + doomed rename (provisioning + cutover) whenever the updater itself + maps the live venv; the park then fails with WinError 5 and the user + gets the misleading 'next update will retry' message forever.""" + from hermes_cli import managed_uv + from hermes_cli.managed_uv import repair_vulnerable_runtime + + root, live, sentinel, scripts_python = self._checkout(tmp_path) + current = _runtime_info(scripts_python, (3, 50, 4)) + monkeypatch.setattr(managed_uv.platform, "system", lambda: "Windows") + monkeypatch.setattr(sys, "executable", str(scripts_python)) + + with patch( + "hermes_cli.managed_uv._windows_runtime_holders", + return_value=(False, ""), + ), \ + patch( + "hermes_cli.managed_uv.probe_sqlite_runtime", + return_value=current, + ), \ + patch( + "hermes_cli.managed_uv._install_safe_python_generation" + ) as mock_install: + result = repair_vulnerable_runtime("uv", project_root=root) + + assert result.status == "skipped" + assert "live venv" in result.detail + mock_install.assert_not_called(), ( + "a self-locked updater must not provision a candidate it can " + "never cut over" + ) + assert sentinel.read_text(encoding="utf-8") == "live" + assert not (root / ".hermes-runtime").exists() + + out = capsys.readouterr().out + assert "SQLite runtime repair deferred" in out + assert "will retry" not in out, ( + "the structural self-lock must not promise that retrying helps" + ) + assert "outside" in out, "the deferral must point at an escape hatch" + + def test_non_self_locked_repair_proceeds(self, tmp_path, monkeypatch): + """The guard must fail OPEN when the updater runs from outside the + venv — an always-firing deferral would recreate the never-converging + loop this fix removes (#86735 class).""" + from hermes_cli import managed_uv + from hermes_cli.managed_uv import repair_vulnerable_runtime + + root, live, sentinel, scripts_python = self._checkout(tmp_path) + current = _runtime_info(scripts_python, (3, 50, 4)) + monkeypatch.setattr(managed_uv.platform, "system", lambda: "Windows") + monkeypatch.setattr( + sys, "executable", str(tmp_path / "outside" / "python.exe") + ) + + with patch( + "hermes_cli.managed_uv._windows_runtime_holders", + return_value=(False, ""), + ), \ + patch( + "hermes_cli.managed_uv.probe_sqlite_runtime", + return_value=current, + ), \ + patch( + "hermes_cli.managed_uv._install_safe_python_generation", + return_value=None, + ) as mock_install: + result = repair_vulnerable_runtime("uv", project_root=root) + + assert result.status == "failed" + assert "provision" in result.detail + mock_install.assert_called_once() + assert sentinel.read_text(encoding="utf-8") == "live" + + def test_self_lock_is_a_noop_off_windows(self, tmp_path, monkeypatch): + """POSIX renames work while the updater maps the venv, so the guard + must stay Windows-only.""" + from hermes_cli import managed_uv + + root, live, sentinel, scripts_python = self._checkout(tmp_path) + monkeypatch.setattr(managed_uv.platform, "system", lambda: "Linux") + monkeypatch.setattr(sys, "executable", str(scripts_python)) + + locked, detail = managed_uv._windows_runtime_self_lock(live) + assert (locked, detail) == (False, "") + + def test_venv_launcher_ancestor_is_a_self_lock(self, tmp_path, monkeypatch): + r"""The venv\Scripts\hermes.exe shim stays mapped while it waits for + this child — an ancestor running from the venv blocks the rename too.""" + from hermes_cli import managed_uv + + root, live, sentinel, scripts_python = self._checkout(tmp_path) + monkeypatch.setattr(managed_uv.platform, "system", lambda: "Windows") + monkeypatch.setattr( + sys, "executable", str(tmp_path / "outside" / "python.exe") + ) + + class _FakeProc: + def __init__(self, pid, exe): + self.pid = pid + self._exe = exe + + def exe(self): + return self._exe + + fake_psutil = SimpleNamespace( + Process=lambda: SimpleNamespace( + parents=lambda: [_FakeProc(999, str(scripts_python))], + ), + ) + with patch.dict(sys.modules, {"psutil": fake_psutil}): + locked, detail = managed_uv._windows_runtime_self_lock(live) + + assert locked + assert "999" in detail From 0d4a63a7f0e5ec9a3b561d03fedd81f34483149d Mon Sep 17 00:00:00 2001 From: Finn763 <165816600+Finn763@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:13:49 +0800 Subject: [PATCH 2/2] test(managed_uv): make self-lock regression assert carry its message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous form was `mock_install.assert_not_called(), ("msg")` — a bare tuple expression whose parenthetical never surfaces as a failure message. Switch to `assert mock_install.call_count == 0, "msg"` so the diagnostic actually appears when the guard regresses (review feedback on #93163). --- tests/hermes_cli/test_managed_uv.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/hermes_cli/test_managed_uv.py b/tests/hermes_cli/test_managed_uv.py index 1a5d337ca9a6..a81fd52501d2 100644 --- a/tests/hermes_cli/test_managed_uv.py +++ b/tests/hermes_cli/test_managed_uv.py @@ -1411,7 +1411,7 @@ def test_self_lock_defers_repair_before_provisioning( assert result.status == "skipped" assert "live venv" in result.detail - mock_install.assert_not_called(), ( + assert mock_install.call_count == 0, ( "a self-locked updater must not provision a candidate it can " "never cut over" )