Files
hermes-agent/tests/hermes_cli/test_update_pause_boundaries.py
Teknium f00b4a0339 fix: the planned-stop consumer takes the pause lock only for a pause on disk and drops settled receipts
consume_planned_stop_marker_for_self took .hermes-update-paused-gateways.lock
on every platform for every stop (up to 2 s when busy), creating the lock
file in the root home, and on a busy lock fell back to keeping the request
and writing a .accepted receipt that nothing ever removed. The lock only
protects the checkpoint into a Windows update pause record; with no record
or claim of this checkout on disk (every non-Windows host, or a pause
recovery/the updater already retired) there is nothing to checkpoint.

The consumer now holds the lock only when a pause is on disk. The
checkpoint hook re-checks after the request was read, so a pause that
appeared after the first look (an update writes its record before its
request) is still checkpointed under the lock. With no pause on disk, the
consumer removes the .accepted receipt and an expired stamped request an
earlier drain left: their debt is settled. While any pause is on disk both
stay (never earlier).

test_a_busy_pause_mutex_never_turns_a_planned_stop_into_an_unplanned_one
now puts a pause record on disk first: without one, keeping the request on
a busy lock was the leftover this fixes; with one, its assertions (planned,
request retained, no block) are unchanged.
2026-10-06 06:18:39 -07:00

255 lines
12 KiB
Python

