fix(update): startup recovery resumes gateways only while holding the checkout lock (review F5-recovery-start-vs-checkout-mutation)

What: _resume_claimed takes the checkout lock (bounded contention wait) around the tree gate and
the verified gateway start; when an update already took the checkout after our claim, the claim
is handed back unowned and untouched, so that update or the next launch owns it.
Why: recovery judged the tree and launched gateways with no checkout custody, so a concurrent
`hermes update` could mutate the checkout under freshly started gateways (the updater skips a
live claim and may discover no running gateway to pause).
Red: pre-fix update_pause_record.py -> "gateways restarted while an update owned the checkout: resume ['default']".
Green: test_recovery_never_starts_gateways_under_an_update_that_took_the_checkout passes (set resumed by the next launch).
This commit is contained in:
Teknium
2026-10-04 23:07:29 -07:00
parent 0f3fedd9a6
commit e3275db9ae
2 changed files with 45 additions and 0 deletions

View File

@@ -647,6 +647,21 @@ def _has_work(token: dict) -> bool:
def _resume_claimed(claim_path: Path, body: dict) -> None:
"""Resume under the checkout lock, so no update mutates the tree between the gate and the
verified start; an update that took the checkout after our claim gets the set back untouched."""
from hermes_cli.update_lock import UpdateLock
fence = UpdateLock()
if not fence.acquire_checkout(install_root()):
with suppress(OSError), _mutex():
_atomic_write(claim_path, {**(read(claim_path) or body), "claimer": UNOWNED})
return
try:
_resume_fenced(claim_path, body)
finally:
fence.release()
def _resume_fenced(claim_path: Path, body: dict) -> None:
with _mutex():
# A live consumer may have checkpointed after claim() returned its snapshot.
body = read(claim_path) or body

View File

@@ -183,6 +183,36 @@ def test_a_launch_killed_mid_recovery_leaves_the_set_to_the_next_launch(tmp_path
assert out.strip() == "done", f"a resumed set was resumed again: {out}"
# An update takes the checkout right after this launch claimed the set (the race window).
_UPDATE_AFTER_CLAIM = """
import subprocess, sys
from hermes_cli import update_pause_record as r
hold = ("import time; from hermes_cli import update_lock as l; from hermes_cli.update_pause_record import install_root;"
"print(l.UpdateLock().acquire_checkout(install_root()), flush=True); time.sleep(120)")
real_claim, updates = r.claim, []
def claim(src):
won = real_claim(src)
updates.append(subprocess.Popen([sys.executable, "-c", hold], stdout=subprocess.PIPE, text=True))
assert updates[-1].stdout.readline().strip() == "True"
return won
r.claim = claim
""" + _RECOVER + """
for update in updates:
update.kill()
update.wait()
"""
@pytest.mark.live_system_guard_bypass
def test_recovery_never_starts_gateways_under_an_update_that_took_the_checkout(tmp_path):
_orphan(tmp_path, {"default": 4242})
env = {"HERMES_HOME": str(tmp_path)}
out, _ = _child(_UPDATE_AFTER_CLAIM, "ok", env=env).communicate(timeout=60)
assert out.strip() == "done", f"gateways restarted while an update owned the checkout: {out}"
out, _ = _child(_RECOVER, "ok", env=env).communicate(timeout=60)
assert out.splitlines()[:1] == ["resume ['default']"], f"the handed-back set was stranded: {out}"
_HOLDER = """
import subprocess, sys, time
from hermes_cli.update_lock import UpdateLock