Files
hermes-agent/hermes_cli/update_lock.py
teknium1 7a61f1d247 fix(update): an adopted own-pid claim is refreshed, not kept near expiry
Adopting a killed update's marker verbatim kept its started_at. When that
claim was close to the 20-minute ceiling, the retry's own lock expired
seconds into its run and a second updater could start over the same
checkout: live, a detached `hermes update` acquired the lock 6s after the
retry adopted a claim 3s from expiry.

A marker naming our pid can only be a dead predecessor's (nothing
pre-writes one for `hermes update`, which always runs under a parent's
claim), so it is a new attempt and is claimed fresh like any dead holder's.
Only one acquire runs per process, so this cannot renew a wedged update.
2026-09-23 11:31:37 -07:00

209 lines
8.3 KiB
Python

"""Cross-process mutual exclusion for in-flight Hermes updates.
The marker file the Tauri updater writes (``UpdateMarkerGuard`` in
``apps/bootstrap-installer/src-tauri/src/update.rs``) and the Electron desktop reads
(``electron/update-marker.ts``) is the single lock for **all** update entrypoints.
Format and location are byte-compatible with both readers.
"""
from __future__ import annotations
import logging
import os
import time
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
logger = logging.getLogger(__name__)
# Keep in sync with UPDATE_MARKER_MAX_AGE_MS in apps/desktop/electron/update-marker.ts:
# a shorter ceiling here would let Python steal a lock Electron still considers live.
# A full update (git pull + uv sync + desktop rebuild) is minutes.
UPDATE_MARKER_MAX_AGE_SECONDS = 20 * 60
MARKER_NAME = ".hermes-update-in-progress"
# Set by an orchestrating updater (Tauri `hermes-setup --update`) to its own pid before
# spawning `hermes update` as a child stage; the parent holds the marker for its whole run,
# so without this the child would refuse its own parent's lock. Keep in sync with
# update_child_env in apps/bootstrap-installer/src-tauri/src/update.rs.
HANDOFF_PID_ENV = "HERMES_UPDATE_HANDOFF_PID"
# Exit code meaning "another updater/instance owns this install right now" — the same
# contract as the Windows shim / venv-holder guards in _cmd_update_impl, matched by the
# Tauri updater (UPDATE_EXIT_CONCURRENT in update.rs) to show "Hermes is still running".
UPDATE_EXIT_CONCURRENT = 2
def update_marker_path() -> Path:
"""Path of the shared update marker.
Uses the *process* Hermes home (never the context-local profile override): the Rust
updater resolves ``$HERMES_HOME`` or the platform default and the desktop pins that same
value into the updater's env, so a profile-scoped path would be one the other owners never look at.
"""
from hermes_constants import get_process_hermes_home
return get_process_hermes_home() / MARKER_NAME
def _pid_alive(pid: int) -> bool:
"""True when a process with ``pid`` currently exists.
Delegates to :func:`gateway.status._pid_exists`. Do NOT hand-roll ``os.kill(pid, 0)``: on
Windows CPython routes ``sig=0`` to ``GenerateConsoleCtrlEvent``, which Ctrl+C's the
target's whole console process group (bpo-14484). Any pid we cannot evaluate counts as
dead so a corrupt marker never wedges the lock.
"""
if pid <= 0:
return False
try:
from gateway.status import _pid_exists
return bool(_pid_exists(pid))
except Exception as exc:
logger.debug("Could not probe pid %s: %s", pid, exc)
return False
def _handoff_pid() -> int | None:
"""Pid of the orchestrating updater that spawned us (:data:`HANDOFF_PID_ENV`); malformed
values count as absent so a broken handoff falls back to the normal refusal."""
try:
pid = int(os.environ.get(HANDOFF_PID_ENV, "").strip())
except ValueError:
return None
return pid if pid > 0 else None
def _is_ancestor_pid(pid: int) -> bool:
"""True when ``pid`` is a live ancestor of this process.
The orchestrating updater spawns ``hermes update`` as a (grand)child, so a live marker
owned by an ancestor can only be the claim we already run under — an unrelated concurrent
updater is never in our parent chain. Never our own pid; any failure is "not an ancestor".
"""
if pid <= 0:
return False
try:
import psutil
return any(parent.pid == pid for parent in psutil.Process().parents())
except Exception as exc:
logger.debug("Could not walk process ancestry for pid %s: %s", pid, exc)
return False
@dataclass(frozen=True)
class UpdateHolder:
"""A confirmed-live update currently holding the lock."""
pid: int
age_seconds: float
def read_live_update(*, path: Path | None = None) -> UpdateHolder | None:
"""Return the live update holding the lock, or ``None``.
Mirrors ``readLiveUpdateMarker`` in ``electron/update-marker.ts``: absent, unreadable,
malformed, dead-pid, and past-the-ceiling all mean "no live update", and a stale marker
file is deleted so it can't strand future runs. Never raises.
"""
marker = path or update_marker_path()
try:
lines = marker.read_text(encoding="utf-8").splitlines()
except OSError:
return None
try:
pid = int(lines[0].strip())
except (IndexError, ValueError):
pid = -1
try:
started_at = float(lines[1].strip())
except (IndexError, ValueError):
started_at = float("-inf")
age = time.time() - started_at
if not _pid_alive(pid) or age > UPDATE_MARKER_MAX_AGE_SECONDS:
with suppress(OSError):
marker.unlink()
return None
return UpdateHolder(pid=pid, age_seconds=age)
def describe_holder(holder: UpdateHolder) -> str:
"""One-line, user-facing explanation of who holds the update lock."""
minutes, seconds = divmod(int(max(holder.age_seconds, 0)), 60)
elapsed = f"{minutes}m {seconds}s" if minutes else f"{seconds}s"
return (
f"✗ Another Hermes update is already running (started {elapsed} ago, "
f"process {holder.pid}).\n"
"\n"
" Running two at once would corrupt the install. Wait for it to finish\n"
" (watch `hermes logs`), or close the Desktop/dashboard window that\n"
" started it, then run `hermes update` again."
)
class UpdateLock:
"""Context manager owning the shared update marker for this process.
``acquired`` is False when another live update holds it; callers decide between hard
refusal (CLI/dashboard) and waiting. Release only removes the marker when *we* still own
it, so a marker rewritten by a handoff partner (the Tauri updater writes its own pid) is
never deleted from under its new owner.
"""
def __init__(self, *, path: Path | None = None) -> None:
self.path = path or update_marker_path()
self.acquired = False
self.holder: UpdateHolder | None = None
def acquire(self) -> bool:
"""Claim the lock. Returns False (and sets ``holder``) if it's taken.
A live holder whose pid matches :data:`HANDOFF_PID_ENV` — or is an ancestor of ours —
is our own orchestrating parent: run under ITS claim and leave its marker untouched on
release. The ancestry path covers staged updaters older than the env-var export.
"""
existing = read_live_update(path=self.path)
# A live claim naming our own pid is a killed update's marker whose pid this retry
# inherited (containers restart pid numbering): no other live process has our pid, and
# nothing pre-writes a marker for `hermes update` (it always runs under a parent's claim).
# It is a new attempt, so it is claimed fresh like a dead holder's. Keeping the old
# started_at would let the ceiling expire mid-run and admit a second updater.
if existing is not None and existing.pid != os.getpid():
if existing.pid == _handoff_pid() or _is_ancestor_pid(existing.pid):
return True
self.holder = existing
return False
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(f"{os.getpid()}\n{int(time.time())}\n", encoding="utf-8")
except OSError as exc:
# Best-effort, like the Rust guard: an unwritable marker must not block the
# update itself (worse than the race it prevents). Degrade to pre-lock behavior.
logger.debug("Could not write update marker %s: %s", self.path, exc)
return True
self.acquired = True
return True
def release(self) -> None:
"""Drop the marker if this process still owns it. Never raises."""
if not self.acquired:
return
self.acquired = False
try:
owner = int(self.path.read_text(encoding="utf-8").splitlines()[0].strip())
except (OSError, IndexError, ValueError):
return
if owner != os.getpid():
return # a handoff partner took ownership — still a live update
with suppress(OSError):
self.path.unlink()
def __enter__(self) -> "UpdateLock":
self.acquire()
return self
def __exit__(self, *_exc) -> None:
self.release()