"""Real-process pause/consume/recovery contracts, not native Windows SCM evidence."""
from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import textwrap
import time
import pytest
REPO = Path(__file__).resolve().parents[2]
def _wait(path, process):
deadline = time.monotonic() + 30
while not path.exists():
assert process.poll() is None, process.communicate()
assert time.monotonic() < deadline, str(path)
time.sleep(0.01)
@pytest.fixture
def cell(tmp_path):
# An unchanged copy owns its private checkout lock/root; never the live install's.
root = tmp_path / "checkout"
(root / "hermes_cli").mkdir(parents=True)
shutil.copyfile(REPO / "hermes_cli/update_pause_record.py", root / "hermes_cli/update_pause_record.py")
home = tmp_path / "home"
(home / ".hermes").mkdir(parents=True)
env = {**os.environ, "HOME": str(home), "USERPROFILE": str(home),
"HERMES_HOME": str(home / ".hermes"), "PYTHONPATH": str(REPO),
"PYTHONDONTWRITEBYTECODE": "1"}
children = []
def launch(code, *args):
script = tmp_path / f"child-{len(children)}.py"
script.write_text(textwrap.dedent('''
import importlib.util, json, os, sys, time
from pathlib import Path
root = Path(sys.argv[1])
spec = importlib.util.spec_from_file_location("hermes_cli.update_pause_record", root / "hermes_cli/update_pause_record.py")
r = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = r
spec.loader.exec_module(r)
''') + textwrap.dedent(code), encoding="utf-8")
proc = subprocess.Popen([sys.executable, str(script), str(root), *map(str, args)],
cwd=tmp_path, env=env, stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
children.append(proc)
return proc
yield tmp_path, home / ".hermes", launch
for proc in children:
if proc.poll() is None:
proc.kill()
proc.communicate(timeout=10)
def _result(proc):
out, err = proc.communicate(timeout=30)
assert proc.returncode == 0, (out, err)
return json.loads(out.splitlines()[-1])
@pytest.mark.parametrize("claim_first", [False, True])
def test_consumption_checkpoints_debt_before_unlink_and_producer_death(cell, claim_first):
base, home, launch = cell
consumer = launch('''
from gateway.status import consume_planned_stop_marker_for_self
Path(sys.argv[2]).write_text(str(os.getpid()), encoding="utf-8")
while not Path(sys.argv[3]).exists(): time.sleep(.01)
accepted = consume_planned_stop_marker_for_self()
Path(sys.argv[4]).write_text(json.dumps(accepted), encoding="utf-8")
while True: time.sleep(.05)
''', base / "ready", base / "consume", base / "consumed")
_wait(base / "ready", consumer)
owner = launch('''
from hermes_cli import update_cmd_windows as w
from types import SimpleNamespace
pid = int(sys.argv[2])
home = Path(os.environ["HERMES_HOME"])
token = r.record_pause({"resume_needed": True, "profiles": {"default": pid},
"identities": {str(pid): r.identity(pid)["ct"]}}, None, [])
# Discovery and the gateway can spell the same profile path differently.
r.mark_stop_requested(token, [pid], {pid: home / ".." / ".hermes" / ".gateway-planned-stop.json"})
# The real callback is the first operation after marker publication. Hold it
# before its checkpoint while another interpreter runs the actual consumer.
def on_request(pid):
Path(sys.argv[3]).touch()
while True: time.sleep(.05)
w._request_socket_pauses([pid], {pid: SimpleNamespace(profile="default", path=home)},
set(), on_request=on_request)
''', consumer.pid, base / "published")
_wait(base / "published", owner)
recovery = None
if claim_first:
owner.kill()
owner.communicate(timeout=10)
recovery = launch('''
won = r.claim(r.record_path())
assert won is not None
Path(sys.argv[2]).touch()
while not Path(sys.argv[3]).exists(): time.sleep(.01)
r._resume_claimed(*won)
print(json.dumps([body["token"] for _, body in r.orphans()]))
''', base / "claimed", base / "recover")
_wait(base / "claimed", recovery)
(base / "consume").touch()
_wait(base / "consumed", consumer)
assert json.loads((base / "consumed").read_text(encoding="utf-8-sig")) is True
assert not (home / ".gateway-planned-stop.json").exists()
if owner.poll() is None:
owner.kill()
owner.communicate(timeout=10)
assert consumer.poll() is None
if recovery is not None:
(base / "recover").touch()
else:
recovery = launch('''
r.recover(["status"])
print(json.dumps([body["token"] for _, body in r.orphans()]))
''')
after = _result(recovery)
assert len(after) == 1, "consumed request lost all durable restart debt"
assert after[0]["profiles"] == {"default": consumer.pid}
assert after[0]["stop_sent"] == [str(consumer.pid)]
def test_failed_consumer_checkpoint_keeps_request_and_user_stop_is_not_update_debt(cell):
_, _, launch = cell
result = _result(launch('''
from gateway import status
from hermes_cli.update_cmd_windows import _write_update_planned_stop_marker
pid = os.getpid()
home = Path(os.environ["HERMES_HOME"])
path = home / ".gateway-planned-stop.json"
token = r.record_pause({"resume_needed": True, "profiles": {"default": pid},
"identities": {str(pid): r.identity(pid)["ct"]}}, None, [])
r.mark_stop_requested(token, [pid], {pid: path})
assert _write_update_planned_stop_marker(home, pid)
# An actual directory at the atomic writer's temp path rejects publication.
obstruction = r.record_path().with_name(f"{r.record_path().name}.{pid}.tmp")
obstruction.mkdir()
# The checkpoint cannot land: the stop is still planned, and the request stays on disk.
try:
planned = status.consume_planned_stop_marker_for_self()
except OSError as exc:
planned = type(exc).__name__
retained = path.exists() and r.read()["token"]["stop_sent"] == []
obstruction.rmdir()
marker = json.loads(path.read_text(encoding="utf-8-sig"))
path.write_text(json.dumps({**marker, "stopper_pid": pid + 1}), encoding="utf-8")
user_consumed = status.consume_planned_stop_marker_for_self()
user_debt = r.read()["token"]["stop_sent"]
assert _write_update_planned_stop_marker(home, pid)
accepted = status.consume_planned_stop_marker_for_self()
print(json.dumps({"planned": planned, "retained": retained, "user_consumed": user_consumed,
"user_debt": user_debt, "accepted": accepted, "sent": r.read()["token"]["stop_sent"],
"pid": pid}))
'''))
assert result == {"planned": True, "retained": True, "user_consumed": True, "user_debt": [],
"accepted": True, "sent": [str(result["pid"])], "pid": result["pid"]}
def test_a_busy_pause_mutex_never_turns_a_planned_stop_into_an_unplanned_one(cell):
base, _, launch = cell
holder = launch('''
r.write({"pause_id": "busy", "resume_needed": True}) # a pause on disk: the consume needs the mutex
with r._mutex():
Path(sys.argv[2]).touch()
while not Path(sys.argv[3]).exists(): time.sleep(.05)
''', base / "held", base / "release")
_wait(base / "held", holder)
try:
result = _result(launch('''
from gateway import status
from hermes_cli.update_cmd_windows import _write_update_planned_stop_marker
pid = os.getpid()
home = Path(os.environ["HERMES_HOME"])
assert _write_update_planned_stop_marker(home, pid)
started = time.monotonic()
try:
planned = status.consume_planned_stop_marker_for_self()
except OSError as exc:
planned = type(exc).__name__
print(json.dumps({"planned": planned, "seconds": time.monotonic() - started,
"retained": (home / ".gateway-planned-stop.json").exists()}))
'''))
finally:
(base / "release").touch()
assert result["planned"] is True and result["retained"], result
assert result["seconds"] < 5, f"the gateway's shutdown blocked {result['seconds']:.1f}s on the pause mutex"
def test_recovery_only_accepts_markers_the_live_consumer_accepts(cell):
_, _, launch = cell
result = _result(launch('''
from datetime import datetime, timedelta, timezone
from gateway import status
pid = os.getpid()
home = Path(os.environ["HERMES_HOME"])
marker_path = home / ".gateway-planned-stop.json"
token = {"profiles": {"default": pid}, "identities": {str(pid): r.identity(pid)["ct"]},
"stopper_pid": pid, "stop_requested": [str(pid)], "stop_markers": {str(pid): str(marker_path)}}
current = status.get_process_start_time(pid)
assert current is not None
fresh = {"target_pid": pid, "stopper_pid": pid, "target_start_time": current,
"written_at": datetime.now(timezone.utc).isoformat()}
cases = {"valid": fresh,
"expired": {**fresh, "written_at": (datetime.now(timezone.utc) - timedelta(seconds=120)).isoformat()},
"wrong_incarnation": {**fresh, "target_start_time": current + 1},
"malformed": {"target_pid": pid, "stopper_pid": pid}}
out = {}
for name, marker in cases.items():
marker_path.write_text(json.dumps(marker), encoding="utf-8")
kept = r.drop_never_stopped(dict(token))
consumed = status.consume_planned_stop_marker_for_self()
out[name] = {"kept": bool(kept["profiles"]), "consumed": consumed}
print(json.dumps(out))
'''))
assert result["valid"] == {"kept": True, "consumed": True}
for name in ("expired", "wrong_incarnation", "malformed"):
assert result[name] == {"kept": False, "consumed": False}, (name, result)
def test_orphan_adoption_preserves_cold_start_generation_and_current_precedence(cell):
_, _, launch = cell
_result(launch('''
token = {"resume_needed": True, "profiles": {}, "cold_start_if_installed": True,
"attested_generation": "old-active", "cold_start_profiles": {"beta": "old-beta", "gamma": "old-gamma"}}
r.write(r.stamp_tree(token), owner=r.UNOWNED)
print(json.dumps(r.read()["token"]))
'''))
adopted = _result(launch('''
adopted, claims = r.adopt_orphans()
token = {"resume_needed": True, "profiles": {}, "cold_start_profiles": {"beta": "current-beta"}}
r.record_pause(token, adopted, claims)
print(json.dumps(r.read()["token"]))
'''))
assert adopted.get("cold_start_if_installed") is True
assert adopted.get("attested_generation") == "old-active"
assert adopted.get("cold_start_profiles") == {"beta": "current-beta", "gamma": "old-gamma"}
again = _result(launch('''
adopted, claims = r.adopt_orphans()
r.record_pause({"resume_needed": True, "profiles": {}, "cold_start_if_installed": True,
"attested_generation": "current-active"}, adopted, claims)
print(json.dumps(r.read()["token"]))
'''))
assert again["attested_generation"] == "current-active"
assert again["cold_start_profiles"] == adopted["cold_start_profiles"]