test(update): durable Windows pause — record ownership, whole-tree gate, claim adoption; wine2e cells

(cherry picked from commit fe97d553c4ee91916a617bb1f1c1eefeed0e6bf0)
(cherry picked from commit f2adbee6548fdc4b96164718ae04ae10582d2ec6)
This commit is contained in:
Teknium
2026-10-03 10:06:14 -07:00
parent eebfab2991
commit 80f16b83dc
2 changed files with 261 additions and 0 deletions

View File

@@ -0,0 +1,153 @@
"""A killed or failed ``hermes update`` never strands — or wrongly starts — a paused gateway.
Failure class: Windows pause durability. ``hermes update`` stops every running gateway before it
touches the checkout. Cell (a): the updater is killed (``taskkill /F /T`` — console closed, Desktop
kill) right after the pause; the next plain ``hermes`` command must bring the gateway back.
Cell (b): the pull fails half-way (a held file makes git's fast-forward die after writing part of
the tree); the gateway must NOT be started onto that torn tree.
"""
from __future__ import annotations
import subprocess
import threading
import time
import psutil
import pytest
from tests.e2e.core.windows_update._machine import (
REQUIRES_OPT_IN,
fail_with,
harness_git,
new_machine,
)
from tests.fakes.fake_llm_provider import FakeLLMServer
pytestmark = [pytest.mark.platforms("windows"), pytest.mark.integration,
pytest.mark.live_system_guard_bypass, REQUIRES_OPT_IN]
_PAUSED = "Paused gateway profile"
_RECORD = ".hermes-update-paused-gateways.json"
_EARLY, _HELD = "AGENTS.md", "website/package.json" # checkout order: the early file is written first
def _alive(pid: int) -> bool:
try:
return psutil.Process(pid).is_running() and psutil.Process(pid).status() != psutil.STATUS_ZOMBIE
except psutil.Error:
return False
def _running_gateway(machine, not_pid: int, timeout: float) -> dict | None:
try:
return machine.wait_gateway_running(not_pid=not_pid, timeout=timeout)
except AssertionError:
return None
def _update_killed_after_pause(machine) -> tuple[bool, str]:
"""Start ``hermes update`` and ``taskkill /F /T`` it the moment it reports the pause."""
log = machine.logs / "update-killed.log"
proc = subprocess.Popen([str(machine.hermes_exe), "update", "--yes"], cwd=machine.profile,
env=machine.env(), stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
seen = threading.Event()
lines: list[str] = []
assert proc.stdout is not None
def pump() -> None:
with log.open("w", encoding="utf-8") as fh:
for raw in iter(proc.stdout.readline, b""):
text = raw.decode("utf-8", "replace").rstrip()
lines.append(text)
fh.write(text + "\n")
fh.flush()
if _PAUSED in text:
seen.set()
threading.Thread(target=pump, daemon=True).start()
deadline = time.monotonic() + 600
while not seen.is_set() and proc.poll() is None and time.monotonic() < deadline:
time.sleep(0.05)
subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"], capture_output=True, timeout=60)
proc.wait(timeout=60)
return seen.is_set(), "\n".join(lines[-40:])
def _mint_two_file_change(machine) -> str:
"""A target commit on top of the installed HEAD that rewrites two existing tracked files."""
base = machine.installed_head()
index = machine.root / "torn.index"
env = {"GIT_INDEX_FILE": str(index), "GIT_AUTHOR_NAME": "Hermes E2E", "GIT_AUTHOR_EMAIL": "e2e@hermes.invalid",
"GIT_COMMITTER_NAME": "Hermes E2E", "GIT_COMMITTER_EMAIL": "e2e@hermes.invalid"}
harness_git("-C", str(machine.serve), "read-tree", base, env=env)
for path in (_EARLY, _HELD):
src = machine.root / ("torn-" + path.replace("/", "_"))
src.write_bytes((machine.install_dir / path).read_bytes() + b"\n")
blob = harness_git("-C", str(machine.serve), "hash-object", "-w", "--no-filters", str(src))
harness_git("-C", str(machine.serve), "update-index", "--cacheinfo", f"100644,{blob},{path}", env=env)
tree = harness_git("-C", str(machine.serve), "write-tree", env=env)
index.unlink(missing_ok=True)
target = harness_git("-C", str(machine.serve), "commit-tree", tree, "-p", base, "-m", "e2e: torn target", env=env)
harness_git("-C", str(machine.serve), "update-ref", "refs/heads/main", target)
return base
@pytest.fixture(scope="module")
def journey(tmp_path_factory):
out: dict = {}
with FakeLLMServer() as srv:
machine = new_machine(tmp_path_factory.mktemp("pd"), srv.base_url, label="pd", system_git=True)
out["machine"] = machine
try:
install = machine.install()
assert install.returncode == 0, fail_with(machine, f"install.ps1 exited {install.returncode}", install)
with machine.gateway_phase():
# (a) kill the updater right after it paused a real gateway
machine.spawn_gateway()
old = int(machine.wait_gateway_running().get("pid") or 0)
machine.advance()
out["a_paused"], out["a_tail"] = _update_killed_after_pause(machine)
out["a_old_stopped"] = not _alive(old)
out["a_record"] = (machine.hermes_home / _RECORD).is_file()
out["a_launch"] = machine.hermes("gateway", "status", label="next-launch")
out["a_after"] = _running_gateway(machine, old, 120)
machine.kill_owned()
# (b) the pull fails with a torn tree
machine.spawn_gateway()
old = int(machine.wait_gateway_running().get("pid") or 0)
out["b_pre"] = _mint_two_file_change(machine)
with (machine.install_dir / _HELD).open("rb"): # no FILE_SHARE_DELETE: git cannot replace it
out["b_update"] = machine.hermes("update", "--yes", label="update-torn", timeout=900)
out["b_head"] = machine.installed_head()
out["b_status"] = harness_git("-C", str(machine.install_dir), "status", "--porcelain",
"--untracked-files=no")
out["b_after"] = _running_gateway(machine, old, 60)
out["b_record"] = (machine.hermes_home / _RECORD).is_file()
machine.kill_owned()
yield out
finally:
machine.teardown()
def test_killed_updater_gateway_resumed_by_next_launch(journey) -> None:
m = journey["machine"]
assert journey["a_paused"] and journey["a_old_stopped"], fail_with(
m, f"premise: the update never paused the gateway before the kill\n{journey['a_tail']}")
assert journey["a_after"] is not None, fail_with(
m, "after `taskkill /F` of an updater that paused the gateway, the next `hermes` launch left it "
f"stopped (pause record present after kill: {journey['a_record']})", journey["a_launch"])
def test_failed_pull_does_not_start_gateway_on_torn_tree(journey) -> None:
m, run = journey["machine"], journey["b_update"]
torn = journey["b_head"] == journey["b_pre"] and _EARLY in journey["b_status"]
assert run.returncode != 0 and torn, fail_with(
m, f"premise: the held file did not tear the pull (rc={run.returncode}, HEAD={journey['b_head']}, "
f"pre={journey['b_pre']}, status={journey['b_status']!r})", run)
assert journey["b_after"] is None, fail_with(
m, f"the gateway was restarted onto a torn checkout ({journey['b_status']!r}) after the failed update "
f"(pid {journey['b_after'].get('pid') if journey['b_after'] else None})", run)
assert journey["b_record"], fail_with(m, "the paused set was not kept for recovery after the torn pull", run)

View File

@@ -0,0 +1,108 @@
"""Paused-gateway record and update-claim adoption, with real processes and a real checkout."""
from __future__ import annotations
import os
import signal
import subprocess
import sys
import textwrap
import time
from pathlib import Path
import pytest
from hermes_cli import update_pause_record as pause_record
REPO = Path(__file__).resolve().parents[2]
pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="POSIX signals; Windows cells live in wine2e")
def _child(code: str, *argv: str, env: dict | None = None) -> subprocess.Popen:
return subprocess.Popen([sys.executable, "-c", textwrap.dedent(code), *argv], cwd=REPO, stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, text=True, env={**os.environ, "PYTHONPATH": str(REPO), **(env or {})})
def test_record_written_by_a_killed_updater_is_orphaned_only_after_its_death(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
owner = _child("""
import sys, time
from hermes_cli import update_pause_record as r
r.write(r.stamp_tree({"resume_needed": True, "profiles": {"default": 4242}}), owner=r.identity())
print("written", flush=True)
time.sleep(120)
""", env={"HERMES_HOME": str(tmp_path)})
try:
assert owner.stdout.readline().strip() == "written"
body = pause_record.read()
assert body["owner"]["pid"] == owner.pid and body["owner"]["ct"].startswith("ct:")
assert body["token"]["profiles"] == {"default": 4242}
assert pause_record.orphaned_record() is None # live owner: its own resume owns the set
finally:
owner.send_signal(signal.SIGKILL) # windows-footgun: ok — module skips on Windows
owner.wait(timeout=10)
orphan = pause_record.orphaned_record()
assert orphan is not None and orphan["token"]["profiles"] == {"default": 4242}
def _git(root: Path, *args: str) -> str:
return subprocess.run(["git", "-C", str(root), *args], check=True, capture_output=True, text=True, encoding="utf-8",
env={**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"}).stdout.strip()
def test_resume_waits_for_a_whole_tree(tmp_path):
root = tmp_path / "checkout"
root.mkdir()
_git(root, "init", "-q")
for name in ("a.py", "b.py", "local.txt"):
(root / name).write_text("v1\n", encoding="utf-8")
_git(root, "add", ".")
_git(root, "commit", "-qm", "v1")
(root / "local.txt").write_text("user edit\n", encoding="utf-8") # dirty before the update: not git's doing
token = pause_record.stamp_tree({"resume_needed": True}, root)
assert pause_record.tree_is_whole(token, root) == (True, "")
(root / "a.py").write_text("v2\n", encoding="utf-8") # git wrote a.py, then died before b.py and HEAD
whole, why = pause_record.tree_is_whole(token, root)
assert not whole and "a.py" in why
(root / "a.py").write_text("v1\n", encoding="utf-8")
marker = root / ".git" / "hermes-update-pull"
marker.write_text("pid\n", encoding="utf-8")
assert pause_record.tree_is_whole(token, root)[0] is False
marker.unlink()
_git(root, "commit", "-qam", "v2") # HEAD moved: only a dependency sync for it makes it whole
whole, why = pause_record.tree_is_whole(token, root)
assert not whole and "dependencies" in why
_HOLDER = """
import subprocess, sys, time
from hermes_cli.update_lock import UpdateLock
lock = UpdateLock()
assert lock.acquire() and lock.acquired
host = subprocess.Popen([sys.executable, sys.argv[1], "hermes_cli.main", *sys.argv[2:]], stdout=subprocess.PIPE, text=True)
print(host.stdout.read().strip(), flush=True)
host.wait()
"""
# A stand-in for the host the update relaunched: its argv names the host command, and the
# ``hermes update`` its agent starts is its child.
_HOST = """
import subprocess, sys
probe = "from hermes_cli.update_lock import UpdateLock; l = UpdateLock(); print(l.acquire(), l.holder is not None)"
print(subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True).stdout.strip())
"""
# The intermediate is a stand-in script, not a real gateway/update: nothing touches the checkout.
@pytest.mark.live_system_guard_bypass
@pytest.mark.parametrize("host_argv, adopts", [(("gateway", "run"), False), (("status",), True)])
def test_update_started_from_a_relaunched_gateway_does_not_share_the_claim(tmp_path, host_argv, adopts):
script = tmp_path / "host.py"
script.write_text(_HOST, encoding="utf-8")
holder = _child(_HOLDER, str(script), *host_argv, env={"HERMES_HOME": str(tmp_path)})
out, _ = holder.communicate(timeout=60)
assert holder.returncode == 0, out
assert out.strip() == ("True False" if adopts else "False True"), out