fix(update): let the Windows repoint run; refuse a minor-line jump
The self-lock/holder preflight (#99711) deferred the repair on the theory that the updater's own mapped python.exe makes the venv rename impossible. Live on windows-latest, a process executing from venv\Scripts\python.exe (and the repo's real .venv with cp311 .pyd extensions loaded) does NOT block the rename; what blocks it with WinError 5 is any ordinary handle under the tree: a process cwd, an open file, a sync client. Since sys.executable is always under the venv on Windows, the deferral fired on every run and no Windows install could repair from `hermes update`. Retire it and its tests; the pyvenv.cfg repoint needs no rename and has none of that exposure. The wine2e lane now runs the cutover probes instead. The repoint keeps the live venv's site-packages, so provisioning's fall-forward to the next minor (3.11 -> 3.12, #76106) must not be pointed at a cp311 tree: refuse before touching pyvenv.cfg. The live Windows test holds the venv the way the field does (a child with cwd inside it), asserts the rename path fails (the symptom) and the repoint succeeds; the rollback and minor-guard tests run on every host.
This commit is contained in:
2
.github/workflows/windows-venv-e2e.yml
vendored
2
.github/workflows/windows-venv-e2e.yml
vendored
@@ -60,7 +60,7 @@ jobs:
|
||||
tests/hermes_cli/test_venv_holder_windows_live.py \
|
||||
tests/hermes_cli/test_taskkill_identity_windows_live.py \
|
||||
tests/hermes_cli/test_git_trampoline_windows_live.py \
|
||||
"tests/hermes_cli/test_managed_uv.py::TestWindowsRuntimeSelfLock" \
|
||||
tests/hermes_cli/test_managed_uv_windows_cutover.py \
|
||||
-o addopts= -v -p no:cacheprovider
|
||||
|
||||
- name: Run Telegram CLOSE-WAIT reconnect live E2E (#87057)
|
||||
|
||||
@@ -5,8 +5,8 @@ is shared. Runtime repair therefore uses an install-scoped store under
|
||||
``<checkout>/.hermes-runtime/python``. A vulnerable interpreter is never reinstalled in place: a
|
||||
new immutable Python generation is provisioned and a relocatable sibling venv built and smoke-tested
|
||||
from it. POSIX installs cut over with same-filesystem directory renames; Windows installs
|
||||
atomically repoint the live venv's ``pyvenv.cfg`` at the new generation instead, because the
|
||||
updater itself executes from that venv and Windows refuses to rename it.
|
||||
atomically repoint the live venv's ``pyvenv.cfg`` at the new generation instead, because any
|
||||
open handle under the venv (cwd, open file, sync client) makes Windows refuse the rename.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -779,18 +779,33 @@ def _cut_over_windows_runtime_config(
|
||||
candidate: Path,
|
||||
*,
|
||||
live: Path,
|
||||
current: SQLiteRuntimeInfo,
|
||||
candidate_info: SQLiteRuntimeInfo,
|
||||
) -> tuple[bool, bool, SQLiteRuntimeInfo | None, str]:
|
||||
"""Repoint a live Windows venv without renaming its locked directory.
|
||||
"""Repoint a live Windows venv instead of renaming its directory.
|
||||
|
||||
Windows refuses to rename a directory while any handle is open inside it: a process whose
|
||||
cwd is under the venv, an open file, an Explorer window, a sync client (OneDrive) or a
|
||||
scanner. The updater cannot enumerate those holders, and the park rename in
|
||||
``_cut_over_candidate`` failed with ``WinError 5`` in the field on every retry (#93032).
|
||||
Mapped executable images do NOT block the rename (proven live on windows-latest), so the
|
||||
updater running from the venv was never the problem.
|
||||
|
||||
``venv\\Scripts\\python.exe`` is a launcher that reads ``home`` from ``pyvenv.cfg`` on every
|
||||
start, so replacing that text file redirects every fresh process to the candidate generation
|
||||
while the updater keeps executing from the directory Windows refuses to rename (#93032).
|
||||
start, so atomically replacing that one file redirects every fresh process to the candidate
|
||||
generation with no directory rename at all.
|
||||
|
||||
The live venv keeps its own ``site-packages``, so the candidate must stay on the same
|
||||
``major.minor`` line: compiled extensions built for one minor do not import under the next.
|
||||
|
||||
The second return value reports whether the live config still references the candidate
|
||||
generation. Callers must preserve that generation if a failed smoke test could not restore
|
||||
the original config.
|
||||
"""
|
||||
if current.python_version[:2] != candidate_info.python_version[:2]:
|
||||
return False, False, None, (
|
||||
f"a Python {_dotted(candidate_info.python_version[:2])} runtime cannot be repointed "
|
||||
f"under a {_dotted(current.python_version[:2])} venv's site-packages")
|
||||
live_config = live / "pyvenv.cfg"
|
||||
candidate_config = candidate / "pyvenv.cfg"
|
||||
try:
|
||||
@@ -867,72 +882,6 @@ def _release_repair_lock(lock: _RepairLock) -> None:
|
||||
|
||||
|
||||
|
||||
def _windows_runtime_holders() -> tuple[bool, str]:
|
||||
if platform.system() != "Windows":
|
||||
return False, ""
|
||||
main_module = sys.modules.get("hermes_cli.main")
|
||||
detector = getattr(main_module, "_detect_venv_python_processes", None)
|
||||
if detector is None:
|
||||
return True, "cannot verify Windows venv holders from this update context"
|
||||
try:
|
||||
holders = detector()
|
||||
except Exception as exc:
|
||||
return True, f"could not verify Windows venv holders: {exc}"
|
||||
if holders:
|
||||
pids = ", ".join(str(item[0]) for item in holders[:6])
|
||||
return True, f"other Hermes processes still hold the venv (PID {pids})"
|
||||
return False, ""
|
||||
|
||||
|
||||
def _windows_runtime_self_lock(live: Path) -> tuple[bool, str]:
|
||||
"""Detect the one holder the generic scan is blind to: THIS process.
|
||||
|
||||
``_detect_venv_python_processes`` excludes the calling process and its ancestors on purpose
|
||||
(``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 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).
|
||||
"""
|
||||
if platform.system() != "Windows":
|
||||
return False, ""
|
||||
try:
|
||||
live_res = str(live.resolve())
|
||||
except OSError:
|
||||
live_res = str(live)
|
||||
live_res = live_res.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)
|
||||
|
||||
why = "Windows cannot rename a directory while a process executes from inside it"
|
||||
exe = sys.executable
|
||||
if _under_live(exe):
|
||||
return True, f"the updater itself runs from the live venv it must replace ({exe}); {why}"
|
||||
# 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.
|
||||
with contextlib.suppress(Exception):
|
||||
import psutil
|
||||
for anc in psutil.Process().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 ({anc_exe}); {why}")
|
||||
return False, ""
|
||||
|
||||
|
||||
def _uv_version_string(uv_bin: str) -> str:
|
||||
"""Return ``uv --version`` output, or ``""`` when it cannot be read."""
|
||||
try:
|
||||
@@ -1017,34 +966,6 @@ def _result(
|
||||
|
||||
|
||||
|
||||
def _repair_windows_preflight(
|
||||
root: Path, live: Path, current: SQLiteRuntimeInfo) -> RuntimeRepairResult | None:
|
||||
"""Defer the repair when Windows holders make the venv rename impossible; else ``None``."""
|
||||
blocked, detail = _windows_runtime_holders()
|
||||
if blocked:
|
||||
print(f" ⚠ SQLite runtime repair deferred: {detail}")
|
||||
return _result("skipped", current, detail)
|
||||
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 identically on every run. Defer BEFORE provisioning — a candidate
|
||||
# staged for a cutover that can never run only leaks an incomplete generation.
|
||||
for line in (
|
||||
f" ⚠ SQLite runtime repair deferred: {self_detail}.",
|
||||
# See #93032.
|
||||
" Retrying `hermes update` from inside this venv cannot help: "
|
||||
"the mapped executable is released only when this process exits.",
|
||||
" To complete the repair, run the updater from an interpreter "
|
||||
"that lives outside this venv, e.g.:",
|
||||
f" cd {root}",
|
||||
" <system Python> -m hermes_cli.main update",
|
||||
" Sessions stay protected meanwhile: Hermes keeps databases "
|
||||
"out of WAL mode on this SQLite build."):
|
||||
print(line)
|
||||
return _result("skipped", current, self_detail)
|
||||
return None
|
||||
|
||||
|
||||
def _repair_under_lock(
|
||||
uv_bin: str, *, root: Path, live: Path, live_python: Path, runtime_root: Path
|
||||
) -> RuntimeRepairResult:
|
||||
@@ -1084,7 +1005,7 @@ def _repair_under_lock(
|
||||
generation_in_use = False
|
||||
if platform.system() == "Windows":
|
||||
cut_over, generation_in_use, final_info, cutover_detail = _cut_over_windows_runtime_config(
|
||||
candidate, live=live)
|
||||
candidate, live=live, current=current, candidate_info=candidate_info)
|
||||
else:
|
||||
cut_over, backup, final_info, cutover_detail = _cut_over_candidate(
|
||||
candidate, project_root=root, live=live)
|
||||
@@ -1116,8 +1037,8 @@ def repair_vulnerable_runtime(
|
||||
|
||||
Every failure before cutover leaves the live venv untouched. POSIX cuts over with directory
|
||||
renames and restores the parked venv synchronously on failure; Windows repoints the live
|
||||
venv's ``pyvenv.cfg`` instead (the updater runs from that venv, so the directory can never
|
||||
be renamed) and restores the original config on a failed smoke.
|
||||
venv's ``pyvenv.cfg`` instead (any open handle under the venv makes a directory rename fail
|
||||
there) and restores the original config on a failed smoke.
|
||||
"""
|
||||
root = Path(project_root) if project_root is not None else _PROJECT_ROOT
|
||||
live = Path(venv_dir) if venv_dir is not None else _default_live_venv(root)
|
||||
@@ -1134,9 +1055,6 @@ def repair_vulnerable_runtime(
|
||||
# See #73109.
|
||||
_sweep_stale_runtime_backups(live, root=root)
|
||||
return _result("safe", current, sqlite_after=current.sqlite_version_string)
|
||||
deferred = _repair_windows_preflight(root, live, current)
|
||||
if deferred is not None:
|
||||
return deferred
|
||||
runtime_root = root / _RUNTIME_DIR_NAME
|
||||
lock = _acquire_repair_lock(runtime_root)
|
||||
if lock is None:
|
||||
|
||||
@@ -1479,148 +1479,3 @@ class TestVenvPythonUpdateBoundary:
|
||||
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
|
||||
assert mock_install.call_count == 0, (
|
||||
"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
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
"""Native-Windows coverage for managed runtime cutover."""
|
||||
"""Windows runtime cutover repoints ``pyvenv.cfg`` instead of renaming the live venv (#93032).
|
||||
|
||||
Windows refuses to rename a directory while any handle is open under it (a process cwd, an open
|
||||
file, a sync client); the POSIX park-rename then fails with ``WinError 5``. Replacing the venv's
|
||||
``pyvenv.cfg`` redirects every fresh process to the new generation with no directory rename.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,63 +11,65 @@ import subprocess
|
||||
import venv
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.sqlite_runtime import SQLiteRuntimeInfo
|
||||
|
||||
|
||||
def _python(venv_dir: Path) -> Path:
|
||||
return venv_dir / "Scripts" / "python.exe"
|
||||
|
||||
|
||||
def _info(exe: Path, version: tuple[int, int, int], sqlite: str) -> SQLiteRuntimeInfo:
|
||||
major, minor, patch = (int(x) for x in sqlite.split("."))
|
||||
return SQLiteRuntimeInfo(
|
||||
executable=exe, base_prefix=exe.parent, python_version=version,
|
||||
sqlite_version=(major, minor, patch),
|
||||
sqlite_version_string=sqlite, sqlite_source_id=sqlite)
|
||||
|
||||
|
||||
@pytest.mark.windows_only
|
||||
def test_runtime_config_cutover_does_not_rename_running_venv(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def test_runtime_config_cutover_repoints_a_running_venv(tmp_path: Path, monkeypatch) -> None:
|
||||
from hermes_cli import managed_uv
|
||||
|
||||
live = tmp_path / "venv"
|
||||
candidate = tmp_path / "candidate"
|
||||
venv.EnvBuilder(with_pip=False).create(live)
|
||||
venv.EnvBuilder(with_pip=False).create(candidate)
|
||||
|
||||
candidate_config = candidate / "pyvenv.cfg"
|
||||
replacement = candidate_config.read_bytes() + b"runtime-cutover = candidate\n"
|
||||
candidate_config.write_bytes(replacement)
|
||||
info = SimpleNamespace(sqlite_version_string="3.53.1")
|
||||
monkeypatch.setattr(
|
||||
managed_uv,
|
||||
"_smoke_candidate_venv",
|
||||
lambda target: (True, "", info),
|
||||
)
|
||||
monkeypatch.setattr(managed_uv, "_smoke_candidate_venv", lambda target: (True, "", info))
|
||||
same = (3, 11, 15)
|
||||
current = _info(_python(live), same, "3.50.4")
|
||||
fixed = _info(candidate / "python.exe", same, "3.53.1")
|
||||
|
||||
# A holder the updater cannot see or evict: a process whose cwd is inside the venv. (A process
|
||||
# merely executing from the venv does not block the rename; proven live on windows-latest.)
|
||||
# The child opens its cwd handle during its own startup, so wait for it to report ready.
|
||||
process = subprocess.Popen(
|
||||
[str(_python(live)), "-c", "import time; time.sleep(30)"],
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
[str(_python(live)), "-c", "import time; print('ready', flush=True); time.sleep(30)"],
|
||||
cwd=str(live / "Scripts"), stdout=subprocess.PIPE, text=True,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
||||
try:
|
||||
ok, runtime_in_use, final_info, detail = (
|
||||
managed_uv._cut_over_windows_runtime_config(candidate, live=live)
|
||||
)
|
||||
|
||||
assert process.poll() is None
|
||||
assert ok is True
|
||||
assert runtime_in_use is True
|
||||
assert final_info is info
|
||||
assert detail == ""
|
||||
assert (live / "pyvenv.cfg").read_bytes() == replacement
|
||||
assert process.stdout.readline().strip() == "ready"
|
||||
# The symptom: the directory rename the POSIX cutover relies on fails with WinError 5.
|
||||
with pytest.raises(OSError):
|
||||
managed_uv._rename_with_retry(live, live.with_name("venv.parked"))
|
||||
assert live.is_dir()
|
||||
|
||||
ok, generation_in_use, final_info, detail = managed_uv._cut_over_windows_runtime_config(
|
||||
candidate, live=live, current=current, candidate_info=fixed)
|
||||
assert process.poll() is None
|
||||
assert (ok, generation_in_use, final_info, detail) == (True, True, info, "")
|
||||
assert (live / "pyvenv.cfg").read_bytes() == replacement
|
||||
probe = subprocess.run(
|
||||
[str(_python(live)), "-I", "-c", "print('repointed')"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=10,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
assert probe.returncode == 0
|
||||
assert probe.stdout.strip() == "repointed"
|
||||
capture_output=True, text=True, check=False, timeout=10,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
||||
assert probe.returncode == 0 and probe.stdout.strip() == "repointed"
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
@@ -72,11 +79,7 @@ def test_runtime_config_cutover_does_not_rename_running_venv(
|
||||
process.wait(timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.windows_only
|
||||
def test_runtime_config_cutover_rolls_back_failed_live_smoke(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def test_runtime_config_cutover_rolls_back_failed_live_smoke(tmp_path: Path, monkeypatch) -> None:
|
||||
from hermes_cli import managed_uv
|
||||
|
||||
live = tmp_path / "venv"
|
||||
@@ -87,93 +90,39 @@ def test_runtime_config_cutover_rolls_back_failed_live_smoke(
|
||||
(live / "pyvenv.cfg").write_bytes(original)
|
||||
(candidate / "pyvenv.cfg").write_bytes(b"home = new-runtime\n")
|
||||
monkeypatch.setattr(
|
||||
managed_uv,
|
||||
"_smoke_candidate_venv",
|
||||
lambda target: (False, "core import smoke failed", None),
|
||||
)
|
||||
managed_uv, "_smoke_candidate_venv", lambda target: (False, "core import smoke failed", None))
|
||||
same = (3, 11, 15)
|
||||
|
||||
ok, runtime_in_use, info, detail = managed_uv._cut_over_windows_runtime_config(
|
||||
candidate, live=live
|
||||
)
|
||||
ok, generation_in_use, info, detail = managed_uv._cut_over_windows_runtime_config(
|
||||
candidate, live=live,
|
||||
current=_info(_python(live), same, "3.50.4"),
|
||||
candidate_info=_info(candidate / "python.exe", same, "3.53.1"))
|
||||
|
||||
assert ok is False
|
||||
assert runtime_in_use is False
|
||||
assert info is None
|
||||
assert (ok, generation_in_use, info) == (False, False, None)
|
||||
assert detail == "post-cutover smoke failed: core import smoke failed"
|
||||
assert (live / "pyvenv.cfg").read_bytes() == original
|
||||
|
||||
|
||||
@pytest.mark.windows_only
|
||||
def test_runtime_repair_uses_config_cutover_on_windows(tmp_path: Path) -> None:
|
||||
from hermes_cli.managed_uv import repair_vulnerable_runtime
|
||||
from hermes_cli.sqlite_runtime import SQLiteRuntimeInfo
|
||||
def test_runtime_config_cutover_refuses_a_minor_line_jump(tmp_path: Path, monkeypatch) -> None:
|
||||
"""The live venv keeps its cp311 site-packages, so a 3.12 generation must never be pointed at it."""
|
||||
from hermes_cli import managed_uv
|
||||
|
||||
root = tmp_path / "checkout"
|
||||
root.mkdir()
|
||||
(root / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
|
||||
live = root / "venv"
|
||||
_python(live).parent.mkdir(parents=True)
|
||||
_python(live).write_bytes(b"live")
|
||||
(live / "pyvenv.cfg").write_text("home = old-runtime\n", encoding="utf-8")
|
||||
live = tmp_path / "venv"
|
||||
candidate = tmp_path / "candidate"
|
||||
live.mkdir()
|
||||
candidate.mkdir()
|
||||
original = b"home = old-runtime\n"
|
||||
(live / "pyvenv.cfg").write_bytes(original)
|
||||
(candidate / "pyvenv.cfg").write_bytes(b"home = new-runtime\n")
|
||||
smoked = []
|
||||
monkeypatch.setattr(managed_uv, "_smoke_candidate_venv", lambda target: smoked.append(target))
|
||||
|
||||
generation = root / ".hermes-runtime" / "python" / "generation-test"
|
||||
candidate_python = generation / "python.exe"
|
||||
candidate_python.parent.mkdir(parents=True)
|
||||
candidate_python.write_bytes(b"candidate")
|
||||
candidate = root / ".hermes-runtime" / "venv-candidate-test"
|
||||
_python(candidate).parent.mkdir(parents=True)
|
||||
_python(candidate).write_bytes(b"candidate")
|
||||
(candidate / "pyvenv.cfg").write_text(
|
||||
f"home = {generation}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
ok, generation_in_use, info, detail = managed_uv._cut_over_windows_runtime_config(
|
||||
candidate, live=live,
|
||||
current=_info(_python(live), (3, 11, 15), "3.50.4"),
|
||||
candidate_info=_info(candidate / "python.exe", (3, 12, 4), "3.53.1"))
|
||||
|
||||
current = SQLiteRuntimeInfo(
|
||||
executable=_python(live),
|
||||
base_prefix=live,
|
||||
python_version=(3, 11, 15),
|
||||
sqlite_version=(3, 50, 4),
|
||||
sqlite_version_string="3.50.4",
|
||||
sqlite_source_id="old",
|
||||
)
|
||||
fixed = SQLiteRuntimeInfo(
|
||||
executable=candidate_python,
|
||||
base_prefix=generation,
|
||||
python_version=(3, 11, 15),
|
||||
sqlite_version=(3, 53, 1),
|
||||
sqlite_version_string="3.53.1",
|
||||
sqlite_source_id="new",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"hermes_cli.managed_uv.probe_sqlite_runtime",
|
||||
side_effect=[current, current],
|
||||
),
|
||||
patch(
|
||||
"hermes_cli.managed_uv._windows_runtime_holders",
|
||||
return_value=(False, ""),
|
||||
),
|
||||
patch(
|
||||
"hermes_cli.managed_uv._install_safe_python_generation",
|
||||
return_value=(generation, candidate_python, fixed),
|
||||
),
|
||||
patch(
|
||||
"hermes_cli.managed_uv._stage_candidate_venv",
|
||||
return_value=candidate,
|
||||
),
|
||||
patch(
|
||||
"hermes_cli.managed_uv._cut_over_windows_runtime_config",
|
||||
return_value=(True, True, fixed, ""),
|
||||
) as windows_cutover,
|
||||
patch("hermes_cli.managed_uv._cut_over_candidate") as directory_cutover,
|
||||
):
|
||||
result = repair_vulnerable_runtime("uv", project_root=root)
|
||||
|
||||
assert result.status == "repaired"
|
||||
assert result.sqlite_before == "3.50.4"
|
||||
assert result.sqlite_after == "3.53.1"
|
||||
windows_cutover.assert_called_once_with(candidate, live=live)
|
||||
directory_cutover.assert_not_called()
|
||||
assert not candidate.exists()
|
||||
assert generation.exists()
|
||||
assert (ok, generation_in_use, info) == (False, False, None)
|
||||
assert "3.12" in detail and "3.11" in detail
|
||||
assert (live / "pyvenv.cfg").read_bytes() == original
|
||||
assert smoked == []
|
||||
|
||||
Reference in New Issue
Block a user